commit 8739805f997e76fba3b5b52ea2e93770d8478638 Author: 冰朔 <565183519@qq.com> Date: Sun Jul 5 17:45:16 2026 +0800 光湖开源源码快照 · Tolaria AGPL 分叉基线 · 独立更新链 diff --git a/.chunk/README.md b/.chunk/README.md new file mode 100644 index 0000000..e8a84d2 --- /dev/null +++ b/.chunk/README.md @@ -0,0 +1,63 @@ +# Chunk Sidecar Validation + +This repo uses Chunk as an experimental inner-loop validation runner for the same gates enforced by the local git hooks. + +## Setup + +Install the CLI: + +```bash +brew install CircleCI-Public/circleci/chunk +``` + +If Homebrew cannot install the formula, use the matching archive from the Chunk CLI GitHub releases and place the `chunk` binary on `PATH`. + +Authenticate before remote sidecar runs: + +```bash +chunk auth set circleci +``` + +`chunk validate` can run locally without CircleCI auth. Remote sidecars require `CIRCLECI_TOKEN` or stored CircleCI auth. + +## Commands + +```bash +chunk validate --list +chunk validate lint +chunk validate typecheck +chunk validate frontend-coverage +chunk validate --remote lint +bash .chunk/run-sidecar-gates-local.sh true +``` + +Use `chunk sidecar setup --name tolaria-hooks` for the first remote environment setup. Once the environment passes, create a snapshot and launch future sidecars from that snapshot to avoid reinstalling Node, Rust, Tauri Linux dependencies, Playwright Chromium, and `cargo-llvm-cov` every time. + +Keep native macOS Tauri QA outside Chunk. The sidecar is Linux-based and is meant to catch portable lint, build, unit coverage, Rust, and Playwright smoke failures before the full local pre-push hook runs. Browser provisioning uses `.chunk/install-playwright-browsers.mjs` because Playwright's built-in installer can outlive the current Chunk SSH session while extracting large archives. + +## Fast Pre-Push Path + +The local pre-push hook prefers the sidecar fast path when Chunk is available. It targets three independent sidecars by name and always passes `--sidecar-id` after resolution, avoiding races with Chunk's global active-sidecar state: + +- `tolaria-hooks-frontend-2`: lint and build first, then frontend coverage with `FRONTEND_COVERAGE_SHARDS=2` and `VITEST_COVERAGE_MAX_WORKERS=2`. +- `tolaria-hooks-rust`: clippy, rustfmt, and `cargo llvm-cov` with `CARGO_BUILD_JOBS=2`. +- `tolaria-hooks-playwright`: curated Playwright smoke with one shared Vite server, eight shards, and four concurrent shard workers. + +Set `LAPUTA_PREPUSH_LOCAL=1` to force the old local path. Use `SIDECAR_FRONTEND_ID`, `SIDECAR_RUST_ID`, or `SIDECAR_PLAYWRIGHT_ID` to pin a lane to a specific sidecar when duplicate names exist in CircleCI. Use `SIDECAR_SNAPSHOT_ID=` when provisioning missing lane sidecars from a prepared snapshot. + +The lane launcher uses `chunk sidecar exec` only to start detached remote lane processes with `setsid -f`, then polls status files. Long foreground `exec` commands hit CircleCI's sidecar API deadline; plain background jobs are still tracked by `exec` and can time out. + +```bash +bash .chunk/run-sidecar-gates-local.sh true +``` + +Latest measured passing run with two frontend coverage shards: lane runtime 318s including sync, frontend completed in 313s, and Playwright smoke completed in 104s. The previous single-coverage frontend path took 441s with 457s external wall time, so the two-shard experiment reduced the sidecar long pole by about 29%. A previous Playwright run exposed `tests/smoke/h1-title-decoupled.spec.ts` as flaky on sidecar, so it remains in regression coverage but is no longer part of the curated smoke lane. + +To compare the frontend coverage experiment against the old single coverage run, use: + +```bash +FRONTEND_COVERAGE_SHARDS=1 bash .chunk/run-sidecar-gates-local.sh false +FRONTEND_COVERAGE_SHARDS=2 bash .chunk/run-sidecar-gates-local.sh false +``` + +The default sidecar fast path uses two frontend coverage shards. Each shard disables per-shard coverage thresholds, then the merged V8/Istanbul coverage map is checked once against the same 70% line/function/branch/statement thresholds. diff --git a/.chunk/config.json b/.chunk/config.json new file mode 100644 index 0000000..b381f0b --- /dev/null +++ b/.chunk/config.json @@ -0,0 +1,157 @@ +{ + "commands": [ + { + "name": "lint", + "run": "pnpm lint", + "role": "gate", + "fileExt": ".ts,.tsx,.js,.jsx,.mjs,.json", + "timeout": 120, + "limit": 3 + }, + { + "name": "typecheck", + "run": "npx tsc --noEmit", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 180, + "limit": 3 + }, + { + "name": "build", + "run": "pnpm build", + "role": "gate", + "fileExt": ".ts,.tsx,.css,.html,.json", + "timeout": 300, + "limit": 2 + }, + { + "name": "frontend-coverage", + "run": "pnpm test:coverage --silent", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 600, + "limit": 2 + }, + { + "name": "rust-lint", + "run": "cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings && cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check", + "role": "gate", + "fileExt": ".rs", + "timeout": 300, + "limit": 2 + }, + { + "name": "rust-coverage", + "run": "cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --ignore-filename-regex \"lib\\.rs|main\\.rs|menu\\.rs\" --fail-under-lines 85 -- --test-threads=1", + "role": "gate", + "fileExt": ".rs", + "timeout": 900, + "limit": 1 + }, + { + "name": "smoke-1", + "run": "bash .chunk/run-playwright-smoke.sh 1/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-2", + "run": "bash .chunk/run-playwright-smoke.sh 2/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-3", + "run": "bash .chunk/run-playwright-smoke.sh 3/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-4", + "run": "bash .chunk/run-playwright-smoke.sh 4/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-5", + "run": "bash .chunk/run-playwright-smoke.sh 5/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-6", + "run": "bash .chunk/run-playwright-smoke.sh 6/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-7", + "run": "bash .chunk/run-playwright-smoke.sh 7/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + }, + { + "name": "smoke-8", + "run": "bash .chunk/run-playwright-smoke.sh 8/8", + "role": "gate", + "fileExt": ".ts,.tsx", + "timeout": 240, + "limit": 1 + } + ], + "stopHookMaxAttempts": 2, + "vcs": { + "org": "refactoringhq", + "repo": "tolaria" + }, + "orgID": "39f93336-5295-4c6c-845f-e692c1d3f968", + "environment": { + "stack": "javascript-rust-tauri", + "setup": [ + { + "name": "system", + "command": "sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential curl file git libwebkit2gtk-4.1-dev libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf pkg-config unzip wget xvfb && sudo rm -rf /var/lib/apt/lists/*" + }, + { + "name": "rust", + "command": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && . \"$HOME/.cargo/env\" && rustup default stable && rustup component add clippy rustfmt && cargo install cargo-llvm-cov --locked" + }, + { + "name": "node", + "command": "curl -fsSL https://nodejs.org/dist/v26.3.0/node-v26.3.0-linux-x64.tar.xz | sudo tar -xJ -C /usr/local --strip-components=1 && node --version && npm --version" + }, + { + "name": "pnpm", + "command": "sudo npm install -g pnpm@10.33.0 && pnpm --version" + }, + { + "name": "install", + "command": "mkdir -p \"$HOME/Documents\" && pnpm install --frozen-lockfile && . \"$HOME/.cargo/env\" && cargo fetch --manifest-path src-tauri/Cargo.toml" + }, + { + "name": "playwright-deps", + "command": "pnpm exec playwright install-deps chromium" + }, + { + "name": "playwright", + "command": "node .chunk/install-playwright-browsers.mjs" + } + ], + "image": "cimg/node", + "image_version": "26.3.0" + } +} diff --git a/.chunk/install-playwright-browsers.mjs b/.chunk/install-playwright-browsers.mjs new file mode 100644 index 0000000..804a032 --- /dev/null +++ b/.chunk/install-playwright-browsers.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/* global console */ +import { execFileSync, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +function run(command, args) { + const result = spawnSync(command, args, { stdio: 'inherit' }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status}`) + } +} + +function parsePlan(planText) { + const entries = [] + const seen = new Set() + + for (const section of planText.split(/\n(?=\S)/)) { + const label = section.split('\n')[0]?.trim() ?? '' + const location = readField(section, 'Install location') + const url = readField(section, 'Download url') + rememberEntry(entries, seen, { label, location, url }) + } + + return entries +} + +function readField(text, name) { + const match = text.match(new RegExp(`^\\s*${name}:\\s*(.+)$`, 'm')) + return match?.[1].trim() ?? '' +} + +function rememberEntry(entries, seen, entry) { + const key = `${entry.location}\n${entry.url}` + if (!entry.location || !entry.url || seen.has(key)) return + seen.add(key) + entries.push(entry) +} + +function installBrowser(entry) { + const markerPath = join(entry.location, 'INSTALLATION_COMPLETE') + if (existsSync(markerPath)) { + console.log(`Already installed: ${entry.label}`) + return + } + + const workDir = join(tmpdir(), `playwright-${Date.now()}`) + const archivePath = join(workDir, 'browser.zip') + const extractDir = join(workDir, 'extract') + + rmSync(entry.location, { force: true, recursive: true }) + mkdirSync(extractDir, { recursive: true }) + + console.log(`Downloading ${entry.label}`) + run('curl', [ + '-fL', + '--retry', + '3', + '--connect-timeout', + '20', + '-o', + archivePath, + entry.url, + ]) + + console.log(`Extracting ${entry.label}`) + run('unzip', ['-q', archivePath, '-d', extractDir]) + + mkdirSync(entry.location, { recursive: true }) + for (const child of readdirSync(extractDir)) { + renameSync(join(extractDir, child), join(entry.location, child)) + } + + writeFileSync(markerPath, '') + rmSync(workDir, { force: true, recursive: true }) +} + +const planText = execFileSync( + 'npx', + ['playwright', 'install', '--dry-run', 'chromium'], + { encoding: 'utf8' }, +) +const entries = parsePlan(planText) + +if (entries.length === 0) { + throw new Error('Playwright did not report any browser archives to install') +} + +for (const entry of entries) { + installBrowser(entry) +} diff --git a/.chunk/run-playwright-shards.sh b/.chunk/run-playwright-shards.sh new file mode 100644 index 0000000..4a9dcbf --- /dev/null +++ b/.chunk/run-playwright-shards.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +total_shards="${1:-${PLAYWRIGHT_SHARDS:-8}}" +concurrency="${PLAYWRIGHT_CONCURRENCY:-$total_shards}" +log_dir="${TMPDIR:-/tmp}/tolaria-playwright-shards-$$" +batch_pids=() +shared_server="${PLAYWRIGHT_SHARED_SERVER:-1}" +server_port="${PLAYWRIGHT_SMOKE_PORT:-41741}" +base_url="${BASE_URL:-http://127.0.0.1:${server_port}}" +server_port="$(node -e 'console.log(new URL(process.argv[1]).port || "41741")' "$base_url")" +server_pid="" + +if [[ ! "$total_shards" =~ ^[1-9][0-9]*$ ]]; then + printf 'Usage: %s [total-shards]\n' "$0" >&2 + exit 2 +fi + +if [[ ! "$concurrency" =~ ^[1-9][0-9]*$ ]]; then + printf 'PLAYWRIGHT_CONCURRENCY must be a positive integer\n' >&2 + exit 2 +fi + +mkdir -p "$log_dir" + +stop_shared_server() { + if [[ -z "$server_pid" ]]; then + return + fi + + kill -TERM "$server_pid" 2>/dev/null || true + sleep 2 + kill -KILL "$server_pid" 2>/dev/null || true + server_pid="" +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + + for pid in "${batch_pids[@]:-}"; do + kill -TERM "$pid" 2>/dev/null || true + done + + sleep 2 + + for pid in "${batch_pids[@]:-}"; do + kill -KILL "$pid" 2>/dev/null || true + done + + stop_shared_server + + exit "$status" +} + +trap cleanup EXIT INT TERM + +wait_for_shared_server() { + local deadline=$((SECONDS + 30)) + + until curl -fsS "$base_url" >/dev/null 2>&1; do + if [[ -n "$server_pid" ]] && ! kill -0 "$server_pid" 2>/dev/null; then + printf '[chunk-playwright] shared server exited before becoming ready\n' >&2 + sed 's/^/[shared-server] /' "${log_dir}/shared-server.log" >&2 || true + return 1 + fi + + if [[ "$SECONDS" -ge "$deadline" ]]; then + printf '[chunk-playwright] shared server did not become ready at %s\n' "$base_url" >&2 + sed 's/^/[shared-server] /' "${log_dir}/shared-server.log" >&2 || true + return 1 + fi + + sleep 1 + done +} + +start_shared_server() { + if [[ "$shared_server" != "1" ]]; then + return + fi + + printf '[chunk-playwright] starting shared server at %s\n' "$base_url" + TOLARIA_VITE_CACHE_DIR="${TOLARIA_VITE_CACHE_DIR:-${TMPDIR:-/tmp}/tolaria-vite-smoke-shared}" \ + node scripts/playwright-smoke-server.mjs "$server_port" >"${log_dir}/shared-server.log" 2>&1 & + server_pid="$!" + wait_for_shared_server +} + +run_batch() { + local first_shard="$1" + local failures=0 + local names=() + local shard="$first_shard" + + batch_pids=() + + while [[ "$shard" -le "$total_shards" && "${#batch_pids[@]}" -lt "$concurrency" ]]; do + local name="smoke-${shard}-${total_shards}" + local log_file="${log_dir}/${name}.log" + + printf '[chunk-playwright] starting %s\n' "$name" + if [[ "$shared_server" == "1" ]]; then + BASE_URL="$base_url" PLAYWRIGHT_REUSE_SERVER=1 PLAYWRIGHT_SMOKE_PORT="$server_port" \ + bash .chunk/run-playwright-smoke.sh "${shard}/${total_shards}" >"$log_file" 2>&1 & + else + bash .chunk/run-playwright-smoke.sh "${shard}/${total_shards}" >"$log_file" 2>&1 & + fi + batch_pids+=("$!") + names+=("$name") + shard=$((shard + 1)) + done + + for index in "${!batch_pids[@]}"; do + local pid="${batch_pids[$index]}" + local name="${names[$index]}" + local log_file="${log_dir}/${name}.log" + + if wait "$pid"; then + printf '[chunk-playwright] %s passed\n' "$name" + else + failures=1 + printf '[chunk-playwright] %s failed\n' "$name" + fi + + sed "s/^/[${name}] /" "$log_file" + done + + batch_pids=() + return "$failures" +} + +next_shard=1 +failed=0 + +start_shared_server + +while [[ "$next_shard" -le "$total_shards" ]]; do + if ! run_batch "$next_shard"; then + failed=1 + fi + next_shard=$((next_shard + concurrency)) +done + +stop_shared_server +exit "$failed" diff --git a/.chunk/run-playwright-smoke.sh b/.chunk/run-playwright-smoke.sh new file mode 100644 index 0000000..f4d35dd --- /dev/null +++ b/.chunk/run-playwright-smoke.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +shard="${1:-}" +shard_label="all" +base_url="${BASE_URL:-}" +port="${PLAYWRIGHT_SMOKE_PORT:-41741}" + +if [[ -n "$shard" ]]; then + if [[ ! "$shard" =~ ^[1-9][0-9]*/[1-9][0-9]*$ ]]; then + printf 'Usage: %s [shard/total]\n' "$0" >&2 + exit 2 + fi + + shard_index="${shard%%/*}" + shard_label="${shard//\//-}" + if [[ -z "$base_url" ]]; then + port=$((41740 + shard_index)) + fi +fi + +base_url="${base_url:-http://127.0.0.1:${port}}" +port="$(node -e 'console.log(new URL(process.argv[1]).port || "41741")' "$base_url")" + +log_file="${TMPDIR:-/tmp}/tolaria-playwright-smoke-${shard_label}.log" +playwright_pid="" + +cleanup() { + local status=$? + + if [[ -n "$playwright_pid" ]]; then + kill -TERM "$playwright_pid" 2>/dev/null || true + sleep 2 + kill -KILL "$playwright_pid" 2>/dev/null || true + fi + + if [[ "${PLAYWRIGHT_REUSE_SERVER:-}" != "1" ]]; then + pkill -TERM -f "playwright-smoke-server.mjs ${port}" 2>/dev/null || true + pkill -TERM -f "vite.js --host 127.0.0.1 --port ${port}" 2>/dev/null || true + fi + + exit "$status" +} + +trap cleanup INT TERM + +mapfile -t smoke_files < <(node <<'NODE' +const fs = require('node:fs') + +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) +const script = packageJson.scripts && packageJson.scripts['playwright:smoke'] +const prefix = 'playwright test --config playwright.smoke.config.ts ' + +if (!script || !script.startsWith(prefix)) { + throw new Error('Unexpected playwright:smoke script format') +} + +for (const file of script.slice(prefix.length).trim().split(/\s+/)) { + if (file) { + console.log(file) + } +} +NODE +) + +: > "$log_file" +playwright_args=( + test + --config playwright.smoke.config.ts + "${smoke_files[@]}" + --reporter=line +) + +if [[ -n "$shard" ]]; then + playwright_args+=(--shard "$shard") +fi + +printf '[chunk-playwright] running %s curated smoke files on port %s' "${#smoke_files[@]}" "$port" +if [[ -n "$shard" ]]; then + printf ' with shard %s' "$shard" +fi +printf '\n' + +set +e +env CI=true BASE_URL="$base_url" PLAYWRIGHT_REUSE_SERVER="${PLAYWRIGHT_REUSE_SERVER:-}" pnpm exec playwright "${playwright_args[@]}" \ + > >(tee "$log_file") 2>&1 & +playwright_pid=$! +wait "$playwright_pid" +playwright_status=$? +playwright_pid="" +set -e + +printf '[chunk-playwright] smoke %s exited with status %s\n' "$shard_label" "$playwright_status" +exit "$playwright_status" diff --git a/.chunk/run-rust-gate.sh b/.chunk/run-rust-gate.sh new file mode 100644 index 0000000..619e1c2 --- /dev/null +++ b/.chunk/run-rust-gate.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +start_time=$(date +%s) + +log_rust() { + printf '[sidecar-rust +%ss] %s\n' "$(($(date +%s) - start_time))" "$*" +} + +mkdir -p "$HOME/Documents" +# shellcheck disable=SC1091 +. "$HOME/.cargo/env" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" + +log_rust 'clippy started' +cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings +log_rust 'clippy passed' + +log_rust 'rustfmt started' +cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check +log_rust 'rustfmt passed' + +log_rust 'coverage started' +cargo llvm-cov \ + --manifest-path src-tauri/Cargo.toml \ + --no-clean \ + --ignore-filename-regex "lib\\.rs|main\\.rs|menu\\.rs" \ + --fail-under-lines 85 \ + -- --test-threads=1 +log_rust "completed in $(($(date +%s) - start_time))s" diff --git a/.chunk/run-sidecar-gates-local.sh b/.chunk/run-sidecar-gates-local.sh new file mode 100755 index 0000000..13d0008 --- /dev/null +++ b/.chunk/run-sidecar-gates-local.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly unavailable_status=86 + +rust_changed="${1:-true}" +playwright_shards="${PLAYWRIGHT_SHARDS:-8}" +playwright_concurrency="${PLAYWRIGHT_CONCURRENCY:-4}" +playwright_shared_server="${PLAYWRIGHT_SHARED_SERVER:-1}" +vitest_coverage_max_workers="${VITEST_COVERAGE_MAX_WORKERS:-${SIDECAR_VITEST_MAX_WORKERS:-2}}" +frontend_coverage_shards="${FRONTEND_COVERAGE_SHARDS:-${SIDECAR_FRONTEND_COVERAGE_SHARDS:-2}}" +cargo_build_jobs="${CARGO_BUILD_JOBS:-2}" +timeout_seconds="${SIDECAR_GATE_TIMEOUT:-1800}" +poll_interval="${SIDECAR_GATE_POLL_INTERVAL:-20}" +launch_timeout="${SIDECAR_GATE_LAUNCH_TIMEOUT:-45}" +launch_attempts="${SIDECAR_GATE_LAUNCH_ATTEMPTS:-3}" +remote_workdir="${SIDECAR_REMOTE_WORKDIR:-/home/user/tolaria}" +sidecar_prefix="${SIDECAR_NAME_PREFIX:-tolaria-hooks}" +frontend_name="${SIDECAR_FRONTEND_NAME:-${sidecar_prefix}-frontend-2}" +rust_name="${SIDECAR_RUST_NAME:-${sidecar_prefix}-rust}" +playwright_name="${SIDECAR_PLAYWRIGHT_NAME:-${sidecar_prefix}-playwright}" +state_dir="$(mktemp -d "${TMPDIR:-/tmp}/tolaria-sidecar-lanes.XXXXXX")" +lanes_file="${state_dir}/lanes.tsv" + +: > "$lanes_file" + +cleanup_state() { + rm -rf "$state_dir" +} + +trap cleanup_state EXIT + +resolve_chunk_bin() { + if [[ -n "${CHUNK_BIN:-}" && -x "$CHUNK_BIN" ]]; then + return 0 + fi + + if command -v chunk >/dev/null 2>&1; then + CHUNK_BIN="$(command -v chunk)" + return 0 + fi + + if [[ -x "$HOME/.local/bin/chunk" ]]; then + CHUNK_BIN="$HOME/.local/bin/chunk" + return 0 + fi + + return 1 +} + +load_org_id() { + if [[ -n "${CIRCLECI_ORG_ID:-}" ]]; then + printf '%s\n' "$CIRCLECI_ORG_ID" + return + fi + + node -e "const fs=require('node:fs'); const c=JSON.parse(fs.readFileSync('.chunk/config.json','utf8')); if (c.orgID) process.stdout.write(c.orgID)" +} + +sidecar_id_by_name() { + local name="$1" + + "$CHUNK_BIN" sidecar list 2>/dev/null | awk -v name="$name" '$1 == name { print $2; found=1; exit } END { exit found ? 0 : 1 }' +} + +create_sidecar_from_snapshot() { + local name="$1" + + if [[ -z "${SIDECAR_SNAPSHOT_ID:-}" ]]; then + return 1 + fi + + echo " -> Creating ${name} from snapshot ${SIDECAR_SNAPSHOT_ID}" >&2 + "$CHUNK_BIN" sidecar create --name "$name" --org-id "$org_id" --image "$SIDECAR_SNAPSHOT_ID" >/dev/null +} + +ensure_sidecar() { + local name="$1" + local explicit_id="${2:-}" + local id + + if [[ -n "$explicit_id" ]]; then + printf '%s\n' "$explicit_id" + return 0 + fi + + if id="$(sidecar_id_by_name "$name")"; then + printf '%s\n' "$id" + return 0 + fi + + echo " -> Sidecar ${name} not found; provisioning it..." >&2 + if ! create_sidecar_from_snapshot "$name"; then + "$CHUNK_BIN" sidecar setup --name "$name" --org-id "$org_id" >/dev/null + fi + + sidecar_id_by_name "$name" +} + +sync_lane() { + local lane="$1" + local sidecar_id="$2" + local log_file="${state_dir}/sync-${lane}.log" + + { + echo "[sidecar-sync] ${lane}: syncing to ${sidecar_id}" + if "$CHUNK_BIN" sidecar sync --sidecar-id "$sidecar_id" --workdir "$remote_workdir"; then + exit 0 + fi + + echo "[sidecar-sync] ${lane}: sync failed, rerunning setup once" + "$CHUNK_BIN" sidecar setup --sidecar-id "$sidecar_id" --dir . + "$CHUNK_BIN" sidecar sync --sidecar-id "$sidecar_id" --workdir "$remote_workdir" + } >"$log_file" 2>&1 +} + +sync_all_lanes() { + local failures=0 + local frontend_pid rust_pid playwright_pid + + sync_lane frontend "$frontend_id" & + frontend_pid=$! + sync_lane rust "$rust_id" & + rust_pid=$! + sync_lane playwright "$playwright_id" & + playwright_pid=$! + + for lane_pid in "$frontend_pid" "$rust_pid" "$playwright_pid"; do + if ! wait "$lane_pid"; then + failures=1 + fi + done + + if [[ "$failures" != "0" ]]; then + echo "Chunk sidecar sync failed" + sed 's/^/ /' "${state_dir}"/sync-*.log 2>/dev/null || true + return 1 + fi +} + +record_lane() { + local lane="$1" + local sidecar_id="$2" + local status_file="$3" + local log_file="$4" + local pid_file="$5" + + printf '%s\t%s\t%s\t%s\t%s\n' "$lane" "$sidecar_id" "$status_file" "$log_file" "$pid_file" >> "$lanes_file" +} + +launch_lane() { + local lane="$1" + local sidecar_id="$2" + local run_id="tolaria-${lane}-$(date +%s)-$$" + local remote_status="/tmp/${run_id}.status" + local remote_log="/tmp/${run_id}.log" + local remote_pid="/tmp/${run_id}.pid" + local remote_launcher="/tmp/${run_id}.launcher.log" + local launch_output_file="${state_dir}/launch-${lane}.log" + local launch_attempt=1 + + while [[ "$launch_attempt" -le "$launch_attempts" ]]; do + local launch_status=0 + local launch_pid + local launch_elapsed=0 + + echo " -> Launching ${lane} on ${sidecar_id} (attempt ${launch_attempt}/${launch_attempts})" + "$CHUNK_BIN" sidecar exec \ + --sidecar-id "$sidecar_id" \ + --command bash \ + --args -lc \ + --args "cd '$remote_workdir' && export RUST_CHANGED='$rust_changed' PLAYWRIGHT_SHARDS='$playwright_shards' PLAYWRIGHT_CONCURRENCY='$playwright_concurrency' PLAYWRIGHT_SHARED_SERVER='$playwright_shared_server' VITEST_COVERAGE_MAX_WORKERS='$vitest_coverage_max_workers' FRONTEND_COVERAGE_SHARDS='$frontend_coverage_shards' CARGO_BUILD_JOBS='$cargo_build_jobs' && rm -f '$remote_status' '$remote_log' '$remote_pid' '$remote_launcher' && nohup setsid -f bash -lc 'bash .chunk/run-sidecar-lane.sh $lane >\"$remote_log\" 2>&1; printf \"%s\\n\" \"\$?\" >\"$remote_status\"' '$remote_launcher' 2>&1" \ + >"$launch_output_file" 2>&1 & + launch_pid=$! + + while kill -0 "$launch_pid" 2>/dev/null; do + if [[ "$launch_elapsed" -ge "$launch_timeout" ]]; then + launch_status=124 + kill "$launch_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$launch_pid" 2>/dev/null || true + wait "$launch_pid" 2>/dev/null || true + break + fi + sleep 1 + launch_elapsed=$((launch_elapsed + 1)) + done + + if [[ "$launch_status" == "0" ]]; then + wait "$launch_pid" || launch_status=$? + fi + + if [[ "$launch_status" == "0" ]]; then + record_lane "$lane" "$sidecar_id" "$remote_status" "$remote_log" "$remote_pid" + return 0 + fi + + cat "$launch_output_file" + if launch_state="$(poll_lane_status "$sidecar_id" "$remote_status" "$remote_log" "$remote_pid" 2>/dev/null)"; then + launch_state="$(printf '%s\n' "$launch_state" | tail -1 | tr -d '[:space:]')" + if [[ "$launch_state" == "running" || "$launch_state" == "0" || "$launch_state" =~ ^[1-9][0-9]*$ ]]; then + echo " -> ${lane} appears to have started despite launch status ${launch_status}" + record_lane "$lane" "$sidecar_id" "$remote_status" "$remote_log" "$remote_pid" + return 0 + fi + fi + echo "Chunk sidecar launch failed for ${lane} with status ${launch_status}" + if [[ "$launch_attempt" -lt "$launch_attempts" ]]; then + sleep 5 + fi + launch_attempt=$((launch_attempt + 1)) + done + + return 1 +} + +fetch_remote_log_tail() { + local lane="$1" + local sidecar_id="$2" + local log_file="$3" + + echo "" + echo "----- ${lane} log tail -----" + "$CHUNK_BIN" sidecar exec \ + --sidecar-id "$sidecar_id" \ + --command bash \ + --args -lc \ + --args "tail -180 '$log_file' 2>/dev/null || true" 2>&1 || true +} + +stop_lane() { + local sidecar_id="$1" + local pid_file="$2" + local log_file="$3" + + "$CHUNK_BIN" sidecar exec \ + --sidecar-id "$sidecar_id" \ + --command bash \ + --args -lc \ + --args "if [ -s '$pid_file' ]; then pid=\$(cat '$pid_file'); kill -TERM -\"\$pid\" 2>/dev/null || kill -TERM \"\$pid\" 2>/dev/null || true; sleep 3; kill -KILL -\"\$pid\" 2>/dev/null || kill -KILL \"\$pid\" 2>/dev/null || true; fi; ps -eo pid=,command= | awk -v target='$log_file' '\$0 ~ target && \$0 !~ /awk/ { print \$1 }' | xargs -r kill -TERM 2>/dev/null || true" \ + >/dev/null 2>&1 || true +} + +stop_all_lanes() { + while IFS=$'\t' read -r _lane sidecar_id _status_file log_file pid_file; do + stop_lane "$sidecar_id" "$pid_file" "$log_file" + done < "$lanes_file" +} + +poll_lane_status() { + local sidecar_id="$1" + local status_file="$2" + local log_file="$3" + local pid_file="$4" + + "$CHUNK_BIN" sidecar exec \ + --sidecar-id "$sidecar_id" \ + --command bash \ + --args -lc \ + --args "if [ -f '$status_file' ]; then cat '$status_file'; elif test -s '$pid_file' && kill -0 \"\$(cat '$pid_file')\" 2>/dev/null; then echo running; elif ps -eo pid=,command= | awk -v target='$log_file' '\$0 ~ target && \$0 !~ /awk/ { found=1 } END { exit found ? 0 : 1 }'; then echo running; else echo missing; fi" 2>&1 +} + +poll_lanes() { + local started_at="$1" + local last_heartbeat=0 + local completed_file="${state_dir}/completed" + local failed=0 + + : > "$completed_file" + + while true; do + local all_done=1 + + while IFS=$'\t' read -r lane sidecar_id status_file log_file pid_file; do + if grep -qx "$lane" "$completed_file"; then + continue + fi + + local poll_status=0 + local poll_output + local status + + poll_output="$(poll_lane_status "$sidecar_id" "$status_file" "$log_file" "$pid_file")" || poll_status=$? + if [[ "$poll_status" != "0" ]]; then + echo " -> ${lane} status poll failed: $(printf '%s\n' "$poll_output" | head -1)" + all_done=0 + continue + fi + + status="$(printf '%s\n' "$poll_output" | tail -1 | tr -d '[:space:]')" + case "$status" in + 0) + echo "$lane" >> "$completed_file" + echo " -> ${lane} passed" + fetch_remote_log_tail "$lane" "$sidecar_id" "$log_file" + ;; + running|"") + all_done=0 + ;; + missing) + echo " -> ${lane} stopped before writing a status file" + fetch_remote_log_tail "$lane" "$sidecar_id" "$log_file" + failed=1 + echo "$lane" >> "$completed_file" + ;; + *) + echo " -> ${lane} failed with status ${status}" + fetch_remote_log_tail "$lane" "$sidecar_id" "$log_file" + failed=1 + echo "$lane" >> "$completed_file" + ;; + esac + done < "$lanes_file" + + if [[ "$failed" != "0" ]]; then + stop_all_lanes + return 1 + fi + + if [[ "$all_done" == "1" ]]; then + return 0 + fi + + local elapsed + elapsed=$(($(date +%s) - started_at)) + if [[ "$elapsed" -ge "$timeout_seconds" ]]; then + echo "Chunk sidecar lanes timed out after ${elapsed}s" + stop_all_lanes + return 124 + fi + + if [[ $((elapsed - last_heartbeat)) -ge 60 ]]; then + echo " -> Sidecar lanes still running (${elapsed}s elapsed)" + last_heartbeat="$elapsed" + fi + + sleep "$poll_interval" + done +} + +if ! resolve_chunk_bin; then + echo "Chunk CLI not found" + exit "$unavailable_status" +fi + +org_id="$(load_org_id)" +if [[ -z "$org_id" ]]; then + echo "Chunk org id not found" + exit "$unavailable_status" +fi + +echo "Chunk sidecar lanes:" +echo " frontend: ${frontend_name}" +echo " rust: ${rust_name}" +echo " playwright: ${playwright_name}" +if [[ -n "${SIDECAR_FRONTEND_ID:-}${SIDECAR_RUST_ID:-}${SIDECAR_PLAYWRIGHT_ID:-}" ]]; then + echo " explicit sidecar IDs are set for one or more lanes" +fi +echo " vitest workers=${vitest_coverage_max_workers}; frontend coverage shards=${frontend_coverage_shards}; playwright shards=${playwright_shards}; concurrency=${playwright_concurrency}; cargo jobs=${cargo_build_jobs}" + +if ! frontend_id="$(ensure_sidecar "$frontend_name" "${SIDECAR_FRONTEND_ID:-}")"; then + echo "Chunk frontend sidecar unavailable" + exit "$unavailable_status" +fi +if ! rust_id="$(ensure_sidecar "$rust_name" "${SIDECAR_RUST_ID:-}")"; then + echo "Chunk rust sidecar unavailable" + exit "$unavailable_status" +fi +if ! playwright_id="$(ensure_sidecar "$playwright_name" "${SIDECAR_PLAYWRIGHT_ID:-}")"; then + echo "Chunk Playwright sidecar unavailable" + exit "$unavailable_status" +fi + +echo "Syncing worktree to sidecar lanes..." +if ! sync_all_lanes; then + exit "$unavailable_status" +fi + +started_at=$(date +%s) +echo "Launching sidecar lanes..." +launch_lane frontend "$frontend_id" +if [[ "$rust_changed" == "true" ]]; then + launch_lane rust "$rust_id" +else + echo " -> Rust lane skipped because RUST_CHANGED=false" +fi +launch_lane playwright "$playwright_id" + +if ! poll_lanes "$started_at"; then + exit 1 +fi + +elapsed=$(($(date +%s) - started_at)) +echo "Chunk sidecar lanes passed in ${elapsed}s" +exit 0 diff --git a/.chunk/run-sidecar-gates.sh b/.chunk/run-sidecar-gates.sh new file mode 100644 index 0000000..1c45fcd --- /dev/null +++ b/.chunk/run-sidecar-gates.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +start_time=$(date +%s) +log_dir="${TMPDIR:-/tmp}/tolaria-sidecar-gates-$$" +rust_changed="${RUST_CHANGED:-true}" +rust_phase="${SIDECAR_RUST_PHASE:-after-coverage}" +playwright_phase="${SIDECAR_PLAYWRIGHT_PHASE:-after}" +playwright_shards="${PLAYWRIGHT_SHARDS:-8}" + +mkdir -p "$log_dir" + +if [[ -z "${VITEST_COVERAGE_MAX_WORKERS:-}" ]]; then + if [[ "$playwright_phase" == "early" ]]; then + export VITEST_COVERAGE_MAX_WORKERS="${SIDECAR_EARLY_VITEST_MAX_WORKERS:-2}" + else + export VITEST_COVERAGE_MAX_WORKERS="${SIDECAR_VITEST_MAX_WORKERS:-3}" + fi +fi + +elapsed_seconds() { + printf '%s' "$(($(date +%s) - start_time))" +} + +log_gate() { + printf '[sidecar-gates +%ss] %s\n' "$(elapsed_seconds)" "$*" +} + +run_job() { + local name="$1" + shift + local log_file="${log_dir}/${name}.log" + + ( + set +e + log_gate "${name} started" + "$@" 2>&1 | tee "$log_file" + local status=${PIPESTATUS[0]} + log_gate "${name} exited with status ${status}" + exit "$status" + ) & + printf '%s:%s\n' "$name" "$!" >> "$jobs_file" +} + +terminate_jobs() { + local pids + pids="$(jobs -pr || true)" + if [[ -n "$pids" ]]; then + kill $pids 2>/dev/null || true + sleep 2 + kill -KILL $pids 2>/dev/null || true + fi +} + +jobs_file="${log_dir}/jobs" +: > "$jobs_file" +trap terminate_jobs INT TERM + +wait_for_jobs() { + local failures=0 + + while IFS=: read -r name pid; do + if wait "$pid"; then + printf '[sidecar-gates] %s passed\n' "$name" + else + failures=1 + printf '[sidecar-gates] %s failed\n' "$name" + fi + done < "$jobs_file" + + return "$failures" +} + +log_gate "rust phase=${rust_phase}; playwright phase=${playwright_phase}; shards=${playwright_shards}; concurrency=${PLAYWRIGHT_CONCURRENCY:-${playwright_shards}}; vitest workers=${VITEST_COVERAGE_MAX_WORKERS:-4}" + +run_job frontend-lint pnpm lint +run_job frontend-build pnpm build +run_job frontend-coverage pnpm test:coverage --silent + +if [[ "$rust_changed" == "true" && "$rust_phase" == "early" ]]; then + run_job rust bash .chunk/run-rust-gate.sh +elif [[ "$rust_changed" != "true" ]]; then + log_gate 'rust skipped because RUST_CHANGED=false' +fi + +if [[ "$playwright_phase" == "early" ]]; then + run_job playwright-smoke bash .chunk/run-playwright-shards.sh "$playwright_shards" +fi + +if ! wait_for_jobs; then + if [[ "$playwright_phase" == "early" ]]; then + log_gate 'one or more sidecar gates failed' + else + log_gate 'stopping before Playwright because a prerequisite gate failed' + fi + exit 1 +fi + +if [[ "$playwright_phase" != "early" || ( "$rust_changed" == "true" && "$rust_phase" != "early" ) ]]; then + : > "$jobs_file" + if [[ "$rust_changed" == "true" && "$rust_phase" != "early" ]]; then + run_job rust bash .chunk/run-rust-gate.sh + fi + if [[ "$playwright_phase" != "early" ]]; then + run_job playwright-smoke bash .chunk/run-playwright-shards.sh "$playwright_shards" + fi + + if ! wait_for_jobs; then + exit 1 + fi +fi + +log_gate "completed in $(elapsed_seconds)s" + +exit 0 diff --git a/.chunk/run-sidecar-lane.sh b/.chunk/run-sidecar-lane.sh new file mode 100755 index 0000000..7f41d46 --- /dev/null +++ b/.chunk/run-sidecar-lane.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +lane="${1:-}" +start_time=$(date +%s) + +elapsed_seconds() { + printf '%s' "$(($(date +%s) - start_time))" +} + +log_lane() { + printf '[sidecar-%s +%ss] %s\n' "$lane" "$(elapsed_seconds)" "$*" +} + +run_job() { + local name="$1" + shift + local log_file="${log_dir}/${name}.log" + + ( + set +e + log_lane "${name} started" + "$@" 2>&1 | tee "$log_file" + local status=${PIPESTATUS[0]} + log_lane "${name} exited with status ${status}" + exit "$status" + ) & + printf '%s:%s\n' "$name" "$!" >> "$jobs_file" +} + +sync_node_dependencies() { + log_lane "syncing pnpm dependencies with lockfile" + pnpm install --frozen-lockfile +} + +terminate_jobs() { + local pids + pids="$(jobs -pr || true)" + if [[ -n "$pids" ]]; then + kill $pids 2>/dev/null || true + sleep 2 + kill -KILL $pids 2>/dev/null || true + fi +} + +wait_for_jobs() { + local failures=0 + + while IFS=: read -r name pid; do + if wait "$pid"; then + printf '[sidecar-%s] %s passed\n' "$lane" "$name" + else + failures=1 + printf '[sidecar-%s] %s failed\n' "$lane" "$name" + fi + done < "$jobs_file" + + return "$failures" +} + +run_frontend_coverage_job() { + local shard_count="${FRONTEND_COVERAGE_SHARDS:-1}" + + if [[ ! "$shard_count" =~ ^[1-9][0-9]*$ ]]; then + printf 'FRONTEND_COVERAGE_SHARDS must be a positive integer\n' >&2 + return 2 + fi + + if [[ "$shard_count" == "1" ]]; then + run_job frontend-coverage pnpm test:coverage --silent + return + fi + + run_job frontend-coverage node scripts/run-vitest-coverage-shards.mjs --silent +} + +run_frontend_lane() { + log_dir="${TMPDIR:-/tmp}/tolaria-sidecar-frontend-$$" + jobs_file="${log_dir}/jobs" + mkdir -p "$log_dir" + : > "$jobs_file" + trap terminate_jobs INT TERM + + sync_node_dependencies + + export VITEST_COVERAGE_MAX_WORKERS="${VITEST_COVERAGE_MAX_WORKERS:-2}" + log_lane "vitest workers=${VITEST_COVERAGE_MAX_WORKERS}; coverage shards=${FRONTEND_COVERAGE_SHARDS:-1}" + run_job frontend-lint pnpm lint + run_job frontend-build pnpm build + + if ! wait_for_jobs; then + return 1 + fi + + : > "$jobs_file" + run_frontend_coverage_job + wait_for_jobs +} + +run_rust_lane() { + if [[ "${RUST_CHANGED:-true}" != "true" ]]; then + log_lane 'skipped because RUST_CHANGED=false' + return 0 + fi + + bash .chunk/run-rust-gate.sh +} + +run_playwright_lane() { + sync_node_dependencies + + export PLAYWRIGHT_SHARED_SERVER="${PLAYWRIGHT_SHARED_SERVER:-1}" + export PLAYWRIGHT_CONCURRENCY="${PLAYWRIGHT_CONCURRENCY:-4}" + local shards="${PLAYWRIGHT_SHARDS:-8}" + + log_lane "shards=${shards}; concurrency=${PLAYWRIGHT_CONCURRENCY}; shared server=${PLAYWRIGHT_SHARED_SERVER}" + bash .chunk/run-playwright-shards.sh "$shards" +} + +case "$lane" in + frontend) + run_frontend_lane + ;; + rust) + run_rust_lane + ;; + playwright) + run_playwright_lane + ;; + *) + printf 'Usage: %s frontend|rust|playwright\n' "$0" >&2 + exit 2 + ;; +esac + +log_lane "completed in $(elapsed_seconds)s" diff --git a/.claude/commands/create-adr.md b/.claude/commands/create-adr.md new file mode 100644 index 0000000..1cbcd6a --- /dev/null +++ b/.claude/commands/create-adr.md @@ -0,0 +1,133 @@ +# Create Architecture Decision Record + +Use this command when you need to document an architectural decision made during a task. + +Inspired by [adr-tools](https://github.com/npryce/adr-tools) (Nygard format), adapted for Laputa's frontmatter-based note format. + +## When to use this + +Create an ADR when your work involves any of these: +- Choosing a storage strategy (vault vs app settings vs database) +- Adding or removing a major dependency +- Supporting a new platform or target +- Introducing or removing a core abstraction +- Making a cross-cutting decision that affects how future code should be written + +Do NOT create ADRs for: bug fixes, UI styling, refactors that preserve behavior, or test additions. + +## Creating a new ADR + +### 1. Find the next ID + +```bash +ls docs/adr/*.md | grep -oP '\d{4}' | sort -n | tail -1 | xargs -I{} printf '%04d\n' $(({} + 1)) +``` + +If no files exist yet, start at `0001`. + +### 2. Create the file + +Filename: `docs/adr/NNNN-short-kebab-title.md` + +Template: + +```markdown +--- +type: ADR +id: "NNNN" +title: "Short decision title" +status: active +date: YYYY-MM-DD +--- + +## Context + +The issue motivating this decision, and any context that influences or constrains it. + +## Decision + +**The change we're proposing or have agreed to implement.** State it clearly in one or two sentences — bold so it stands out. + +## Options considered + +- **Option A** (chosen): brief description — pros / cons +- **Option B**: brief description — pros / cons +- **Option C**: brief description — pros / cons + +## Consequences + +What becomes easier or harder as a result? +What risks does this introduce that will need to be mitigated? +What would trigger re-evaluation of this decision? + +## Advice + +*(optional)* Input received before making this decision — who was consulted, what they said. +Omit this section if the decision was made without external input. +``` + +### 3. Update the index + +Add a row to `docs/adr/README.md`: + +```markdown +| [NNNN](NNNN-short-kebab-title.md) | Title | active | +``` + +### 4. Include in the same commit as the feature + +```bash +git add docs/adr/NNNN-*.md docs/adr/README.md +# fold into the feature commit — do not create a separate commit just for the ADR +``` + +--- + +## Superseding an existing ADR + +Equivalent of `adr new -s ` from adr-tools — do this in two steps: + +### Step 1: Mark the old ADR as superseded + +Edit the existing file — add `superseded_by` and update `status`: + +```yaml +--- +type: ADR +id: "000N" +title: "Old decision title" +status: superseded # ← change from active +superseded_by: "NNNN" # ← add this +date: YYYY-MM-DD +--- +``` + +**Never edit the content sections** of an active ADR — only the status metadata. + +### Step 2: Create the new ADR + +Follow the steps above. In the **Context** section, reference the superseded ADR: + +```markdown +## Context + +Supersedes [ADR-000N](000N-old-title.md). + +[explain why the old decision no longer holds] +``` + +### Step 3: Update the README index + +Change the old row's status to `superseded`, add the new row. + +--- + +## Best practices (from adr-tools / Nygard) + +- **One decision per ADR** — if you find yourself writing "and also", split it +- **Write Decision first** — if you can't state it in 1-2 sentences, the decision is too vague +- **Context is the "why now"** — what forced this decision to be made today? +- **Consequences should include negatives** — a one-sided ADR is a red flag +- **Committed = immutable** — once pushed, the content doesn't change; only status metadata does +- **If in doubt, create one** — cheaper to have an unnecessary ADR than to lose context +- Date = today's date, `YYYY-MM-DD` diff --git a/.claude/commands/laputa-done.md b/.claude/commands/laputa-done.md new file mode 100644 index 0000000..1529e6c --- /dev/null +++ b/.claude/commands/laputa-done.md @@ -0,0 +1,42 @@ +# /laputa-done + +Mark a Laputa task as done: add completion comment, move to In Review, then self-dispatch the next task. + +Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass **and `git push origin main` has succeeded**. + +⚠️ A task is NOT done until the push succeeds. If the push is blocked by the pre-push hook (clippy, tests, CodeScene, build): +- Read the error +- Fix it (never use `--no-verify`) +- Commit the fix and push again +- Repeat until push exits with code 0 + +## Steps + +**1. Add completion comment to the task** + +Summarize what was done — this is the context Luca and Brian will read in Todoist: + +```bash +curl -s -X POST "https://api.todoist.com/api/v1/comments" \ + -H "Authorization: Bearer $TODOIST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "task_id": "$ARGUMENTS", + "content": "✅ Implementation complete.\n\n**What changed:** [brief summary of the implementation]\n**ADR:** [if an ADR was created, reference it here; otherwise omit]\n**Playwright:** all tests pass\n**Native QA:** tested with pnpm tauri dev — [describe what was tested and what was observed]" + }' +``` + +**2. Move task to In Review** + +```bash +curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \ + -H "Authorization: Bearer $TODOIST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"section_id": "6g3XjX33FF4Vj86M"}' +``` + +**3. Pick the next task** + +Run `/laputa-next-task` to get the next task and start working on it immediately. + +If there are no tasks, `/laputa-next-task` will wait 10 minutes and retry automatically. Do NOT exit — stay alive and let it loop. diff --git a/.claude/commands/laputa-next-task.md b/.claude/commands/laputa-next-task.md new file mode 100644 index 0000000..22fba6b --- /dev/null +++ b/.claude/commands/laputa-next-task.md @@ -0,0 +1,60 @@ +# /laputa-next-task + +Pick the next Laputa task from Todoist and move it to In Progress. + +Priority order: **To Rework** first, then **Open** (sorted by Todoist priority p1→p4). + +## Steps + +1. Fetch tasks from To Rework (`6g6QqvR9rRpvJWvv`), then Open (`6g3XjWR832hVHhCM`) +2. **Sort by priority — this is mandatory.** Todoist returns tasks in arbitrary order. You must sort them yourself: + - Todoist priority field: `4` = p1 (urgent), `3` = p2, `2` = p3, `1` = p4 + - Sort descending by `priority` field (4 first, 1 last) + - To Rework tasks always come before Open tasks regardless of priority + - **Never pick a p3/p4 task if a p1/p2 task exists in the same section** +3. Take the first task from the sorted list +4. Move it to In Progress (`6g3XjWjfmJFcGgHM`) via Todoist API: + +```bash +curl -s -X POST "https://api.todoist.com/api/v1/tasks//move" \ + -H "Authorization: Bearer $TODOIST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"section_id": "6g3XjWjfmJFcGgHM"}' +``` + +5. Add a "started" comment to the task: + +```bash +curl -s -X POST "https://api.todoist.com/api/v1/comments" \ + -H "Authorization: Bearer $TODOIST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"task_id": "", "content": "🚀 Starting work. [Brief description of approach or what needs to be fixed]"}' +``` + +6. Fetch the full task details (description, comments) from Todoist: + +```bash +curl -s "https://api.todoist.com/api/v1/tasks/" \ + -H "Authorization: Bearer $TODOIST_API_KEY" + +curl -s "https://api.todoist.com/api/v1/comments?task_id=" \ + -H "Authorization: Bearer $TODOIST_API_KEY" +``` + +6. For To Rework tasks: read the ❌ QA failed comment — it tells you exactly what to fix +7. Output: task ID, title, and full description so you can start working immediately + +If no tasks are available in either section → wait 10 minutes and try again (loop forever): + +```bash +while true; do + # ... check tasks ... + if no_tasks; then + sleep 600 # 10 minutes + else + break # got a task, proceed + fi +done +``` + +Do NOT exit when there are no tasks. Keep looping until a task appears. This keeps Claude Code alive permanently — the watchdog is a safety net only, not the primary dispatcher. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fba3ea8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "mcp__codescene__*", + "Read(*)", + "Bash(cat*)", + "Bash(ls*)", + "Write(*)" + ] + } +} diff --git a/.codacy.yaml b/.codacy.yaml new file mode 100644 index 0000000..d39189e --- /dev/null +++ b/.codacy.yaml @@ -0,0 +1,21 @@ +--- +exclude_paths: + - ".chunk/**" + - "coverage/**" + - "dist/**" + - "e2e/**" + - "node_modules/**" + - "scripts/**" + - "site/.vitepress/cache/**" + - "site/.vitepress/dist/**" + - "src/test/**" + - "src-tauri/gen/**" + - "src-tauri/resources/agent-docs/**" + - "src-tauri/resources/mcp-server/**" + - "src-tauri/target/**" + - "target/**" + - "test-results/**" + - "tests/**" + - "**/*.test.ts" + - "**/*.test.tsx" + - "vite.config.ts" diff --git a/.codescene-thresholds b/.codescene-thresholds new file mode 100644 index 0000000..c317ae7 --- /dev/null +++ b/.codescene-thresholds @@ -0,0 +1,2 @@ +HOTSPOT_THRESHOLD=10.0 +AVERAGE_THRESHOLD=9.99 diff --git a/.codesceneignore b/.codesceneignore new file mode 100644 index 0000000..dc59d40 --- /dev/null +++ b/.codesceneignore @@ -0,0 +1,5 @@ +# Exclude third-party tools and their dependencies from CodeScene analysis +tools/ +e2e/ +tests/ +scripts/ diff --git a/.codescenerc b/.codescenerc new file mode 100644 index 0000000..9867481 --- /dev/null +++ b/.codescenerc @@ -0,0 +1,9 @@ +{ + "exclude": [ + "tools/", + "scripts/", + "src-tauri/gen/", + "coverage/", + "dist/" + ] +} diff --git a/.editor-performance-thresholds.json b/.editor-performance-thresholds.json new file mode 100644 index 0000000..e48915e --- /dev/null +++ b/.editor-performance-thresholds.json @@ -0,0 +1,83 @@ +{ + "scenarios": { + "small": { + "contentBytes": 1451, + "metrics": { + "blockApplyMs": { + "baselineMs": 12.3, + "maxMs": 37 + }, + "blockResolveMs": { + "baselineMs": 11.6, + "maxMs": 37 + }, + "editFrameMs": { + "baselineMs": 3.2, + "maxMs": 16 + }, + "editorVisibleMs": { + "baselineMs": 223.2, + "maxMs": 282 + }, + "firstContentMs": { + "baselineMs": 237.4, + "maxMs": 293 + }, + "fullAppliedMs": { + "baselineMs": 240.6, + "maxMs": 294 + }, + "noteOpenEditorSwapMs": { + "baselineMs": 90.4, + "maxMs": 115 + }, + "noteOpenTotalMs": { + "baselineMs": 138.7, + "maxMs": 180 + } + }, + "sectionCount": 5 + }, + "large": { + "contentBytes": 130377, + "metrics": { + "blockApplyMs": { + "baselineMs": 279.1, + "maxMs": 362 + }, + "blockResolveMs": { + "baselineMs": 37.8, + "maxMs": 63 + }, + "editFrameMs": { + "baselineMs": 5, + "maxMs": 16 + }, + "editorVisibleMs": { + "baselineMs": 197.9, + "maxMs": 261 + }, + "firstContentMs": { + "baselineMs": 305.1, + "maxMs": 401 + }, + "fullAppliedMs": { + "baselineMs": 508.4, + "maxMs": 664 + }, + "noteOpenEditorSwapMs": { + "baselineMs": 384.3, + "maxMs": 498 + }, + "noteOpenTotalMs": { + "baselineMs": 448.4, + "maxMs": 587 + } + }, + "sectionCount": 460 + } + }, + "version": 1, + "updatedAt": "2026-06-29T07:53:27.234Z", + "description": "Ratcheted editor performance budgets for synthetic small and large note opens. Lower is better; maxMs values should only move down unless intentionally rebaselined." +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6979548 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env.local and fill in real values. +# These are never committed — .env.local is gitignored. +# Release workflows must mirror these values into GitHub Actions secrets: +# - VITE_SENTRY_DSN +# - SENTRY_DSN (same value as VITE_SENTRY_DSN for Rust-side crash reporting) +# - VITE_POSTHOG_KEY +# - VITE_POSTHOG_HOST + +# Sentry DSN (https://sentry.io → Project → Settings → Client Keys) +VITE_SENTRY_DSN= + +# PostHog (https://posthog.com → Project → Settings → Project API Key) +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST=https://eu.i.posthog.com + +# Lara CLI (https://github.com/translated/lara-cli) +LARA_ACCESS_KEY_ID= +LARA_ACCESS_KEY_SECRET= diff --git a/.githooks-info b/.githooks-info new file mode 100644 index 0000000..f5a66e8 --- /dev/null +++ b/.githooks-info @@ -0,0 +1,3 @@ +# Git Hooks + +See .github/HOOKS.md for details. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6fe0494 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +custom: https://refactoring.fm/ diff --git a/.github/HOOKS.md b/.github/HOOKS.md new file mode 100644 index 0000000..604693f --- /dev/null +++ b/.github/HOOKS.md @@ -0,0 +1,64 @@ +# Git Hooks + +This repo uses Husky hooks from `.husky/`. Those files are the source of truth. + +## Installation + +`pnpm install` runs the `prepare` script and installs the hooks into `.git/hooks`. + +If you need to reinstall them manually: + +```bash +pnpm exec husky +``` + +The hooks expect `node` and `pnpm` to be available. If they are installed via `nvm`, the hooks will try to load `~/.nvm/nvm.sh` automatically. + +## Policy + +- Commit on `main` only. +- Push from `main` to `origin/main` only. +- Never use `--no-verify`. +- `.codescene-thresholds` is a ratchet. It can only move up. + +## Pre-commit + +`.husky/pre-commit` blocks commits unless all of the following are true: + +- `HEAD` is attached to `main` +- staged TypeScript files pass `pnpm lint --quiet` +- TypeScript passes `npx tsc --noEmit` +- frontend tests pass via `pnpm test --run --silent` +- current CodeScene Hotspot and Average health are both at or above `.codescene-thresholds` + +If `CODESCENE_PAT` or `CODESCENE_PROJECT_ID` is missing, the CodeScene portion is skipped, but the rest of the hook still runs. + +## Pre-push + +`.husky/pre-push` blocks pushes unless all of the following are true: + +- the current branch is `main` +- every pushed branch ref is `refs/heads/main -> refs/heads/main` +- TypeScript and the Vite build pass +- frontend coverage passes +- Rust lint and Rust coverage pass when `src-tauri/` changed +- the curated Playwright core smoke lane passes via `pnpm playwright:smoke` +- current CodeScene Hotspot and Average health are both at or above `.codescene-thresholds` + +If the remote CodeScene scores are better than the current thresholds, the hook updates `.codescene-thresholds`, stages it, and stops the push. Commit that file normally, then push again. The hook does not auto-commit or bypass itself. + +## Legacy Files + +The legacy `pre-commit` file under `.github/hooks/` is archival only. Do not copy it into `.git/hooks`; use Husky and `.husky/` instead. The old design `post-commit` auto-implementation hook was removed because it depended on obsolete one-off scripts. `install-hooks.sh` remains as a reinstall helper that runs Husky. + +## Troubleshooting + +If a hook cannot find `node` or `pnpm`: + +```bash +export NVM_DIR="$HOME/.nvm" +. "$NVM_DIR/nvm.sh" +nvm use node +``` + +Then retry the commit or push. diff --git a/.github/SETUP.md b/.github/SETUP.md new file mode 100644 index 0000000..d56b055 --- /dev/null +++ b/.github/SETUP.md @@ -0,0 +1,227 @@ +# CI/CD Setup Guide + +## Quick Start + +### 1. Add GitHub Secrets + +Nel repository GitHub (Settings → Secrets and variables → Actions → New repository secret): + +**CODESCENE_TOKEN** +``` + +``` + +**CODESCENE_PROJECT_ID** +Trova l'ID del progetto nella dashboard CodeScene (URL: `https://codescene.io/projects//...`) + +**VITE_SENTRY_DSN** +``` + +``` + +**SENTRY_DSN** +``` + +``` + +**VITE_POSTHOG_KEY** +``` + +``` + +**VITE_POSTHOG_HOST** +``` +https://eu.i.posthog.com +``` + +**Windows Authenticode release signing** + +Windows release artifacts can be Authenticode-signed when a trusted code-signing certificate is available. Until Windows certificate provisioning is complete, the release workflow warns and publishes Windows artifacts with Tauri updater signatures only. + +To enable Authenticode, configure a trusted certificate exported as base64 PFX data: + +``` +WINDOWS_CODE_SIGNING_CERTIFICATE= +WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD= +``` + +Optional: + +``` +WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT= +WINDOWS_CODE_SIGNING_TIMESTAMP_URL=https://timestamp.digicert.com +``` + +Legacy aliases `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`, `WINDOWS_CERTIFICATE_THUMBPRINT`, and `WINDOWS_TIMESTAMP_URL` are still accepted by the signing script. Do not use a self-signed certificate for public releases; Windows Authenticode release signing needs a certificate from a trusted CA or signing service. + +### 2. Enable GitHub Actions + +- Vai su Settings → Actions → General +- Assicurati che "Allow all actions and reusable workflows" sia selezionato + +### 3. Configure Branch Protection (Optional ma Raccomandato) + +Settings → Branches → Add branch protection rule: + +**Branch name pattern**: `main` + +Abilita: +- ✅ Require status checks to pass before merging + - Select: `Tests & Quality Checks` +- ✅ Require branches to be up to date before merging +- ✅ Do not allow bypassing the above settings + +Questo forza tutti i check a passare prima di poter fare merge su main. + +### 4. Test Locally Prima di Pushare + +```bash +# Full test suite +pnpm test && cargo test --manifest-path=src-tauri/Cargo.toml + +# Coverage +pnpm test:coverage + +# Lint +pnpm lint +cargo clippy --manifest-path=src-tauri/Cargo.toml + +# Format check +cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check +``` + +## What Gets Checked + +### ✅ Tests +- Frontend: Vitest +- Backend: `cargo test` + +### 📊 Coverage +- Threshold: 70% (lines, functions, branches, statements) +- Configurabile in `vite.config.ts` + +### 🏥 Code Health +- CodeScene delta analysis +- **Fail se code health diminuisce** +- Confronta HEAD vs base branch + +### 📡 Telemetry In Release Builds +- `release.yml` e `release-stable.yml` devono ricevere `VITE_SENTRY_DSN`, `SENTRY_DSN`, `VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST` +- `VITE_SENTRY_DSN` inizializza il frontend Sentry bundle +- `SENTRY_DSN` inizializza Sentry nel binary Rust/Tauri +- `VITE_POSTHOG_KEY` / `VITE_POSTHOG_HOST` permettono ai build distribuiti di inizializzare PostHog quando l'utente abilita analytics + +### 📝 Documentation +- **Warning se modifichi `src/` o `src-tauri/` ma non aggiorni `docs/`** +- Non blocca il merge, solo un reminder +- Skip il check con `[skip docs]` nel commit message +- Aggiorna docs solo se la modifica invalida qualcosa già documentato + +### 🎨 Lint & Format +- ESLint per frontend +- Clippy + rustfmt per Rust + +## Workflow File + +Il workflow è in `.github/workflows/ci.yml`. + +**Trigger**: +- Push su `main` o `experiment/*` +- Pull request verso `main` + +**Runner**: `macos-latest` (necessario per Tauri + Rust) + +## Customization + +### Soglie Coverage + +Modifica `vite.config.ts`: + +```typescript +coverage: { + thresholds: { + lines: 80, // Aumenta se vuoi più coverage + functions: 80, + branches: 80, + statements: 80, + } +} +``` + +### Documentation Check + +Il check **avvisa** (non fallisce) se: +1. Modifichi file in `src/` o `src-tauri/` +2. NON modifichi nulla in `docs/` + +**Quando aggiornare docs:** +- Cambi architettura → aggiorna `docs/ARCHITECTURE.md` +- Cambi astrazioni chiave → aggiorna `docs/ABSTRACTIONS.md` +- Cambi theme system → aggiorna `docs/THEMING.md` +- Bug fix / refactor interno → `[skip docs]` nel commit message + +**Skip il check:** +```bash +git commit -m "fix: editor scroll bug [skip docs]" +``` + +### CodeScene Fail Threshold + +Nel workflow, modifica: + +```yaml +- name: CodeScene Delta Analysis + uses: codescene-oss/codescene-delta-analysis-action@v1 + with: + fail-on-declining-code-health: true # Cambia a false per warning-only + minimum-code-health-score: 8.0 # Aggiungi per soglia assoluta +``` + +## Troubleshooting + +### CodeScene fails con "Project not found" +- Verifica che `CODESCENE_PROJECT_ID` sia corretto +- Controlla che il token abbia accesso al progetto + +### Coverage check fails +- Verifica che `@vitest/coverage-v8` sia installato: `pnpm add -D @vitest/coverage-v8` +- Le soglie sono configurabili in `vite.config.ts` + +### Docs check avvisa anche se non serve aggiornare docs +- È solo un warning, non blocca +- Skip con `[skip docs]` nel commit message +- Oppure ignora — è un reminder, non un requisito + +### Workflow non si attiva +- Verifica che il file sia in `.github/workflows/ci.yml` +- Controlla che GitHub Actions sia abilitato nelle settings +- Il workflow parte solo su push/PR verso `main` o branch `experiment/*` + +## Example CI Pass + +``` +✅ Run frontend tests +✅ Run Rust tests +✅ Run frontend coverage (75% lines, 73% functions) +✅ CodeScene Delta Analysis (code health: 9.2 → 9.3) +✅ Check docs are updated (docs/ARCHITECTURE.md modified) +✅ Lint frontend +✅ Clippy (Rust) +✅ Format check (Rust) +``` + +## Example CI Warning + +``` +⚠️ Code files changed but docs/ not updated + Changed code files: + - src/components/Editor.tsx + - src-tauri/src/vault.rs + + If this change affects architecture/abstractions/design documented in docs/, + please update the relevant documentation files. + + To skip this check, include [skip docs] in your commit message. +``` + +Questo è solo un reminder. Se la modifica non invalida la documentazione esistente, puoi ignorarlo o usare `[skip docs]`. diff --git a/.github/hooks/install-hooks.sh b/.github/hooks/install-hooks.sh new file mode 100755 index 0000000..5b952c3 --- /dev/null +++ b/.github/hooks/install-hooks.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +if ! command -v pnpm >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" --no-use + nvm use --silent node >/dev/null 2>&1 || true + fi +fi + +if ! command -v pnpm >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then + echo "❌ node and pnpm must be available to install Husky hooks" + exit 1 +fi + +echo "Installing Husky hooks from .husky/ ..." +pnpm exec husky +echo "✅ Husky hooks installed" +echo "" +echo "Source of truth:" +echo " - .husky/pre-commit" +echo " - .husky/pre-push" +echo "" +echo "Never use --no-verify in this repo." diff --git a/.github/hooks/pre-commit b/.github/hooks/pre-commit new file mode 100644 index 0000000..94e6aed --- /dev/null +++ b/.github/hooks/pre-commit @@ -0,0 +1,81 @@ +#!/bin/bash +# Pre-commit hook: CodeScene Code Health Check +# Copy to .git/hooks/pre-commit and make executable + +set -e + +# Allow bypass with --no-verify or [skip codescene] in commit message +if git log -1 --pretty=%B 2>/dev/null | grep -qi '\[skip codescene\]'; then + echo "⏭️ CodeScene check skipped (commit message contains [skip codescene])" + exit 0 +fi + +echo "🔍 Running CodeScene Code Health check..." + +# Check if we have staged files to analyze +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|rs)$' || true) + +if [ -z "$STAGED_FILES" ]; then + echo "✅ No TypeScript/Rust files staged, skipping CodeScene check" + exit 0 +fi + +# Get current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Determine base branch for comparison +if [ "$CURRENT_BRANCH" = "main" ]; then + BASE_REF="HEAD~1" +else + BASE_REF="origin/main" +fi + +echo " Comparing against: $BASE_REF" + +# Check if we have CodeScene configured (MCP or CLI) +CODESCENE_MCP_CONFIG="$HOME/.claude/mcp.json" +CODESCENE_TOKEN_FILE="$HOME/.codescene/token" + +if [ ! -f "$CODESCENE_MCP_CONFIG" ] && [ ! -f "$CODESCENE_TOKEN_FILE" ]; then + echo "⚠️ CodeScene not configured" + echo " Install CodeScene MCP (configured in ~/.claude/mcp.json)" + echo " Or place token at ~/.codescene/token" + echo " Proceeding without check (use 'git commit --no-verify' to skip this warning)" + exit 0 +fi + +# Simple health check using git diff stats +echo " Analyzing code changes..." + +# Get file changes +LINES_ADDED=$(git diff --cached --numstat | awk '{sum+=$1} END {print sum}') +LINES_REMOVED=$(git diff --cached --numstat | awk '{sum+=$2} END {print sum}') + +# Check for large files (potential complexity) +LARGE_FILES=$(git diff --cached --numstat | awk '$1 > 500 || $2 > 500 {print $3}') + +if [ ! -z "$LARGE_FILES" ]; then + echo "⚠️ Large file changes detected (>500 lines):" + echo "$LARGE_FILES" | sed 's/^/ - /' + echo "" + echo " Consider:" + echo " - Breaking into smaller commits" + echo " - Reviewing with Claude Code + CodeScene MCP" + echo " - Running: claude 'Review code health of staged changes'" + echo "" + read -p " Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ Commit aborted" + exit 1 + fi +fi + +echo "✅ CodeScene check passed" +echo " +$LINES_ADDED -$LINES_REMOVED lines" +echo "" +echo " 💡 For detailed code health analysis, run:" +echo " claude 'Check code health of this commit with CodeScene MCP'" +echo "" + +exit 0 diff --git a/.github/scripts/configure-windows-authenticode.ps1 b/.github/scripts/configure-windows-authenticode.ps1 new file mode 100644 index 0000000..f5aade3 --- /dev/null +++ b/.github/scripts/configure-windows-authenticode.ps1 @@ -0,0 +1,115 @@ +param( + [string]$ConfigPath = "src-tauri/tauri.windows-signing.conf.json" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Read-FirstEnv { + param([string[]]$Names) + + foreach ($Name in $Names) { + $Value = [Environment]::GetEnvironmentVariable($Name) + if (-not [string]::IsNullOrWhiteSpace($Value)) { + return $Value.Trim() + } + } + + throw "Set one of these environment variables: $($Names -join ', ')" +} + +function Read-OptionalEnv { + param( + [string[]]$Names, + [string]$DefaultValue + ) + + foreach ($Name in $Names) { + $Value = [Environment]::GetEnvironmentVariable($Name) + if (-not [string]::IsNullOrWhiteSpace($Value)) { + return $Value.Trim() + } + } + + return $DefaultValue +} + +function Normalize-Thumbprint { + param([string]$Thumbprint) + + return ($Thumbprint -replace "\s", "").ToUpperInvariant() +} + +function Convert-CertificateSecretToBytes { + param([string]$CertificateSecret) + + $Base64Lines = $CertificateSecret -split "\r?\n" | + Where-Object { $_ -notmatch "^-+BEGIN " -and $_ -notmatch "^-+END " } + $CertificateBase64 = ($Base64Lines -join "") -replace "\s", "" + + try { + return [Convert]::FromBase64String($CertificateBase64) + } catch { + throw "Windows code-signing certificate must be base64-encoded PFX data." + } +} + +$CertificateSecret = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE", "WINDOWS_CERTIFICATE") +$CertificatePassword = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_PASSWORD") +$ConfiguredThumbprint = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT", "WINDOWS_CERTIFICATE_THUMBPRINT") "" +$DigestAlgorithm = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_DIGEST_ALGORITHM") "sha256" +$TimestampUrl = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_TIMESTAMP_URL", "WINDOWS_TIMESTAMP_URL") "http://timestamp.digicert.com" + +$TempRoot = Join-Path ([IO.Path]::GetTempPath()) "tolaria-windows-signing" +if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + $TempRoot = Join-Path $env:RUNNER_TEMP "tolaria-windows-signing" +} +New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null + +$PfxPath = Join-Path $TempRoot "certificate.pfx" +[IO.File]::WriteAllBytes($PfxPath, (Convert-CertificateSecretToBytes $CertificateSecret)) + +$SecurePassword = ConvertTo-SecureString -String $CertificatePassword -Force -AsPlainText +$ImportedCertificates = @(Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $SecurePassword) +Remove-Item -Force -ErrorAction SilentlyContinue $PfxPath + +$ImportedCertificate = $ImportedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1 +if ($null -eq $ImportedCertificate) { + throw "The imported Windows code-signing certificate does not include a private key." +} + +if ([string]::IsNullOrWhiteSpace($ConfiguredThumbprint)) { + $CertificateThumbprint = Normalize-Thumbprint $ImportedCertificate.Thumbprint +} else { + $CertificateThumbprint = Normalize-Thumbprint $ConfiguredThumbprint +} + +$StoreCertificate = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { (Normalize-Thumbprint $_.Thumbprint) -eq $CertificateThumbprint } | + Select-Object -First 1 +if ($null -eq $StoreCertificate) { + throw "The requested Windows code-signing certificate thumbprint was not found in Cert:\CurrentUser\My." +} + +$Config = @{ + bundle = @{ + windows = @{ + certificateThumbprint = $CertificateThumbprint + digestAlgorithm = $DigestAlgorithm + timestampUrl = $TimestampUrl + } + } +} + +$ResolvedConfigPath = Resolve-Path -Path (Split-Path -Parent $ConfigPath) -ErrorAction SilentlyContinue +if ($null -eq $ResolvedConfigPath) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ConfigPath) | Out-Null +} + +$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding utf8NoBOM + +if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT=$CertificateThumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +} + +Write-Host "Prepared Windows Authenticode signing config at $ConfigPath." diff --git a/.github/scripts/prefetch-tauri-nsis.ps1 b/.github/scripts/prefetch-tauri-nsis.ps1 new file mode 100644 index 0000000..79da404 --- /dev/null +++ b/.github/scripts/prefetch-tauri-nsis.ps1 @@ -0,0 +1,137 @@ +$ErrorActionPreference = "Stop" + +$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip" +$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D" +$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll" +$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860" +$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll" + +$nsisRequiredFiles = @( + "makensis.exe", + "Bin\makensis.exe", + "Stubs\lzma-x86-unicode", + "Stubs\lzma_solid-x86-unicode", + "Include\MUI2.nsh", + "Include\FileFunc.nsh", + "Include\x64.nsh", + "Include\nsDialogs.nsh", + "Include\WinMessages.nsh", + "Include\Win\COM.nsh", + "Include\Win\Propkey.nsh", + "Include\Win\RestartManager.nsh" +) + +function Get-UpperSha1 { + param([Parameter(Mandatory = $true)][string]$Path) + + return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant() +} + +function Test-FileSha1 { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedSha1 + ) + + return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1) +} + +function Save-VerifiedDownload { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$OutFile, + [Parameter(Mandatory = $true)][string]$ExpectedSha1 + ) + + $parent = Split-Path -Parent $OutFile + New-Item -ItemType Directory -Force -Path $parent | Out-Null + + $tempFile = "$OutFile.download" + for ($attempt = 1; $attempt -le 5; $attempt++) { + try { + Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile + Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120 + + $actualSha1 = Get-UpperSha1 -Path $tempFile + if ($actualSha1 -ne $ExpectedSha1) { + throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1." + } + + Move-Item -Force -LiteralPath $tempFile -Destination $OutFile + return + } catch { + Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile + if ($attempt -eq 5) { + throw + } + + $delaySeconds = [Math]::Min(30, 5 * $attempt) + Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s." + Start-Sleep -Seconds $delaySeconds + } + } +} + +function Find-MissingFile { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string[]]$RelativePaths + ) + + foreach ($relativePath in $RelativePaths) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) { + return $relativePath + } + } + + return $null +} + +if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache." +} + +$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri" +$nsisPath = Join-Path $tauriToolsPath "NSIS" +$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() +} else { + $env:RUNNER_TEMP +} + +New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null + +$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles +if ($missingNsisFile) { + Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11." + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11") + + $zipPath = Join-Path $downloadRoot "nsis-3.11.zip" + Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1 + Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath + + $extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11" + if (-not (Test-Path -LiteralPath $extractedNsisPath)) { + throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory." + } + + Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath +} else { + Write-Host "Tauri NSIS cache already contains NSIS 3.11." +} + +$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath +if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) { + Write-Host "Downloading Tauri NSIS utility plugin." + Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1 +} else { + Write-Host "Tauri NSIS utility plugin is already cached." +} + +$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath)) +if ($missingFile) { + throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile." +} + +Write-Host "Tauri NSIS toolchain ready at $nsisPath." diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..250e5e0 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,133 @@ +# CI/CD Setup + +## GitHub Actions Workflow + +Il workflow `ci.yml` esegue i seguenti check automatici: + +### 1. Tests +- Frontend: `pnpm test` +- Rust backend: `cargo test` + +### 2. Test Coverage +- Frontend: vitest con coverage reporting +- Upload automatico su Codecov dai report LCOV frontend + Rust +- Threshold configurabile in `vitest.config.ts` + +### 3. Code Health (CodeScene) +- Delta analysis su ogni PR/push +- Fail se il code health diminuisce +- Richiede secrets configurati (vedi sotto) + +### 4. Documentation Check +- Verifica che se cambia codice in `src/` o `src-tauri/`, anche `docs/` viene aggiornato +- **Warning only** — non blocca il merge, solo un reminder +- Skip con `[skip docs]` nel commit message +- Aggiorna docs solo se la modifica invalida architettura/astrazioni/design già documentati + +### 5. Lint & Format +- ESLint per frontend +- Clippy + rustfmt per Rust + +## Setup Required + +### CodeScene Secrets +Aggiungi questi secrets nel repository GitHub (Settings → Secrets → Actions): + +``` +CODESCENE_TOKEN= +CODESCENE_PROJECT_ID= +``` + +Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token). +Il project ID lo trovi nella dashboard CodeScene. + +### Codecov Setup +- Installa/attiva il repo in Codecov una volta sola tramite GitHub App / import del repository. +- Nessun `CODECOV_TOKEN` richiesto in GitHub Actions: `ci.yml` usa OIDC (`id-token: write` + `use_oidc: true`). +- Il workflow carica `coverage/lcov.info` (Vitest) e `coverage/rust.lcov` (cargo-llvm-cov). +- L'action Codecov resta con integrity validation attiva. Se Codecov ruota la chiave GPG del CLI, aggiorna il pin dell'action invece di usare `skip_validation`. + +### Telemetry Secrets For Release Builds +Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`: + +``` +VITE_SENTRY_DSN= +SENTRY_DSN= +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST=https://eu.i.posthog.com +``` + +Senza questi valori, i build distribuiti possono mantenere i toggle telemetry nelle Settings ma non inizializzare davvero PostHog/Sentry. + +### Windows Authenticode Secrets For Release Builds +Windows alpha e stable release builds usano sempre le firme Tauri updater. Se i secret Authenticode sono presenti, il workflow firma anche gli installer Windows e verifica le firme; se mancano, emette un warning e pubblica gli artifact Windows senza Authenticode finche' il certificato non e' pronto. + +``` +WINDOWS_CODE_SIGNING_CERTIFICATE= +WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD= +``` + +Opzionale: + +``` +WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT= +WINDOWS_CODE_SIGNING_TIMESTAMP_URL=https://timestamp.digicert.com +``` + +Il certificato deve essere un certificato di code signing trusted; un certificato self-signed non e' adatto per i release artifact pubblici. + +### Coverage Thresholds +Configura in `vitest.config.ts`: + +```typescript +export default defineConfig({ + test: { + coverage: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + // Fail CI se sotto threshold + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80 + } + } + } +}) +``` + +## Local Testing + +Prima di pushare, puoi testare localmente: + +```bash +# Run all tests +pnpm test && cargo test + +# Check coverage +pnpm test:coverage + +# Lint +pnpm lint +cargo clippy +cargo fmt --check + +# CodeScene (local) +codescene delta-analysis --base-revision origin/main +``` + +## Workflow Triggers + +- **Push**: su `main` +- **Pull Request**: verso `main` +- **Manuale**: `workflow_dispatch` + +Nota: l'upload a Codecov gira su push a `main` e sulle PR dello stesso repository. Le PR da fork saltano l'upload per evitare problemi di permessi OIDC. + +## Status Checks + +Tutti i check devono passare prima di poter fare merge. +Se un check fallisce, vedrai il dettaglio nei logs di GitHub Actions. diff --git a/.github/workflows/auto-update-prs.yml b/.github/workflows/auto-update-prs.yml new file mode 100644 index 0000000..b055ad8 --- /dev/null +++ b/.github/workflows/auto-update-prs.yml @@ -0,0 +1,38 @@ +name: Auto-update PR branches + +# When main advances, automatically update all open PR branches +# so they stay up to date and can be auto-merged without manual rebase. + +on: + push: + branches: [main] + +jobs: + update-prs: + name: Update open PR branches + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update all open PR branches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get all open PRs targeting main + PRS=$(gh pr list --base main --state open --json number,headRefName --jq '.[]') + + echo "$PRS" | while IFS= read -r pr; do + PR_NUM=$(echo "$pr" | jq -r '.number') + BRANCH=$(echo "$pr" | jq -r '.headRefName') + + echo "Updating PR #$PR_NUM ($BRANCH)..." + # GitHub native update — does a merge of main into the branch + gh pr update-branch "$PR_NUM" 2>&1 && echo "✅ #$PR_NUM updated" || echo "⚠️ #$PR_NUM skipped (already up to date or conflict)" + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..09af790 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,296 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +env: + # Bump this when Tauri/Rust target artifacts capture stale absolute paths. + RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria + # Keep large production frontend builds below CI runner memory limits. + NODE_OPTIONS: --max-old-space-size=4096 + +jobs: + frontend-static-quality: + name: Frontend Static Quality Checks + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for CodeScene + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Keep frontend and Rust quality gates in separate macOS jobs so the + # expensive Rust target cache restore no longer blocks the frontend lane. + # ── 0. Build check (catches type errors and bundler failures) ───────── + - name: TypeScript type check + run: pnpm exec tsc --noEmit + + - name: Vite build check + # TypeScript is checked explicitly above; run Vite directly here to avoid + # paying for the package build script's duplicate `tsc -b` pass. + run: pnpm exec vite build + + - name: Check whether docs build is needed + id: docs-changes + shell: bash + run: | + set -euo pipefail + BASE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then + echo "should-build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --name-only "$BASE_SHA" HEAD | grep -qE '^(docs/|site/|scripts/build-agent-docs\.mjs|package\.json|pnpm-lock\.yaml|\.github/workflows/ci\.yml)'; then + echo "should-build=true" >> "$GITHUB_OUTPUT" + else + echo "should-build=false" >> "$GITHUB_OUTPUT" + fi + + - name: Docs build check + if: steps.docs-changes.outputs.should-build == 'true' + run: pnpm docs:build + + # ── 1. Code Health (CodeScene — Hotspot + Average Code Health gates) ── + # Enforces minimum floors on BOTH hotspot and average code health. + # Thresholds come from .codescene-thresholds so CI and local hooks match. + - name: Code Health gates + env: + CODESCENE_PAT: ${{ secrets.CODESCENE_PAT }} + CODESCENE_PROJECT_ID: ${{ secrets.CODESCENE_PROJECT_ID }} + run: | + HOTSPOT_THRESHOLD=$(grep '^HOTSPOT_THRESHOLD=' .codescene-thresholds | cut -d= -f2) + AVERAGE_THRESHOLD=$(grep '^AVERAGE_THRESHOLD=' .codescene-thresholds | cut -d= -f2) + API_RESPONSE=$(curl -sf \ + -H "Authorization: Bearer $CODESCENE_PAT" \ + -H "Accept: application/json" \ + "https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID") + HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])") + AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])") + echo "Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_THRESHOLD)" + echo "Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_THRESHOLD)" + python3 -c " + hotspot = float('$HOTSPOT_SCORE') + average = float('$AVERAGE_SCORE') + ht = float('$HOTSPOT_THRESHOLD') + at = float('$AVERAGE_THRESHOLD') + failed = False + if hotspot < ht: + print(f'❌ Hotspot Code Health {hotspot:.2f} is below threshold {ht}') + failed = True + else: + print(f'✅ Hotspot Code Health {hotspot:.2f} ≥ {ht}') + if average < at: + print(f'❌ Average Code Health {average:.2f} is below threshold {at}') + failed = True + else: + print(f'✅ Average Code Health {average:.2f} ≥ {at}') + if failed: + exit(1) + " + + # ── 2. Documentation check (warning only — does not fail build) ─────── + - name: Check docs are updated + continue-on-error: true + run: | + if git log -1 --pretty=%B | grep -i '\[skip docs\]' > /dev/null; then + echo "⏭️ Documentation check skipped" + exit 0 + fi + if git diff --name-only origin/main | grep -E '^(src/|src-tauri/)' > /dev/null; then + if ! git diff --name-only origin/main | grep -E '^docs/' > /dev/null; then + echo "⚠️ Code files changed but docs/ not updated" + git diff --name-only origin/main | grep -E '^(src/|src-tauri/)' + echo "If this change affects architecture/abstractions/theme documented in docs/, update them." + echo "To suppress: include [skip docs] in your commit message." + fi + fi + echo "✅ Documentation check passed" + + # ── 3. Lint & format ────────────────────────────────────────────────── + - name: Lint frontend + run: pnpm lint + + frontend-tests: + name: Frontend Tests & Coverage + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The coverage command runs the canonical frontend test suite. + - name: Bundle MCP server resources (required by Tauri build) + run: node scripts/bundle-mcp-server.mjs + + - name: Frontend tests + coverage (≥70% lines/functions/branches/statements) + run: pnpm test:coverage + # Thresholds configured in vite.config.ts — exits non-zero if coverage drops + + - name: Upload frontend coverage to Codecov + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: codecov/codecov-action@5975040f7f7d40edaff8d784b576fd65ae95c073 + with: + use_oidc: true + fail_ci_if_error: true + disable_search: true + files: ./coverage/lcov.info + flags: frontend + verbose: true + # OIDC avoids long-lived CODECOV_TOKEN secrets. + + rust-quality: + name: Rust Tests & Quality Checks + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + components: rustfmt, clippy, llvm-tools-preview + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@e5de28abeb52d916c5e5875d54b21a9e738b61ec + + - name: Rust tests + coverage (≥85% lines) + run: | + mkdir -p coverage + cargo llvm-cov \ + --manifest-path src-tauri/Cargo.toml \ + --ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \ + --lcov \ + --output-path coverage/rust.lcov \ + --fail-under-lines 85 + # cargo-llvm-cov exits non-zero if line coverage drops below 85% + # lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable. + + - name: Upload Rust coverage to Codecov + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: codecov/codecov-action@5975040f7f7d40edaff8d784b576fd65ae95c073 + with: + use_oidc: true + fail_ci_if_error: true + disable_search: true + files: ./coverage/rust.lcov + flags: rust + verbose: true + # OIDC avoids long-lived CODECOV_TOKEN secrets. + + - name: Clippy (Rust) + run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings + + - name: Format check (Rust) + run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check + + linux-build: + name: Linux build verification + # Keep the normal push CI lane under the 10-minute target. The release + # workflows already perform the full Linux/AppImage build after main + # pushes, so this slower compatibility check stays available for PRs and + # manual diagnostics without blocking every direct push. + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Tauri Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + libfuse2 \ + librsvg2-dev \ + patchelf \ + build-essential \ + file + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + components: clippy + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Frontend build + run: pnpm build + + - name: Cargo check + run: cargo check --manifest-path=src-tauri/Cargo.toml + + - name: Clippy + run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..d82adb2 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,104 @@ +name: Deploy docs + +on: + push: + branches: [main] + paths: + - ".github/workflows/deploy-docs.yml" + - "package.json" + - "pnpm-lock.yaml" + - "scripts/build-agent-docs.mjs" + - "site/**" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +env: + NODE_OPTIONS: --max-old-space-size=4096 + +jobs: + build: + name: Build VitePress site + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: latest + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build docs and download pages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pnpm docs:build + + DIST="site/.vitepress/dist" + mkdir -p "$DIST/alpha" "$DIST/stable" "$DIST/download" "$DIST/releases" "$DIST/stable/download" + + gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > "$DIST/releases.json" + + STABLE_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == false)][0].tagName // ""')" + if [ -n "$STABLE_TAG" ]; then + gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output "$DIST/stable/latest.json" || echo '{}' > "$DIST/stable/latest.json" + else + echo '{}' > "$DIST/stable/latest.json" + fi + + ALPHA_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == true)][0].tagName // ""')" + if [ -n "$ALPHA_TAG" ]; then + gh release download --repo ${{ github.repository }} "$ALPHA_TAG" --pattern "alpha-latest.json" --output "$DIST/alpha/latest.json" || echo '{}' > "$DIST/alpha/latest.json" + else + echo '{}' > "$DIST/alpha/latest.json" + fi + + bun scripts/build-release-download-page.ts --latest-json "$DIST/stable/latest.json" --releases-json "$DIST/releases.json" --output-file "$DIST/download/index.html" + bun scripts/build-release-history-page.ts --releases-json "$DIST/releases.json" --output-file "$DIST/releases/index.html" + cp "$DIST/download/index.html" "$DIST/stable/download/index.html" + cp "$DIST/alpha/latest.json" "$DIST/latest.json" + cp "$DIST/alpha/latest.json" "$DIST/latest-canary.json" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: site/.vitepress/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-build-artifacts.yml b/.github/workflows/release-build-artifacts.yml new file mode 100644 index 0000000..a5ffb6a --- /dev/null +++ b/.github/workflows/release-build-artifacts.yml @@ -0,0 +1,619 @@ +name: Release build artifacts + +on: + workflow_call: + inputs: + version: + required: true + type: string + macos_bundles: + required: false + type: string + default: "" + upload_macos_dmg: + required: true + type: boolean + +env: + # Bump this when Tauri/Rust target artifacts capture stale absolute paths. + RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria + # The production Vite bundle can exceed Node's default ~2GB heap on + # macOS arm64 runners while Tauri runs beforeBuildCommand. + NODE_OPTIONS: --max-old-space-size=4096 + +jobs: + build: + name: Build (${{ matrix.arch }}) + runs-on: macos-15 + strategy: + fail-fast: true + matrix: + include: + - arch: aarch64 + target: aarch64-apple-darwin + - arch: x86_64 + target: x86_64-apple-darwin + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Setup Bun (required for bundle-qmd.sh) + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: latest + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: ${{ matrix.target }} + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Clear cached bundle artifacts + run: | + rm -rf src-tauri/target/${{ matrix.target }}/release/bundle + + - name: Set version + run: | + VERSION="${{ inputs.version }}" + jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml + + - name: Import Apple Developer certificate into keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERT_PATH="$RUNNER_TEMP/apple_cert.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db" + KEYCHAIN_PASSWORD="$(uuidgen)" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Validate telemetry env + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} + run: | + python3 <<'PY' + import os + import re + import sys + from urllib.parse import urlparse + + DISALLOWED_PLACEHOLDERS = { + "", + "-", + "_", + "false", + "true", + "null", + "undefined", + "none", + "disabled", + } + + def normalize(name: str) -> str: + value = os.getenv(name, "").strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1].strip() + return value + + def normalize_http_like(value: str) -> str: + if "://" in value: + return value + return f"https://{value}" + + def normalize_hostname(hostname: str) -> str: + normalized = hostname.strip().rstrip('.').lower() + if normalized.startswith('[') and normalized.endswith(']'): + normalized = normalized[1:-1] + return normalized + + def is_ip_address(hostname: str) -> bool: + if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname): + return all(0 <= int(part) <= 255 for part in hostname.split('.')) + return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None + + def is_allowed_hostname(hostname: str) -> bool: + normalized = normalize_hostname(hostname) + if not normalized or normalized in DISALLOWED_PLACEHOLDERS: + return False + if normalized == 'localhost': + return True + return '.' in normalized or is_ip_address(normalized) + + def is_http_url(value: str) -> bool: + parsed = urlparse(normalize_http_like(value)) + return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "") + + values = { + name: normalize(name) + for name in ( + "VITE_SENTRY_DSN", + "SENTRY_DSN", + "VITE_POSTHOG_KEY", + "VITE_POSTHOG_HOST", + ) + } + errors = [] + + for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"): + value = values[name] + if value.lower() in DISALLOWED_PLACEHOLDERS: + errors.append(f"{name} must be set to a real value, not a placeholder") + elif not is_http_url(value): + errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host") + + if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS: + errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder") + + if errors: + print("Telemetry env validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + raise SystemExit(1) + + print("Telemetry env validation passed.") + PY + + - name: Build Tauri app (with signing + notarization) + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_RELEASE: ${{ inputs.version }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} + run: | + MACOS_BUNDLES="${{ inputs.macos_bundles }}" + if [ -n "$MACOS_BUNDLES" ]; then + pnpm tauri build --target ${{ matrix.target }} --bundles "$MACOS_BUNDLES" + else + pnpm tauri build --target ${{ matrix.target }} + fi + + - name: Upload .dmg + if: ${{ inputs.upload_macos_dmg }} + uses: actions/upload-artifact@v4 + with: + name: dmg-${{ matrix.arch }} + path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg + retention-days: 1 + + - name: Upload updater artifacts (.tar.gz + .sig) + uses: actions/upload-artifact@v4 + with: + name: updater-${{ matrix.arch }} + path: | + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig + retention-days: 1 + + build-linux: + name: Build (linux-x86_64) + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Tauri Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + fcitx5-frontend-gtk3 \ + libfuse2 \ + librsvg2-dev \ + curl \ + wget \ + patchelf \ + build-essential \ + file \ + cpio \ + rpm + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: x86_64-unknown-linux-gnu + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Clear cached bundle artifacts + run: | + rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle + + - name: Set version + run: | + VERSION="${{ inputs.version }}" + jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml + + - name: Build Tauri app (Linux bundles) + env: + APPIMAGE_EXTRACT_AND_RUN: 1 + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_RELEASE: ${{ inputs.version }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} + run: | + pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage + + - name: Validate Linux bundles + run: | + shopt -s nullglob + appimages=( + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + ) + installers=( + "${appimages[@]}" + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm + ) + signatures=( + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig + ) + if [ ${#appimages[@]} -eq 0 ]; then + echo "::error::Linux build produced no AppImage bundle." + exit 1 + fi + if [ ${#installers[@]} -eq 0 ]; then + echo "::error::Linux build produced no AppImage, deb or rpm bundle." + exit 1 + fi + if [ ${#signatures[@]} -eq 0 ]; then + echo "::error::Linux build produced no updater signature (.sig) artifact." + exit 1 + fi + + validate_desktop_categories() { + local package_path="$1" + local extract_dir="$2" + + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + + case "$package_path" in + *.deb) + dpkg-deb -x "$package_path" "$extract_dir" + ;; + *.rpm) + ( + cd "$extract_dir" + rpm2cpio "$GITHUB_WORKSPACE/$package_path" | cpio -id --quiet + ) + ;; + *) + echo "::error::Unsupported package format for desktop entry validation: $package_path" + exit 1 + ;; + esac + + mapfile -t desktop_files < <(find "$extract_dir/usr/share/applications" -type f -name "*.desktop" 2>/dev/null) + if [ ${#desktop_files[@]} -eq 0 ]; then + echo "::error::$package_path did not include a desktop entry under /usr/share/applications." + exit 1 + fi + + for desktop_file in "${desktop_files[@]}"; do + local categories + categories=$(grep "^Categories=" "$desktop_file" | cut -d= -f2- || true) + if [ -z "$categories" ]; then + echo "::error::$package_path has an empty Categories field in $(basename "$desktop_file")." + exit 1 + fi + if [[ "$categories" != *";" ]]; then + echo "::error::$package_path has a Categories field that is not semicolon-terminated: $categories" + exit 1 + fi + case ";$categories" in + *";Office;"*|*";Utility;"*) + ;; + *) + echo "::error::$package_path has an unexpected launcher category: $categories" + exit 1 + ;; + esac + done + } + + for deb in src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb; do + validate_desktop_categories "$deb" "$RUNNER_TEMP/tolaria-deb-desktop" + done + + for rpm_package in src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm; do + validate_desktop_categories "$rpm_package" "$RUNNER_TEMP/tolaria-rpm-desktop" + done + + - name: Upload Linux bundles + uses: actions/upload-artifact@v4 + with: + name: linux-x86_64-bundles + path: | + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz + src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig + if-no-files-found: error + retention-days: 1 + + build-windows: + name: Build (windows-x86_64) + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + targets: x86_64-pc-windows-msvc + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~\.cargo\registry + ~\.cargo\git + src-tauri\target + key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}- + + - name: Cache Tauri Windows tools + uses: actions/cache@v4 + with: + path: ~\AppData\Local\tauri + key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3 + + - name: Prefetch Tauri NSIS toolchain + shell: pwsh + run: ./.github/scripts/prefetch-tauri-nsis.ps1 + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Clear cached Windows bundle artifacts + shell: pwsh + run: | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle" + + - name: Set version + shell: pwsh + run: | + $version = "${{ inputs.version }}" + $tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json + $tauri.version = $version + $tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json" + (Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml" + + - name: Validate Windows release env + id: windows-signing + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }} + WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do + if [ -z "${!name}" ]; then + echo "::error::$name is required to build signed Windows updater artifacts." + exit 1 + fi + done + + has_certificate=false + has_password=false + if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE" ]; then + has_certificate=true + fi + if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then + has_password=true + fi + + if [ "$has_certificate" != "$has_password" ]; then + echo "::error::Windows Authenticode signing is partially configured. Set both certificate and password secrets, or remove both to build with Tauri updater signatures only." + exit 1 + fi + + if [ "$has_certificate" = "true" ]; then + echo "authenticode_available=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::Windows Authenticode certificate secrets are not configured. Building Windows artifacts without Authenticode signatures; Tauri updater signatures are still required." + echo "authenticode_available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare Windows Authenticode signing + if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }} + shell: pwsh + env: + WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }} + WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }} + WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }} + WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} + WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} + run: ./.github/scripts/configure-windows-authenticode.ps1 + + - name: Build Tauri app (Windows bundles) + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_RELEASE: ${{ inputs.version }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} + run: | + if ("${{ steps.windows-signing.outputs.authenticode_available }}" -eq "true") { + pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json + } else { + pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis + } + + - name: Validate Windows Authenticode signatures + if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }} + shell: pwsh + run: | + $expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT + if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) { + throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step." + } + $expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant() + $paths = @() + $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue + $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue + $paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue + $paths = @($paths | Sort-Object FullName -Unique) + if ($paths.Count -eq 0) { + throw "No Windows executable or installer artifacts found to verify." + } + foreach ($path in $paths) { + $signature = Get-AuthenticodeSignature -FilePath $path.FullName + if ($signature.Status -ne "Valid") { + throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)" + } + if ($null -eq $signature.SignerCertificate) { + throw "Missing signer certificate for $($path.FullName)." + } + $actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant() + if ($actualThumbprint -ne $expectedThumbprint) { + throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint" + } + Write-Host "Authenticode signature OK: $($path.FullName)" + } + + - name: Validate Windows bundles + shell: bash + run: | + shopt -s nullglob + installers=( + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + ) + signatures=( + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig + ) + if [ ${#installers[@]} -eq 0 ]; then + echo "::error::Windows build produced no installable NSIS or MSI bundle." + exit 1 + fi + for installer in "${installers[@]}"; do + if [[ "$(basename "$installer")" != *"${{ inputs.version }}"* ]]; then + echo "::error::Windows build produced an installer for a different version: $(basename "$installer")" + exit 1 + fi + done + if [ ${#signatures[@]} -eq 0 ]; then + echo "::error::Windows build produced no updater signature (.sig) artifact." + exit 1 + fi + + - name: Upload Windows bundles + uses: actions/upload-artifact@v4 + with: + name: windows-x86_64-bundles + path: | + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..bd843ca --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,300 @@ +name: Release (Stable) + +on: + push: + tags: + - 'stable-v*' + - 'v20*' + +concurrency: + group: release-stable-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ───────────────────────────────────────────────────────────── + # Phase 1: Compute the stable version string once + # ───────────────────────────────────────────────────────────── + version: + name: Compute stable version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + display_version: ${{ steps.ver.outputs.display_version }} + tag: ${{ steps.ver.outputs.tag }} + steps: + - id: ver + shell: bash + run: | + python3 <<'PY' > version.env + import os + import re + from datetime import date + + tag = os.environ["GITHUB_REF_NAME"] + legacy_match = re.fullmatch(r"stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})", tag) + date_match = re.fullmatch(r"v(\d{4})-(\d{2})-(\d{2})", tag) + + if date_match: + year, month, day = map(int, date_match.groups()) + date(year, month, day) + version = f"{year}.{month}.{day}" + display_version = tag + elif legacy_match: + year, month, day = map(int, legacy_match.groups()) + date(year, month, day) + version = f"{year}.{month}.{day}" + display_version = version + else: + raise SystemExit(f"Stable tags must use vYYYY-MM-DD or stable-vYYYY.M.D, got {tag}") + + print(f"version={version}") + print(f"display_version={display_version}") + print(f"tag={tag}") + PY + + cat version.env >> "$GITHUB_OUTPUT" + DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-) + echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY" + + # ------------------------------------------------------------- + # Phase 2: Build shared release artifacts + # ------------------------------------------------------------- + build-artifacts: + name: Build release artifacts + needs: version + uses: ./.github/workflows/release-build-artifacts.yml + with: + version: ${{ needs.version.outputs.version }} + macos_bundles: "" + upload_macos_dmg: true + secrets: inherit + + # ───────────────────────────────────────────────────────────── + # Phase 3: Publish GitHub Release + # ───────────────────────────────────────────────────────────── + release: + name: GitHub Release (stable) + needs: [version, build-artifacts] + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Normalize macOS release artifact names + run: | + normalize_macos_artifacts() { + local arch="$1" + local normalized_updater="$2" + local normalized_dmg="$3" + local updater_dir="updater-${arch}" + local updater_file + updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit) + if [ -z "$updater_file" ]; then + echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2 + return 1 + fi + + local sig_file="${updater_file}.sig" + if [ ! -f "$sig_file" ]; then + echo "::error::Missing macOS updater signature for ${updater_file}" >&2 + return 1 + fi + + local normalized_sig="${normalized_updater}.sig" + if [ "$updater_file" != "$normalized_updater" ]; then + mv "$updater_file" "$normalized_updater" + fi + if [ "$sig_file" != "$normalized_sig" ]; then + mv "$sig_file" "$normalized_sig" + fi + + local dmg_dir="dmg-${arch}" + local dmg_file + dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit) + if [ -z "$dmg_file" ]; then + echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2 + return 1 + fi + + if [ "$dmg_file" != "$normalized_dmg" ]; then + mv "$dmg_file" "$normalized_dmg" + fi + } + + normalize_macos_artifacts aarch64 \ + "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \ + "dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg" + normalize_macos_artifacts x86_64 \ + "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \ + "dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg" + + - name: Generate release notes + run: | + NOTES_FILE="release-notes/${{ needs.version.outputs.tag }}.md" + if [ -f "$NOTES_FILE" ]; then + cat "$NOTES_FILE" > release_notes.md + else + PREV_TAG=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags/v20* refs/tags/stable-v* | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "") + if [ -z "$PREV_TAG" ]; then + NOTES=$(git log --oneline --no-merges -20) + else + NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}") + fi + { + echo "## What's Changed" + echo "" + echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done + } > release_notes.md + fi + { + echo "" + echo "---" + echo "**Stable release — manually promoted from \`main\`**" + echo "" + echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**" + echo "" + echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*" + } >> release_notes.md + + - name: Build stable-latest.json + run: | + VERSION="${{ needs.version.outputs.version }}" + TAG="${{ needs.version.outputs.tag }}" + REPO="${GITHUB_REPOSITORY}" + REPO_NAME="${REPO#*/}" + PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/" + + find_required() { + local patterns=("$@") + for pattern in "${patterns[@]}"; do + set -- $pattern + if [ -e "$1" ]; then + printf '%s\n' "$1" + return 0 + fi + done + echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2 + return 1 + } + + ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig") + ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}" + ARM_SIG=$(cat "$ARM_SIG_FILE") + ARM_TARBALL=$(basename "$ARM_UPDATER_FILE") + ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")") + + INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig") + INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}" + INTEL_SIG=$(cat "$INTEL_SIG_FILE") + INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE") + INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")") + + LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig") + LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}" + LINUX_SIG=$(cat "$LINUX_SIG_FILE") + LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE") + LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")") + + WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig") + WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}" + WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE") + WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE") + WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")") + + cat > stable-latest.json << EOF + { + "version": "${VERSION}", + "notes": "Stable release. See ${PAGES_URL} for full release notes.", + "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "platforms": { + "darwin-aarch64": { + "signature": "${ARM_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}", + "dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}" + }, + "darwin-x86_64": { + "signature": "${INTEL_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}", + "dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_DMG}" + }, + "linux-x86_64": { + "signature": "${LINUX_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}" + }, + "windows-x86_64": { + "signature": "${WINDOWS_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}" + } + } + } + EOF + echo "stable-latest.json:"; cat stable-latest.json + + - name: Publish GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 + with: + tag_name: ${{ needs.version.outputs.tag }} + name: Tolaria ${{ needs.version.outputs.display_version }} + body_path: release_notes.md + draft: false + prerelease: false + files: | + dmg-aarch64/*.dmg + updater-aarch64/*.app.tar.gz + updater-aarch64/*.app.tar.gz.sig + dmg-x86_64/*.dmg + updater-x86_64/*.app.tar.gz + updater-x86_64/*.app.tar.gz.sig + linux-x86_64-bundles/*.deb + linux-x86_64-bundles/*.deb.sig + linux-x86_64-bundles/*.rpm + linux-x86_64-bundles/*.AppImage + linux-x86_64-bundles/*.AppImage.sig + linux-x86_64-bundles/*.AppImage.tar.gz + linux-x86_64-bundles/*.AppImage.tar.gz.sig + linux-x86_64-bundles/*/*.deb + linux-x86_64-bundles/*/*.deb.sig + linux-x86_64-bundles/*/*.rpm + linux-x86_64-bundles/*/*.AppImage + linux-x86_64-bundles/*/*.AppImage.sig + linux-x86_64-bundles/*/*.AppImage.tar.gz + linux-x86_64-bundles/*/*.AppImage.tar.gz.sig + windows-x86_64-bundles/*.exe + windows-x86_64-bundles/*.exe.sig + windows-x86_64-bundles/*.msi + windows-x86_64-bundles/*.msi.sig + windows-x86_64-bundles/*.zip + windows-x86_64-bundles/*.zip.sig + windows-x86_64-bundles/*/*.exe + windows-x86_64-bundles/*/*.exe.sig + windows-x86_64-bundles/*/*.msi + windows-x86_64-bundles/*/*.msi.sig + windows-x86_64-bundles/*/*.zip + windows-x86_64-bundles/*/*.zip.sig + stable-latest.json + + # ───────────────────────────────────────────────────────────── + # Phase 4: Trigger the main-branch GitHub Pages deployment + # ───────────────────────────────────────────────────────────── + pages: + name: Update docs and release pages + needs: [version, release] + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Dispatch docs deployment from main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run deploy-docs.yml --repo ${{ github.repository }} --ref main + echo "Triggered deploy-docs.yml on main after publishing ${{ needs.version.outputs.tag }}." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc911e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,410 @@ +name: Release (Alpha) + +on: + push: + branches: + - main + paths-ignore: + - ".husky/**" + - ".github/workflows/deploy-docs.yml" + - ".github/workflows/release.yml" + - "site/**" + +concurrency: + group: release-alpha-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ───────────────────────────────────────────────────────────── + # Phase 1: Compute the alpha version string once + # Alpha builds use calendar semver and stay newer than the latest stable tag. + # ───────────────────────────────────────────────────────────── + version: + name: Compute alpha version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + display_version: ${{ steps.ver.outputs.display_version }} + tag: ${{ steps.ver.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: ver + shell: bash + run: | + python3 <<'PY' > version.env + import re + import subprocess + from datetime import datetime, timedelta, timezone + + def lines(command: list[str]) -> list[str]: + output = subprocess.check_output(command, text=True).strip() + return [line for line in output.splitlines() if line] + + alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$") + + def parse_alpha_tag(tag: str) -> tuple[str, int] | None: + match = alpha_pattern.fullmatch(tag) + if not match: + return None + calendar_version, sequence = match.groups() + return calendar_version, int(sequence) + + def alpha_version(calendar_version: str, sequence: int) -> str: + return f"{calendar_version}-alpha.{sequence}" + + def alpha_tag(calendar_version: str, sequence: int) -> str: + return f"alpha-v{calendar_version}-alpha.{sequence:04d}" + + existing_tags = [ + tag for tag in lines(["git", "tag", "--points-at", "HEAD"]) + if tag.startswith("alpha-v") + ] + + if existing_tags: + tag = existing_tags[0] + parsed = parse_alpha_tag(tag) + version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v") + else: + today = datetime.now(timezone.utc).date() + stable_date = None + stable_patterns = ( + re.compile(r"^v(\d{4})-(\d{2})-(\d{2})$"), + re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$"), + ) + + stable_tags = lines([ + "git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", + "refs/tags/v20*", "refs/tags/stable-v*", + ]) + + for stable_tag in stable_tags: + match = next((pattern.fullmatch(stable_tag) for pattern in stable_patterns if pattern.fullmatch(stable_tag)), None) + if not match: + continue + + year, month, day = map(int, match.groups()) + try: + stable_date = datetime(year, month, day, tzinfo=timezone.utc).date() + except ValueError: + continue + break + + alpha_date = today if stable_date is None or today > stable_date else stable_date + timedelta(days=1) + calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}" + sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1 + + version = alpha_version(calendar_version, sequence) + tag = alpha_tag(calendar_version, sequence) + + display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version) + display_version = ( + f"Alpha {int(display_match.group(1))}.{int(display_match.group(2))}.{int(display_match.group(3))}.{int(display_match.group(4))}" + if display_match + else version + ) + + print(f"version={version}") + print(f"display_version={display_version}") + print(f"tag={tag}") + PY + + cat version.env >> "$GITHUB_OUTPUT" + VERSION=$(grep '^version=' version.env | cut -d= -f2-) + DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-) + echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY" + + # ------------------------------------------------------------- + # Phase 2: Build shared release artifacts + # ------------------------------------------------------------- + build-artifacts: + name: Build release artifacts + needs: version + uses: ./.github/workflows/release-build-artifacts.yml + with: + version: ${{ needs.version.outputs.version }} + macos_bundles: app + upload_macos_dmg: false + secrets: inherit + + # ───────────────────────────────────────────────────────────── + # Phase 3: Publish GitHub Release + # No lipo/re-signing — use the per-arch artifacts directly + # ───────────────────────────────────────────────────────────── + release: + name: GitHub Release (alpha) + needs: [version, build-artifacts] + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Normalize macOS updater artifact names + run: | + normalize_updater() { + local arch="$1" + local normalized_updater="$2" + local artifact_dir="updater-${arch}" + local updater_file + updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit) + if [ -z "$updater_file" ]; then + echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2 + return 1 + fi + + local sig_file="${updater_file}.sig" + if [ ! -f "$sig_file" ]; then + echo "::error::Missing macOS updater signature for ${updater_file}" >&2 + return 1 + fi + + local normalized_sig="${normalized_updater}.sig" + if [ "$updater_file" != "$normalized_updater" ]; then + mv "$updater_file" "$normalized_updater" + fi + if [ "$sig_file" != "$normalized_sig" ]; then + mv "$sig_file" "$normalized_sig" + fi + } + + normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" + normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" + + - name: Generate release notes + run: | + PREV_TAG=$(python3 <<'PY' + import re + import subprocess + + current_tag = '${{ needs.version.outputs.tag }}' + pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$') + + output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip() + tags = [line for line in output.splitlines() if line and line != current_tag] + + parsed_tags = [] + for tag in tags: + match = pattern.fullmatch(tag) + if not match: + continue + year, month, day, sequence = map(int, match.groups()) + parsed_tags.append(((year, month, day, sequence), tag)) + + print(max(parsed_tags)[1] if parsed_tags else '') + PY + ) + if [ -z "$PREV_TAG" ]; then + NOTES=$(git log --oneline --no-merges -20) + else + NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD") + fi + { + echo "## What's Changed (Alpha)" + echo "" + echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done + echo "" + echo "---" + echo "**Alpha build — updated on every push to \`main\`**" + echo "" + echo "**Includes macOS (Apple Silicon and Intel), Linux x64, and Windows x64 bundles**" + echo "" + echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*" + } > release_notes.md + + - name: Build alpha-latest.json + run: | + VERSION="${{ needs.version.outputs.version }}" + TAG="${{ needs.version.outputs.tag }}" + REPO="${GITHUB_REPOSITORY}" + REPO_NAME="${REPO#*/}" + PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/" + + find_required() { + for pattern in "$@"; do + set -- $pattern + if [ -e "$1" ]; then + printf '%s\n' "$1" + return 0 + fi + done + return 1 + } + + ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig") + ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}" + ARM_SIG=$(cat "$ARM_SIG_FILE") + ARM_UPDATER=$(basename "$ARM_UPDATER_FILE") + + INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig") + INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}" + INTEL_SIG=$(cat "$INTEL_SIG_FILE") + INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE") + + LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig") + LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}" + LINUX_SIG=$(cat "$LINUX_SIG_FILE") + LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE") + LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")") + + WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig") + WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}" + WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE") + WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE") + WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")") + + cat > alpha-latest.json << EOF + { + "version": "${VERSION}", + "notes": "Alpha build. See ${PAGES_URL} for full release notes.", + "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "platforms": { + "darwin-aarch64": { + "signature": "${ARM_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}" + }, + "darwin-x86_64": { + "signature": "${INTEL_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}" + }, + "linux-x86_64": { + "signature": "${LINUX_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}" + }, + "windows-x86_64": { + "signature": "${WINDOWS_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}", + "download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}" + } + } + } + EOF + echo "alpha-latest.json:"; cat alpha-latest.json + + - name: Publish GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 + with: + tag_name: ${{ needs.version.outputs.tag }} + name: Tolaria ${{ needs.version.outputs.display_version }} + body_path: release_notes.md + draft: false + prerelease: true + files: | + updater-aarch64/*.app.tar.gz + updater-aarch64/*.app.tar.gz.sig + updater-x86_64/*.app.tar.gz + updater-x86_64/*.app.tar.gz.sig + linux-x86_64-bundles/*.deb + linux-x86_64-bundles/*.deb.sig + linux-x86_64-bundles/*.rpm + linux-x86_64-bundles/*.AppImage + linux-x86_64-bundles/*.AppImage.sig + linux-x86_64-bundles/*.AppImage.tar.gz + linux-x86_64-bundles/*.AppImage.tar.gz.sig + linux-x86_64-bundles/*/*.deb + linux-x86_64-bundles/*/*.deb.sig + linux-x86_64-bundles/*/*.rpm + linux-x86_64-bundles/*/*.AppImage + linux-x86_64-bundles/*/*.AppImage.sig + linux-x86_64-bundles/*/*.AppImage.tar.gz + linux-x86_64-bundles/*/*.AppImage.tar.gz.sig + windows-x86_64-bundles/*.exe + windows-x86_64-bundles/*.exe.sig + windows-x86_64-bundles/*.msi + windows-x86_64-bundles/*.msi.sig + windows-x86_64-bundles/*.zip + windows-x86_64-bundles/*.zip.sig + windows-x86_64-bundles/*/*.exe + windows-x86_64-bundles/*/*.exe.sig + windows-x86_64-bundles/*/*.msi + windows-x86_64-bundles/*/*.msi.sig + windows-x86_64-bundles/*/*.zip + windows-x86_64-bundles/*/*.zip.sig + alpha-latest.json + + # ───────────────────────────────────────────────────────────── + # Phase 4: Update GitHub Pages with docs, release history, and download assets + # ───────────────────────────────────────────────────────────── + pages: + name: Update docs and release pages + needs: [version, release] + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: github-pages + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: latest + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build docs and release pages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VITEPRESS_BASE="/" pnpm docs:build + mkdir -p _site/alpha _site/stable _site/release-notes + cp -R site/.vitepress/dist/. _site/ + if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi + gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json + STABLE_TAG=$(gh release list --repo ${{ github.repository }} --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // ""') + + gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json + if [ -n "$STABLE_TAG" ]; then + gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json + else + echo '{}' > _site/stable/latest.json + fi + bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html + bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html + mkdir -p _site/download + cp _site/stable/download/index.html _site/download/index.html + + cp _site/alpha/latest.json _site/latest.json + cp _site/alpha/latest.json _site/latest-canary.json + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./_site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a89e22a --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +site/.vitepress/cache/ +site/.vitepress/dist/ +_site/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright +/test-results/ +/playwright-report/ + +# Coverage reports +/coverage/ + +# Demo vault and helper scripts +demo-vault/ +generated-fixtures/ +select_demo_notes*.py +final_selection.py + +# Claude Code task signals +.claude-done +.claude-blocked +src-tauri/target + +# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs) +src-tauri/resources/mcp-server/ + +# Python cache +__pycache__/ +*.py[cod] + +# Dev screenshots +screenshots/ + +# Stale planning docs (keep locally if needed, not in repo) +REDESIGN-PLAN.md +SF-SYMBOLS-MIGRATION.md +CODE-HEALTH-REPORT.md + +# Local home dir artifact from worktree ops +(HOME)/ + +# Runtime / process files +.claude-pid + +# Generated vault index files (qmd/search artifacts) +.laputa-index.json + +# Tauri signing keys (never commit private keys) +*.key +*.key.pub + +# Local environment variables (never commit) +.env +.env.local +.env.*.local + +# Local Codacy CLI runtime/config generated by the MCP server +.codacy/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..c3bf579 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,55 @@ +#!/bin/sh +# Pre-commit: fast local lint gate before commit. Full suite runs in pre-push/CI. +set -e + +ensure_node_tooling() { + if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then + return 0 + fi + + NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" --no-use + nvm use --silent node >/dev/null 2>&1 || true + fi + + if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then + echo "❌ node and pnpm must be available before committing" + echo " Install them or make sure your nvm setup is available to git hooks." + exit 1 + fi +} + +ensure_node_tooling + +echo "🔍 Pre-commit checks..." + +STAGED_FILES=$(git diff --cached --name-only) +APP_CHANGED=false + +for FILE in $STAGED_FILES; do + case "$FILE" in + .github/workflows/*|.husky/*|docs/*|*.md) + ;; + *) + APP_CHANGED=true + ;; + esac +done + +if [ "$APP_CHANGED" = false ]; then + echo " → app checks skipped (docs/workflow/hooks only)" + echo "✅ Pre-commit passed" + exit 0 +fi + +# Lint only when frontend source files are staged. Typecheck and test coverage +# run in the pre-push gate. +STAGED_LINTABLE=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx|js|jsx|mjs)$' || true) +if [ -n "$STAGED_LINTABLE" ]; then + echo " → lint..." + pnpm lint --quiet +fi + +echo "✅ Pre-commit passed" diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..1b917b0 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,338 @@ +#!/bin/sh +# Pre-push: full CI checks run locally before any push. +# This replaces remote CI for normal task pushes. +# DO NOT skip with --no-verify (Claude Code is configured to never do this). +# +# ── Optimizations (Feb 2026) ───────────────────────────────────────────── +# +# 1. --no-clean on cargo llvm-cov: reuses previous instrumented build +# artifacts for incremental compilation. Reduces Rust coverage from +# ~8 min (full recompile) to ~30-60s (incremental rebuild). +# +# 2. Change detection: skips Rust checks entirely when no files under +# src-tauri/ changed. Saves ~1-2 min on frontend-only pushes. +# +# 3. Merged redundant test runs: frontend coverage (step 2) already runs +# all tests, so the separate test step was removed. +# +# 4. Fast-fail ordering: within Rust checks, fast lints (fmt ~2s, clippy +# ~15s) run before slow coverage (~30-60s) for quicker feedback. +# +# 5. Force full coverage: set LAPUTA_FULL_COVERAGE=1 to run cargo llvm-cov +# without --no-clean (clean rebuild, accurate baseline). +# +# Expected times (warm cache, incremental): +# Frontend only: ~1 min +# Frontend+Rust: ~2-3 min +# Full coverage: ~9-10 min (with LAPUTA_FULL_COVERAGE=1) +# ───────────────────────────────────────────────────────────────────────── +set -e + +ensure_cargo_tooling() { + if command -v cargo >/dev/null 2>&1; then + return 0 + fi + + if [ -s "$HOME/.cargo/env" ]; then + # shellcheck disable=SC1091 + . "$HOME/.cargo/env" + fi + + if ! command -v cargo >/dev/null 2>&1; then + echo "❌ cargo must be available before pushing" + echo " Install Rust via https://rustup.rs or ensure ~/.cargo/bin is in PATH." + exit 1 + fi +} + +ensure_node_tooling() { + if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then + return 0 + fi + + NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" --no-use + nvm use --silent node >/dev/null 2>&1 || true + fi + + if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then + echo "❌ node and pnpm must be available before pushing" + echo " Install them or make sure your nvm setup is available to git hooks." + exit 1 + fi +} + +require_main_push() { + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "HEAD" ]; then + echo "❌ Pushes must happen from main or a detached HEAD that is pushed directly to main. Current branch: $CURRENT_BRANCH" + exit 1 + fi + + while IFS=' ' read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do + [ -z "$LOCAL_REF" ] && continue + + case "$LOCAL_REF:$REMOTE_REF" in + refs/heads/main:refs/heads/main) + ;; + HEAD:refs/heads/main) + ;; + refs/tags/*:refs/tags/*) + ;; + *) + echo "❌ Pushes must be main -> main only." + echo " Attempted: ${LOCAL_REF:-} -> ${REMOTE_REF:-}" + exit 1 + ;; + esac + done </dev/null || echo "") +RUST_CHANGED=true +APP_CHANGED=true +SITE_CHANGED=false + +if [ -n "$PUSH_TARGET" ]; then + CHANGED=$(git diff --name-only "$PUSH_TARGET"..HEAD) + APP_CHANGED=false + if ! echo "$CHANGED" | grep -qE '^(src-tauri/|Cargo)'; then + RUST_CHANGED=false + fi + for FILE in $CHANGED; do + case "$FILE" in + site/*) + SITE_CHANGED=true + ;; + .github/workflows/*|.husky/*|docs/*|*.md) + ;; + *) + APP_CHANGED=true + ;; + esac + done +fi + +if [ "$APP_CHANGED" = false ]; then + if [ "$SITE_CHANGED" = true ]; then + echo "" + echo "📚 Docs-only push detected; running docs build..." + pnpm docs:build + echo " ✅ Docs build OK" + else + echo "" + echo "⏭️ App checks skipped (docs/workflow/hooks only)" + fi + ELAPSED=$(($(date +%s) - START_TIME)) + echo "" + echo "✅ Pre-push passed in ${ELAPSED}s" + exit 0 +fi + +run_sidecar_automatic_checks() { + if [ "${LAPUTA_PREPUSH_LOCAL:-0}" = "1" ]; then + echo "☁️ Chunk sidecar checks disabled by LAPUTA_PREPUSH_LOCAL=1" + return 1 + fi + + if ! command -v bash >/dev/null 2>&1; then + echo "☁️ bash not found; falling back to local automatic checks" + return 1 + fi + + echo "" + echo "☁️ Running automatic checks on Chunk sidecar..." + if bash .chunk/run-sidecar-gates-local.sh "$RUST_CHANGED"; then + echo " ✅ Chunk sidecar automatic checks OK" + return 0 + else + SIDECAR_STATUS=$? + fi + + if [ "$SIDECAR_STATUS" -eq 86 ]; then + echo " ⚠️ Chunk sidecar unavailable; falling back to local automatic checks" + return 1 + fi + + echo " ❌ Chunk sidecar automatic checks FAILED" + exit "$SIDECAR_STATUS" +} + +if ! run_sidecar_automatic_checks; then + ensure_cargo_tooling + + # ── 0. Frontend lint ─────────────────────────────────────────────────── + echo "" + echo "🔎 [0/6] Frontend lint..." + pnpm lint + echo " ✅ Lint OK" + + # ── 1. TypeScript + Vite build ────────────────────────────────────────── + echo "" + echo "📦 [1/6] TypeScript + Vite build..." + pnpm build + echo " ✅ Build OK" + + # ── 2. Frontend coverage (≥70%) — includes all unit tests ─────────────── + echo "" + echo "📊 [2/6] Frontend tests + coverage (≥70%)..." + FRONTEND_COVERAGE_CONCURRENCY="${FRONTEND_COVERAGE_CONCURRENCY:-1}" \ + node scripts/run-vitest-coverage-shards.mjs --silent + echo " ✅ Frontend coverage OK" + + # ── 3. Rust lint (clippy + fmt) — fast, run before coverage ───────────── + echo "" + if [ "$RUST_CHANGED" = true ]; then + echo "🔧 [3/6] Clippy + rustfmt..." + cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings + cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check + echo " ✅ Rust lint OK" + else + echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)" + fi + + # ── 4. Rust coverage (≥85% lines) ────────────────────────────────────── + echo "" + if [ "$RUST_CHANGED" = true ]; then + LLVM_COV_FLAGS="--no-clean" + if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then + LLVM_COV_FLAGS="" + echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..." + else + echo "🦀 [4/6] Rust coverage (≥85%, incremental)..." + fi + # Unset GIT_DIR so git tests create isolated repos without inheriting hook context + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE + # shellcheck disable=SC2086 + cargo llvm-cov \ + --manifest-path src-tauri/Cargo.toml \ + $LLVM_COV_FLAGS \ + --ignore-filename-regex "lib\.rs|main\.rs|menu\.rs" \ + --fail-under-lines 85 \ + -- --test-threads=1 + echo " ✅ Rust coverage OK" + else + echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)" + fi + + # ── 5. Playwright core smoke lane (if any exist) ────────────────────── + echo "" + SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1) + if [ -n "$SMOKE_FILES" ]; then + echo "🎭 [5/6] Playwright core smoke tests..." + if ! pnpm playwright:smoke; then + echo " ❌ Core smoke tests FAILED" + exit 1 + fi + echo " ✅ Core smoke tests OK" + else + echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)" + fi +fi + +# ── 6. CodeScene code health gate (ratchet) ────────────────────────────── +# Thresholds live in .codescene-thresholds and only ever go UP (ratchet). +# If remote scores improved, the hook updates the file and stops so the new +# floor is committed with normal verified hooks before the next push. +# If the remote baseline is already below threshold, allow recovery pushes to +# land; otherwise the stale remote score would block the refactors required to +# restore the gate. +THRESHOLDS_FILE="$(git rev-parse --show-toplevel)/.codescene-thresholds" +HOTSPOT_MIN=9.45 +AVERAGE_MIN=9.29 +if [ -f "$THRESHOLDS_FILE" ]; then + HOTSPOT_MIN=$(grep HOTSPOT_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2) + AVERAGE_MIN=$(grep AVERAGE_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2) +fi + +echo "" +echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..." +if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then + echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping" +else + API_RESPONSE=$(curl -sf \ + -H "Authorization: Bearer $CODESCENE_PAT" \ + -H "Accept: application/json" \ + "https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}") + HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "") + AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "") + if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then + echo " ⚠️ Could not fetch remote scores — skipping (CI will enforce)" + else + echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_MIN)" + echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_MIN)" + PYTHON_STATUS=0 + python3 -c " +import sys + +hotspot = float('$HOTSPOT_SCORE') +average = float('$AVERAGE_SCORE') +hotspot_min = float('$HOTSPOT_MIN') +average_min = float('$AVERAGE_MIN') +failed = False + +if hotspot < hotspot_min: + print(f'WARN: Hotspot Code Health {hotspot:.2f} < {hotspot_min} — remote baseline is currently red') + failed = True +else: + print(f'OK: Hotspot {hotspot:.2f} >= {hotspot_min}') + +if average < average_min: + print(f'WARN: Average Code Health {average:.2f} < {average_min} — remote baseline is currently red') + failed = True +else: + print(f'OK: Average {average:.2f} >= {average_min}') + +if failed: + print(' ⚠️ Recovery mode: allowing this push so refactors can land and restore the gate on a later analysis.') + sys.exit(0) + +import math +thresholds_file = '$THRESHOLDS_FILE' +new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100) +new_average = max(average_min, math.floor(average * 100) / 100) +if new_hotspot > hotspot_min or new_average > average_min: + with open(thresholds_file, 'w') as f: + f.write(f'HOTSPOT_THRESHOLD={new_hotspot}\nAVERAGE_THRESHOLD={new_average}\n') + print(f' 📈 Ratchet updated: Hotspot {hotspot_min} → {new_hotspot}, Average {average_min} → {new_average}') + sys.exit(3) +" || PYTHON_STATUS=$? + if [ "$PYTHON_STATUS" -ne 0 ] && [ "$PYTHON_STATUS" -ne 3 ]; then + exit "$PYTHON_STATUS" + fi + if [ "$PYTHON_STATUS" -eq 3 ]; then + git add "$THRESHOLDS_FILE" + echo " ❌ Commit the updated .codescene-thresholds with a normal verified commit, then push again." + exit 1 + fi + fi +fi + +END_TIME=$(date +%s) +ELAPSED=$((END_TIME - START_TIME)) +MINUTES=$((ELAPSED / 60)) +SECONDS=$((ELAPSED % 60)) + +echo "" +echo "================================================" +echo "✅ All checks passed — pushing (${MINUTES}m ${SECONDS}s)" +echo "" diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..f79744f --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,10 @@ +tests/ +e2e/ +node_modules/ +dist/ +coverage/ +test-results/ +src-tauri/target/ +target/ +src-tauri/gen/apple/assets/mcp-server/index.js +src-tauri/gen/apple/assets/mcp-server/ws-bridge.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0181340 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,194 @@ +# AGENTS.md — Tolaria App + +## 1. Development Process + +### Start working on a task + +**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate. + +- Read task description and all comments fully +- For To Rework: the ❌ QA failed comment tells you exactly what to fix +- Check `docs/adr/` for relevant architecture decisions before structural choices +- Check `docs/ARCHITECTURE.md` and `docs/ABSTRACTIONS.md` for relevant structural information +- For UI tasks: study app visual language and components first. Prioritize reusing existing components, assets, and variables over recreating them. +- If working on a Todoist task, add a comment: `🚀 Starting work on this task. [Brief description of approach]` + +### Commits & pushes + +- Local work may happen on `main`, in detached HEAD worktrees, or in other temporary local states. The production path is still direct-to-main: final verified work is pushed to `origin/main`, with no PR branch flow. +- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:` +- Pre-commit is a lightweight lint gate only. Pre-push runs the full check suite (build + tests + coverage + core Playwright smoke + CodeScene), preferably on three Chunk sidecar lanes for automatic test/coverage work: frontend lint/build/coverage, Rust coverage, and Playwright smoke. The goal is lower wall-clock time than local hooks while keeping each heavy gate isolated; keep local Playwright mainly for authoring, focused reproduction, or sidecar outages. +- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify** + +### TDD (mandatory) + +Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes. + +**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows. + +### Localization (mandatory for UI copy) + +All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy: + +```bash +pnpm l10n:translate +``` + +Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact. + +### Product analytics (mandatory for meaningful features) + +New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures. + +When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment. + +### Code health (mandatory) + +Pre-push enforces **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Pre-commit is lint-only; CodeScene remains mandatory through the file-level review rules below and the pre-push ratchet gate. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`. + +**Release rule:** CodeScene is a before/after gate, not just a final score. Every task must record the starting CodeScene state before edits and the final state after edits. If touched code gets worse, refactor before committing. + +**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar. + +**CodeScene access order:** use CodeScene MCP tools if available. If MCP is unavailable, use the installed `cs` CLI for file-level review/delta work, and use the CodeScene API (`CODESCENE_PAT` + `CODESCENE_PROJECT_ID`) for project-wide Hotspot/Average threshold checks from `.codescene-thresholds`. + +**Before editing any existing code file:** capture its current file-level CodeScene score. After your edits, re-run the same file-level review and verify the score is higher. If the file already starts at `10.0`, it must remain `10.0`. + +**New files:** every new **scorable code file** must reach CodeScene score `10.0` before commit. If CodeScene reports `null` / "no scorable code" for a new file, it must still have zero CodeScene findings/warnings. + +**Before every commit:** run CodeScene file-level review on every touched or newly created code file and verify the rule above. **Boy Scout Rule:** every file you touch must leave with a higher score, unless it was already `10.0`, in which case it must stay `10.0`. + +**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions. + +### Security scan with Codacy (mandatory) + +Use Codacy as a security and static-analysis gate before a task is considered releasable. + +- Prefer the Codacy MCP inside Codex to inspect repository/file issues for every touched code file. +- If MCP is unavailable, use the local CLI wrapper, e.g. `.codacy/cli.sh analyze --format sarif`; choose the relevant tool when useful (`eslint`, `opengrep`, `trivy`, `lizard`). +- **Always fix Critical and High severity findings introduced by your change.** Do not move the task to In Review with new Critical/High Codacy issues. +- Review Medium findings. Fix them when they are real defects or security-sensitive; otherwise explain why they are acceptable in the completion comment. +- Never silence a Codacy rule just to pass the scan. Prefer small code changes that remove the finding. + +### Check suite (runs on every push) +```bash +pnpm lint && npx tsc --noEmit && pnpm test && pnpm test:coverage # frontend ≥70% +cargo test && cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85 +``` + +Coverage is a release gate, not a vanity metric: +- Frontend coverage must stay ≥70%. +- Rust line coverage must stay ≥85%. +- For bug fixes, add a regression test when practical. +- For new behavior, add targeted coverage close to the changed code; do not rely only on broad E2E coverage. + +### UI and native QA + +**Phase 1 — Playwright (only for core user flows):** + +Write Playwright test in `tests/smoke/.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. Prefer `.chunk/run-playwright-smoke.sh` on a Chunk sidecar for the curated smoke lane because local Playwright is expensive; keep `pnpm playwright:smoke` available for focused local reproduction. The curated smoke suite must stay under **5 minutes** when sharded on sidecars; use `pnpm playwright:regression` for the full Playwright pass. + +```bash +pnpm dev --port 5201 & +sleep 3 +BASE_URL="http://localhost:5201" npx playwright test tests/smoke/.spec.ts +``` + +**Phase 2 — Native app QA:** + +```bash +pnpm tauri dev & +sleep 10 +bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa +bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png +``` + +Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard. + +Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌). + +### Release-readiness checklist + +Before pushing or moving a task to In Review, verify the release gates and add a **completion comment** to the Todoist task. The comment must include: + +- What was implemented (a few lines covering logic and UX/UI). +- QA: what was tested and how (Playwright / native screenshot / osascript). +- Tests/coverage: commands run and final coverage result. +- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push; final scores must pass `.codescene-thresholds`. +- Coverage commands passed (`pnpm test:coverage` and `cargo llvm-cov ... --fail-under-lines 85`) or the change is docs-only. +- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings. +- Localization: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”. +- PostHog: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”. +- Refactoring: any files refactored to meet the CodeScene gate, or "none needed". +- ADRs: any new/updated ADRs, or "none". +- Docs: any updated docs (`ARCHITECTURE.md`, `ABSTRACTIONS.md`, etc.), or "none". +- Demo vault dirt checked: `git status --short -- demo-vault demo-vault-v2` is empty unless fixture changes are intentional. + +### ADRs & docs + +ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors. + +After any Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit. + +--- + +## 2. Product Rules + +### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`) + +Default to `demo-vault-v2/` for testing. + +- Treat `demo-vault/` and `demo-vault-v2/` as disposable QA fixtures unless the task explicitly changes demo content. +- If you create untracked notes, attachments, or other temporary files there for testing, delete them before the task is complete. +- If you modify tracked demo-vault files only to test or QA behavior, revert those edits before the final commit. +- Before declaring a task done, make sure `git status --short -- demo-vault demo-vault-v2` is empty unless demo fixture changes are part of the task. +- If a fresh run starts and the only local dirt is inside `demo-vault/` or `demo-vault-v2/`, clean those paths first and continue. That case is recoverable QA residue, not a blocker. + +### User vault (`~/Laputa/`) + +Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: +- **Never commit or push** any test notes to the remote vault +- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state. +- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote. + +### UI components — mandatory rules + +**Always use shadcn/ui components.** Never use raw HTML form elements (``, ``, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent: + +| Need | Use | +|---|---| +| Text input | `Input` from shadcn/ui | +| Dropdown/select | `Select` from shadcn/ui | +| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native ``) | +| Button | `Button` from shadcn/ui | +| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) | +| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel | +| Emoji picker | Reuse the emoji picker component already used for note/type icons | +| Color picker | Reuse the color swatch picker used for type customization | +| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui | +| Dialog/modal | `Dialog` from shadcn/ui | + +**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Tolaria — if it looks like a browser default, it's wrong. + +--- + +## 3. Reference + +### macOS / Tauri gotchas + +- `Option+N` → special chars on macOS. Use `e.code` or `Cmd+N` +- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")` +- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus +- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing + +### QA scripts + +```bash +bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria +bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png +bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s" +``` + +### Diagrams + +Prefer Mermaid (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6909a95 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +--- +type: Note +_organized: true +--- + +@AGENTS.md + +This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e8fc749 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to Tolaria + +Thanks for being here! Tolaria is still early, and every bug report, idea, and contribution genuinely helps shape the app. + +## 🗳️ Where to share what + +To keep things clean: + +- 🐛 Bugs → GitHub Issues +- 💡 Feature requests / ideas → Canny • + +If you have a feature idea, please check Canny first and upvote it if it already exists. + +## 📥 Pull requests are welcome + +PRs are very welcome. + +A few things to keep in mind before opening one: + +- Bug fixes are always great +- Small improvements are great too +- For bigger features, please check Canny first before building + - Try to avoid things that are already marked **in progress** + - Requests marked **planned** are usually great contribution targets +- Keep PRs small, focused, and easy to review +- Include a short explanation of the problem and your solution +- Follow the dev process described in Tolaria’s `AGENTS.md` (tests, code health, etc.) +- Avoid bundling unrelated refactors into the same PR + +If you want to contribute a feature, the best place to start is here: + +## 📋 What makes a good bug report + +If you open a bug report on GitHub, it really helps to include: + +- your Tolaria version +- your OS version +- steps to reproduce +- what you expected to happen +- what actually happened +- screenshots or screen recordings if useful + +The clearer the report, the easier it is for us to reproduce and fix it. + +## 🙏 Thank you + +Tolaria is getting better because people care enough to try it, report what’s broken, suggest what’s missing, and contribute improvements. + +That means a lot. Thanks for helping build it. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..e7433cc --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,8 @@ +--- +type: Note +_organized: true +--- + +@AGENTS.md + +This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..77c48a4 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# 🌊 光湖 + +> 基于 [Tolaria](https://github.com/refactoringhq/tolaria) 的独立开源分支。 +> 上游采用 AGPL-3.0-or-later,本项目继续遵守同一许可证并保留完整历史。 + +光湖由冰朔维护,现行源码仓库: +`https://guanghubingshuo.com/code/bingshuo/guanghu` + +本分支拥有独立的产品名称、应用标识与发布链,不会检查、下载或安装 +Tolaria 官方更新。在光湖自己的签名发布服务完成前,应用内更新保持关闭。 + +## 上游来源 + +Tolaria 提供了跨平台 Markdown 知识库的基础能力。光湖在此基础上继续开发 +第五域入口、编号导航、人格协作与本地知识系统。上游版权与贡献者署名保持不变。 + +--- + +## Tolaria 上游说明 + +Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases: + +* Operate second brains and personal knowledge +* Organize company docs as context for AI +* Store OpenClaw/assistants memory and procedures + +Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucaronin)). I have a massive workspace of 10,000+ notes, which are the result of my [Refactoring](https://refactoring.fm/) work + a ton of personal journaling and *second braining*. + +1776506856823-CleanShot_2026-04-18_at_12 06 57_2x + +## Sponsors + +Tolaria is supported by a small panel of tools that help keep the project healthy, tested, and ready for AI-assisted development. I use these tools every day. + + + + + + + + +
+ + + + Codacy + + + + + + + CodeScene + + + + + + + CircleCI + + + + + + + Unblocked + + +
+ +## Walkthroughs + +You can find some Loom walkthroughs below — they are short and to the point: +- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528) +- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571) +- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882) + +## Principles + +- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app. +- 🔌 **Git-first** — Every vault is a git repository. You get full version history, the ability to use any git remote, and zero dependency on Tolaria servers. +- 🛜 **Offline-first, zero lock-in** — No accounts, no subscriptions, no cloud dependencies. Your vault works completely offline and always will. If you stop using Tolaria, you lose nothing. +- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others. +- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria. +- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes. +- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out. +- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this. +- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem. + +## Installation + +### Homebrew + +Install via Homebrew on macOS: + +```batch +brew install --cask tolaria +``` + +### Download from releases + +Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install. + +## Getting started + +When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app. + +The public user docs live in [`site/`](site/) and are published to GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md). + +## Open source and local setup + +Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇 + +### Prerequisites + +- Node.js 20+ +- pnpm 8+ +- Rust stable +- macOS or Linux for development + +#### Linux system dependencies + +Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3: + +- Arch / Manjaro: + ```bash + sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \ + appmenu-gtk-module libappindicator-gtk3 librsvg + ``` +- Debian / Ubuntu (22.04+): + ```bash + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libsoup-3.0-dev patchelf + ``` +- Fedora 38+: + ```bash + sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \ + libappindicator-gtk3-devel librsvg2-devel + ``` + +The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow. + +### Quick start + +```bash +pnpm install +pnpm dev +``` + +Open `http://localhost:5173` for the browser-based mock mode, or run the native desktop app with: + +```bash +pnpm tauri dev +``` + +## Tech Docs + +- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow +- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models +- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase +- 📚 [ADRs](docs/adr) — Architecture Decision Records + +## Security + +If you believe you have found a security issue, please report it privately as described in [SECURITY.md](./SECURITY.md). + +## License + +Tolaria is licensed under AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project’s trademark policy. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1d956d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Policy + +Thanks for helping keep Tolaria safe. + +If you believe you have found a security vulnerability, **please do not open a public GitHub issue**. Report it privately instead. + +## Supported versions + +We currently support security fixes for: + +| Version | Supported | +| --- | --- | +| Latest stable release | ✅ | +| `main` branch | Best effort | +| Older releases / prereleases | ❌ | + +## Reporting a vulnerability + +Please use GitHub's private vulnerability reporting flow for this repository. + +Include as much of the following as you can: + +- a short description of the issue +- reproduction steps or a proof of concept +- affected version / commit, if known +- impact assessment +- any suggested mitigation + +If the issue involves sensitive user data, credentials, or a working exploit, keep the report private and do not post details publicly. + +## What to expect + +We will try to: + +- acknowledge receipt within a few business days +- reproduce and assess the report +- work on a fix or mitigation if the issue is valid +- coordinate public disclosure after users have had a reasonable chance to update + +## Disclosure guidelines + +Please give us a reasonable amount of time to investigate and ship a fix before publishing details. + +We appreciate responsible disclosure and good-faith research. + +## Out of scope + +The following are generally out of scope unless they demonstrate a real security impact: + +- missing best-practice headers or hardening with no practical exploit +- self-XSS or editor behavior that requires unrealistic user actions +- reports that only affect unsupported old builds +- purely theoretical issues with no plausible attack path + +If you are unsure whether something qualifies, please still report it privately. diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 0000000..670fac2 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,14 @@ +# Upstream provenance + +光湖是 Tolaria 的独立开源分支。 + +- 上游项目: https://github.com/refactoringhq/tolaria +- 上游许可证: AGPL-3.0-or-later +- 本次分叉的本地基线: `af6891b` +- 第一笔光湖入口提交: `9bd612c` +- 光湖独立身份与更新隔离提交: `8939145` +- 光湖仓库: https://guanghubingshuo.com/code/bingshuo/guanghu + +由于创建仓库时 GitHub 网络不可用,光湖远端首次发布采用完整源码快照。 +上游作者、许可证和来源不因快照发布而改变。网络恢复后可补充完整上游历史镜像。 + diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..62bb433 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "files": { + "includes": [ + "**", + "!src-tauri/gen/**", + "!target/**", + "!dist/**", + "!node_modules/**" + ] + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "overrides": [ + { + "includes": ["site/**/*.vue"], + "linter": { + "rules": { + "correctness": { + "noUnusedImports": "off", + "noUnusedVariables": "off", + "useHookAtTopLevel": "off" + } + } + } + } + ], + "linter": { + "rules": { + "correctness": { + "useQwikValidLexicalScope": "off" + } + } + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4fd0ad5 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "phosphor" +} diff --git a/demo-vault-v2/.fixture-manifest.json b/demo-vault-v2/.fixture-manifest.json new file mode 100644 index 0000000..b9a7686 --- /dev/null +++ b/demo-vault-v2/.fixture-manifest.json @@ -0,0 +1,61 @@ +{ + "name": "Tolaria QA fixture", + "purpose": "Curated local vault for native QA and developer flows. This is not the public Getting Started starter vault.", + "large_fixture": { + "generator": "python3 scripts/generate_demo_vault.py", + "default_output": "generated-fixtures/demo-vault-large" + }, + "scenarios": [ + { + "id": "exact-match-search", + "reason": "Quick Open should rank the exact title 'Writing' above prefix matches.", + "files": [ + "topic-writing.md", + "writing-for-clarity-vs-writing-for-credit.md", + "writing-weekly-rhythm.md" + ] + }, + { + "id": "relationship-rendering", + "reason": "Relationship keys should render in the inspector instead of as plain properties.", + "files": [ + "responsibility-sponsorships.md", + "measure-sponsorship-mrr.md", + "measure-close-rate.md", + "procedure-quarterly-sponsor-outreach.md", + "procedure-sponsor-onboarding.md", + "24q4-laputa-start.md", + "24q4.md", + "person-luca-rossi.md" + ] + }, + { + "id": "project-navigation", + "reason": "Projects, quarters, and a saved view give keyboard QA a compact but representative browsing path.", + "files": [ + "24q4.md", + "25q1.md", + "25q2.md", + "24q4-laputa-start.md", + "25q1-laputa-v1.md", + "25q2-laputa-v2.md", + "views/active-projects.yml" + ] + }, + { + "id": "attachment-rendering", + "reason": "A note with a real binary attachment keeps image/block QA anchored to the fixture.", + "files": [ + "laputa-qa-reference.md", + "attachments/laputa-reference.png" + ] + }, + { + "id": "rtl-mixed-direction", + "reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.", + "files": [ + "rtl-mixed-direction-qa.md" + ] + } + ] +} diff --git a/demo-vault-v2/.gitignore b/demo-vault-v2/.gitignore new file mode 100644 index 0000000..9ff2df4 --- /dev/null +++ b/demo-vault-v2/.gitignore @@ -0,0 +1,5 @@ +.git/ +.laputa/ +.laputa-index.json +.DS_Store + diff --git a/demo-vault-v2/24q4-laputa-start.md b/demo-vault-v2/24q4-laputa-start.md new file mode 100644 index 0000000..71479bc --- /dev/null +++ b/demo-vault-v2/24q4-laputa-start.md @@ -0,0 +1,17 @@ +--- +type: Project +aliases: + - "[[Start Laputa App Project]]" +belongs_to: "[[24q4]]" +owner: "[[person-luca-rossi]]" +status: Done +--- + +# Start Laputa App Project + +The original spike that proved Tolaria could read a markdown vault, render note metadata, and support keyboard-first navigation. + +- Set the initial four-panel layout. +- Proved the note list, editor, and inspector could coexist in one flow. +- Led directly into [[25q1-laputa-v1]]. + diff --git a/demo-vault-v2/24q4.md b/demo-vault-v2/24q4.md new file mode 100644 index 0000000..765b17e --- /dev/null +++ b/demo-vault-v2/24q4.md @@ -0,0 +1,16 @@ +--- +type: Quarter +aliases: + - "[[Q4 2024]]" +status: Done +has: + - "[[24q4-laputa-start]]" +--- + +# Q4 2024 + +The quarter where the Laputa prototype became real enough to replace sketches and notes. + +- Started [[24q4-laputa-start]] as the first working app spike. +- Captured the initial panel layout and editor decisions. + diff --git a/demo-vault-v2/25q1-laputa-v1.md b/demo-vault-v2/25q1-laputa-v1.md new file mode 100644 index 0000000..facbde0 --- /dev/null +++ b/demo-vault-v2/25q1-laputa-v1.md @@ -0,0 +1,17 @@ +--- +type: Project +aliases: + - "[[Laputa App V1]]" +belongs_to: "[[25q1]]" +owner: "[[person-luca-rossi]]" +status: Done +--- + +# Laputa App V1 + +The first usable release for daily browsing, quick open, and note-property editing. + +- Shipped the working command palette. +- Made the inspector practical for real frontmatter editing. +- Captured enough confidence to continue with [[25q2-laputa-v2]]. + diff --git a/demo-vault-v2/25q1.md b/demo-vault-v2/25q1.md new file mode 100644 index 0000000..a6ce6a7 --- /dev/null +++ b/demo-vault-v2/25q1.md @@ -0,0 +1,13 @@ +--- +type: Quarter +aliases: + - "[[Q1 2025]]" +status: Done +has: + - "[[25q1-laputa-v1]]" +--- + +# Q1 2025 + +The first period where Laputa was usable for daily navigation, quick open, and inspector flows. + diff --git a/demo-vault-v2/25q2-laputa-v2.md b/demo-vault-v2/25q2-laputa-v2.md new file mode 100644 index 0000000..2a40ac6 --- /dev/null +++ b/demo-vault-v2/25q2-laputa-v2.md @@ -0,0 +1,19 @@ +--- +type: Project +aliases: + - "[[Laputa App V2]]" +belongs_to: "[[25q2]]" +owner: "[[person-luca-rossi]]" +status: Active +related_to: + - "[[laputa-qa-reference]]" +--- + +# Laputa App V2 + +The active polish project used for current QA, especially richer editing, keyboard navigation, and attachment rendering. + +- Tightened the editor interaction model. +- Reduced friction in wikilink navigation. +- Uses [[laputa-qa-reference]] as a lightweight visual reference note. + diff --git a/demo-vault-v2/25q2.md b/demo-vault-v2/25q2.md new file mode 100644 index 0000000..08a42cc --- /dev/null +++ b/demo-vault-v2/25q2.md @@ -0,0 +1,13 @@ +--- +type: Quarter +aliases: + - "[[Q2 2025]]" +status: Active +has: + - "[[25q2-laputa-v2]]" +--- + +# Q2 2025 + +The polish cycle focused on richer editing, faster linking, and better keyboard QA. + diff --git a/demo-vault-v2/area-building.md b/demo-vault-v2/area-building.md new file mode 100644 index 0000000..1be6e50 --- /dev/null +++ b/demo-vault-v2/area-building.md @@ -0,0 +1,12 @@ +--- +type: Area +aliases: + - "[[Building]]" +has: + - "[[responsibility-sponsorships]]" +--- + +# Building + +The business-facing area used in QA to anchor a responsibility with linked procedures and metrics. + diff --git a/demo-vault-v2/attachments/laputa-reference.png b/demo-vault-v2/attachments/laputa-reference.png new file mode 100644 index 0000000..17a288d Binary files /dev/null and b/demo-vault-v2/attachments/laputa-reference.png differ diff --git a/demo-vault-v2/event-team-sync-2025-01-13.md b/demo-vault-v2/event-team-sync-2025-01-13.md new file mode 100644 index 0000000..716cbe0 --- /dev/null +++ b/demo-vault-v2/event-team-sync-2025-01-13.md @@ -0,0 +1,13 @@ +--- +type: Event +belongs_to: "[[25q1-laputa-v1]]" +related_to: + - "[[person-luca-rossi]]" + - "[[person-matteo-cellini]]" +date: 2025-01-13 +--- + +# Team sync — 2025-01-13 + +Short checkpoint on V1 priorities: stabilize quick open, tighten keyboard navigation, and keep the inspector fast. + diff --git a/demo-vault-v2/laputa-qa-reference.md b/demo-vault-v2/laputa-qa-reference.md new file mode 100644 index 0000000..5c0e482 --- /dev/null +++ b/demo-vault-v2/laputa-qa-reference.md @@ -0,0 +1,16 @@ +--- +type: Note +aliases: + - "[[Laputa QA Reference]]" +related_to: + - "[[25q2-laputa-v2]]" +--- + +# Laputa QA Reference + +This note anchors the fixture's binary attachment scenario and gives native QA a simple note with an embedded image. + +![Fixture reference image](attachments/laputa-reference.png) + +Use this note to confirm that the editor renders an attached asset without dragging in a huge demo corpus. + diff --git a/demo-vault-v2/measure-close-rate.md b/demo-vault-v2/measure-close-rate.md new file mode 100644 index 0000000..b04a404 --- /dev/null +++ b/demo-vault-v2/measure-close-rate.md @@ -0,0 +1,12 @@ +--- +type: Measure +aliases: + - "[[Sponsorship Close Rate]]" +belongs_to: "[[responsibility-sponsorships]]" +unit: percent +--- + +# Sponsorship Close Rate + +Tracks how many qualified sponsor conversations become signed deals. + diff --git a/demo-vault-v2/measure-sponsorship-mrr.md b/demo-vault-v2/measure-sponsorship-mrr.md new file mode 100644 index 0000000..539ed40 --- /dev/null +++ b/demo-vault-v2/measure-sponsorship-mrr.md @@ -0,0 +1,12 @@ +--- +type: Measure +aliases: + - "[[Sponsorship MRR]]" +belongs_to: "[[responsibility-sponsorships]]" +unit: EUR/month +--- + +# Sponsorship MRR + +Tracks monthly recurring sponsorship revenue so the responsibility note has a real linked metric. + diff --git a/demo-vault-v2/note-on-clear-prose.md b/demo-vault-v2/note-on-clear-prose.md new file mode 100644 index 0000000..38791b1 --- /dev/null +++ b/demo-vault-v2/note-on-clear-prose.md @@ -0,0 +1,9 @@ +--- +type: Note +topics: + - "[[topic-writing]]" +--- + +# On Clear Prose + +Reference note for a book worth reopening whenever prose starts to feel bloated. diff --git a/demo-vault-v2/person-luca-rossi.md b/demo-vault-v2/person-luca-rossi.md new file mode 100644 index 0000000..52f7f59 --- /dev/null +++ b/demo-vault-v2/person-luca-rossi.md @@ -0,0 +1,11 @@ +--- +type: Person +aliases: + - "[[Luca Rossi]]" +tier: 1st +--- + +# Luca Rossi + +Owns the Laputa product work and remains the primary owner on the fixture's project notes. + diff --git a/demo-vault-v2/person-matteo-cellini.md b/demo-vault-v2/person-matteo-cellini.md new file mode 100644 index 0000000..e8cd716 --- /dev/null +++ b/demo-vault-v2/person-matteo-cellini.md @@ -0,0 +1,11 @@ +--- +type: Person +aliases: + - "[[Matteo Cellini]]" +tier: 1st +--- + +# Matteo Cellini + +Owns sponsor outreach and makes the responsibility/procedure relationships feel like real working notes. + diff --git a/demo-vault-v2/procedure-quarterly-sponsor-outreach.md b/demo-vault-v2/procedure-quarterly-sponsor-outreach.md new file mode 100644 index 0000000..7d612e8 --- /dev/null +++ b/demo-vault-v2/procedure-quarterly-sponsor-outreach.md @@ -0,0 +1,16 @@ +--- +type: Procedure +aliases: + - "[[Quarterly Sponsor Outreach]]" +belongs_to: "[[responsibility-sponsorships]]" +owner: "[[person-matteo-cellini]]" +cadence: Quarterly +--- + +# Quarterly Sponsor Outreach + +Review the pipeline, choose the next target companies, and send a fresh outreach batch each quarter. + +- Start from last quarter's warm leads. +- Share the shortlist with [[person-matteo-cellini]] before sending outreach. + diff --git a/demo-vault-v2/procedure-sponsor-onboarding.md b/demo-vault-v2/procedure-sponsor-onboarding.md new file mode 100644 index 0000000..2fa9f78 --- /dev/null +++ b/demo-vault-v2/procedure-sponsor-onboarding.md @@ -0,0 +1,17 @@ +--- +type: Procedure +aliases: + - "[[Sponsor Onboarding]]" +belongs_to: "[[responsibility-sponsorships]]" +owner: "[[person-luca-rossi]]" +cadence: "As needed" +--- + +# Sponsor Onboarding + +Turn a signed sponsor into a smooth first placement with minimal back-and-forth. + +- Confirm the publication date. +- Review copy and assets. +- Hand off recurring communication to [[person-matteo-cellini]]. + diff --git a/demo-vault-v2/refactoring-business-plan.md b/demo-vault-v2/refactoring-business-plan.md new file mode 100644 index 0000000..12ef38e --- /dev/null +++ b/demo-vault-v2/refactoring-business-plan.md @@ -0,0 +1,140 @@ +--- +type: Note +_display: sheet +tags: + - spreadsheet + - business-plan +_sheet: + frozen_rows: 14 + columns: + A: + width: 276.63636363636374 + B: + width: 185 + C: + width: 132 + D: + width: 132 + E: + width: 112 + F: + width: 132 + G: + width: 132 + H: + width: 132 + I: + width: 132 + J: + width: 128 + K: + width: 132 + L: + width: 120 + M: + width: 96 + N: + width: 120 + O: + width: 108 + P: + width: 124 + Q: + width: 124 + R: + width: 132 + S: + width: 360 + cells: + A1: + bold: true + italic: true + A14: + bold: true + B14: + bold: true + C14: + bold: true + D14: + bold: true + E14: + bold: true + F14: + bold: true + G14: + bold: true + H14: + bold: true + I14: + bold: true + J14: + bold: true + K14: + bold: true + L14: + bold: true + M14: + bold: true + N14: + bold: true + O14: + bold: true + P14: + bold: true + Q14: + bold: true + R14: + bold: true + S14: + bold: true +--- +Refactoring business plan,3-year monthly operating model,,,,,,,,,,,,,,,,, +Start free newsletter subscribers,2500,,,,,,,,,,,,,,,,, +Monthly free subscriber growth rate,0.07,,,,,,,,,,,,,,,,, +Monthly free subscriber churn,0.012,,,,,,,,,,,,,,,,, +Paid conversion rate on incremental audience,0.035,,,,,,,,,,,,,,,,, +Paid subscription price,12,,,,,,,,,,,,,,,,, +Paid subscriber monthly churn,0.035,,,,,,,,,,,,,,,,, +Sponsorship CPM,55,,,,,,,,,,,,,,,,, +Sponsorship slots per month,2,,,,,,,,,,,,,,,,, +Average open rate,0.46,,,,,,,,,,,,,,,,, +Consulting project price,6000,,,,,,,,,,,,,,,,, +Consulting projects per month at maturity,2,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,, +Month #,Month,Free subscribers,Paid subscribers,Paid conversion,Newsletter revenue,Sponsor impressions,Sponsorship revenue,Consulting revenue,Digital products,Total revenue,Content/Ops,Tools,Contractors,Marketing,Total expenses,Net profit,Cumulative cash,Key hypothesis +1,Jun 2026,=$B$2,=C15*$B$5,=D15/C15,=D15*$B$6,=C15*$B$10,=G15/1000*$B$8*$B$9,=1/36*$B$12*$B$11,=1*50,=F15+H15+I15+J15,=1500+A15*50,=350+A15*10,=0,=C15*0.12,=SUM(L15:O15),=K15-P15,=Q15,Validate positioning and grow high-signal audience +2,Jul 2026,=C15*(1+$B$3-$B$4),=D15*(1-$B$7)+(C16-C15)*$B$5,=D16/C16,=D16*$B$6,=C16*$B$10,=G16/1000*$B$8*$B$9,=2/36*$B$12*$B$11,=2*50,=F16+H16+I16+J16,=1500+A16*50,=350+A16*10,=0,=C16*0.12,=SUM(L16:O16),=K16-P16,=R15+Q16,Validate positioning and grow high-signal audience +3,Aug 2026,=C16*(1+$B$3-$B$4),=D16*(1-$B$7)+(C17-C16)*$B$5,=D17/C17,=D17*$B$6,=C17*$B$10,=G17/1000*$B$8*$B$9,=3/36*$B$12*$B$11,=3*50,=F17+H17+I17+J17,=1500+A17*50,=350+A17*10,=0,=C17*0.12,=SUM(L17:O17),=K17-P17,=R16+Q17,Validate positioning and grow high-signal audience +4,Sep 2026,=C17*(1+$B$3-$B$4),=D17*(1-$B$7)+(C18-C17)*$B$5,=D18/C18,=D18*$B$6,=C18*$B$10,=G18/1000*$B$8*$B$9,=4/36*$B$12*$B$11,=4*50,=F18+H18+I18+J18,=1500+A18*50,=350+A18*10,=0,=C18*0.12,=SUM(L18:O18),=K18-P18,=R17+Q18,Validate positioning and grow high-signal audience +5,Oct 2026,=C18*(1+$B$3-$B$4),=D18*(1-$B$7)+(C19-C18)*$B$5,=D19/C19,=D19*$B$6,=C19*$B$10,=G19/1000*$B$8*$B$9,=5/36*$B$12*$B$11,=5*50,=F19+H19+I19+J19,=1500+A19*50,=350+A19*10,=0,=C19*0.12,=SUM(L19:O19),=K19-P19,=R18+Q19,Validate positioning and grow high-signal audience +6,Nov 2026,=C19*(1+$B$3-$B$4),=D19*(1-$B$7)+(C20-C19)*$B$5,=D20/C20,=D20*$B$6,=C20*$B$10,=G20/1000*$B$8*$B$9,=6/36*$B$12*$B$11,=6*50,=F20+H20+I20+J20,=1500+A20*50,=350+A20*10,=0,=C20*0.12,=SUM(L20:O20),=K20-P20,=R19+Q20,Validate positioning and grow high-signal audience +7,Dec 2026,=C20*(1+$B$3-$B$4),=D20*(1-$B$7)+(C21-C20)*$B$5,=D21/C21,=D21*$B$6,=C21*$B$10,=G21/1000*$B$8*$B$9,=7/36*$B$12*$B$11,=7*50,=F21+H21+I21+J21,=1500+A21*50,=350+A21*10,=2000+A21*125,=C21*0.12,=SUM(L21:O21),=K21-P21,=R20+Q21,Package repeatable advisory offers and first sponsors +8,Jan 2027,=C21*(1+$B$3-$B$4),=D21*(1-$B$7)+(C22-C21)*$B$5,=D22/C22,=D22*$B$6,=C22*$B$10,=G22/1000*$B$8*$B$9,=8/36*$B$12*$B$11,=8*50,=F22+H22+I22+J22,=1500+A22*50,=350+A22*10,=2000+A22*125,=C22*0.12,=SUM(L22:O22),=K22-P22,=R21+Q22,Package repeatable advisory offers and first sponsors +9,Feb 2027,=C22*(1+$B$3-$B$4),=D22*(1-$B$7)+(C23-C22)*$B$5,=D23/C23,=D23*$B$6,=C23*$B$10,=G23/1000*$B$8*$B$9,=9/36*$B$12*$B$11,=9*50,=F23+H23+I23+J23,=1500+A23*50,=350+A23*10,=2000+A23*125,=C23*0.12,=SUM(L23:O23),=K23-P23,=R22+Q23,Package repeatable advisory offers and first sponsors +10,Mar 2027,=C23*(1+$B$3-$B$4),=D23*(1-$B$7)+(C24-C23)*$B$5,=D24/C24,=D24*$B$6,=C24*$B$10,=G24/1000*$B$8*$B$9,=10/36*$B$12*$B$11,=450+(10-9)*175,=F24+H24+I24+J24,=1500+A24*50,=350+A24*10,=2000+A24*125,=C24*0.12,=SUM(L24:O24),=K24-P24,=R23+Q24,Package repeatable advisory offers and first sponsors +11,Apr 2027,=C24*(1+$B$3-$B$4),=D24*(1-$B$7)+(C25-C24)*$B$5,=D25/C25,=D25*$B$6,=C25*$B$10,=G25/1000*$B$8*$B$9,=11/36*$B$12*$B$11,=450+(11-9)*175,=F25+H25+I25+J25,=1500+A25*50,=350+A25*10,=2000+A25*125,=C25*0.12,=SUM(L25:O25),=K25-P25,=R24+Q25,Package repeatable advisory offers and first sponsors +12,May 2027,=C25*(1+$B$3-$B$4),=D25*(1-$B$7)+(C26-C25)*$B$5,=D26/C26,=D26*$B$6,=C26*$B$10,=G26/1000*$B$8*$B$9,=12/36*$B$12*$B$11,=450+(12-9)*175,=F26+H26+I26+J26,=1500+A26*50,=350+A26*10,=2000+A26*125,=C26*0.12,=SUM(L26:O26),=K26-P26,=R25+Q26,Package repeatable advisory offers and first sponsors +13,Jun 2027,=C26*(1+$B$3-$B$4),=D26*(1-$B$7)+(C27-C26)*$B$5,=D27/C27,=D27*$B$6,=C27*$B$10,=G27/1000*$B$8*$B$9,=13/36*$B$12*$B$11,=450+(13-9)*175,=F27+H27+I27+J27,=1500+A27*50,=350+A27*10,=2000+A27*125,=C27*0.12,=SUM(L27:O27),=K27-P27,=R26+Q27,Scale paid membership and recurring sponsorship inventory +14,Jul 2027,=C27*(1+$B$3-$B$4),=D27*(1-$B$7)+(C28-C27)*$B$5,=D28/C28,=D28*$B$6,=C28*$B$10,=G28/1000*$B$8*$B$9,=14/36*$B$12*$B$11,=450+(14-9)*175,=F28+H28+I28+J28,=1500+A28*50,=350+A28*10,=2000+A28*125,=C28*0.12,=SUM(L28:O28),=K28-P28,=R27+Q28,Scale paid membership and recurring sponsorship inventory +15,Aug 2027,=C28*(1+$B$3-$B$4),=D28*(1-$B$7)+(C29-C28)*$B$5,=D29/C29,=D29*$B$6,=C29*$B$10,=G29/1000*$B$8*$B$9,=15/36*$B$12*$B$11,=450+(15-9)*175,=F29+H29+I29+J29,=1500+A29*50,=350+A29*10,=2000+A29*125,=C29*0.12,=SUM(L29:O29),=K29-P29,=R28+Q29,Scale paid membership and recurring sponsorship inventory +16,Sep 2027,=C29*(1+$B$3-$B$4),=D29*(1-$B$7)+(C30-C29)*$B$5,=D30/C30,=D30*$B$6,=C30*$B$10,=G30/1000*$B$8*$B$9,=16/36*$B$12*$B$11,=450+(16-9)*175,=F30+H30+I30+J30,=1500+A30*50,=350+A30*10,=2000+A30*125,=C30*0.12,=SUM(L30:O30),=K30-P30,=R29+Q30,Scale paid membership and recurring sponsorship inventory +17,Oct 2027,=C30*(1+$B$3-$B$4),=D30*(1-$B$7)+(C31-C30)*$B$5,=D31/C31,=D31*$B$6,=C31*$B$10,=G31/1000*$B$8*$B$9,=17/36*$B$12*$B$11,=450+(17-9)*175,=F31+H31+I31+J31,=1500+A31*50,=350+A31*10,=2000+A31*125,=C31*0.12,=SUM(L31:O31),=K31-P31,=R30+Q31,Scale paid membership and recurring sponsorship inventory +18,Nov 2027,=C31*(1+$B$3-$B$4),=D31*(1-$B$7)+(C32-C31)*$B$5,=D32/C32,=D32*$B$6,=C32*$B$10,=G32/1000*$B$8*$B$9,=18/36*$B$12*$B$11,=450+(18-9)*175,=F32+H32+I32+J32,=1500+A32*50,=350+A32*10,=2000+A32*125,=C32*0.12,=SUM(L32:O32),=K32-P32,=R31+Q32,Scale paid membership and recurring sponsorship inventory +19,Dec 2027,=C32*(1+$B$3-$B$4),=D32*(1-$B$7)+(C33-C32)*$B$5,=D33/C33,=D33*$B$6,=C33*$B$10,=G33/1000*$B$8*$B$9,=19/36*$B$12*$B$11,=450+(19-9)*175,=F33+H33+I33+J33,=1500+A33*50,=350+A33*10,=2000+A33*125,=C33*0.12,=SUM(L33:O33),=K33-P33,=R32+Q33,Scale paid membership and recurring sponsorship inventory +20,Jan 2028,=C33*(1+$B$3-$B$4),=D33*(1-$B$7)+(C34-C33)*$B$5,=D34/C34,=D34*$B$6,=C34*$B$10,=G34/1000*$B$8*$B$9,=20/36*$B$12*$B$11,=450+(20-9)*175,=F34+H34+I34+J34,=1500+A34*50,=350+A34*10,=2000+A34*125,=C34*0.12,=SUM(L34:O34),=K34-P34,=R33+Q34,Scale paid membership and recurring sponsorship inventory +21,Feb 2028,=C34*(1+$B$3-$B$4),=D34*(1-$B$7)+(C35-C34)*$B$5,=D35/C35,=D35*$B$6,=C35*$B$10,=G35/1000*$B$8*$B$9,=21/36*$B$12*$B$11,=450+(21-9)*175,=F35+H35+I35+J35,=1500+A35*50,=350+A35*10,=2000+A35*125,=C35*0.12,=SUM(L35:O35),=K35-P35,=R34+Q35,Scale paid membership and recurring sponsorship inventory +22,Mar 2028,=C35*(1+$B$3-$B$4),=D35*(1-$B$7)+(C36-C35)*$B$5,=D36/C36,=D36*$B$6,=C36*$B$10,=G36/1000*$B$8*$B$9,=22/36*$B$12*$B$11,=450+(22-9)*175,=F36+H36+I36+J36,=1500+A36*50,=350+A36*10,=2000+A36*125,=C36*0.12,=SUM(L36:O36),=K36-P36,=R35+Q36,Scale paid membership and recurring sponsorship inventory +23,Apr 2028,=C36*(1+$B$3-$B$4),=D36*(1-$B$7)+(C37-C36)*$B$5,=D37/C37,=D37*$B$6,=C37*$B$10,=G37/1000*$B$8*$B$9,=23/36*$B$12*$B$11,=450+(23-9)*175,=F37+H37+I37+J37,=1500+A37*50,=350+A37*10,=2000+A37*125,=C37*0.12,=SUM(L37:O37),=K37-P37,=R36+Q37,Scale paid membership and recurring sponsorship inventory +24,May 2028,=C37*(1+$B$3-$B$4),=D37*(1-$B$7)+(C38-C37)*$B$5,=D38/C38,=D38*$B$6,=C38*$B$10,=G38/1000*$B$8*$B$9,=24/36*$B$12*$B$11,=450+(24-9)*175,=F38+H38+I38+J38,=1500+A38*50,=350+A38*10,=2000+A38*125,=C38*0.12,=SUM(L38:O38),=K38-P38,=R37+Q38,Scale paid membership and recurring sponsorship inventory +25,Jun 2028,=C38*(1+$B$3-$B$4),=D38*(1-$B$7)+(C39-C38)*$B$5,=D39/C39,=D39*$B$6,=C39*$B$10,=G39/1000*$B$8*$B$9,=25/36*$B$12*$B$11,=450+(25-9)*175,=F39+H39+I39+J39,=1500+A39*50,=350+A39*10,=2000+A39*125,=C39*0.12,=SUM(L39:O39),=K39-P39,=R38+Q39,Expand products and reduce founder-delivery dependency +26,Jul 2028,=C39*(1+$B$3-$B$4),=D39*(1-$B$7)+(C40-C39)*$B$5,=D40/C40,=D40*$B$6,=C40*$B$10,=G40/1000*$B$8*$B$9,=26/36*$B$12*$B$11,=450+(26-9)*175,=F40+H40+I40+J40,=1500+A40*50,=350+A40*10,=2000+A40*125,=C40*0.12,=SUM(L40:O40),=K40-P40,=R39+Q40,Expand products and reduce founder-delivery dependency +27,Aug 2028,=C40*(1+$B$3-$B$4),=D40*(1-$B$7)+(C41-C40)*$B$5,=D41/C41,=D41*$B$6,=C41*$B$10,=G41/1000*$B$8*$B$9,=27/36*$B$12*$B$11,=450+(27-9)*175,=F41+H41+I41+J41,=1500+A41*50,=350+A41*10,=2000+A41*125,=C41*0.12,=SUM(L41:O41),=K41-P41,=R40+Q41,Expand products and reduce founder-delivery dependency +28,Sep 2028,=C41*(1+$B$3-$B$4),=D41*(1-$B$7)+(C42-C41)*$B$5,=D42/C42,=D42*$B$6,=C42*$B$10,=G42/1000*$B$8*$B$9,=28/36*$B$12*$B$11,=450+(28-9)*175,=F42+H42+I42+J42,=1500+A42*50,=350+A42*10,=2000+A42*125,=C42*0.12,=SUM(L42:O42),=K42-P42,=R41+Q42,Expand products and reduce founder-delivery dependency +29,Oct 2028,=C42*(1+$B$3-$B$4),=D42*(1-$B$7)+(C43-C42)*$B$5,=D43/C43,=D43*$B$6,=C43*$B$10,=G43/1000*$B$8*$B$9,=29/36*$B$12*$B$11,=450+(29-9)*175,=F43+H43+I43+J43,=1500+A43*50,=350+A43*10,=2000+A43*125,=C43*0.12,=SUM(L43:O43),=K43-P43,=R42+Q43,Expand products and reduce founder-delivery dependency +30,Nov 2028,=C43*(1+$B$3-$B$4),=D43*(1-$B$7)+(C44-C43)*$B$5,=D44/C44,=D44*$B$6,=C44*$B$10,=G44/1000*$B$8*$B$9,=30/36*$B$12*$B$11,=450+(30-9)*175,=F44+H44+I44+J44,=1500+A44*50,=350+A44*10,=2000+A44*125,=C44*0.12,=SUM(L44:O44),=K44-P44,=R43+Q44,Expand products and reduce founder-delivery dependency +31,Dec 2028,=C44*(1+$B$3-$B$4),=D44*(1-$B$7)+(C45-C44)*$B$5,=D45/C45,=D45*$B$6,=C45*$B$10,=G45/1000*$B$8*$B$9,=31/36*$B$12*$B$11,=450+(31-9)*175,=F45+H45+I45+J45,=1500+A45*50,=350+A45*10,=2000+A45*125,=C45*0.12,=SUM(L45:O45),=K45-P45,=R44+Q45,Expand products and reduce founder-delivery dependency +32,Jan 2029,=C45*(1+$B$3-$B$4),=D45*(1-$B$7)+(C46-C45)*$B$5,=D46/C46,=D46*$B$6,=C46*$B$10,=G46/1000*$B$8*$B$9,=32/36*$B$12*$B$11,=450+(32-9)*175,=F46+H46+I46+J46,=1500+A46*50,=350+A46*10,=2000+A46*125,=C46*0.12,=SUM(L46:O46),=K46-P46,=R45+Q46,Expand products and reduce founder-delivery dependency +33,Feb 2029,=C46*(1+$B$3-$B$4),=D46*(1-$B$7)+(C47-C46)*$B$5,=D47/C47,=D47*$B$6,=C47*$B$10,=G47/1000*$B$8*$B$9,=33/36*$B$12*$B$11,=450+(33-9)*175,=F47+H47+I47+J47,=1500+A47*50,=350+A47*10,=2000+A47*125,=C47*0.12,=SUM(L47:O47),=K47-P47,=R46+Q47,Expand products and reduce founder-delivery dependency +34,Mar 2029,=C47*(1+$B$3-$B$4),=D47*(1-$B$7)+(C48-C47)*$B$5,=D48/C48,=D48*$B$6,=C48*$B$10,=G48/1000*$B$8*$B$9,=34/36*$B$12*$B$11,=450+(34-9)*175,=F48+H48+I48+J48,=1500+A48*50,=350+A48*10,=2000+A48*125,=C48*0.12,=SUM(L48:O48),=K48-P48,=R47+Q48,Expand products and reduce founder-delivery dependency +35,Apr 2029,=C48*(1+$B$3-$B$4),=D48*(1-$B$7)+(C49-C48)*$B$5,=D49/C49,=D49*$B$6,=C49*$B$10,=G49/1000*$B$8*$B$9,=35/36*$B$12*$B$11,=450+(35-9)*175,=F49+H49+I49+J49,=1500+A49*50,=350+A49*10,=2000+A49*125,=C49*0.12,=SUM(L49:O49),=K49-P49,=R48+Q49,Expand products and reduce founder-delivery dependency +36,May 2029,=C49*(1+$B$3-$B$4),=D49*(1-$B$7)+(C50-C49)*$B$5,=D50/C50,=D50*$B$6,=C50*$B$10,=G50/1000*$B$8*$B$9,=36/36*$B$12*$B$11,=450+(36-9)*175,=F50+H50+I50+J50,=1500+A50*50,=350+A50*10,=2000+A50*125,=C50*0.12,=SUM(L50:O50),=K50-P50,=R49+Q50,Expand products and reduce founder-delivery dependency diff --git a/demo-vault-v2/responsibility-sponsorships.md b/demo-vault-v2/responsibility-sponsorships.md new file mode 100644 index 0000000..e112127 --- /dev/null +++ b/demo-vault-v2/responsibility-sponsorships.md @@ -0,0 +1,21 @@ +--- +type: Responsibility +aliases: + - "[[Sponsorships]]" +belongs_to: "[[area-building]]" +has_measures: + - "[[measure-sponsorship-mrr]]" + - "[[measure-close-rate]]" +has_procedures: + - "[[procedure-quarterly-sponsor-outreach]]" + - "[[procedure-sponsor-onboarding]]" +status: Open +--- + +# Sponsorships + +The compact responsibility used to verify that wikilink-valued fields render as relationships instead of plain text properties. + +- `status` stays a normal property. +- `has_measures` and `has_procedures` should render in the relationships area. + diff --git a/demo-vault-v2/rtl-mixed-direction-qa.md b/demo-vault-v2/rtl-mixed-direction-qa.md new file mode 100644 index 0000000..2dec819 --- /dev/null +++ b/demo-vault-v2/rtl-mixed-direction-qa.md @@ -0,0 +1,17 @@ +--- +type: Note +topics: + - "[[topic-writing]]" +--- + +# RTL Mixed Direction QA + +مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا. + +English text should keep reading left to right when it appears next to Arabic content. + +English then مرحبا بالعالم keeps both scripts readable on one line. + +مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words. + +Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction. diff --git a/demo-vault-v2/tolaria-sheet-prototype-sample.md b/demo-vault-v2/tolaria-sheet-prototype-sample.md new file mode 100644 index 0000000..bfe6e59 --- /dev/null +++ b/demo-vault-v2/tolaria-sheet-prototype-sample.md @@ -0,0 +1,30 @@ +--- +type: Note +_display: sheet +tags: + - spreadsheet +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 + B: + width: 140 + C: + width: 140 + D: + width: 140 + E: + width: 140 + cells: + A2: + bold: true + A5: + bold: true +--- +Metric,January,February,March,Quarter +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,700,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +Growth,,=(C5-B5)/B5,=(D5-C5)/C5,=(E5-B5)/B5 diff --git a/demo-vault-v2/topic-writing.md b/demo-vault-v2/topic-writing.md new file mode 100644 index 0000000..35013c9 --- /dev/null +++ b/demo-vault-v2/topic-writing.md @@ -0,0 +1,13 @@ +--- +type: Topic +aliases: + - "[[Writing]]" +--- + +# Writing + +The exact-match Quick Open target for search ranking QA. + +- [[writing-for-clarity-vs-writing-for-credit]] +- [[writing-weekly-rhythm]] +- [[note-on-clear-prose]] diff --git a/demo-vault-v2/type/area.md b/demo-vault-v2/type/area.md new file mode 100644 index 0000000..a58b436 --- /dev/null +++ b/demo-vault-v2/type/area.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: folders +color: amber +sidebar label: Areas +--- + +# Area + +Areas are ongoing domains of responsibility with no fixed end date. + diff --git a/demo-vault-v2/type/event.md b/demo-vault-v2/type/event.md new file mode 100644 index 0000000..901a5c3 --- /dev/null +++ b/demo-vault-v2/type/event.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: calendar +color: orange +sidebar label: Events +--- + +# Event + +Events are time-bound occurrences tied to projects, people, or periods. + diff --git a/demo-vault-v2/type/measure.md b/demo-vault-v2/type/measure.md new file mode 100644 index 0000000..3e3e8a9 --- /dev/null +++ b/demo-vault-v2/type/measure.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: chart-line-up +color: cyan +sidebar label: Measures +--- + +# Measure + +Measures track the numbers that matter for a responsibility or project. + diff --git a/demo-vault-v2/type/note.md b/demo-vault-v2/type/note.md new file mode 100644 index 0000000..347b4d0 --- /dev/null +++ b/demo-vault-v2/type/note.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: note +color: slate +sidebar label: Notes +--- + +# Note + +Notes capture references, ideas, or QA artifacts that do not need a more specific type. + diff --git a/demo-vault-v2/type/person.md b/demo-vault-v2/type/person.md new file mode 100644 index 0000000..0bf537f --- /dev/null +++ b/demo-vault-v2/type/person.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: user +color: rose +sidebar label: People +--- + +# Person + +People notes represent collaborators, owners, or recurring contacts. + diff --git a/demo-vault-v2/type/procedure.md b/demo-vault-v2/type/procedure.md new file mode 100644 index 0000000..9560c7a --- /dev/null +++ b/demo-vault-v2/type/procedure.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: checklist +color: violet +sidebar label: Procedures +--- + +# Procedure + +Procedures describe repeatable workflows that support a responsibility. + diff --git a/demo-vault-v2/type/project.md b/demo-vault-v2/type/project.md new file mode 100644 index 0000000..0ea5ad4 --- /dev/null +++ b/demo-vault-v2/type/project.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: rocket +color: blue +sidebar label: Projects +--- + +# Project + +Projects are time-bound efforts with an owner, a status, and a clear outcome. + diff --git a/demo-vault-v2/type/quarter.md b/demo-vault-v2/type/quarter.md new file mode 100644 index 0000000..d51d59d --- /dev/null +++ b/demo-vault-v2/type/quarter.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: clock-countdown +color: emerald +sidebar label: Quarters +--- + +# Quarter + +Quarter notes group the initiatives and outcomes for a planning window. + diff --git a/demo-vault-v2/type/responsibility.md b/demo-vault-v2/type/responsibility.md new file mode 100644 index 0000000..70f179f --- /dev/null +++ b/demo-vault-v2/type/responsibility.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: briefcase +color: green +sidebar label: Responsibilities +--- + +# Responsibility + +Responsibilities are long-lived ownership areas with linked procedures and measures. + diff --git a/demo-vault-v2/type/topic.md b/demo-vault-v2/type/topic.md new file mode 100644 index 0000000..fcabf9a --- /dev/null +++ b/demo-vault-v2/type/topic.md @@ -0,0 +1,11 @@ +--- +type: Type +icon: books +color: indigo +sidebar label: Topics +--- + +# Topic + +Topics collect related notes and make search/navigation scenarios easy to inspect. + diff --git a/demo-vault-v2/views/active-projects.yml b/demo-vault-v2/views/active-projects.yml new file mode 100644 index 0000000..793904f --- /dev/null +++ b/demo-vault-v2/views/active-projects.yml @@ -0,0 +1,9 @@ +name: Active Projects +icon: rocket +color: blue +sort: "modified:desc" +filters: + all: + - field: type + op: equals + value: Project diff --git a/demo-vault-v2/writing-for-clarity-vs-writing-for-credit.md b/demo-vault-v2/writing-for-clarity-vs-writing-for-credit.md new file mode 100644 index 0000000..8e7bee4 --- /dev/null +++ b/demo-vault-v2/writing-for-clarity-vs-writing-for-credit.md @@ -0,0 +1,11 @@ +--- +type: Note +topics: + - "[[topic-writing]]" +status: Published +--- + +# Writing for Clarity vs. Writing for Credit + +Write to be understood before you write to impress. This note exists so `Writing` has a strong prefix match underneath the exact title result. + diff --git a/demo-vault-v2/writing-weekly-rhythm.md b/demo-vault-v2/writing-weekly-rhythm.md new file mode 100644 index 0000000..298d58f --- /dev/null +++ b/demo-vault-v2/writing-weekly-rhythm.md @@ -0,0 +1,11 @@ +--- +type: Note +topics: + - "[[topic-writing]]" +status: Active +--- + +# Writing Weekly Rhythm + +A short operational note about keeping a weekly publishing cadence without overproducing drafts. + diff --git a/design/add-property-inline.pen b/design/add-property-inline.pen new file mode 100644 index 0000000..4109362 --- /dev/null +++ b/design/add-property-inline.pen @@ -0,0 +1,448 @@ +{ + "children": [ + { + "type": "frame", + "id": "apEmpty", + "x": 0, + "y": 0, + "name": "Add Property — Inline Form (Empty State)", + "theme": { "Mode": "Light" }, + "width": 320, + "height": 180, + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "text", + "id": "apEmptyDesc", + "name": "description", + "fill": "$--muted-foreground", + "content": "Inline add-property form: replaces the old grey popup. Fields are horizontal, matching existing property row layout. Shows after clicking + Add property.", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "apEmptyExisting1", + "name": "Existing Property Row — Status", + "width": "fill_container", + "cornerRadius": 4, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apEmptyLabel1", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "apEmptyBadge1", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "apEmptyBadgeText1", + "fill": "$--accent-green", + "content": "ACTIVE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "apEmptySep", + "name": "Separator", + "width": "fill_container", + "height": 1, + "fill": "$--border" + }, + { + "type": "frame", + "id": "apEmptyForm", + "name": "Inline Add Property Form — Empty", + "width": "fill_container", + "gap": 6, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "apEmptyNameInput", + "name": "Name Input", + "width": 90, + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apEmptyNamePlaceholder", + "fill": "$--muted-foreground", + "content": "Name", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "apEmptyTypeSelect", + "name": "Type Selector", + "width": 72, + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "padding": [4, 6], + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apEmptyTypeIcon", + "width": 12, + "height": 12, + "iconFontName": "type", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "apEmptyTypePlaceholder", + "fill": "$--muted-foreground", + "content": "Text", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "apEmptyTypeChevron", + "width": 10, + "height": 10, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "apEmptyValueInput", + "name": "Value Input", + "width": "fill_container", + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apEmptyValuePlaceholder", + "fill": "$--muted-foreground", + "content": "Value", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "apEmptyConfirmBtn", + "name": "Confirm Button", + "width": 24, + "height": 24, + "fill": "$--primary", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apEmptyConfirmIcon", + "width": 14, + "height": 14, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#ffffff" + } + ] + }, + { + "type": "frame", + "id": "apEmptyCancelBtn", + "name": "Cancel Button", + "width": 24, + "height": 24, + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apEmptyCancelIcon", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "apFilled", + "x": 400, + "y": 0, + "name": "Add Property — Inline Form (Filled State)", + "theme": { "Mode": "Light" }, + "width": 320, + "height": 180, + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "text", + "id": "apFilledDesc", + "name": "description", + "fill": "$--muted-foreground", + "content": "Filled state: user has typed a property name, selected a type (Date), and entered a value. Confirm button is enabled (blue). The form looks like the property row it will create.", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "apFilledExisting1", + "name": "Existing Property Row — Status", + "width": "fill_container", + "cornerRadius": 4, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apFilledLabel1", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "apFilledBadge1", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "apFilledBadgeText1", + "fill": "$--accent-green", + "content": "ACTIVE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "apFilledSep", + "name": "Separator", + "width": "fill_container", + "height": 1, + "fill": "$--border" + }, + { + "type": "frame", + "id": "apFilledForm", + "name": "Inline Add Property Form — Filled", + "width": "fill_container", + "gap": 6, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "apFilledNameInput", + "name": "Name Input (filled)", + "width": 90, + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" }, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apFilledNameText", + "fill": "$--foreground", + "content": "deadline", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "apFilledTypeSelect", + "name": "Type Selector (Date selected)", + "width": 72, + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "padding": [4, 6], + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apFilledTypeIcon", + "width": 12, + "height": 12, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "apFilledTypeText", + "fill": "$--foreground", + "content": "Date", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "apFilledTypeChevron", + "width": 10, + "height": 10, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "apFilledValueInput", + "name": "Value Input (filled)", + "width": "fill_container", + "height": 26, + "fill": "$--muted", + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" }, + "padding": [4, 6], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "apFilledValueText", + "fill": "$--foreground", + "content": "2026-03-15", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "apFilledConfirmBtn", + "name": "Confirm Button (enabled)", + "width": 24, + "height": 24, + "fill": "$--primary", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apFilledConfirmIcon", + "width": 14, + "height": 14, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#ffffff" + } + ] + }, + { + "type": "frame", + "id": "apFilledCancelBtn", + "name": "Cancel Button", + "width": 24, + "height": 24, + "cornerRadius": 4, + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "apFilledCancelIcon", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + } + ], + "variables": { + "--background": { "type": "color", "value": [{ "value": "#ffffff" }] }, + "--foreground": { "type": "color", "value": [{ "value": "#37352F" }] }, + "--muted": { "type": "color", "value": [{ "value": "#F0F0EF" }] }, + "--muted-foreground": { "type": "color", "value": [{ "value": "#9B9A97" }] }, + "--border": { "type": "color", "value": [{ "value": "#E9E9E7" }] }, + "--primary": { "type": "color", "value": [{ "value": "#155DFF" }] }, + "--accent-green": { "type": "color", "value": [{ "value": "#38A169" }] }, + "--accent-green-light": { "type": "color", "value": [{ "value": "rgba(56, 161, 105, 0.1)" }] } + } +} \ No newline at end of file diff --git a/design/ai-agent-panel-ui.pen b/design/ai-agent-panel-ui.pen new file mode 100644 index 0000000..1014831 --- /dev/null +++ b/design/ai-agent-panel-ui.pen @@ -0,0 +1,340 @@ +{ + "children": [ + { + "type": "frame", + "id": "ai_panel_closed", + "name": "AI Agent Panel — Closed (trigger button in toolbar)", + "x": 0, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_closed_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_closed_toolbar", + "name": "Right Toolbar", + "layout": "vertical", + "width": 36, + "height": "fill_container", + "fill": "$--background", + "padding": [8, 4], + "gap": 4, + "children": [ + { + "type": "frame", + "id": "ai_trigger_btn", + "name": "AI Trigger Button", + "width": 28, + "height": 28, + "cornerRadius": 6, + "fill": "$--muted", + "layout": "horizontal", + "padding": [6, 6], + "children": [ + { + "type": "text", + "id": "ai_trigger_icon", + "content": "✦", + "fill": "$--muted-foreground", + "fontSize": 14 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ai_panel_open_idle", + "name": "AI Agent Panel — Open, Idle (empty state)", + "x": 1320, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_open_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_panel_sidebar", + "name": "AI Panel Sidebar", + "layout": "vertical", + "width": 320, + "height": "fill_container", + "fill": "$--background", + "children": [ + { + "type": "frame", + "id": "ai_panel_header", + "name": "Panel Header", + "layout": "horizontal", + "width": "fill_container", + "height": 45, + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "ai_header_label", + "content": "AI", + "fontSize": 13, + "fontWeight": "600", + "fill": "$--muted-foreground", + "width": "fill_container" + }, + { + "type": "text", + "id": "ai_close_icon", + "content": "✕", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ai_empty_state", + "name": "Empty State", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "padding": [24, 24], + "children": [ + { + "type": "text", + "id": "ai_empty_hint", + "content": "Ask the AI agent to work on your vault…", + "fontSize": 13, + "fill": "$--muted-foreground", + "textAlign": "center", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_input_row", + "name": "Input Row", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "children": [ + { + "type": "rectangle", + "id": "ai_input_field", + "width": "fill_container", + "height": 32, + "cornerRadius": 6, + "fill": "$--muted" + }, + { + "type": "frame", + "id": "ai_send_btn", + "name": "Send Button", + "width": 28, + "height": 28, + "cornerRadius": 6, + "fill": "$--primary", + "layout": "horizontal", + "padding": [6, 6], + "children": [ + { + "type": "text", + "id": "ai_send_icon", + "content": "→", + "fontSize": 12, + "fill": "$--primary-foreground" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ai_panel_active", + "name": "AI Agent Panel — Active (streaming, action cards visible)", + "x": 2640, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_active_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_active_panel", + "name": "AI Panel — With Messages", + "layout": "vertical", + "width": 320, + "height": "fill_container", + "fill": "$--background", + "children": [ + { + "type": "frame", + "id": "ai_active_header", + "name": "Panel Header", + "layout": "horizontal", + "width": "fill_container", + "height": 45, + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "ai_active_label", + "content": "AI", + "fontSize": 13, + "fontWeight": "600", + "fill": "$--muted-foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_message_thread", + "name": "Message Thread", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "padding": [12, 12], + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ai_user_msg", + "name": "User Message", + "layout": "vertical", + "width": "fill_container", + "gap": 4, + "children": [ + { + "type": "text", + "content": "You", + "fontSize": 11, + "fontWeight": "600", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "content": "Crea una nota evento per la riunione con Marco domani", + "fontSize": 13, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_agent_msg", + "name": "Agent Message + Actions", + "layout": "vertical", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "ai_action_done", + "name": "Action Card — Done", + "layout": "horizontal", + "width": "fill_container", + "height": 28, + "padding": [0, 8], + "gap": 6, + "cornerRadius": 4, + "fill": "$--muted", + "children": [ + { + "type": "text", + "content": "✓", + "fontSize": 12, + "fill": "$--primary" + }, + { + "type": "text", + "content": "Loaded vault context", + "fontSize": 12, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_action_progress", + "name": "Action Card — In Progress", + "layout": "horizontal", + "width": "fill_container", + "height": 28, + "padding": [0, 8], + "gap": 6, + "cornerRadius": 4, + "fill": "$--muted", + "children": [ + { + "type": "text", + "content": "◌", + "fontSize": 12, + "fill": "$--primary" + }, + { + "type": "text", + "content": "Creating: 2026-03-01-meeting-marco.md", + "fontSize": 12, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "text", + "content": "Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.", + "fontSize": 13, + "fill": "$--foreground", + "width": "fill_container" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design/ai-agent-wiring.pen b/design/ai-agent-wiring.pen new file mode 100644 index 0000000..576c27d --- /dev/null +++ b/design/ai-agent-wiring.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"ai_wiring_state_machine","name":"AI Agent Wiring — State Machine (idle → thinking → tool-executing → response)","x":0,"y":0,"width":1280,"height":600,"fill":"$--background","layout":"horizontal","gap":32,"padding":[40,40],"alignItems":"flex-start","theme":{"Mode":"Light"},"children":[{"type":"frame","id":"state_idle","name":"State: Idle","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"idle_badge","width":80,"height":28,"cornerRadius":99,"fill":"$--accent-green-light","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"idle_label","content":"Idle","fill":"$--foreground","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"idle_desc","content":"User types message in input bar.\nModel selector available.\nSend button enabled when input non-empty.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"idle_input_mock","width":"fill_container","height":36,"cornerRadius":8,"fill":"$--background","strokeColor":"$--border","strokeThickness":1,"layout":"horizontal","padding":[8,10],"gap":8,"alignItems":"center","children":[{"type":"text","id":"idle_placeholder","content":"Ask the AI agent...","fill":"$--muted-foreground","fontSize":12}]}]},{"type":"frame","id":"state_arrow_1","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_1","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_thinking","name":"State: Thinking","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"thinking_badge","width":100,"height":28,"cornerRadius":99,"fill":"rgba(74,158,255,0.15)","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"thinking_label","content":"Thinking","fill":"$--primary","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"thinking_desc","content":"Claude API called with:\n• System prompt (vault context)\n• Tool definitions (14 MCP tools)\n• Conversation history\n\nStreaming indicator shown.\nInput disabled.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"thinking_indicator","width":"fill_container","height":24,"layout":"horizontal","gap":4,"alignItems":"center","padding":[0,4],"children":[{"type":"text","id":"thinking_dots","content":"● ● ●","fill":"$--muted-foreground","fontSize":12}]}]},{"type":"frame","id":"state_arrow_2","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_2","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_tool_exec","name":"State: Tool Executing","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"tool_badge","width":140,"height":28,"cornerRadius":99,"fill":"rgba(255,170,51,0.15)","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"tool_label","content":"Tool Executing","fill":"#CC8800","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"tool_desc","content":"Claude returns tool_use block.\nFor each tool call:\n1. Send {id,tool,args} → WS:9710\n2. Wait for {id,result}\n3. Feed tool_result back to Claude\n4. Action card: spinner → ✓\n\nMay loop multiple times.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"tool_card_mock","width":"fill_container","height":"fit_content(30)","cornerRadius":6,"fill":"rgba(74,158,255,0.1)","layout":"horizontal","gap":8,"padding":[6,10],"alignItems":"center","children":[{"type":"text","id":"tool_icon","content":"⟳","fill":"$--muted-foreground","fontSize":12},{"type":"text","id":"tool_name","content":"search_notes","fill":"$--foreground","fontSize":12},{"type":"text","id":"tool_status","content":"●","fill":"#CC8800","fontSize":10}]}]},{"type":"frame","id":"state_arrow_3","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_3","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_response","name":"State: Response","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"response_badge","width":110,"height":28,"cornerRadius":99,"fill":"$--accent-green-light","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"response_label","content":"Response","fill":"$--foreground","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"response_desc","content":"Claude returns final text block.\nDisplayed as assistant message.\n\nAction cards show ✓ for all tools.\n[Undo ↩] button available.\nInput re-enabled for follow-up.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"undo_btn_mock","width":80,"height":28,"cornerRadius":6,"fill":"$--background","strokeColor":"$--border","strokeThickness":1,"layout":"horizontal","padding":[4,12],"gap":4,"alignItems":"center","children":[{"type":"text","id":"undo_icon","content":"↩","fill":"$--muted-foreground","fontSize":12},{"type":"text","id":"undo_text","content":"Undo","fill":"$--muted-foreground","fontSize":12}]}]}]},{"type":"frame","id":"ai_wiring_data_flow","name":"AI Agent Wiring — Data Flow","x":0,"y":660,"width":1280,"height":400,"fill":"$--background","layout":"vertical","gap":20,"padding":[32,40],"theme":{"Mode":"Light"},"children":[{"type":"text","id":"flow_title","content":"Data Flow: useAiAgent Hook","fill":"$--foreground","fontSize":18,"fontWeight":"700"},{"type":"frame","id":"flow_diagram","width":"fill_container","height":"fit_content(200)","layout":"vertical","gap":8,"children":[{"type":"text","id":"flow_1","content":"1. User sends message → useAiAgent.sendMessage(text)","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_2","content":"2. Build system prompt: vault context + selected notes","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_3","content":"3. POST /api/ai/agent → Vite proxy → Anthropic API (with tools, stream:false)","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_4","content":"4. If response has tool_use → execute via WS bridge (port 9710) → feed result back → goto 3","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_5","content":"5. If response has text only → display as assistant message → back to idle","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_6","content":"6. Before each run: snapshot touched files (read via WS). Undo restores snapshots.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.6}]},{"type":"frame","id":"flow_components","width":"fill_container","height":"fit_content(60)","layout":"horizontal","gap":24,"children":[{"type":"frame","id":"comp_hook","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_hook_name","content":"useAiAgent","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_hook_desc","content":"Hook: state, sendMessage, undo","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_proxy","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_proxy_name","content":"/api/ai/agent","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_proxy_desc","content":"Vite proxy → Anthropic","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_ws","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_ws_name","content":"WS Bridge :9710","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_ws_desc","content":"Tool execution","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_panel","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_panel_name","content":"AiPanel.tsx","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_panel_desc","content":"UI: messages, actions, undo","fill":"$--muted-foreground","fontSize":11}]}]}]}],"variables":{}} \ No newline at end of file diff --git a/design/align-property-dropdown.pen b/design/align-property-dropdown.pen new file mode 100644 index 0000000..e880b7c --- /dev/null +++ b/design/align-property-dropdown.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"content":"BEFORE (mismatched styles)","fill":"#9CA3AF","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"dFBnk","letterSpacing":1,"name":"titleBefore","type":"text"},{"alignItems":"center","children":[{"content":"Type","fill":"#9CA3AF","fontFamily":"JetBrains Mono","fontSize":10,"fontWeight":"500","id":"EC2s2","letterSpacing":0.5,"name":"typeLabelBefore","type":"text"},{"alignItems":"center","children":[{"content":"Project ▾","fill":"#7C3AED","fontFamily":"Inter","fontSize":12,"fontWeight":"500","id":"d8mDu","name":"typePillText","type":"text"}],"cornerRadius":6,"fill":"#EDE9FE","gap":4,"id":"Km8Xu","name":"typePillBefore","padding":[2,8],"type":"frame"}],"id":"ICZ33","justifyContent":"space_between","name":"Type Row — Old Style","type":"frame","width":"fill_container"},{"fill":"#E5E7EB","height":1,"id":"e0bqn","name":"divider1","type":"rectangle","width":"fill_container"},{"content":"Add Property form type selector:","fill":"#9CA3AF","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"EbI7c","name":"addLabel","type":"text"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Property name","fill":"#9CA3AF","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"gNteI","name":"addInputText","type":"text"}],"cornerRadius":4,"fill":"#F3F4F6","height":26,"id":"0tXsa","name":"addInput","padding":[0,6],"stroke":{"align":"inside","fill":"#E5E7EB","thickness":1},"type":"frame","width":90},{"alignItems":"center","children":[{"fill":"#9CA3AF","height":14,"iconFontFamily":"lucide","iconFontName":"type","id":"ZUNFA","name":"addTypeIcon","type":"icon_font","width":14},{"content":"Text ▾","fill":"#374151","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"LJf42","name":"addTypeText","type":"text"}],"cornerRadius":4,"fill":"#F3F4F6","gap":4,"height":26,"id":"sQpJ5","name":"addTypeSelect","padding":[0,6],"stroke":{"align":"inside","fill":"#E5E7EB","thickness":1},"type":"frame","width":82}],"gap":6,"id":"MoHiU","name":"Add Property Row","type":"frame","width":"fill_container"}],"fill":"#FFFFFF","gap":16,"id":"07ZYk","layout":"vertical","name":"Align Property Dropdown — Before","padding":20,"type":"frame","width":280,"x":0,"y":0},{"children":[{"content":"AFTER (unified style)","fill":"#16A34A","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"kfWbF","letterSpacing":1,"name":"titleAfter","type":"text"},{"alignItems":"center","children":[{"content":"Type","fill":"#9CA3AF","fontFamily":"JetBrains Mono","fontSize":10,"fontWeight":"500","id":"7ifiz","letterSpacing":0.5,"name":"typeLabelAfter","type":"text"},{"alignItems":"center","children":[{"content":"Project ▾","fill":"#374151","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"vbqJ1","name":"typeTextAfter","type":"text"}],"cornerRadius":4,"fill":"#F3F4F6","gap":4,"height":26,"id":"GrDBq","name":"typeSelectAfter","padding":[0,6],"stroke":{"align":"inside","fill":"#E5E7EB","thickness":1},"type":"frame"}],"id":"gSUcm","justifyContent":"space_between","name":"Type Row — Unified Style","type":"frame","width":"fill_container"},{"fill":"#E5E7EB","height":1,"id":"Qn9Ju","name":"divider2","type":"rectangle","width":"fill_container"},{"content":"Add Property form type selector:","fill":"#9CA3AF","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"L2xAX","name":"addLabel2","type":"text"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Property name","fill":"#9CA3AF","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"hvpLW","name":"addInputText2","type":"text"}],"cornerRadius":4,"fill":"#F3F4F6","height":26,"id":"pH05L","name":"addInput2","padding":[0,6],"stroke":{"align":"inside","fill":"#E5E7EB","thickness":1},"type":"frame","width":90},{"alignItems":"center","children":[{"fill":"#9CA3AF","height":14,"iconFontFamily":"lucide","iconFontName":"type","id":"KTwLT","name":"addTypeIcon2","type":"icon_font","width":14},{"content":"Text ▾","fill":"#374151","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"R7qMP","name":"addTypeText2","type":"text"}],"cornerRadius":4,"fill":"#F3F4F6","gap":4,"height":26,"id":"8AbX1","name":"addTypeSelect2","padding":[0,6],"stroke":{"align":"inside","fill":"#E5E7EB","thickness":1},"type":"frame","width":82}],"gap":6,"id":"B3Xp2","name":"Add Property Row","type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"#16A34A","height":14,"iconFontFamily":"lucide","iconFontName":"check","id":"pjX8a","name":"matchIcon","type":"icon_font","width":14},{"content":"Both selectors now share the same visual style: bordered, muted bg, 4px radius, 26px height","fill":"#166534","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"Vbnnu","lineHeight":1.4,"name":"matchText","type":"text"}],"cornerRadius":6,"fill":"#F0FDF4","gap":6,"id":"ZUwuH","name":"matchNote","padding":[8,10],"type":"frame","width":"fill_container"}],"fill":"#FFFFFF","gap":16,"id":"ywjeP","layout":"vertical","name":"Align Property Dropdown — After (Unified)","padding":20,"type":"frame","width":280,"x":320,"y":0}],"variables":{}} \ No newline at end of file diff --git a/design/archived-note-indicator.pen b/design/archived-note-indicator.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/archived-note-indicator.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/auto-pull-vault.pen b/design/auto-pull-vault.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/auto-pull-vault.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/autocomplete-type-color.pen b/design/autocomplete-type-color.pen new file mode 100644 index 0000000..a3af113 --- /dev/null +++ b/design/autocomplete-type-color.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"frame1","x":0,"y":0,"name":"Autocomplete — correct type colors","clip":true,"width":500,"height":400,"fill":"#FFFFFF","layout":"vertical","gap":0,"children":[{"type":"frame","id":"header","name":"Search Input","width":"fill_container","height":44,"fill":"#FFFFFF","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"#E5E7EB"},"padding":[0,16],"alignItems":"center","children":[{"type":"text","id":"searchText","content":"[[ever","fontSize":15,"fontFamily":"Inter","fill":"#1F2937"}]},{"type":"frame","id":"results","name":"Results List","width":"fill_container","layout":"vertical","gap":0,"fill":"#FFFFFF","children":[{"type":"frame","id":"row1","name":"Result Row — Typed (Evergreen)","width":"fill_container","height":36,"padding":[0,12],"alignItems":"center","justifyContent":"spaceBetween","fill":"#F3F4F6","children":[{"type":"frame","id":"row1Left","gap":6,"alignItems":"center","children":[{"type":"text","id":"icon1","content":"🌿","fontSize":14},{"type":"text","id":"title1","content":"Evergreen: Writing Process","fontSize":14,"fontFamily":"Inter","fill":"#1F2937"}]},{"type":"frame","id":"badge1","padding":[2,8],"cornerRadius":4,"fill":"#ECFDF5","children":[{"type":"text","id":"badgeText1","content":"Evergreen","fontSize":11,"fontFamily":"Inter","fill":"#10B981","fontWeight":"500"}]}]},{"type":"frame","id":"row2","name":"Result Row — Typed (Project)","width":"fill_container","height":36,"padding":[0,12],"alignItems":"center","justifyContent":"spaceBetween","children":[{"type":"frame","id":"row2Left","gap":6,"alignItems":"center","children":[{"type":"text","id":"icon2","content":"🔧","fontSize":14},{"type":"text","id":"title2","content":"Evergreen Garden Project","fontSize":14,"fontFamily":"Inter","fill":"#1F2937"}]},{"type":"frame","id":"badge2","padding":[2,8],"cornerRadius":4,"fill":"#FEF2F2","children":[{"type":"text","id":"badgeText2","content":"Project","fontSize":11,"fontFamily":"Inter","fill":"#EF4444","fontWeight":"500"}]}]},{"type":"frame","id":"row3","name":"Result Row — Typed (Topic)","width":"fill_container","height":36,"padding":[0,12],"alignItems":"center","justifyContent":"spaceBetween","children":[{"type":"frame","id":"row3Left","gap":6,"alignItems":"center","children":[{"type":"text","id":"icon3","content":"🏷️","fontSize":14},{"type":"text","id":"title3","content":"Evergreen Notes Philosophy","fontSize":14,"fontFamily":"Inter","fill":"#1F2937"}]},{"type":"frame","id":"badge3","padding":[2,8],"cornerRadius":4,"fill":"#ECFDF5","children":[{"type":"text","id":"badgeText3","content":"Topic","fontSize":11,"fontFamily":"Inter","fill":"#10B981","fontWeight":"500"}]}]},{"type":"frame","id":"row4","name":"Result Row — Untyped (neutral grey)","width":"fill_container","height":36,"padding":[0,12],"alignItems":"center","justifyContent":"spaceBetween","children":[{"type":"frame","id":"row4Left","gap":6,"alignItems":"center","children":[{"type":"text","id":"title4","content":"My evergreen garden notes","fontSize":14,"fontFamily":"Inter","fill":"#1F2937"}]}]},{"type":"frame","id":"row5","name":"Result Row — Typed (Person)","width":"fill_container","height":36,"padding":[0,12],"alignItems":"center","justifyContent":"spaceBetween","children":[{"type":"frame","id":"row5Left","gap":6,"alignItems":"center","children":[{"type":"text","id":"icon5","content":"👤","fontSize":14},{"type":"text","id":"title5","content":"Evergreen Smith","fontSize":14,"fontFamily":"Inter","fill":"#1F2937"}]},{"type":"frame","id":"badge5","padding":[2,8],"cornerRadius":4,"fill":"#FFFBEB","children":[{"type":"text","id":"badgeText5","content":"Person","fontSize":11,"fontFamily":"Inter","fill":"#F59E0B","fontWeight":"500"}]}]}]}]},{"type":"frame","id":"frame2","x":0,"y":440,"name":"Legend — Type Color Mapping","width":500,"height":200,"fill":"#FFFFFF","layout":"vertical","gap":8,"padding":16,"children":[{"type":"text","id":"legendTitle","content":"Type → Color Mapping (Fixed)","fontSize":16,"fontFamily":"Inter","fill":"#1F2937","fontWeight":"700"},{"type":"text","id":"legend1","content":"• Project/Experiment → Red (--accent-red)","fontSize":12,"fontFamily":"Inter","fill":"#EF4444"},{"type":"text","id":"legend2","content":"• Responsibility/Procedure → Purple (--accent-purple)","fontSize":12,"fontFamily":"Inter","fill":"#8B5CF6"},{"type":"text","id":"legend3","content":"• Person/Event → Yellow (--accent-yellow)","fontSize":12,"fontFamily":"Inter","fill":"#F59E0B"},{"type":"text","id":"legend4","content":"• Topic → Green (--accent-green)","fontSize":12,"fontFamily":"Inter","fill":"#10B981"},{"type":"text","id":"legend5","content":"• Type → Blue (--accent-blue)","fontSize":12,"fontFamily":"Inter","fill":"#3B82F6"},{"type":"text","id":"legend6","content":"• Custom types → custom color from Type document","fontSize":12,"fontFamily":"Inter","fill":"#6B7280"},{"type":"text","id":"legend7","content":"• Untyped (Note/null) → Grey (--muted-foreground), no icon","fontSize":12,"fontFamily":"Inter","fill":"#9CA3AF"}]}],"variables":{}} \ No newline at end of file diff --git a/design/build-number-status-bar.pen b/design/build-number-status-bar.pen new file mode 100644 index 0000000..ea0475d --- /dev/null +++ b/design/build-number-status-bar.pen @@ -0,0 +1,96 @@ +{ + "children": [ + { + "type": "frame", + "id": "build_number_status_bar", + "name": "Build Number — Status Bar", + "x": 0, + "y": 0, + "width": 1200, + "height": 36, + "fill": "$--background", + "layout": "horizontal", + "gap": 12, + "padding": [ + 0, + 16 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "status_vault", + "content": "vault-name", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "status_sep1", + "content": "|", + "fill": "$--border", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "status_build", + "content": "b223", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "status_note", + "content": "← dynamic build number from package.json, replacing hardcoded v0.4.2", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontStyle": "italic" + } + ] + }, + { + "type": "frame", + "id": "build_number_fallback", + "name": "Build Number — Fallback (no build info)", + "x": 0, + "y": 60, + "width": 400, + "height": 36, + "fill": "$--background", + "layout": "horizontal", + "gap": 12, + "padding": [ + 0, + 16 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "fallback_build", + "content": "b?", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "fallback_note", + "content": "← shows b? when build number unavailable", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontStyle": "italic" + } + ] + } + ] +} \ No newline at end of file diff --git a/design/command-palette-type-aware.pen b/design/command-palette-type-aware.pen new file mode 100644 index 0000000..9b2a4db --- /dev/null +++ b/design/command-palette-type-aware.pen @@ -0,0 +1,387 @@ +{ + "children": [ + { + "type": "frame", + "id": "cp_new_ev", + "name": "Command Palette — type \"new ev\" autocomplete", + "x": 0, + "y": 0, + "width": 620, + "height": "fit_content(500)", + "fill": "$--background", + "layout": "vertical", + "gap": 24, + "padding": [32, 32], + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "cp_new_title", + "content": "Command Palette — \"new ev\" autocomplete", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "cp_new_dialog", + "name": "Palette Dialog", + "width": 520, + "height": "fit_content(440)", + "fill": "$--popover", + "layout": "vertical", + "cornerRadius": [12, 12, 12, 12], + "stroke": "$--border", + "strokeThickness": 1, + "children": [ + { + "type": "frame", + "id": "cp_new_input_row", + "width": "fill_container", + "height": 48, + "padding": [0, 16], + "layout": "horizontal", + "verticalAlign": "center", + "stroke": "$--border", + "strokeSides": ["bottom"], + "strokeThickness": 1, + "children": [ + { + "type": "text", + "id": "cp_new_query", + "content": "new ev", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 15 + } + ] + }, + { + "type": "frame", + "id": "cp_new_results", + "name": "Results", + "width": "fill_container", + "layout": "vertical", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "cp_new_group_label", + "content": "NOTE", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 1, + "padding": [8, 16, 4, 16] + }, + { + "type": "frame", + "id": "cp_new_row1", + "name": "New Event (selected)", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "fill": "$--accent", + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_new_row1_label", + "content": "New Event", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + }, + { + "type": "frame", + "id": "cp_new_row2", + "name": "New Event Log", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_new_row2_label", + "content": "New Event Log", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + }, + { + "type": "frame", + "id": "cp_new_row3", + "name": "New Experiment", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_new_row3_label", + "content": "New Experiment", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + }, + { + "type": "frame", + "id": "cp_new_footer", + "width": "fill_container", + "height": 32, + "padding": [0, 16], + "layout": "horizontal", + "verticalAlign": "center", + "gap": 16, + "stroke": "$--border", + "strokeSides": ["top"], + "strokeThickness": 1, + "children": [ + { + "type": "text", + "id": "cp_new_hint1", + "content": "↑↓ navigate", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "cp_new_hint2", + "content": "↵ select", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "cp_new_hint3", + "content": "esc close", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "cp_list_pe", + "name": "Command Palette — type \"list pe\" autocomplete", + "x": 720, + "y": 0, + "width": 620, + "height": "fit_content(500)", + "fill": "$--background", + "layout": "vertical", + "gap": 24, + "padding": [32, 32], + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "cp_list_title", + "content": "Command Palette — \"list pe\" autocomplete", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "cp_list_dialog", + "name": "Palette Dialog", + "width": 520, + "height": "fit_content(440)", + "fill": "$--popover", + "layout": "vertical", + "cornerRadius": [12, 12, 12, 12], + "stroke": "$--border", + "strokeThickness": 1, + "children": [ + { + "type": "frame", + "id": "cp_list_input_row", + "width": "fill_container", + "height": 48, + "padding": [0, 16], + "layout": "horizontal", + "verticalAlign": "center", + "stroke": "$--border", + "strokeSides": ["bottom"], + "strokeThickness": 1, + "children": [ + { + "type": "text", + "id": "cp_list_query", + "content": "list pe", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 15 + } + ] + }, + { + "type": "frame", + "id": "cp_list_results", + "name": "Results", + "width": "fill_container", + "layout": "vertical", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "cp_list_group_label", + "content": "NAVIGATION", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 1, + "padding": [8, 16, 4, 16] + }, + { + "type": "frame", + "id": "cp_list_row1", + "name": "List People (selected)", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "fill": "$--accent", + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_list_row1_label", + "content": "List People", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + }, + { + "type": "frame", + "id": "cp_list_row2", + "name": "List Procedures", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_list_row2_label", + "content": "List Procedures", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + }, + { + "type": "frame", + "id": "cp_list_row3", + "name": "List Projects", + "width": "fill_container", + "height": 36, + "padding": [0, 12], + "layout": "horizontal", + "verticalAlign": "center", + "horizontalAlign": "spaceBetween", + "cornerRadius": [6, 6, 6, 6], + "margin": [0, 4], + "children": [ + { + "type": "text", + "id": "cp_list_row3_label", + "content": "List Projects", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + }, + { + "type": "frame", + "id": "cp_list_footer", + "width": "fill_container", + "height": 32, + "padding": [0, 16], + "layout": "horizontal", + "verticalAlign": "center", + "gap": 16, + "stroke": "$--border", + "strokeSides": ["top"], + "strokeThickness": 1, + "children": [ + { + "type": "text", + "id": "cp_list_hint1", + "content": "↑↓ navigate", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "cp_list_hint2", + "content": "↵ select", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "cp_list_hint3", + "content": "esc close", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/design/date-picker-shadcn.pen b/design/date-picker-shadcn.pen new file mode 100644 index 0000000..6f60223 --- /dev/null +++ b/design/date-picker-shadcn.pen @@ -0,0 +1,285 @@ +{ + "children": [ + { + "type": "frame", + "id": "datePickerClosed", + "x": 0, + "y": 0, + "name": "Date Picker — closed state (showing formatted date as button)", + "width": 320, + "height": 40, + "fill": "#ffffff", + "gap": 8, + "padding": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dateClosedLabel", + "name": "label", + "fill": "#6b7280", + "content": "deadline", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "frame", + "id": "dateClosedButton", + "name": "date-button", + "width": "fill_container", + "justifyContent": "flex-end", + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dateClosedIcon", + "name": "calendar-icon", + "fill": "#9ca3af", + "content": "📅", + "fontSize": 12 + }, + { + "type": "text", + "id": "dateClosedValue", + "name": "date-value", + "fill": "#374151", + "content": "Mar 31, 2026", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "datePickerOpen", + "x": 0, + "y": 160, + "name": "Date Picker — open state (calendar popup below button)", + "width": 320, + "height": 380, + "fill": "#ffffff", + "layout": "vertical", + "gap": 4, + "padding": 0, + "children": [ + { + "type": "frame", + "id": "dateOpenRow", + "name": "property-row", + "width": "fill_container", + "height": 40, + "gap": 8, + "padding": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dateOpenLabel", + "name": "label", + "fill": "#6b7280", + "content": "deadline", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "frame", + "id": "dateOpenButton", + "name": "date-button-active", + "width": "fill_container", + "justifyContent": "flex-end", + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dateOpenIcon", + "name": "calendar-icon", + "fill": "#6366f1", + "content": "📅", + "fontSize": 12 + }, + { + "type": "text", + "id": "dateOpenValue", + "name": "date-value", + "fill": "#111827", + "content": "Mar 31, 2026", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "calendarPopup", + "name": "calendar-popup", + "width": 280, + "height": 320, + "fill": "#ffffff", + "layout": "vertical", + "cornerRadius": 8, + "padding": 12, + "gap": 8, + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#e5e7eb" + }, + "children": [ + { + "type": "frame", + "id": "calendarHeader", + "name": "calendar-header", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { + "type": "text", + "id": "calPrevBtn", + "name": "prev-month", + "fill": "#6b7280", + "content": "◀", + "fontSize": 12 + }, + { + "type": "text", + "id": "calMonthYear", + "name": "month-year", + "fill": "#111827", + "content": "March 2026", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "calNextBtn", + "name": "next-month", + "fill": "#6b7280", + "content": "▶", + "fontSize": 12 + } + ] + }, + { + "type": "frame", + "id": "calWeekdayRow", + "name": "weekday-headers", + "width": "fill_container", + "height": 24, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "wd1", "fill": "#9ca3af", "content": "Su", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd2", "fill": "#9ca3af", "content": "Mo", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd3", "fill": "#9ca3af", "content": "Tu", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd4", "fill": "#9ca3af", "content": "We", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd5", "fill": "#9ca3af", "content": "Th", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd6", "fill": "#9ca3af", "content": "Fr", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd7", "fill": "#9ca3af", "content": "Sa", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "calGrid", + "name": "calendar-grid", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "calRow1", + "name": "week-1", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "d1", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d2", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d3", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d4", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d5", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d6", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d7", "fill": "#374151", "content": "1", "fontSize": 12, "fontFamily": "Inter" } + ] + }, + { + "type": "frame", + "id": "calRow5", + "name": "week-5-selected", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "d29", "fill": "#374151", "content": "29", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d30", "fill": "#374151", "content": "30", "fontSize": 12, "fontFamily": "Inter" }, + { + "type": "frame", + "id": "selectedDay", + "name": "selected-day-31", + "width": 28, + "height": 28, + "fill": "#111827", + "cornerRadius": 14, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { "type": "text", "id": "d31", "fill": "#ffffff", "content": "31", "fontSize": 12, "fontFamily": "Inter", "fontWeight": "600" } + ] + }, + { "type": "text", "id": "d32", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d33", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d34", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d35", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" } + ] + } + ] + }, + { + "type": "frame", + "id": "calFooter", + "name": "calendar-footer", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "flex-end", + "gap": 8, + "children": [ + { + "type": "text", + "id": "calClearBtn", + "name": "clear-button", + "fill": "#6b7280", + "content": "Clear", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/design/design-full-layouts.pen b/design/design-full-layouts.pen new file mode 100644 index 0000000..455a82f --- /dev/null +++ b/design/design-full-layouts.pen @@ -0,0 +1,13769 @@ +{ + "children": [ + { + "type": "frame", + "id": "X6DiX", + "x": 0, + "y": 0, + "name": "Full Layout — Editor Focused", + "theme": { + "Mode": "Light" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "QXp_S", + "name": "macOS Title Bar", + "width": "fill_container", + "height": 38, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a7ql_", + "name": "trafficLights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "PIagL", + "name": "closeBtn", + "fill": "#FF5F57", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "UkrIX", + "name": "minimizeBtn", + "fill": "#FEBC2E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "rpllF", + "name": "maximizeBtn", + "fill": "#28C840", + "width": 12, + "height": 12 + } + ] + } + ] + }, + { + "type": "frame", + "id": "YHu8M", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "AUMkh", + "name": "Panel: Sidebar", + "clip": true, + "width": 250, + "height": "fill_container", + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--sidebar-border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "jIs8c", + "name": "Filters", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 1, + "padding": [ + 8, + 8, + 16, + 8 + ], + "children": [ + { + "type": "frame", + "id": "uDixt", + "name": "filterAll", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0ZAuF", + "name": "leftAll", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "CvmcV", + "name": "iconAll", + "width": 18, + "height": 18, + "iconFontName": "tray-fill", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "_IXF5", + "name": "fAllTxt", + "fill": "$--foreground", + "content": "Untagged", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "cc5p7", + "name": "countAll", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wekdD", + "name": "badgeAllTxt", + "fill": "$--muted-foreground", + "content": "24", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "UzJob", + "name": "filterUntagged", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UassE", + "name": "leftUntagged", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "94gjp", + "name": "untaggedIcon", + "width": 18, + "height": 18, + "iconFontName": "cardholder", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--primary" + }, + { + "type": "text", + "id": "Z4kx8", + "name": "untaggedTxt", + "fill": "$--primary", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "mxu-s", + "name": "countUntagged", + "height": 20, + "fill": "$--primary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "766If", + "name": "untaggedBadgeTxt", + "fill": "$--primary-foreground", + "content": "9247", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ], + "fill": "#4a9eff18" + }, + { + "type": "frame", + "id": "j6x6d", + "name": "filterTrash", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "2-DOi", + "name": "leftTrash", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "otR4H", + "name": "trashIcon", + "width": 18, + "height": 18, + "iconFontName": "trash", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "XnkPL", + "name": "trashTxt", + "fill": "$--foreground", + "content": "Archive", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "FVqx6", + "name": "countTrash", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3QQmO", + "name": "trashBadgeTxt", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "IGH4B", + "name": "filterChanges", + "width": "fill_container", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RM0XF", + "name": "leftChanges", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "i-4iz", + "name": "iconChanges", + "width": 18, + "height": 18, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "Px5GR", + "name": "fChangesTxt", + "fill": "$--foreground", + "content": "Changes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "wO3WO", + "name": "countChanges", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YJxJn", + "name": "badgeChangesTxt", + "fill": "$--muted-foreground", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "jXROv", + "name": "Sections", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "sO-XL", + "name": "projGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6, + 8, + 6 + ], + "children": [ + { + "type": "frame", + "id": "8e7Vt", + "name": "projHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rcsfO", + "name": "leftProj", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gytXN", + "name": "projIcon", + "width": 18, + "height": 18, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-red" + }, + { + "type": "text", + "id": "-s_mS", + "name": "projLabel", + "fill": "$--foreground", + "content": "Projects", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "CwD_O", + "name": "projChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "52A6U", + "name": "projItem1", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BgNaS", + "name": "proj1Txt", + "fill": "$--accent-red", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "VdDh5", + "name": "projItem2", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "A0wXl", + "name": "proj2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fZ-0x", + "name": "projItem3", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TtXDd", + "name": "proj3Txt", + "fill": "$--muted-foreground", + "content": "AI Habit Tracker", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "GZd6p", + "name": "respGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "E6xqR", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fa0Nk", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "S8AHA", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "medal", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "xe_g8", + "name": "respLabel", + "fill": "$--foreground", + "content": "Responsibilities", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "XpuwI", + "name": "respChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "shgFZ", + "name": "procGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "8Ujcl", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "J4tD5", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jdHxB", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "Gt0UH", + "name": "Procedures", + "fill": "$--foreground", + "content": "Procedures", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "GFHmg", + "name": "procChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hiql_", + "name": "peopleGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "hC4r1", + "name": "peopleHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TrVIO", + "name": "leftPeople", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Dr2Jm", + "name": "peopleIcon", + "width": 18, + "height": 18, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "lzFwD", + "name": "peopleLbl", + "fill": "$--foreground", + "content": "People", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "u7Y66", + "name": "peopleChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "v05Mj", + "name": "eventsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "KVmYN", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "P0NRp", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "NaHtD", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "calendar-blank", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "YsEle", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Events", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "b0yk2", + "name": "eventsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oMYo2", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "TUZI-", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "6uK9k", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "WJIXx", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "3dn7l", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Topics", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "yPQuN", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N-qVJ", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "dsXnU", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aW1pQ", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "SQjcs", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "Vv4ha", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "P1KUL", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "MhWVM", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zgDZ5", + "name": "leftEvents", + "gap": 8, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "sMBNg", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "gFAJs", + "name": "eventsLbl", + "fill": "$--muted-foreground", + "content": "Create new type", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "EyZli", + "name": "Commit Area", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "padding": 12, + "children": [ + { + "type": "frame", + "id": "1JRzZ", + "name": "commitBtn", + "width": "fill_container", + "fill": "$--primary", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Ts-7a", + "name": "commitIcon", + "width": 14, + "height": 14, + "iconFontName": "git-commit-horizontal", + "iconFontFamily": "lucide", + "fill": "$--primary-foreground" + }, + { + "type": "text", + "id": "pEh20", + "name": "commitTxt", + "fill": "$--primary-foreground", + "content": "Commit & Push", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "ZC4Tp", + "name": "commitBadge", + "height": 18, + "fill": "#ffffff40", + "cornerRadius": 9, + "padding": [ + 0, + 5 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dH3M0", + "name": "commitBadgeTxt", + "fill": "$--white", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "iayXd", + "name": "Resize Handle", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "KYP3S", + "name": "Panel: NoteList", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$--card", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "udhLQ", + "name": "NoteList Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 14, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ajmdp", + "name": "nlTitle", + "fill": "$--foreground", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "5SvsJ", + "name": "nlActions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "qFJVP", + "name": "searchIcon", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "cgafy", + "name": "plusIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "koQ0J", + "name": "Note Items", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "1H3yZ", + "name": "Backlinks Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "3kSDX", + "name": "Note Item Selected", + "width": "fill_container", + "fill": "$--accent-red-light", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E9E9E7" + }, + "children": [ + { + "type": "rectangle", + "id": "Ha8HS", + "name": "leftAccent", + "fill": "$--accent-red", + "width": 3, + "height": "fill_container" + }, + { + "type": "frame", + "id": "pvIeZ", + "name": "noteSelContent", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "miGdK", + "name": "noteSelRow", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XsFs9", + "name": "titleGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qGt0S", + "name": "noteSelTitle", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "nbrro", + "name": "ico", + "width": 14, + "height": 14, + "iconFontName": "wrench", + "iconFontFamily": "lucide", + "fill": "$--accent-red" + } + ] + } + ] + }, + { + "type": "text", + "id": "2ONoL", + "name": "noteSelSnip", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "9An2M", + "name": "noteSelTime", + "fill": "$--accent-red", + "content": "2m ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "kZZVQ", + "name": "Related Notes Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "15OPP", + "name": "Related Notes Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VZfPV", + "name": "rnLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "yt3sX", + "name": "rnLabel", + "fill": "$--muted-foreground", + "content": "RELATED NOTES", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "rMUGk", + "name": "rnCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lxFxs", + "name": "rnRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "T9Tni", + "name": "rnFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "7zq2S", + "name": "rnPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "k2dd-", + "name": "rnChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "64sl5", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "Jgqoc", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "u-lDr", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Sx4pi", + "name": "note2Title", + "fill": "$--foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "FKgVd", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + } + ] + }, + { + "type": "text", + "id": "Or7kg", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Redesigning portfolio site with modern stack and updated visual language...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "icctC", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lpkip", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "hV9DU", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "4MowJ", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OqjO4", + "name": "note2Title", + "fill": "$--foreground", + "content": "Sample note 2", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "geEmd", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + } + ] + }, + { + "type": "text", + "id": "ya9zF", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Lorem ipsum dolor sit amet consequetur with modern stack and updated language...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "yfwf5", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "piEtY", + "name": "Events Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "fOL6M", + "name": "Events Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cx0o6", + "name": "evLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4073P", + "name": "evLabel", + "fill": "$--muted-foreground", + "content": "EVENTS", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "up3pe", + "name": "evCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QCl7r", + "name": "evRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "r-KNu", + "name": "evFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "4-yEN", + "name": "evPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "is8Ce", + "name": "evChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "2Bw0D", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "bDrW1", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "dIBZK", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YXOiG", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "UHaDj", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "wHYGL", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zyqm_", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BVMQi", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "Kj543", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "_csvb", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fsnPK", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Matteo", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "O3Je-", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "NGfVJ", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dw3Za", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "fKf2N", + "name": "Resize Handle 2", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "MLegO", + "name": "Panel: Editor", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "2UACq", + "name": "Tab Bar", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RjysZ", + "name": "Tab Active", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZWVUX", + "name": "tab1Txt", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "5xN9D", + "name": "tab1Close", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "bqwJh", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jxV4d", + "name": "tab2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "ZS9l6", + "name": "tab2Close", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "fqCqt", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "KdlSR", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "68hyI", + "name": "iconPlus", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "-LdMX", + "name": "iconSplit", + "width": 16, + "height": 16, + "iconFontName": "columns", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "tyTM3", + "name": "iconExpand", + "width": 16, + "height": 16, + "iconFontName": "arrows-out-simple", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "4w-0l", + "name": "Editor Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "NuCqT", + "name": "Editor Left", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "bkLPb", + "name": "Info Bar", + "width": "fill_container", + "height": 45, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "htutC", + "name": "infoLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K65hN", + "name": "breadcrumbType", + "fill": "$--muted-foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "3Xf0o", + "name": "breadcrumbSep", + "fill": "$--muted-foreground", + "content": "›", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "eNhLA", + "name": "breadcrumbName", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "TLxAY", + "name": "dotSep", + "fill": "$--muted-foreground", + "content": "·", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "5hBHI", + "name": "modifiedTxt", + "fill": "$--accent-yellow", + "content": "M", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "xfmHb", + "name": "infoRight", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "tzPx4", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "z9LJI", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "19AWC", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "cursor-text", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "8TamW", + "name": "iconAI", + "width": 16, + "height": 16, + "iconFontName": "sparkle", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "a6NOM", + "name": "iconMore", + "width": 16, + "height": 16, + "iconFontName": "dots-three", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "uoKNo", + "name": "Editor Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 32, + 64 + ], + "children": [ + { + "type": "text", + "id": "bhmHx", + "name": "editorP1", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app, built with Tauri v2 + React + TypeScript + CodeMirror 6. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "pmflZ", + "name": "editorH2", + "fill": "$--foreground", + "content": "Architecture", + "lineHeight": 1.3, + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "YPHOR", + "name": "editorP2", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The app uses a four-panel layout: Sidebar for navigation, NoteList for browsing, Editor for content, and Inspector for metadata. All data lives in markdown files with YAML frontmatter, git-versioned.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "8m_Ts", + "name": "Inspector", + "clip": true, + "width": 260, + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "gr1Z5", + "name": "Inspector Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "608WF", + "name": "leftGroup", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Bpugj", + "name": "propIcon", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "hxMyO", + "name": "inspTitle", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "_i79H", + "name": "sidebarIcon", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "VgKF5", + "name": "Inspector Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "JeUUj", + "name": "Properties Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "LEpT-", + "name": "addPropBtn", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "1nsVc", + "name": "addPropTxt", + "fill": "$--muted-foreground", + "content": "+ Add property", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AUN9O", + "name": "propType", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EnTXQ", + "name": "propTypeLbl", + "fill": "$--muted-foreground", + "content": "TYPE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vS0Af", + "name": "propTypeVal", + "fill": "$--foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "s0Vdq", + "name": "propStatus", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UUhF3", + "name": "propStatusLbl", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "UMqtv", + "name": "propStatusBadge", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "transparent" + }, + "gap": 8, + "padding": [ + 1, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "P2h15", + "name": "Badge Text", + "fill": "$--accent-green", + "content": "ACTIVE", + "lineHeight": 1.33, + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ye0W8", + "name": "propOwner", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZvN9n", + "name": "propOwnerLbl", + "fill": "$--muted-foreground", + "content": "OWNER", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "p18lp", + "name": "propOwnerVal", + "fill": "$--foreground", + "content": "Luca", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "YgQHc", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "MK0WC", + "name": "relGroup1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "_rh_c", + "name": "relGrp1Title", + "fill": "$--muted-foreground", + "content": "BELONGS TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "wQayu", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5BZc-", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Laputa", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "bj3hA", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "9NFxD", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HoFKQ", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Building", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "U_GEF", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "Hf4E8", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "yVLK0", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "EShyo", + "name": "relGroup2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "CPHxH", + "name": "relGrp2Title", + "fill": "$--muted-foreground", + "content": "RELATED TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "F4ygG", + "name": "relMigrationBtn", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5xPef", + "name": "migrationTxt", + "fill": "$--accent-red", + "content": "Shadcn Migration", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "tLBGZ", + "name": "expIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "flask", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + }, + { + "type": "frame", + "id": "3UaSi", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "suN12", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zL5_Y", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "JbVdn", + "name": "blTitle", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ob0bb", + "name": "blTitleTxt", + "rotation": -0.5285936385085725, + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "3ZsuW", + "name": "blItem1", + "fill": "$--primary", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wkQVV", + "name": "blItem2", + "fill": "$--primary", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "6s0lo", + "name": "blItem3", + "fill": "$--primary", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "r7vXk", + "name": "History Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "iw_bo", + "name": "histTitle", + "fill": "$--muted-foreground", + "content": "HISTORY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xzvFM", + "name": "histItem1", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "Ut4ez", + "name": "histItem1Hash", + "fill": "$--accent-blue", + "content": "a089f44 · feat: migrate to shadcn/ui", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "svS7D", + "name": "histItem1Date", + "fill": "$--muted-foreground", + "content": "Feb 16, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rwIrR", + "name": "histItem2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "l6CYP", + "name": "histItem2Hash", + "fill": "$--accent-blue", + "content": "5a4b4ac · feat: emoji favicon", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "httIc", + "name": "histItem2Date", + "fill": "$--muted-foreground", + "content": "Feb 15, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Lo-el", + "name": "lightStatusBar", + "width": "fill_container", + "height": 30, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "padding": [ + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "b51Tr", + "name": "Status Left", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ojWSJ", + "name": "versionIcon", + "width": 14, + "height": 14, + "iconFontName": "box", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "qB18p", + "name": "versionText", + "fill": "$--muted-foreground", + "content": "v0.4.2", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "text", + "id": "ykdyj", + "name": "sep1", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "3B0P6", + "name": "branchIcon", + "width": 14, + "height": 14, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "HpD4X", + "name": "branchText", + "fill": "$--muted-foreground", + "content": "main", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "6RVGg", + "name": "sep2", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "h9Ul_", + "name": "syncIcon", + "width": 13, + "height": 13, + "iconFontName": "refresh-cw", + "iconFontFamily": "lucide", + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "yer4n", + "name": "syncText", + "fill": "$--muted-foreground", + "content": "Synced 2m ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Ggt4q", + "name": "Status Right", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "YowOM", + "name": "aiIcon", + "width": 13, + "height": 13, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "bVd9g", + "name": "aiText", + "fill": "$--muted-foreground", + "content": "Claude Sonnet 4", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "8QSq1", + "name": "sep3", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "z-rFh", + "name": "notesCount", + "width": 13, + "height": 13, + "iconFontName": "file-text", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "U_qqx", + "name": "notesText", + "fill": "$--muted-foreground", + "content": "1,247 notes", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MQE9Z", + "name": "sep4", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "XB_ZN", + "name": "bellIcon", + "width": 13, + "height": 13, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "gmulL", + "name": "settingsIcon", + "width": 13, + "height": 13, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "QR-Au", + "x": 0, + "y": 1050, + "name": "Full Layout — Search Panel Active", + "theme": { + "Mode": "Light" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "D2byp", + "name": "macOS Title Bar", + "width": "fill_container", + "height": 38, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EDFgx", + "name": "trafficLights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "DfcH7", + "name": "closeBtn", + "fill": "#FF5F57", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "k4jwL", + "name": "minimizeBtn", + "fill": "#FEBC2E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "4la7Z", + "name": "maximizeBtn", + "fill": "#28C840", + "width": 12, + "height": 12 + } + ] + } + ] + }, + { + "type": "frame", + "id": "md1g3", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "A_kBk", + "name": "Panel: Sidebar", + "clip": true, + "width": 250, + "height": "fill_container", + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--sidebar-border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GLS8B", + "name": "Filters", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 1, + "padding": [ + 8, + 8, + 16, + 8 + ], + "children": [ + { + "type": "frame", + "id": "5KmsU", + "name": "filterAll", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CO7qs", + "name": "leftAll", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UqiN-", + "name": "iconAll", + "width": 18, + "height": 18, + "iconFontName": "tray-fill", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "GQxwa", + "name": "fAllTxt", + "fill": "$--foreground", + "content": "Untagged", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "OszI0", + "name": "countAll", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "aws3Z", + "name": "badgeAllTxt", + "fill": "$--muted-foreground", + "content": "24", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dwASK", + "name": "filterUntagged", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RFfYP", + "name": "leftUntagged", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "b4vff", + "name": "untaggedIcon", + "width": 18, + "height": 18, + "iconFontName": "cardholder", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--primary" + }, + { + "type": "text", + "id": "t3WAm", + "name": "untaggedTxt", + "fill": "$--primary", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "fHpat", + "name": "countUntagged", + "height": 20, + "fill": "$--primary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZOPgU", + "name": "untaggedBadgeTxt", + "fill": "$--primary-foreground", + "content": "9247", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ], + "fill": "#4a9eff18" + }, + { + "type": "frame", + "id": "xfTev", + "name": "filterTrash", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Y4E4C", + "name": "leftTrash", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "kK01T", + "name": "trashIcon", + "width": 18, + "height": 18, + "iconFontName": "trash", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "-LXnc", + "name": "trashTxt", + "fill": "$--foreground", + "content": "Archive", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "nt-Xb", + "name": "countTrash", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rRo0q", + "name": "trashBadgeTxt", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "F3pkw", + "name": "filterChanges", + "width": "fill_container", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wcNVU", + "name": "leftChanges", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "xhrmm", + "name": "iconChanges", + "width": 18, + "height": 18, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "prgnw", + "name": "fChangesTxt", + "fill": "$--foreground", + "content": "Changes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "PugNW", + "name": "countChanges", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5bfMY", + "name": "badgeChangesTxt", + "fill": "$--muted-foreground", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "JUzOE", + "name": "Sections", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "OXkKG", + "name": "projGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6, + 8, + 6 + ], + "children": [ + { + "type": "frame", + "id": "G9YEB", + "name": "projHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jL0bz", + "name": "leftProj", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1LJDb", + "name": "projIcon", + "width": 18, + "height": 18, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-red" + }, + { + "type": "text", + "id": "fsRhn", + "name": "projLabel", + "fill": "$--foreground", + "content": "Projects", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "EK_iu", + "name": "projChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "_MfW7", + "name": "projItem1", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "buYPs", + "name": "proj1Txt", + "fill": "$--accent-red", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "TsI_D", + "name": "projItem2", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "_h7S1", + "name": "proj2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bNfnr", + "name": "projItem3", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9SY8F", + "name": "proj3Txt", + "fill": "$--muted-foreground", + "content": "AI Habit Tracker", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "CjIEL", + "name": "respGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "U5NLf", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "5yyfh", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hjjpp", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "medal", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "8paVp", + "name": "respLabel", + "fill": "$--foreground", + "content": "Responsibilities", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "YKDWL", + "name": "respChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lmqzR", + "name": "procGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "UOvjm", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "j6Mzi", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hTccp", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "0o3Rd", + "name": "Procedures", + "fill": "$--foreground", + "content": "Procedures", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "CuU3s", + "name": "procChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Lbn-0", + "name": "peopleGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "k4VBq", + "name": "peopleHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Ilgp-", + "name": "leftPeople", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "AK0H9", + "name": "peopleIcon", + "width": 18, + "height": 18, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "s9plC", + "name": "peopleLbl", + "fill": "$--foreground", + "content": "People", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "Ry1Km", + "name": "peopleChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Xo_LE", + "name": "eventsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "x-sYh", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XG0mM", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "xinWJ", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "calendar-blank", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "5YiYH", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Events", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "4kX5T", + "name": "eventsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N2Kl0", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "OC_Wz", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "_COf_", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "5ddUS", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "mYK3y", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Topics", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "CUHPs", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "c-AgB", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "updPo", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kIAbE", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JQy54", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "Hh9wq", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "27zSn", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "FQdQQ", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FWkK6", + "name": "leftEvents", + "gap": 8, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "V0gxx", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "AM8SJ", + "name": "eventsLbl", + "fill": "$--muted-foreground", + "content": "Create new type", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "a4iCv", + "name": "Commit Area", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "padding": 12, + "children": [ + { + "type": "frame", + "id": "c6wjQ", + "name": "commitBtn", + "width": "fill_container", + "fill": "$--primary", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8YmeU", + "name": "commitIcon", + "width": 14, + "height": 14, + "iconFontName": "git-commit-horizontal", + "iconFontFamily": "lucide", + "fill": "$--primary-foreground" + }, + { + "type": "text", + "id": "3miZQ", + "name": "commitTxt", + "fill": "$--primary-foreground", + "content": "Commit & Push", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "YXy6F", + "name": "commitBadge", + "height": 18, + "fill": "#ffffff40", + "cornerRadius": 9, + "padding": [ + 0, + 5 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9q2NX", + "name": "commitBadgeTxt", + "fill": "$--white", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "S5zsk", + "name": "Resize Handle", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "RL1ew", + "name": "Panel: NoteList", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$--card", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "AVROe", + "name": "NoteList Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 14, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3Dlx5", + "name": "nlTitle", + "fill": "$--foreground", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "B_hDb", + "name": "nlActions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1o_2j", + "name": "searchIcon", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "wI4DJ", + "name": "plusIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Rqh8Y", + "name": "Note Items", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GsD3O", + "name": "Backlinks Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "O3ith", + "name": "Note Item Selected", + "width": "fill_container", + "fill": "$--accent-red-light", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E9E9E7" + }, + "children": [ + { + "type": "rectangle", + "id": "LmFnl", + "name": "leftAccent", + "fill": "$--accent-red", + "width": 3, + "height": "fill_container" + }, + { + "type": "frame", + "id": "6jJVC", + "name": "noteSelContent", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "3eUuB", + "name": "noteSelRow", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HwQTu", + "name": "titleGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oh0Nx", + "name": "noteSelTitle", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "92t6N", + "name": "ico", + "width": 14, + "height": 14, + "iconFontName": "wrench", + "iconFontFamily": "lucide", + "fill": "$--accent-red" + } + ] + } + ] + }, + { + "type": "text", + "id": "iapFF", + "name": "noteSelSnip", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dbFN3", + "name": "noteSelTime", + "fill": "$--accent-red", + "content": "2m ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "-5PW8", + "name": "Related Notes Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "IkgDl", + "name": "Related Notes Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "el34m", + "name": "rnLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ffLM0", + "name": "rnLabel", + "fill": "$--muted-foreground", + "content": "RELATED NOTES", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "A7jbI", + "name": "rnCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "6Cw-D", + "name": "rnRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EkBrm", + "name": "rnFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "OiAPI", + "name": "rnPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "IFfVO", + "name": "rnChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "XG9Tg", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "ibW95", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "_MlWz", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MZJ5r", + "name": "note2Title", + "fill": "$--foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "_qdsw", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + } + ] + }, + { + "type": "text", + "id": "2liVx", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Redesigning portfolio site with modern stack and updated visual language...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "TCQuI", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "43UHB", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "DSC7W", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fjSDQ", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wTu8C", + "name": "note2Title", + "fill": "$--foreground", + "content": "Sample note 2", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "1QPiQ", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + } + ] + }, + { + "type": "text", + "id": "43L7m", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Lorem ipsum dolor sit amet consequetur with modern stack and updated language...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "RcKQy", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "GnvhS", + "name": "Events Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "D_Dxu", + "name": "Events Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vDIey", + "name": "evLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "q7YSK", + "name": "evLabel", + "fill": "$--muted-foreground", + "content": "EVENTS", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "HrCep", + "name": "evCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KC4JY", + "name": "evRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8Bqzc", + "name": "evFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "Ncjba", + "name": "evPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "wVeQy", + "name": "evChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "LLaDT", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "dk5T8", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yq1MX", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "azv9n", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "bzPbO", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "W2zQX", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "yiflh", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qpVBh", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "H2GoU", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jUxXL", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zlSrF", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Matteo", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "QFA7g", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "S7tfE", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "84akW", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "kqcSS", + "name": "Resize Handle 2", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "yI94h", + "name": "Panel: Editor", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "IbaLP", + "name": "Tab Bar", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WFTLH", + "name": "Tab Active", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5qYgu", + "name": "tab1Txt", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "TG1oa", + "name": "tab1Close", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "s3CbP", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LJqW3", + "name": "tab2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "9u2Lp", + "name": "tab2Close", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "beMtf", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "2jnhM", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DB5oM", + "name": "iconPlus", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "aD6Zp", + "name": "iconSplit", + "width": 16, + "height": 16, + "iconFontName": "columns", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "jOY8N", + "name": "iconExpand", + "width": 16, + "height": 16, + "iconFontName": "arrows-out-simple", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RIloA", + "name": "Editor Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "xQh-S", + "name": "Search Scrim", + "x": 0, + "y": 0, + "width": "fill_container", + "height": "fill_container", + "fill": "#00000008" + }, + { + "type": "frame", + "id": "O1H0q", + "name": "Editor Left", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "yVgkY", + "name": "Info Bar", + "width": "fill_container", + "height": 45, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "6R_ol", + "name": "infoLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hqrF6", + "name": "breadcrumbType", + "fill": "$--muted-foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wri-y", + "name": "breadcrumbSep", + "fill": "$--muted-foreground", + "content": "›", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "aJ9Ij", + "name": "breadcrumbName", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "PXDzs", + "name": "dotSep", + "fill": "$--muted-foreground", + "content": "·", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Y7ORU", + "name": "modifiedTxt", + "fill": "$--accent-yellow", + "content": "M", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "3mw5a", + "name": "infoRight", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "TLrxL", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "3qqRK", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "k_wJK", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "cursor-text", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "l1J1b", + "name": "iconAI", + "width": 16, + "height": 16, + "iconFontName": "sparkle", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "OYxYL", + "name": "iconMore", + "width": 16, + "height": 16, + "iconFontName": "dots-three", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "L5ga1", + "name": "Editor Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 32, + 64 + ], + "children": [ + { + "type": "text", + "id": "31dfV", + "name": "editorP1", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app, built with Tauri v2 + React + TypeScript + CodeMirror 6. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "hT17f", + "name": "editorH2", + "fill": "$--foreground", + "content": "Architecture", + "lineHeight": 1.3, + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "rhk-8", + "name": "editorP2", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The app uses a four-panel layout: Sidebar for navigation, NoteList for browsing, Editor for content, and Inspector for metadata. All data lives in markdown files with YAML frontmatter, git-versioned.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "GmAF6", + "name": "Inspector", + "clip": true, + "width": 260, + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "x_zWw", + "name": "Inspector Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jusHS", + "name": "leftGroup", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jAvXS", + "name": "propIcon", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "2DFD2", + "name": "inspTitle", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "h3_LP", + "name": "sidebarIcon", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "fAW8G", + "name": "Inspector Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "6kMFv", + "name": "Properties Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "o0GPt", + "name": "addPropBtn", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nFYSs", + "name": "addPropTxt", + "fill": "$--muted-foreground", + "content": "+ Add property", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Lix0z", + "name": "propType", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ui6BQ", + "name": "propTypeLbl", + "fill": "$--muted-foreground", + "content": "TYPE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "xvb_A", + "name": "propTypeVal", + "fill": "$--foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EoxR3", + "name": "propStatus", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mhPbZ", + "name": "propStatusLbl", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "V8dLb", + "name": "propStatusBadge", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "transparent" + }, + "gap": 8, + "padding": [ + 1, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HklId", + "name": "Badge Text", + "fill": "$--accent-green", + "content": "ACTIVE", + "lineHeight": 1.33, + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xWWeV", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "faCRJ", + "name": "relGroup1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "e4hku", + "name": "relGrp1Title", + "fill": "$--muted-foreground", + "content": "BELONGS TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Ucmgk", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HP94T", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Laputa", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "VCM1s", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "NKNrl", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "7Fgue", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Building", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "ot-0i", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "p85Ij", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "twL0t", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "L799V", + "name": "relGroup2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "hT1h0", + "name": "relGrp2Title", + "fill": "$--muted-foreground", + "content": "RELATED TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D_WgI", + "name": "relMigrationBtn", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ec81_", + "name": "migrationTxt", + "fill": "$--accent-red", + "content": "Shadcn Migration", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "S-vxq", + "name": "expIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "flask", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + }, + { + "type": "frame", + "id": "qiAMM", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ny0XL", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "EW804", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "iakOI", + "name": "blTitle", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mIh0h", + "name": "blTitleTxt", + "rotation": -0.5285936385085725, + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "qRWB3", + "name": "blItem1", + "fill": "$--primary", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W5Kbo", + "name": "blItem2", + "fill": "$--primary", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "74gGV", + "name": "blItem3", + "fill": "$--primary", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MGyob", + "name": "History Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "yQzzw", + "name": "histTitle", + "fill": "$--muted-foreground", + "content": "HISTORY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D4Tgg", + "name": "histItem1", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "WGiuL", + "name": "histItem1Hash", + "fill": "$--accent-blue", + "content": "a089f44 · feat: migrate to shadcn/ui", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "it7Kq", + "name": "histItem1Date", + "fill": "$--muted-foreground", + "content": "Feb 16, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "q_JXB", + "name": "histItem2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "FvrLg", + "name": "histItem2Hash", + "fill": "$--accent-blue", + "content": "5a4b4ac · feat: emoji favicon", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kDCXM", + "name": "histItem2Date", + "fill": "$--muted-foreground", + "content": "Feb 15, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "iCeGZ", + "name": "Search Panel", + "x": 80, + "y": 60, + "width": 480, + "fill": "$--card", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "shadow": [ + { + "x": 0, + "y": 8, + "blur": 32, + "spread": -4, + "fill": "#00000020" + } + ], + "layout": "vertical", + "clip": true, + "children": [ + { + "type": "frame", + "id": "CF2zr", + "name": "searchInputRow", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 14, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "O_fof", + "name": "searchIcon", + "width": 18, + "height": 18, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--primary" + }, + { + "type": "text", + "id": "iLTe7", + "name": "searchQuery", + "fill": "$--foreground", + "content": "time management", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Ouy78", + "name": "searchClearBtn", + "width": 20, + "height": 20, + "fill": "$--muted", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mI1q5", + "name": "clearX", + "width": 12, + "height": 12, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fGvPp", + "name": "resultsHeader", + "width": "fill_container", + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "h0VR7", + "name": "resultsCount", + "fill": "$--muted-foreground", + "content": "3 results", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "8NV5p", + "name": "searchResult1", + "width": "fill_container", + "fill": "$--accent-blue-light", + "layout": "vertical", + "gap": 4, + "padding": [ + 10, + 16 + ], + "children": [ + { + "type": "frame", + "id": "nGwMG", + "name": "sr1Row", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fselJ", + "name": "sr1Title", + "fill": "$--foreground", + "content": "Time Management Strategies", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "6BhB1", + "name": "sr1Icon", + "width": 14, + "height": 14, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "text", + "id": "mrzGz", + "name": "sr1Snippet", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Effective time management requires clear priorities and consistent review cycles...", + "lineHeight": 1.4, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "uGf_u", + "name": "searchResult2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 10, + 16 + ], + "children": [ + { + "type": "frame", + "id": "M3Z_b", + "name": "sr2Row", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "_afkK", + "name": "sr2Title", + "fill": "$--foreground", + "content": "Weekly Review Process", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "f9OL0", + "name": "sr2Icon", + "width": 14, + "height": 14, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "fill": "$--accent-purple" + } + ] + }, + { + "type": "text", + "id": "Rn89e", + "name": "sr2Snippet", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Every Sunday: review tasks, update time blocks, plan the week ahead...", + "lineHeight": 1.4, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "D4qLW", + "name": "searchResult3", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 10, + 16, + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "dXBPc", + "name": "sr3Row", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "7eWQ_", + "name": "sr3Title", + "fill": "$--foreground", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "0KeJ1", + "name": "sr3Icon", + "width": 14, + "height": 14, + "iconFontName": "calendar-blank", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + }, + { + "type": "text", + "id": "UcByL", + "name": "sr3Snippet", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Allocated time management focus blocks in Q1 for deep work sessions...", + "lineHeight": 1.4, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IMqpw", + "name": "searchFooter", + "width": "fill_container", + "fill": "$--muted", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "gap": 16, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6gOIh", + "name": "footerHint1", + "fill": "$--muted-foreground", + "content": "↑↓ Navigate", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "LHkSK", + "name": "footerHint2", + "fill": "$--muted-foreground", + "content": "↵ Open", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qqfIt", + "name": "footerHint3", + "fill": "$--muted-foreground", + "content": "Esc Close", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "L0KqX", + "name": "lightStatusBar", + "width": "fill_container", + "height": 30, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "padding": [ + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Np9m-", + "name": "Status Left", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "V3HFi", + "name": "versionIcon", + "width": 14, + "height": 14, + "iconFontName": "box", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "YkNbh", + "name": "versionText", + "fill": "$--muted-foreground", + "content": "v0.4.2", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "text", + "id": "JH56P", + "name": "sep1", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "qL-f2", + "name": "branchIcon", + "width": 14, + "height": 14, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "yro7z", + "name": "branchText", + "fill": "$--muted-foreground", + "content": "main", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "CXGWw", + "name": "sep2", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "xOHNm", + "name": "syncIcon", + "width": 13, + "height": 13, + "iconFontName": "refresh-cw", + "iconFontFamily": "lucide", + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "j4lBc", + "name": "syncText", + "fill": "$--muted-foreground", + "content": "Synced 2m ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ihq0v", + "name": "Status Right", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "kz722", + "name": "aiIcon", + "width": 13, + "height": 13, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "qzFAM", + "name": "aiText", + "fill": "$--muted-foreground", + "content": "Claude Sonnet 4", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "stI-S", + "name": "sep3", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "DXxdw", + "name": "notesCount", + "width": 13, + "height": 13, + "iconFontName": "file-text", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "OehL-", + "name": "notesText", + "fill": "$--muted-foreground", + "content": "1,247 notes", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ZNpGu", + "name": "sep4", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "g-Zfe", + "name": "bellIcon", + "width": 13, + "height": 13, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "nUudo", + "name": "settingsIcon", + "width": 13, + "height": 13, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6DhJh", + "x": 0, + "y": 2100, + "name": "Full Layout — Rich Properties + Autocomplete", + "theme": { + "Mode": "Light" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "U52CB", + "name": "macOS Title Bar", + "width": "fill_container", + "height": 38, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "9xPhv", + "name": "trafficLights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "xK0i1", + "name": "closeBtn", + "fill": "#FF5F57", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "W-zEa", + "name": "minimizeBtn", + "fill": "#FEBC2E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "Lz9Ly", + "name": "maximizeBtn", + "fill": "#28C840", + "width": 12, + "height": 12 + } + ] + } + ] + }, + { + "type": "frame", + "id": "-eZ2Q", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "qWB0S", + "name": "Panel: Sidebar", + "clip": true, + "width": 250, + "height": "fill_container", + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--sidebar-border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "MJJ3h", + "name": "Filters", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 1, + "padding": [ + 8, + 8, + 16, + 8 + ], + "children": [ + { + "type": "frame", + "id": "Apugb", + "name": "filterAll", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MRMwA", + "name": "leftAll", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "bF9xl", + "name": "iconAll", + "width": 18, + "height": 18, + "iconFontName": "tray-fill", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "vDABp", + "name": "fAllTxt", + "fill": "$--foreground", + "content": "Untagged", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "uMV2b", + "name": "countAll", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5CjlO", + "name": "badgeAllTxt", + "fill": "$--muted-foreground", + "content": "24", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nYVMy", + "name": "filterUntagged", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "y2a7H", + "name": "leftUntagged", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "GTbCN", + "name": "untaggedIcon", + "width": 18, + "height": 18, + "iconFontName": "cardholder", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "MyPei", + "name": "untaggedTxt", + "fill": "$--foreground", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Jq-_7", + "name": "countUntagged", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sq-ni", + "name": "untaggedBadgeTxt", + "fill": "$--muted-foreground", + "content": "6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "OwWRW", + "name": "filterTrash", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "nEDEe", + "name": "leftTrash", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "q71pa", + "name": "trashIcon", + "width": 18, + "height": 18, + "iconFontName": "trash", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "ycx_R", + "name": "trashTxt", + "fill": "$--foreground", + "content": "Archive", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "q1o3f", + "name": "countTrash", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JDhNE", + "name": "trashBadgeTxt", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "1cNey", + "name": "filterChanges", + "width": "fill_container", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "3Zjt9", + "name": "leftChanges", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "HwG_w", + "name": "iconChanges", + "width": 18, + "height": 18, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "hsvfN", + "name": "fChangesTxt", + "fill": "$--foreground", + "content": "Changes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "3iQa3", + "name": "countChanges", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IpnSM", + "name": "badgeChangesTxt", + "fill": "$--muted-foreground", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "4zVQn", + "name": "Sections", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "2KL00", + "name": "projGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6, + 8, + 6 + ], + "children": [ + { + "type": "frame", + "id": "60D7x", + "name": "projHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Ns0Uw", + "name": "leftProj", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "TlBFF", + "name": "projIcon", + "width": 18, + "height": 18, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-red" + }, + { + "type": "text", + "id": "DU0f7", + "name": "projLabel", + "fill": "$--foreground", + "content": "Projects", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "LhRc1", + "name": "projChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "XpysB", + "name": "projItem1", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EHkKO", + "name": "proj1Txt", + "fill": "$--accent-red", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "PY1Qz", + "name": "projItem2", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lklgj", + "name": "proj2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "uDbn3", + "name": "projItem3", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4p5Tw", + "name": "proj3Txt", + "fill": "$--muted-foreground", + "content": "AI Habit Tracker", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lTCT0", + "name": "respGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "lNMXl", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Sifl6", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Lzww_", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "medal", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "MDzfV", + "name": "respLabel", + "fill": "$--foreground", + "content": "Responsibilities", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "nIT14", + "name": "respChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jxdL-", + "name": "procGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "K6ZYE", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0yx1-", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "q8gWU", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "UFUbg", + "name": "Procedures", + "fill": "$--foreground", + "content": "Procedures", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "znhle", + "name": "procChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "sVqwB", + "name": "peopleGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "x5bql", + "name": "peopleHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rT7Sw", + "name": "leftPeople", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EdWKN", + "name": "peopleIcon", + "width": 18, + "height": 18, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "IeV1X", + "name": "peopleLbl", + "fill": "$--foreground", + "content": "People", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "ijXZt", + "name": "peopleChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "jJrjZ", + "name": "personItem1", + "width": "fill_container", + "fill": "$--accent-yellow-light", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "2qK41", + "name": "person1Txt", + "fill": "$--accent-yellow", + "content": "Grant", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "RhLex", + "name": "personItem2", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "o6wg_", + "name": "person2Txt", + "fill": "$--muted-foreground", + "content": "Matteo", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "_tP3q", + "name": "personItem3", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tLGQc", + "name": "person3Txt", + "fill": "$--muted-foreground", + "content": "Sarah", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "HNWez", + "name": "eventsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "4TSY6", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "esbbT", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "6v9eG", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "calendar-blank", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "Gbmba", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Events", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "4HZQC", + "name": "eventsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "k8Qek", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "ZJKIA", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aOgWv", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4uMpd", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "55-Jj", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Topics", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "C5q2c", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Dl7QT", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "zssYh", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XuI_M", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hKKJ2", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "HJ6Sg", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "R9E3Y", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "KyUdZ", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "7E72I", + "name": "leftEvents", + "gap": 8, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "akfbn", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "NUGRe", + "name": "eventsLbl", + "fill": "$--muted-foreground", + "content": "Create new type", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "uAgKJ", + "name": "Commit Area", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "padding": 12, + "children": [ + { + "type": "frame", + "id": "0zA0z", + "name": "commitBtn", + "width": "fill_container", + "fill": "$--primary", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "w07m8", + "name": "commitIcon", + "width": 14, + "height": 14, + "iconFontName": "git-commit-horizontal", + "iconFontFamily": "lucide", + "fill": "$--primary-foreground" + }, + { + "type": "text", + "id": "4D-Y_", + "name": "commitTxt", + "fill": "$--primary-foreground", + "content": "Commit & Push", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "vWH8M", + "name": "commitBadge", + "height": 18, + "fill": "#ffffff40", + "cornerRadius": 9, + "padding": [ + 0, + 5 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "969L9", + "name": "commitBadgeTxt", + "fill": "$--white", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "UYNhO", + "name": "Resize Handle", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "m93C0", + "name": "Panel: NoteList", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$--card", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "978jd", + "name": "NoteList Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 14, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hSxft", + "name": "nlTitle", + "fill": "$--foreground", + "content": "People", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "vOlfM", + "name": "nlActions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8oc-r", + "name": "searchIcon", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "oF9bA", + "name": "plusIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "e99_i", + "name": "Note Items", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "3lYpU", + "name": "Backlinks Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "lmika", + "name": "Note Item Selected", + "width": "fill_container", + "fill": "$--accent-yellow-light", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E9E9E7" + }, + "children": [ + { + "type": "rectangle", + "id": "B3XPh", + "name": "leftAccent", + "fill": "$--accent-yellow", + "width": 3, + "height": "fill_container" + }, + { + "type": "frame", + "id": "_6k8_", + "name": "noteSelContent", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "3aPer", + "name": "noteSelRow", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CKtmP", + "name": "titleGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4sDfy", + "name": "noteSelTitle", + "fill": "$--foreground", + "content": "Grant", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "C_5rR", + "name": "ico", + "width": 14, + "height": 14, + "iconFontName": "users", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "2j16A", + "name": "noteSelSnip", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Engineering lead at Acme Corp, focused on infrastructure and platform...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "4aybL", + "name": "noteSelTime", + "fill": "$--accent-yellow", + "content": "3h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "-2gWn", + "name": "Related Notes Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ZDyjn", + "name": "Related Notes Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C3498", + "name": "rnLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "L6f44", + "name": "rnLabel", + "fill": "$--muted-foreground", + "content": "RELATED NOTES", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SP2mP", + "name": "rnCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VUxRC", + "name": "rnRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "K0u69", + "name": "rnFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "5I6Q6", + "name": "rnPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "nWc9j", + "name": "rnChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "0g78J", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "UYt-3", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UHgKQ", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "I3Kzu", + "name": "note2Title", + "fill": "$--foreground", + "content": "Matteo", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "2SrYa", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "u9IDo", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Product manager working on growth initiatives and user research...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bIgVf", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZCkrg", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "2HTTQ", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "lpW_m", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Yjt0e", + "name": "note2Title", + "fill": "$--foreground", + "content": "Sarah", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "n8Bdp", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "sBpy_", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Designer specializing in design systems and component libraries...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SfEiI", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "1h ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "UjKyN", + "name": "Events Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "1ieBk", + "name": "Events Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jL0Cn", + "name": "evLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WPdoU", + "name": "evLabel", + "fill": "$--muted-foreground", + "content": "EVENTS", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "begIt", + "name": "evCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "unj5n", + "name": "evRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "96nxy", + "name": "evFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "anHN8", + "name": "evPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "51Def", + "name": "evChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "1Y6r5", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "NtQjv", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AGbrd", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xla4h", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "_FbgU", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "0VoCK", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "6Xbdy", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KazQF", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "1_Cz1", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "s4GwN", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U3H8E", + "name": "note3Title", + "fill": "$--foreground", + "content": "Meeting with Matteo", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "gqOjx", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "-o6br", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Discussed Q1 goals and hiring priorities with the engineering team leads...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "N4dE4", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Yesterday", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "hli5_", + "name": "Resize Handle 2", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "OSNm1", + "name": "Panel: Editor", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "0bMNp", + "name": "Tab Bar", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MIU7l", + "name": "Tab Active", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "08oJY", + "name": "tab1Txt", + "fill": "$--foreground", + "content": "Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "Nb4TP", + "name": "tab1Close", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "x41zt", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OeJUm", + "name": "tab2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "j0rhL", + "name": "tab2Close", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "JSjRx", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "K33DX", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "cvEPw", + "name": "iconPlus", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "l33JZ", + "name": "iconSplit", + "width": 16, + "height": 16, + "iconFontName": "columns", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "CnxJJ", + "name": "iconExpand", + "width": 16, + "height": 16, + "iconFontName": "arrows-out-simple", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "2tTb3", + "name": "Editor Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "VvcZt", + "name": "Editor Left", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "FS1KF", + "name": "Info Bar", + "width": "fill_container", + "height": 45, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "IRSz3", + "name": "infoLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CPWzB", + "name": "breadcrumbType", + "fill": "$--muted-foreground", + "content": "Person", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vGAbj", + "name": "breadcrumbSep", + "fill": "$--muted-foreground", + "content": "›", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "3UmsD", + "name": "breadcrumbName", + "fill": "$--foreground", + "content": "Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "DN8so", + "name": "dotSep", + "fill": "$--muted-foreground", + "content": "·", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kXgl0", + "name": "modifiedTxt", + "fill": "$--accent-yellow", + "content": "M", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "3wpHS", + "name": "infoRight", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MDVCo", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "N_aAh", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "3BBMe", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "cursor-text", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "tKvI2", + "name": "iconAI", + "width": 16, + "height": 16, + "iconFontName": "sparkle", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "D88o2", + "name": "iconMore", + "width": 16, + "height": 16, + "iconFontName": "dots-three", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vlazI", + "name": "Editor Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 32, + 64 + ], + "children": [ + { + "type": "text", + "id": "gabKY", + "name": "editorP1", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Grant is an engineering lead at Acme Corp, focused on infrastructure, Kubernetes, and platform reliability. He drives the technical roadmap for backend systems and mentors junior engineers.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "rZOkl", + "name": "editorH2", + "fill": "$--foreground", + "content": "Key Responsibilities", + "lineHeight": 1.3, + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VqiRL", + "name": "editorP2", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Leads the infrastructure team. Oversees Kubernetes cluster management, CI/CD pipelines, and service mesh. Reports to CTO. Weekly 1:1 on Thursdays.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "B_y3t", + "name": "Inspector", + "clip": true, + "width": 260, + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "IY9fD", + "name": "Inspector Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "w2rRY", + "name": "leftGroup", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rCy6H", + "name": "propIcon", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "74Es2", + "name": "inspTitle", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "pRO4e", + "name": "sidebarIcon", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "iSZtX", + "name": "Inspector Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "FGspn", + "name": "Properties Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "9Cl0d", + "name": "addPropBtn", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qqk-x", + "name": "addPropTxt", + "fill": "$--muted-foreground", + "content": "+ Add property", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "10Rpz", + "name": "propType", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BCqzK", + "name": "propTypeLbl", + "fill": "$--muted-foreground", + "content": "TYPE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "l7rGQ", + "name": "propTypeVal", + "fill": "$--foreground", + "content": "Person", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "X-DIV", + "name": "propCompany", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "_jYqg", + "name": "propCompanyLbl", + "fill": "$--muted-foreground", + "content": "COMPANY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "hBEEy", + "name": "propCompanyVal", + "fill": "$--foreground", + "content": "Acme Corp", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DplyA", + "name": "propRole", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Artv7", + "name": "propRoleLbl", + "fill": "$--muted-foreground", + "content": "ROLE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ZtdR4", + "name": "propRoleVal", + "fill": "$--foreground", + "content": "Eng Lead", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kr9yE", + "name": "propStatus", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lCQ2c", + "name": "propStatusLbl", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "UktKt", + "name": "propStatusBadge", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "transparent" + }, + "gap": 8, + "padding": [ + 1, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PZMuf", + "name": "Badge Text", + "fill": "$--accent-green", + "content": "ACTIVE", + "lineHeight": 1.33, + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "uq-Ch", + "name": "propTags", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "9-GAi", + "name": "propTagsLbl", + "fill": "$--muted-foreground", + "content": "TAGS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "um9TH", + "name": "tagsList", + "width": "fill_container", + "gap": 4, + "flexWrap": "wrap", + "children": [ + { + "type": "frame", + "id": "pAZPG", + "name": "tag1", + "fill": "$--accent-green-light", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LxWxl", + "name": "tag1Txt", + "fill": "$--accent-green", + "content": "infrastructure", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "uu7oj", + "name": "tag2", + "fill": "$--accent-blue-light", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F3Ono", + "name": "tag2Txt", + "fill": "$--accent-blue", + "content": "kubernetes", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "oII4d", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "LxgzI", + "name": "relGroup1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "M1hqt", + "name": "relGrp1Title", + "fill": "$--muted-foreground", + "content": "WORKS AT", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "1axQn", + "name": "relAcmeBtn", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "VKyg3", + "name": "acmeTxt", + "fill": "$--accent-red", + "content": "Acme Corp", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "GG63z", + "name": "companyIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + }, + { + "type": "frame", + "id": "kpHDy", + "name": "linkExistingActive", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--primary" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Q7UgS", + "name": "linkTxt", + "fill": "$--foreground", + "content": "Infra", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "RBm2-", + "name": "searchIco", + "width": 14, + "height": 14, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "X_w5s", + "name": "autocompleteDropdown", + "width": "fill_container", + "fill": "$--card", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "shadow": [ + { + "x": 0, + "y": 4, + "blur": 16, + "spread": -2, + "fill": "#00000015" + } + ], + "layout": "vertical", + "clip": true, + "children": [ + { + "type": "frame", + "id": "3Pxkn", + "name": "acItem1", + "width": "fill_container", + "fill": "$--accent-blue-light", + "gap": 8, + "padding": [ + 8, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "BgVut", + "name": "acIcon1", + "width": 14, + "height": 14, + "iconFontName": "medal", + "iconFontFamily": "phosphor", + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "RPfuk", + "name": "acTxt1", + "fill": "$--foreground", + "content": "Infrastructure Team", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "sxoEV", + "name": "acItem2", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "7ikv0", + "name": "acIcon2", + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "gVVyY", + "name": "acTxt2", + "fill": "$--foreground", + "content": "Infrastructure", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "r85eA", + "name": "acItem3", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "_BaES", + "name": "acIcon3", + "width": 14, + "height": 14, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + }, + { + "type": "text", + "id": "x-wlM", + "name": "acTxt3", + "fill": "$--foreground", + "content": "Infra Migration", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "N5Sll", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "imLWJ", + "name": "blTitle", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y-UG1", + "name": "blTitleTxt", + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "6K6Bd", + "name": "blItem1", + "fill": "$--primary", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "6EGhq", + "name": "blItem2", + "fill": "$--primary", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "FCfBL", + "name": "blItem3", + "fill": "$--primary", + "content": "Hiring Pipeline", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WAxYr", + "name": "lightStatusBar", + "width": "fill_container", + "height": 30, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "padding": [ + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "q_a9K", + "name": "Status Left", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Mlziz", + "name": "versionIcon", + "width": 14, + "height": 14, + "iconFontName": "box", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "gzUtx", + "name": "versionText", + "fill": "$--muted-foreground", + "content": "v0.4.2", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "text", + "id": "3x0kF", + "name": "sep1", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "hY-Is", + "name": "branchIcon", + "width": 14, + "height": 14, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "k9Hay", + "name": "branchText", + "fill": "$--muted-foreground", + "content": "main", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WKBM-", + "name": "sep2", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "lhY_1", + "name": "syncIcon", + "width": 13, + "height": 13, + "iconFontName": "refresh-cw", + "iconFontFamily": "lucide", + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "uELg8", + "name": "syncText", + "fill": "$--muted-foreground", + "content": "Synced 2m ago", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "c38hL", + "name": "Status Right", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "leRDx", + "name": "aiIcon", + "width": 13, + "height": 13, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "W6In1", + "name": "aiText", + "fill": "$--muted-foreground", + "content": "Claude Sonnet 4", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "rxqIt", + "name": "sep3", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "B0jTr", + "name": "notesCount", + "width": 13, + "height": 13, + "iconFontName": "file-text", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "01ygw", + "name": "notesText", + "fill": "$--muted-foreground", + "content": "1,247 notes", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BeA6T", + "name": "sep4", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "yNSZ0", + "name": "bellIcon", + "width": 13, + "height": 13, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "RbzrU", + "name": "settingsIcon", + "width": 13, + "height": 13, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "cDqLP", + "x": 0, + "y": 3150, + "name": "Full Layout — Changes View", + "theme": { + "Mode": "Light" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "2HK9s", + "name": "macOS Title Bar", + "width": "fill_container", + "height": 38, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AiPEP", + "name": "trafficLights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "X3X_h", + "name": "closeBtn", + "fill": "#FF5F57", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "P1J0D", + "name": "minimizeBtn", + "fill": "#FEBC2E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "3zuNz", + "name": "maximizeBtn", + "fill": "#28C840", + "width": 12, + "height": 12 + } + ] + } + ] + }, + { + "type": "frame", + "id": "-ekXq", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "M2KC-", + "name": "Panel: Sidebar", + "clip": true, + "width": 250, + "height": "fill_container", + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--sidebar-border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ZulZE", + "name": "Filters", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 1, + "padding": [ + 8, + 8, + 16, + 8 + ], + "children": [ + { + "type": "frame", + "id": "IS4Fm", + "name": "filterAll", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vPb76", + "name": "leftAll", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yuOG7", + "name": "iconAll", + "width": 18, + "height": 18, + "iconFontName": "tray-fill", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "XsyTZ", + "name": "fAllTxt", + "fill": "$--foreground", + "content": "Untagged", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "XP413", + "name": "countAll", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jwZW-", + "name": "badgeAllTxt", + "fill": "$--muted-foreground", + "content": "24", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nJQFj", + "name": "filterUntagged", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rYzZ5", + "name": "leftUntagged", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rOyw2", + "name": "untaggedIcon", + "width": 18, + "height": 18, + "iconFontName": "cardholder", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "y-u3E", + "name": "untaggedTxt", + "fill": "$--foreground", + "content": "All Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "kQQSy", + "name": "countUntagged", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "-He5A", + "name": "untaggedBadgeTxt", + "fill": "$--muted-foreground", + "content": "6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "z-rwa", + "name": "filterTrash", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EZw96", + "name": "leftTrash", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yFQYH", + "name": "trashIcon", + "width": 18, + "height": 18, + "iconFontName": "trash", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "zgVFp", + "name": "trashTxt", + "fill": "$--foreground", + "content": "Archive", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "2ZVsp", + "name": "countTrash", + "height": 20, + "fill": "$--secondary", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6KjzA", + "name": "trashBadgeTxt", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Jz98b", + "name": "filterChanges", + "width": "fill_container", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hCAhH", + "name": "leftChanges", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zut43", + "name": "iconChanges", + "width": 18, + "height": 18, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-orange" + }, + { + "type": "text", + "id": "eNZE_", + "name": "fChangesTxt", + "fill": "$--accent-orange", + "content": "Changes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Vd1IP", + "name": "countChanges", + "height": 20, + "fill": "$--accent-orange", + "cornerRadius": 9999, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WQevI", + "name": "badgeChangesTxt", + "fill": "$--primary-foreground", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ], + "fill": "#D9730D18" + } + ] + }, + { + "type": "frame", + "id": "iZGMX", + "name": "Sections", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "K9kps", + "name": "projGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6, + 8, + 6 + ], + "children": [ + { + "type": "frame", + "id": "NqU-g", + "name": "projHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "l4Db7", + "name": "leftProj", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UH57P", + "name": "projIcon", + "width": 18, + "height": 18, + "iconFontName": "wrench", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-red" + }, + { + "type": "text", + "id": "qhWRv", + "name": "projLabel", + "fill": "$--foreground", + "content": "Projects", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "2z0Fc", + "name": "projChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "hZaR_", + "name": "projItem1", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "KMKlm", + "name": "proj1Txt", + "fill": "$--accent-red", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "4auYC", + "name": "projItem2", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "-h_S-", + "name": "proj2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Xby4k", + "name": "projItem3", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 6, + 16, + 6, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zflkU", + "name": "proj3Txt", + "fill": "$--muted-foreground", + "content": "AI Habit Tracker", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yOvdh", + "name": "respGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "9NVPP", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KLgB7", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "_RJw9", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "medal", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "P2TLS", + "name": "respLabel", + "fill": "$--foreground", + "content": "Responsibilities", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "FMkvu", + "name": "respChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jdhEx", + "name": "procGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "yNxrB", + "name": "respHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ZbG_K", + "name": "leftResp", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Lrz9B", + "name": "respIcon", + "width": 18, + "height": 18, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "JYP7R", + "name": "Procedures", + "fill": "$--foreground", + "content": "Procedures", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "cuKa1", + "name": "procChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "9ZtV7", + "name": "peopleGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "bqUk7", + "name": "peopleHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "GDmNS", + "name": "leftPeople", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4N-jT", + "name": "peopleIcon", + "width": 18, + "height": 18, + "iconFontName": "users", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "eYxow", + "name": "peopleLbl", + "fill": "$--foreground", + "content": "People", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "zxTf-", + "name": "peopleChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oO2nv", + "name": "eventsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "01fhT", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BpjDl", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "WNSv4", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "calendar-blank", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "Z-BdI", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Events", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "VtYYq", + "name": "eventsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QB60u", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "dr3Dy", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gip-z", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "YWJHr", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "M4fMh", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Topics", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "be_vU", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jtFdo", + "name": "topicsGroup", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": { + "type": "color", + "color": "$--border", + "enabled": false + } + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 4, + 6 + ], + "children": [ + { + "type": "frame", + "id": "t051Q", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CSC3E", + "name": "leftEvents", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "wt2dG", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "leaf", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--accent-green" + }, + { + "type": "text", + "id": "qqxUQ", + "name": "eventsLbl", + "fill": "$--foreground", + "content": "Notes", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "icon_font", + "id": "OyEhu", + "name": "topicsChev", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "b0y4m", + "name": "eventsHeader", + "width": "fill_container", + "cornerRadius": 4, + "gap": 8, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XKefw", + "name": "leftEvents", + "gap": 8, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "5MMXe", + "name": "eventsIcon", + "width": 18, + "height": 18, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "weight": 700, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "yJWsr", + "name": "eventsLbl", + "fill": "$--muted-foreground", + "content": "Create new type", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wTdWJ", + "name": "Commit Area", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "padding": 12, + "children": [ + { + "type": "frame", + "id": "alvR1", + "name": "commitBtn", + "width": "fill_container", + "fill": "$--primary", + "cornerRadius": 6, + "gap": 6, + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "OZpvZ", + "name": "commitIcon", + "width": 14, + "height": 14, + "iconFontName": "git-commit-horizontal", + "iconFontFamily": "lucide", + "fill": "$--primary-foreground" + }, + { + "type": "text", + "id": "vye-W", + "name": "commitTxt", + "fill": "$--primary-foreground", + "content": "Commit & Push", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "M_lH9", + "name": "commitBadge", + "height": 18, + "fill": "#ffffff40", + "cornerRadius": 9, + "padding": [ + 0, + 5 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kTQAa", + "name": "commitBadgeTxt", + "fill": "$--white", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "aBuSJ", + "name": "Resize Handle", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "5CXKO", + "name": "Panel: NoteList", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$--card", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "UhLWn", + "name": "NoteList Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 14, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jyxqb", + "name": "nlTitle", + "fill": "$--foreground", + "content": "Changes", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "0eL1W", + "name": "nlActions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "XsWjG", + "name": "searchIcon", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "bBObW", + "name": "plusIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "TK7b7", + "name": "Note Items", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "dri89", + "name": "Backlinks Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "9mLoL", + "name": "Note Item Selected", + "width": "fill_container", + "fill": "$--accent-yellow-light", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E9E9E7" + }, + "children": [ + { + "type": "rectangle", + "id": "lmpZx", + "name": "leftAccent", + "fill": "$--accent-yellow", + "width": 3, + "height": "fill_container" + }, + { + "type": "frame", + "id": "CkBEU", + "name": "noteSelContent", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "0KzHG", + "name": "noteSelRow", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ScgZY", + "name": "titleGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3mYfY", + "name": "noteSelTitle", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "jqZDh", + "name": "modBadge", + "width": 18, + "height": 18, + "fill": "$--accent-yellow-light", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "v8EXM", + "name": "modBadgeTxt", + "fill": "$--accent-yellow", + "content": "M", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "mCaj6", + "name": "noteSelSnip", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mi4c4", + "name": "noteSelTime", + "fill": "$--accent-yellow", + "content": "Modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "MYwXd", + "name": "Related Notes Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "fKL4E", + "name": "Related Notes Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "eCRui", + "name": "rnLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UEtOs", + "name": "rnLabel", + "fill": "$--muted-foreground", + "content": "MODIFIED", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "geohb", + "name": "rnCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vW3Ux", + "name": "rnRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8-7YX", + "name": "rnFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "rJ2Qt", + "name": "rnPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "Agecx", + "name": "rnChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ef8xK", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "ikmO0", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DgJzU", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0SmlX", + "name": "note2Title", + "fill": "$--foreground", + "content": "Weekly Review Process", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "Ipmvo", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "pencil-simple", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "6-aLC", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Every Sunday: review tasks, update time blocks, plan the week ahead...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "35sd_", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "Modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "D1PDV", + "name": "Note Item", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "m3Yy9", + "name": "note2Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "G7YLm", + "name": "leafGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "VeAEm", + "name": "note2Title", + "fill": "$--foreground", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rXUE_", + "name": "leafIco", + "width": 14, + "height": 14, + "iconFontName": "pencil-simple", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "1G-GA", + "name": "note2Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Allocated focus blocks in Q1 for deep work sessions on infrastructure...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "p_rqw", + "name": "note2Time", + "fill": "$--muted-foreground", + "content": "Modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "3o8WQ", + "name": "Events Group", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "G7UWp", + "name": "Events Header", + "width": "fill_container", + "height": 32, + "fill": "$--muted", + "padding": [ + 0, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "msMtG", + "name": "evLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SGt7e", + "name": "evLabel", + "fill": "$--muted-foreground", + "content": "MODIFIED", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "1PVP7", + "name": "evCount", + "fill": "$--muted-foreground", + "content": "2", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FCzPr", + "name": "evRight", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UYR0D", + "name": "evFilter", + "width": 12, + "height": 12, + "iconFontName": "funnel", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "4jbCj", + "name": "evPlus", + "width": 12, + "height": 12, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "WRN9r", + "name": "evChev", + "width": 12, + "height": 12, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RiDwJ", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "Rvp4A", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "bnvDp", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QjRDJ", + "name": "note3Title", + "fill": "$--foreground", + "content": "Time Management Strategies", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "LeQCT", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "pencil-simple", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "vwDsc", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Effective time management requires clear priorities and consistent review...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "OPKF_", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PsBeq", + "name": "Note Item 2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 4, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "sPOHy", + "name": "note3Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "1Ecj7", + "name": "calGroup", + "width": "fill_container", + "gap": 6, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DlZRH", + "name": "note3Title", + "fill": "$--foreground", + "content": "Time Management Strategies", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "yi-xp", + "name": "calIco", + "width": 14, + "height": 14, + "iconFontName": "pencil-simple", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + } + ] + } + ] + }, + { + "type": "text", + "id": "CJPuv", + "name": "note3Snip", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Effective time management requires clear priorities and consistent review...", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "6MtFY", + "name": "note3Time", + "fill": "$--muted-foreground", + "content": "Modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "0kPjR", + "name": "Resize Handle 2", + "fill": "$--border", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "XRb-G", + "name": "Panel: Editor", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GMRdK", + "name": "Tab Bar", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iXash", + "name": "Tab Active", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eo9dK", + "name": "tab1Txt", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "6MLT7", + "name": "tab1Close", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "0UmLQ", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "1JK8S", + "name": "tab2Txt", + "fill": "$--muted-foreground", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "k2PZa", + "name": "tab2Close", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "bM7NZ", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "G6gUe", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "oVC29", + "name": "iconPlus", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "XwYxq", + "name": "iconSplit", + "width": 16, + "height": 16, + "iconFontName": "columns", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "8z4xd", + "name": "iconExpand", + "width": 16, + "height": 16, + "iconFontName": "arrows-out-simple", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZIoKq", + "name": "Editor Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "tJCEQ", + "name": "Editor Left", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "L9_bR", + "name": "Info Bar", + "width": "fill_container", + "height": 45, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "padding": [ + 6, + 16 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "7YAl6", + "name": "infoLeft", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "8RsE6", + "name": "breadcrumbType", + "fill": "$--muted-foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "aPIWW", + "name": "breadcrumbSep", + "fill": "$--muted-foreground", + "content": "›", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Hko61", + "name": "breadcrumbName", + "fill": "$--foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "UgLju", + "name": "dotSep", + "fill": "$--muted-foreground", + "content": "·", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "atZy3", + "name": "modifiedTxt", + "fill": "$--accent-yellow", + "content": "M", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "i_O9Q", + "name": "infoRight", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mATmR", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "7-BlR", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "git-branch", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "hggOM", + "name": "iconSearch", + "width": 16, + "height": 16, + "iconFontName": "cursor-text", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "lKS8-", + "name": "iconAI", + "width": 16, + "height": 16, + "iconFontName": "sparkle", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "etlhz", + "name": "iconMore", + "width": 16, + "height": 16, + "iconFontName": "dots-three", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "n-cpK", + "name": "Editor Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 32, + 64 + ], + "children": [ + { + "type": "text", + "id": "jnGoo", + "name": "editorP1", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Personal knowledge and life management desktop app, built with Tauri v2 + React + TypeScript + CodeMirror 6. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fwFRq", + "name": "editorH2", + "fill": "$--foreground", + "content": "Architecture", + "lineHeight": 1.3, + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "qK0e0", + "name": "editorP2", + "fill": "$--foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The app uses a four-panel layout: Sidebar for navigation, NoteList for browsing, Editor for content, and Inspector for metadata. All data lives in markdown files with YAML frontmatter, git-versioned.", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RkIhR", + "name": "Inspector", + "clip": true, + "width": 260, + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "0CJaZ", + "name": "Inspector Header", + "width": "fill_container", + "height": 45, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TPquS", + "name": "leftGroup", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "AVtUy", + "name": "propIcon", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "Lgi69", + "name": "inspTitle", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "uWuxn", + "name": "sidebarIcon", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "u6jJ2", + "name": "Inspector Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "MRZfg", + "name": "Properties Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "qC-P7", + "name": "addPropBtn", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HBg9i", + "name": "addPropTxt", + "fill": "$--muted-foreground", + "content": "+ Add property", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oHFVh", + "name": "propType", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Mk9GH", + "name": "propTypeLbl", + "fill": "$--muted-foreground", + "content": "TYPE", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "NUS5u", + "name": "propTypeVal", + "fill": "$--foreground", + "content": "Project", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EAnqI", + "name": "propStatus", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YZlCv", + "name": "propStatusLbl", + "fill": "$--muted-foreground", + "content": "STATUS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "khF0Y", + "name": "propStatusBadge", + "fill": "$--accent-green-light", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "transparent" + }, + "gap": 8, + "padding": [ + 1, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "H3X2o", + "name": "Badge Text", + "fill": "$--accent-green", + "content": "ACTIVE", + "lineHeight": 1.33, + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "F0I-7", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "10XXp", + "name": "relGroup1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "qh7dI", + "name": "relGrp1Title", + "fill": "$--muted-foreground", + "content": "BELONGS TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ovNo_", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "u6cth", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Laputa", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "911p5", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "TwrqD", + "name": "relLaputaBtn", + "width": "fill_container", + "fill": "$--accent-green-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "R02qX", + "name": "laputaTxt", + "fill": "$--accent-green", + "content": "Building", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "URmQn", + "name": "topicIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "tag", + "iconFontFamily": "phosphor", + "fill": "$--accent-green" + } + ] + }, + { + "type": "frame", + "id": "y-RCD", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Sohlo", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "NON3K", + "name": "relGroup2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "PRmP1", + "name": "relGrp2Title", + "fill": "$--muted-foreground", + "content": "RELATED TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ZORAR", + "name": "relMigrationBtn", + "width": "fill_container", + "fill": "$--accent-red-light", + "cornerRadius": 6, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zBinS", + "name": "migrationTxt", + "fill": "$--accent-red", + "content": "Shadcn Migration", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "fmvl7", + "name": "expIcon", + "opacity": 0.5, + "width": 14, + "height": 14, + "iconFontName": "flask", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + }, + { + "type": "frame", + "id": "rk_6m", + "name": "linkExisting", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [ + 6, + 10 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0BiPL", + "name": "laputaTxt", + "fill": "$--muted-foreground", + "content": "+ Link existing", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "4CbGG", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "bYwPw", + "name": "blTitle", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FXmIm", + "name": "blTitleTxt", + "rotation": -0.5285936385085725, + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "QkxSV", + "name": "blItem1", + "fill": "$--primary", + "content": "Portfolio Rewrite", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "5GEwY", + "name": "blItem2", + "fill": "$--primary", + "content": "Meeting with Grant", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JjS06", + "name": "blItem3", + "fill": "$--primary", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "-AVRp", + "name": "History Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "s2sCa", + "name": "histTitle", + "fill": "$--muted-foreground", + "content": "HISTORY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Wh7sL", + "name": "histItem1", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "WJkad", + "name": "histItem1Hash", + "fill": "$--accent-blue", + "content": "a089f44 · feat: migrate to shadcn/ui", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tdi_I", + "name": "histItem1Date", + "fill": "$--muted-foreground", + "content": "Feb 16, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LM_vp", + "name": "histItem2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "left": 2 + }, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 2, + "padding": [ + 0, + 0, + 0, + 10 + ], + "children": [ + { + "type": "text", + "id": "FAnJW", + "name": "histItem2Hash", + "fill": "$--accent-blue", + "content": "5a4b4ac · feat: emoji favicon", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "9bpmf", + "name": "histItem2Date", + "fill": "$--muted-foreground", + "content": "Feb 15, 2026", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "gH1Q6", + "name": "lightStatusBar", + "width": "fill_container", + "height": 30, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "padding": [ + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "8q9j3", + "name": "Status Left", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "HoLpj", + "name": "versionIcon", + "width": 14, + "height": 14, + "iconFontName": "box", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "k-eWA", + "name": "versionText", + "fill": "$--muted-foreground", + "content": "v0.4.2", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Y-wAn", + "name": "sep1", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "gtv6Y", + "name": "branchIcon", + "width": 14, + "height": 14, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "xnDxG", + "name": "branchText", + "fill": "$--muted-foreground", + "content": "main", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qpDn7", + "name": "sep2", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "NUI5N", + "name": "syncIcon", + "width": 13, + "height": 13, + "iconFontName": "pencil-simple", + "iconFontFamily": "phosphor", + "fill": "$--accent-yellow" + }, + { + "type": "text", + "id": "sDa8O", + "name": "syncText", + "fill": "$--muted-foreground", + "content": "3 modified", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "4RcrW", + "name": "Status Right", + "height": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "5g3ye", + "name": "aiIcon", + "width": 13, + "height": 13, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "$--accent-purple" + }, + { + "type": "text", + "id": "D1hG8", + "name": "aiText", + "fill": "$--muted-foreground", + "content": "Claude Sonnet 4", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "l4igr", + "name": "sep3", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "2uctj", + "name": "notesCount", + "width": 13, + "height": 13, + "iconFontName": "file-text", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "uv6xG", + "name": "notesText", + "fill": "$--muted-foreground", + "content": "1,247 notes", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tViOI", + "name": "sep4", + "fill": "$--border", + "content": "|", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "i4ZVl", + "name": "bellIcon", + "width": 13, + "height": 13, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + }, + { + "type": "icon_font", + "id": "-T7ch", + "name": "settingsIcon", + "width": 13, + "height": 13, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + } + ], + "variables": { + "--accent": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#252545", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-blue": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-green": { + "type": "color", + "value": [ + { + "value": "#0F7B6C" + }, + { + "value": "#4caf50", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#00B38B" + }, + { + "value": "#00B38B", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-orange": { + "type": "color", + "value": [ + { + "value": "#D9730D" + }, + { + "value": "#ff9800", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-purple": { + "type": "color", + "value": [ + { + "value": "#9065B0" + }, + { + "value": "#9c72ff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#A932FF" + }, + { + "value": "#A932FF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-red": { + "type": "color", + "value": [ + { + "value": "#E03E3E" + }, + { + "value": "#f44336", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--background": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#0f0f1a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--black": { + "type": "color", + "value": "#000000" + }, + "--border": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--card": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#16162a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--card-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--destructive": { + "type": "color", + "value": [ + { + "value": "#E03E3E" + }, + { + "value": "#f44336", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--destructive-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--font-primary": { + "type": "string", + "value": [ + { + "value": "Inter" + }, + { + "value": "-apple-system, BlinkMacSystemFont, Inter, sans-serif" + }, + { + "value": "Inter" + } + ] + }, + "--font-system": { + "type": "string", + "value": "-apple-system, BlinkMacSystemFont, sans-serif" + }, + "--foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--input": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--muted": { + "type": "color", + "value": [ + { + "value": "#F0F0EF" + }, + { + "value": "#1e1e3a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--muted-foreground": { + "type": "color", + "value": [ + { + "value": "#787774" + }, + { + "value": "#888888", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--popover": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#1e1e3a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--popover-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--primary": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--primary-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--radius-lg": { + "type": "number", + "value": 8 + }, + "--radius-md": { + "type": "number", + "value": 6 + }, + "--radius-sm": { + "type": "number", + "value": 4 + }, + "--ring": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar": { + "type": "color", + "value": [ + { + "value": "#F7F6F3" + }, + { + "value": "#1a1a2e", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-accent": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#252545", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-accent-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-border": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-primary": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-primary-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-ring": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--white": { + "type": "color", + "value": "#ffffff" + }, + "--accent-yellow": { + "type": "color", + "value": [ + { + "value": "#F0B100" + }, + { + "value": "#F0B100", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-blue-light": { + "type": "color", + "value": [ + { + "value": "#155DFF14" + }, + { + "value": "#155DFF20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-green-light": { + "type": "color", + "value": [ + { + "value": "#00B38B14" + }, + { + "value": "#00B38B20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-purple-light": { + "type": "color", + "value": [ + { + "value": "#A932FF14" + }, + { + "value": "#A932FF20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-red-light": { + "type": "color", + "value": [ + { + "value": "#E03E3E14" + }, + { + "value": "#f4433620", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-yellow-light": { + "type": "color", + "value": [ + { + "value": "#F0B10014" + }, + { + "value": "#F0B10020", + "theme": { + "Mode": "Dark" + } + } + ] + } + } +} \ No newline at end of file diff --git a/design/drag-drop-images.pen b/design/drag-drop-images.pen new file mode 100644 index 0000000..1f92f39 --- /dev/null +++ b/design/drag-drop-images.pen @@ -0,0 +1,78 @@ +{ + "children": [ + { + "type": "frame", + "id": "drag_drop_idle", + "name": "Drag & Drop Images — Idle (no drag)", + "x": 0, + "y": 0, + "width": 900, + "height": 600, + "fill": "$--background", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "drag_idle_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "cornerRadius": 0 + } + ] + }, + { + "type": "frame", + "id": "drag_drop_active", + "name": "Drag & Drop Images — Drag Over (overlay shown)", + "x": 940, + "y": 0, + "width": 900, + "height": 600, + "fill": "$--background", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "drag_active_container", + "name": "Editor with Drag Overlay", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "rectangle", + "id": "drag_active_editor_bg", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background" + }, + { + "type": "frame", + "id": "drag_overlay", + "name": "Drop Overlay", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "fill": "rgba(0,0,0,0.4)", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "drag_overlay_label", + "content": "Drop image to insert", + "fontSize": 18, + "fontWeight": "600", + "fill": "#ffffff", + "textAlign": "center" + } + ] + } + ] + } + ] + } + ] +} diff --git a/design/fix-property-dropdown-narrow.pen b/design/fix-property-dropdown-narrow.pen new file mode 100644 index 0000000..deff115 --- /dev/null +++ b/design/fix-property-dropdown-narrow.pen @@ -0,0 +1,56 @@ +{ + "children": [ + { + "type": "frame", + "id": "fpdn_before", + "name": "Property Dropdown Narrow — Before (clipped)", + "x": 0, + "y": 0, + "width": 280, + "height": 200, + "fill": "#1a1a1a", + "layout": "vertical", + "gap": 8, + "padding": 12, + "theme": "dark", + "children": [ + { + "type": "text", + "id": "fpdn_before_label", + "content": "Bug: StatusDropdown clipped by overflow:hidden container at 200-280px panel width", + "fill": "#ff4444", + "fontFamily": "SF Pro Text", + "fontSize": 12, + "fontWeight": "regular", + "letterSpacing": 0 + } + ] + }, + { + "type": "frame", + "id": "fpdn_after", + "name": "Property Dropdown Narrow — After (portal, no clip)", + "x": 320, + "y": 0, + "width": 280, + "height": 200, + "fill": "#1a1a1a", + "layout": "vertical", + "gap": 8, + "padding": 12, + "theme": "dark", + "children": [ + { + "type": "text", + "id": "fpdn_after_label", + "content": "Fix: StatusDropdown + DisplayModeSelector use createPortal with fixed positioning — renders above overflow:hidden, visible at any panel width", + "fill": "#44bb44", + "fontFamily": "SF Pro Text", + "fontSize": 12, + "fontWeight": "regular", + "letterSpacing": 0 + } + ] + } + ] +} diff --git a/design/getting-started-vault.pen b/design/getting-started-vault.pen new file mode 100644 index 0000000..4ac91aa --- /dev/null +++ b/design/getting-started-vault.pen @@ -0,0 +1,381 @@ +{ + "children": [ + { + "type": "frame", + "id": "welcome_screen", + "name": "Getting Started — Welcome Screen", + "x": 0, + "y": 0, + "width": 1440, + "height": 900, + "fill": "#F7F6F3", + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "welcome_card", + "name": "Welcome Card", + "width": 520, + "height": "fit_content", + "fill": "#FFFFFF", + "cornerRadius": [12, 12, 12, 12], + "layout": "vertical", + "alignItems": "center", + "gap": 24, + "padding": [48, 48, 48, 48], + "stroke": "#E9E9E7", + "strokeThickness": 1, + "children": [ + { + "type": "frame", + "id": "welcome_icon_wrap", + "name": "Icon", + "width": 64, + "height": 64, + "fill": "#EBF4FF", + "cornerRadius": [16, 16, 16, 16], + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "welcome_icon_text", + "content": "✦", + "fontSize": 28, + "fill": "#2383E2", + "fontFamily": "Inter" + } + ] + }, + { + "type": "frame", + "id": "welcome_text_group", + "name": "Text Group", + "layout": "vertical", + "alignItems": "center", + "gap": 8, + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "welcome_title", + "content": "Welcome to Laputa", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700", + "letterSpacing": -0.5, + "textAlign": "center" + }, + { + "type": "text", + "id": "welcome_subtitle", + "content": "Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.", + "fill": "#787774", + "fontFamily": "Inter", + "fontSize": 14, + "lineHeight": 1.6, + "textAlign": "center" + } + ] + }, + { + "type": "rectangle", + "id": "welcome_divider", + "width": "fill_container", + "height": 1, + "fill": "#E9E9E7" + }, + { + "type": "frame", + "id": "welcome_actions", + "name": "Actions", + "layout": "vertical", + "gap": 12, + "width": "fill_container", + "children": [ + { + "type": "frame", + "id": "btn_create_vault", + "name": "Create Getting Started Vault Button", + "width": "fill_container", + "height": 44, + "fill": "#2383E2", + "cornerRadius": [8, 8, 8, 8], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "btn_create_icon", + "content": "+", + "fill": "#FFFFFF", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "btn_create_label", + "content": "Create Getting Started vault", + "fill": "#FFFFFF", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "btn_open_folder", + "name": "Open Existing Folder Button", + "width": "fill_container", + "height": 44, + "fill": "#FFFFFF", + "stroke": "#E9E9E7", + "strokeThickness": 1, + "cornerRadius": [8, 8, 8, 8], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "btn_open_icon", + "content": "📂", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "text", + "id": "btn_open_label", + "content": "Open an existing folder", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "welcome_hint", + "content": "The Getting Started vault will be created in ~/Documents/Laputa", + "fill": "#787774", + "fontFamily": "Inter", + "fontSize": 12, + "textAlign": "center" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vault_missing_screen", + "name": "Getting Started — Vault Missing Error", + "x": 1540, + "y": 0, + "width": 1440, + "height": 900, + "fill": "#F7F6F3", + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "missing_card", + "name": "Missing Vault Card", + "width": 520, + "height": "fit_content", + "fill": "#FFFFFF", + "cornerRadius": [12, 12, 12, 12], + "layout": "vertical", + "alignItems": "center", + "gap": 24, + "padding": [48, 48, 48, 48], + "stroke": "#E9E9E7", + "strokeThickness": 1, + "children": [ + { + "type": "frame", + "id": "missing_icon_wrap", + "name": "Warning Icon", + "width": 64, + "height": 64, + "fill": "#FFF3E0", + "cornerRadius": [16, 16, 16, 16], + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "missing_icon_text", + "content": "⚠", + "fontSize": 28, + "fill": "#E8890C", + "fontFamily": "Inter" + } + ] + }, + { + "type": "frame", + "id": "missing_text_group", + "name": "Text Group", + "layout": "vertical", + "alignItems": "center", + "gap": 8, + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "missing_title", + "content": "Vault not found", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700", + "letterSpacing": -0.5, + "textAlign": "center" + }, + { + "type": "text", + "id": "missing_subtitle", + "content": "The vault folder could not be found on disk.\nIt may have been moved or deleted.", + "fill": "#787774", + "fontFamily": "Inter", + "fontSize": 14, + "lineHeight": 1.6, + "textAlign": "center" + } + ] + }, + { + "type": "frame", + "id": "missing_path_badge", + "name": "Path Badge", + "width": "fill_container", + "height": "fit_content", + "fill": "#F7F6F3", + "cornerRadius": [6, 6, 6, 6], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "center", + "padding": [8, 12, 8, 12], + "children": [ + { + "type": "text", + "id": "missing_path_text", + "content": "~/Laputa", + "fill": "#787774", + "fontFamily": "SF Mono, monospace", + "fontSize": 12 + } + ] + }, + { + "type": "rectangle", + "id": "missing_divider", + "width": "fill_container", + "height": 1, + "fill": "#E9E9E7" + }, + { + "type": "frame", + "id": "missing_actions", + "name": "Actions", + "layout": "vertical", + "gap": 12, + "width": "fill_container", + "children": [ + { + "type": "frame", + "id": "btn_recreate_vault", + "name": "Create Getting Started Vault Button", + "width": "fill_container", + "height": 44, + "fill": "#2383E2", + "cornerRadius": [8, 8, 8, 8], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "btn_recreate_icon", + "content": "+", + "fill": "#FFFFFF", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "btn_recreate_label", + "content": "Create Getting Started vault", + "fill": "#FFFFFF", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "btn_choose_folder", + "name": "Choose Different Folder Button", + "width": "fill_container", + "height": 44, + "fill": "#FFFFFF", + "stroke": "#E9E9E7", + "strokeThickness": 1, + "cornerRadius": [8, 8, 8, 8], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "btn_choose_icon", + "content": "📂", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "text", + "id": "btn_choose_label", + "content": "Choose a different folder", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/design/git-status-bar.pen b/design/git-status-bar.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/git-status-bar.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/github-oauth-fix.pen b/design/github-oauth-fix.pen new file mode 100644 index 0000000..7dbabdc --- /dev/null +++ b/design/github-oauth-fix.pen @@ -0,0 +1,258 @@ +{ + "children": [ + { + "type": "frame", + "id": "gofix01", + "x": 0, + "y": 0, + "name": "GitHub OAuth Fix — Modal with inline device flow", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { + "type": "frame", + "id": "gofix01-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } + ] + }, + { + "type": "frame", + "id": "gofix01-desc", + "name": "Description", + "width": "fill_container", + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] + }, + { + "type": "frame", + "id": "gofix01-body", + "name": "DeviceFlowLogin", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [24, 24], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gofix01-login-btn", + "name": "LoginButton", + "width": 200, + "height": 36, + "fill": "$--foreground", + "cornerRadius": 6, + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" }, + { "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "gofix02", + "x": 660, + "y": 0, + "name": "GitHub OAuth Fix — Device code waiting state", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { + "type": "frame", + "id": "gofix02-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } + ] + }, + { + "type": "frame", + "id": "gofix02-desc", + "name": "Description", + "width": "fill_container", + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] + }, + { + "type": "frame", + "id": "gofix02-body", + "name": "DeviceCodeCard", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [24, 24], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gofix02-card", + "name": "CodeCard", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [16, 24], + "cornerRadius": 8, + "alignItems": "center", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" }, + { + "type": "frame", + "id": "gofix02-code-row", + "gap": 8, + "alignItems": "center", + "children": [ + { "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" }, + { "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" } + ] + }, + { "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" }, + { + "type": "frame", + "id": "gofix02-spinner-row", + "gap": 6, + "alignItems": "center", + "children": [ + { "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" } + ] + } + ] + }, + { + "type": "frame", + "id": "gofix02-cancel-btn", + "name": "CancelButton", + "width": 80, + "height": 32, + "cornerRadius": 6, + "alignItems": "center", + "justifyContent": "center", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "gofix03", + "x": 1320, + "y": 0, + "name": "GitHub OAuth Fix — Expired/Error state with retry", + "clip": true, + "width": 560, + "height": 420, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "children": [ + { + "type": "frame", + "id": "gofix03-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }, + "children": [ + { "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" }, + { "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] } + ] + }, + { + "type": "frame", + "id": "gofix03-desc", + "name": "Description", + "width": "fill_container", + "padding": [16, 24], + "children": [ + { "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 } + ] + }, + { + "type": "frame", + "id": "gofix03-body", + "name": "ErrorState", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [24, 24], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gofix03-login-btn", + "name": "LoginButton", + "width": 200, + "height": 36, + "fill": "$--foreground", + "cornerRadius": 6, + "alignItems": "center", + "justifyContent": "center", + "gap": 8, + "children": [ + { "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" }, + { "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" } + ] + }, + { + "type": "frame", + "id": "gofix03-error-row", + "gap": 8, + "alignItems": "center", + "children": [ + { "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" }, + { "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" } + ] + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/design/gs-git-init.pen b/design/gs-git-init.pen new file mode 100644 index 0000000..ddf305f --- /dev/null +++ b/design/gs-git-init.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"gs_git_init_after","name":"Getting Started Vault — Git Initialized","x":0,"y":0,"width":600,"height":"fit_content(200)","fill":"#FFFFFF","layout":"vertical","gap":16,"padding":[32,32],"children":[{"type":"text","id":"gs_title","content":"After: Getting Started vault created","fontFamily":"Inter","fontSize":20,"fontWeight":"700","fill":"#111111"},{"type":"text","id":"gs_desc","content":"When a new Getting Started vault is created, it is automatically initialized as a git repo with an initial commit containing all sample files.","fontFamily":"Inter","fontSize":14,"lineHeight":1.5,"fill":"#555555"},{"type":"frame","id":"gs_terminal","name":"Git Log Output","width":"fill_container","height":"fit_content(80)","fill":"#1E1E1E","cornerRadius":[8,8,8,8],"padding":[16,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"gs_prompt","content":"$ git log --oneline","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#AAAAAA"},{"type":"text","id":"gs_log","content":"a1b2c3d Initial vault setup","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#66FF66"}]},{"type":"frame","id":"gs_details","name":"Details","width":"fill_container","layout":"vertical","gap":8,"children":[{"type":"text","id":"gs_detail1","content":"git init + git add . + git commit","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail2","content":"Fallback author: Laputa (if no global git config)","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail3","content":"No UI changes — backend only (git.rs + getting_started.rs)","fontFamily":"Inter","fontSize":13,"fill":"#333333"}]}]}],"variables":{}} \ No newline at end of file diff --git a/design/gs-wikilinks-fix.pen b/design/gs-wikilinks-fix.pen new file mode 100644 index 0000000..448ebce --- /dev/null +++ b/design/gs-wikilinks-fix.pen @@ -0,0 +1,17 @@ +{ + "children": [ + { + "name": "Getting Started Wikilinks — Fixed (styled with type color)", + "type": "FRAME", + "width": 800, + "height": 400, + "children": [ + { + "name": "Description", + "type": "TEXT", + "characters": "Wikilinks in Getting Started vault now resolve correctly:\n- path/to/note targets match entry paths ending in /path/to/note.md\n- Display text shows entry title (not raw path)\n- Color reflects the linked note's type (e.g. blue for Note type)\n- Broken links show muted color as expected\n\nBefore: [[note/welcome-to-laputa]] showed raw path, unstyled\nAfter: [[note/welcome-to-laputa]] shows 'Welcome to Laputa' in Note blue" + } + ] + } + ] +} diff --git a/design/hide-backlinks-empty.pen b/design/hide-backlinks-empty.pen new file mode 100644 index 0000000..ae27a8d --- /dev/null +++ b/design/hide-backlinks-empty.pen @@ -0,0 +1,65 @@ +{ + "children": [ + { + "type": "frame", + "id": "hideBacklinksEmpty_before", + "x": 0, + "y": 0, + "name": "Hide Empty Backlinks Label — Before (shows No backlinks label)", + "width": 280, + "height": 80, + "fill": "#ffffff", + "gap": 4, + "padding": 12, + "alignItems": "flex-start", + "children": [ + { + "type": "text", + "id": "bl_header_before", + "name": "section-header", + "fill": "#6b7280", + "content": "Backlinks", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1.2 + }, + { + "type": "text", + "id": "bl_empty_before", + "name": "empty-label", + "fill": "#9ca3af", + "content": "No backlinks", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hideBacklinksEmpty_after", + "x": 320, + "y": 0, + "name": "Hide Empty Backlinks Label — After (section hidden when empty)", + "width": 280, + "height": 80, + "fill": "#ffffff", + "gap": 4, + "padding": 12, + "alignItems": "flex-start", + "children": [ + { + "type": "text", + "id": "bl_note_after", + "name": "note", + "fill": "#9ca3af", + "content": "(Backlinks, Referenced By, and Relations sections are hidden when empty — no placeholder label shown)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] +} \ No newline at end of file diff --git a/design/keyboard-first-nav.pen b/design/keyboard-first-nav.pen new file mode 100644 index 0000000..3a6bf14 --- /dev/null +++ b/design/keyboard-first-nav.pen @@ -0,0 +1,156 @@ +{ + "children": [ + { + "type": "frame", + "id": "kfn_notelist_nav", + "name": "Keyboard-first — Note list keyboard navigation", + "x": 0, + "y": 0, + "width": 280, + "height": "fit_content(400)", + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "padding": [8, 0], + "children": [ + { + "type": "text", + "id": "kfn_notelist_label", + "content": "Note list — ↑↓ highlight, Enter opens", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "padding": [0, 12, 8, 12] + }, + { + "type": "frame", + "id": "kfn_note_item_selected", + "layout": "horizontal", + "width": "fill_container", + "height": 48, + "fill": "$--accent", + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "kfn_note_title_active", + "content": "My selected note (keyboard highlight)", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "kfn_note_item_normal", + "layout": "horizontal", + "width": "fill_container", + "height": 48, + "fill": "transparent", + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "kfn_note_title_normal", + "content": "Another note", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + } + ] + }, + { + "type": "frame", + "id": "kfn_menu_bar", + "name": "Keyboard-first — Menu bar shortcuts added", + "x": 320, + "y": 0, + "width": 400, + "height": "fit_content(200)", + "fill": "$--background", + "layout": "vertical", + "gap": 4, + "padding": [16, 16], + "children": [ + { + "type": "text", + "id": "kfn_menu_title", + "content": "New menu bar shortcuts", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kfn_s1", + "content": "Archive Note — ⌘E\nTrash Note — ⌘⌫\nFind in Vault — ⌘⇧F\nGo Back — ⌘[\nGo Forward — ⌘]", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "lineHeight": 1.8 + } + ] + }, + { + "type": "frame", + "id": "kfn_inspector_tab", + "name": "Keyboard-first — Inspector Tab+Enter navigation", + "x": 760, + "y": 0, + "width": 300, + "height": "fit_content(200)", + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "padding": [16, 16], + "children": [ + { + "type": "text", + "id": "kfn_inspector_title", + "content": "Inspector — Tab focuses rows, Enter starts editing", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "kfn_prop_focused", + "layout": "horizontal", + "width": "fill_container", + "height": 36, + "fill": "$--muted", + "cornerRadius": 4, + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "kfn_prop_key", + "content": "Status", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "kfn_prop_val", + "content": "In Progress (focused — ring visible)", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 12 + } + ] + } + ] + } + ] +} diff --git a/design/mcp-autodetect-status-bar.pen b/design/mcp-autodetect-status-bar.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/mcp-autodetect-status-bar.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/new-note-creation.pen b/design/new-note-creation.pen new file mode 100644 index 0000000..cddd569 --- /dev/null +++ b/design/new-note-creation.pen @@ -0,0 +1,185 @@ +{ + "children": [ + { + "type": "frame", + "id": "nnc_pending_save", + "name": "New Note Creation — Unsaved State (Cmd+N)", + "x": 0, + "y": 0, + "width": 800, + "height": 120, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [ + 0, + 0 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "frame", + "id": "nnc_tab_bar", + "name": "Tab Bar", + "layout": "horizontal", + "width": 800, + "height": 40, + "fill": "$--card", + "gap": 0, + "padding": [ + 0, + 8 + ], + "children": [ + { + "type": "frame", + "id": "nnc_tab_active", + "name": "Tab — Unsaved Note (blue dot + italic)", + "layout": "horizontal", + "width": 180, + "height": 40, + "fill": "$--background", + "gap": 6, + "padding": [ + 0, + 12 + ], + "children": [ + { + "type": "rectangle", + "id": "nnc_dot_pending", + "width": 8, + "height": 8, + "cornerRadius": 4, + "fill": "#3b82f6" + }, + { + "type": "text", + "id": "nnc_tab_label", + "content": "Untitled Note", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "400", + "fontStyle": "italic" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_editor_area", + "name": "Editor — Empty New Note", + "layout": "vertical", + "width": 800, + "height": 80, + "fill": "$--background", + "gap": 8, + "padding": [ + 16, + 24 + ], + "children": [ + { + "type": "text", + "id": "nnc_placeholder", + "content": "Start typing...", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_after_first_save", + "name": "New Note Creation — After First Save", + "x": 820, + "y": 0, + "width": 800, + "height": 120, + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "padding": [ + 0, + 0 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "frame", + "id": "nnc_tab_bar_saved", + "name": "Tab Bar — Saved", + "layout": "horizontal", + "width": 800, + "height": 40, + "fill": "$--card", + "gap": 0, + "padding": [ + 0, + 8 + ], + "children": [ + { + "type": "frame", + "id": "nnc_tab_saved", + "name": "Tab — Saved Note", + "layout": "horizontal", + "width": 180, + "height": 40, + "fill": "$--background", + "gap": 6, + "padding": [ + 0, + 12 + ], + "children": [ + { + "type": "text", + "id": "nnc_tab_label_saved", + "content": "My New Note", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "400" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_editor_saved", + "name": "Editor — Note with Content", + "layout": "vertical", + "width": 800, + "height": 80, + "fill": "$--background", + "gap": 8, + "padding": [ + 16, + 24 + ], + "children": [ + { + "type": "text", + "id": "nnc_content", + "content": "My New Note content...", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/design/note-list-title-position.pen b/design/note-list-title-position.pen new file mode 100644 index 0000000..9cc46bf --- /dev/null +++ b/design/note-list-title-position.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"AeVO5","name":"Sidebar Collapsed — NoteList Title Position","x":0,"y":0,"width":600,"fill":"$--background","layout":"vertical","gap":12,"padding":16,"children":[{"type":"text","id":"ryDyy","name":"label","content":"Sidebar Collapsed — NoteList with traffic light clearance","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","letterSpacing":1},{"type":"frame","id":"xZMtO","name":"App Window (sidebar collapsed)","clip":true,"width":"fill_container","cornerRadius":8,"stroke":{"align":"inside","thickness":1,"fill":"$--border"},"layout":"vertical","children":[{"type":"frame","id":"ymeIo","name":"Content Row","width":"fill_container","height":320,"layout":"none","children":[{"type":"frame","id":"VOhi9","name":"Panel: NoteList","x":0,"y":0,"width":300,"height":320,"fill":"$--card","stroke":{"align":"inside","fill":"$--border","thickness":{"right":1}},"layout":"vertical","children":[{"type":"frame","id":"Q884J","name":"NoteList Header (padded for traffic lights)","width":"fill_container","height":52,"alignItems":"center","justifyContent":"space_between","padding":[0,16,0,80],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"children":[{"type":"text","id":"Ra8AY","content":"All Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"600"},{"type":"frame","id":"0TYpG","name":"actions","alignItems":"center","gap":12,"children":[{"type":"icon_font","id":"v4VUg","iconFontFamily":"phosphor","iconFontName":"magnifying-glass","width":16,"height":16,"fill":"$--muted-foreground"},{"type":"icon_font","id":"y4CYF","iconFontFamily":"phosphor","iconFontName":"plus","width":16,"height":16,"fill":"$--muted-foreground"}]}]},{"type":"frame","id":"OCJxT","name":"Note Item 1","layout":"vertical","width":"fill_container","gap":4,"padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"children":[{"type":"text","id":"OThF7","content":"Build Laputa App","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600"},{"type":"text","id":"rwqIZ","content":"Personal knowledge management app built with Tauri.","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"lineHeight":1.5,"textGrowth":"fixed-width","width":"fill_container"},{"type":"text","id":"0phHO","content":"2m ago","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11}]},{"type":"frame","id":"YmjMb","name":"Note Item 2","layout":"vertical","width":"fill_container","gap":4,"padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"children":[{"type":"text","id":"gh2fT","content":"Facebook Ads Strategy","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600"},{"type":"text","id":"Yn5dO","content":"Lookalike audiences convert 3x better than cold.","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"lineHeight":1.5,"textGrowth":"fixed-width","width":"fill_container"},{"type":"text","id":"5AKHZ","content":"1h ago","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11}]}]},{"type":"frame","id":"V4qF5","name":"Panel: Editor","x":300,"y":0,"width":300,"height":320,"fill":"$--background","layout":"vertical","children":[{"type":"frame","id":"ztw3q","name":"Tab Bar","width":"fill_container","height":45,"fill":"$--sidebar","alignItems":"center","stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"children":[{"type":"text","id":"NGfeH","content":" Build Laputa App","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500"}]},{"type":"frame","id":"DXXof","name":"Editor Body","width":"fill_container","height":"fill_container","fill":"$--background","padding":[24,32],"children":[{"type":"text","id":"Hgsg7","content":"Build Laputa App","fill":"$--foreground","fontFamily":"Inter","fontSize":28,"fontWeight":"700"}]}]},{"type":"frame","id":"aSWvV","name":"Traffic Lights","x":0,"y":0,"gap":8,"padding":[18,0,0,14],"children":[{"type":"ellipse","id":"wwhm9","width":12,"height":12,"fill":"$--traffic-red"},{"type":"ellipse","id":"vYx2N","width":12,"height":12,"fill":"$--traffic-yellow"},{"type":"ellipse","id":"eRAiC","width":12,"height":12,"fill":"$--traffic-green"}]}]}]},{"type":"frame","id":"u5rgz","name":"Annotation","layout":"horizontal","gap":8,"alignItems":"center","padding":[8,12],"fill":"#E8F4FE","cornerRadius":6,"children":[{"type":"text","id":"hDyxd","content":"→","fill":"$--primary","fontFamily":"Inter","fontSize":14,"fontWeight":"700"},{"type":"text","id":"EtBGq","content":"paddingLeft: 80px clears traffic lights when sidebar is hidden (matches sidebar title bar padding)","fill":"$--foreground","fontFamily":"Inter","fontSize":12,"lineHeight":1.4,"textGrowth":"fixed-width","width":520}]}]}],"variables":{"--background":{"type":"color","value":"#FFFFFF"},"--foreground":{"type":"color","value":"#37352F"},"--card":{"type":"color","value":"#FFFFFF"},"--border":{"type":"color","value":"#E9E9E7"},"--muted":{"type":"color","value":"#F0F0EF"},"--muted-foreground":{"type":"color","value":"#787774"},"--sidebar":{"type":"color","value":"#F7F6F3"},"--sidebar-border":{"type":"color","value":"#E9E9E7"},"--primary":{"type":"color","value":"#155DFF"},"--destructive":{"type":"color","value":"#E03E3E"},"--accent-red-light":{"type":"color","value":"#E03E3E18"},"--traffic-red":{"type":"color","value":"#FF5F57"},"--traffic-yellow":{"type":"color","value":"#FEBC2E"},"--traffic-green":{"type":"color","value":"#28C840"}}} \ No newline at end of file diff --git a/design/note-subtitle-metadata.pen b/design/note-subtitle-metadata.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/note-subtitle-metadata.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/note-templates.pen b/design/note-templates.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/note-templates.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/note-type-labels.pen b/design/note-type-labels.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/note-type-labels.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/notelist-multiselect.pen b/design/notelist-multiselect.pen new file mode 100644 index 0000000..8109f8c --- /dev/null +++ b/design/notelist-multiselect.pen @@ -0,0 +1,422 @@ +{ + "children": [ + { + "type": "frame", + "id": "ms_selected", + "name": "Multi-select — 2 notes selected (bulk action bar visible)", + "width": 340, + "height": "fit_content(600)", + "x": 0, + "y": 0, + "fill": "#FFFFFF", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "ms_hdr1", + "name": "header", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "alignItems": "center", + "justifyContent": "space_between", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_h1t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" }, + { "type": "frame", "id": "ms_h1icons", "gap": 12, "alignItems": "center", "children": [ + { "type": "text", "id": "ms_h1s", "content": "\uD83D\uDD0D", "fontSize": 14 }, + { "type": "text", "id": "ms_h1p", "content": "+", "fontSize": 16, "fill": "#9B9A97" } + ]} + ] + }, + { + "type": "frame", + "id": "ms_item1", + "name": "item-selected-1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#EBF5FF", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "ms_i1row", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { "type": "frame", "id": "ms_i1chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } }, + { "type": "text", "id": "ms_i1t", "content": "Build Laputa App", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" } + ] + }, + { "type": "text", "id": "ms_i1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_item2", + "name": "item-normal", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "ms_i2row", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { "type": "frame", "id": "ms_i2chk", "width": 16, "height": 16, "cornerRadius": 3, "stroke": { "fill": "#D4D4D4", "thickness": 1 } }, + { "type": "text", "id": "ms_i2t", "content": "Facebook Ads Strategy", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" } + ] + }, + { "type": "text", "id": "ms_i2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_item3", + "name": "item-selected-2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#EBF5FF", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "ms_i3row", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { "type": "frame", "id": "ms_i3chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } }, + { "type": "text", "id": "ms_i3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" } + ] + }, + { "type": "text", "id": "ms_i3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_item4", + "name": "item-normal-2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_i4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_i4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { "type": "frame", "id": "ms_spacer1", "width": "fill_container", "height": 120 }, + { + "type": "frame", + "id": "ms_bulkbar", + "name": "bulk-action-bar", + "width": "fill_container", + "height": 48, + "padding": [0, 16], + "fill": "#37352F", + "alignItems": "center", + "justifyContent": "space_between", + "children": [ + { "type": "text", "id": "ms_bcount", "content": "2 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { + "type": "frame", + "id": "ms_bactions", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ms_barchive", + "padding": [6, 12], + "cornerRadius": 6, + "fill": "#FFFFFF20", + "children": [ + { "type": "text", "id": "ms_bat1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "ms_btrash", + "padding": [6, 12], + "cornerRadius": 6, + "fill": "#E03E3E30", + "children": [ + { "type": "text", "id": "ms_bat2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "ms_bclose", + "padding": [6, 8], + "children": [ + { "type": "text", "id": "ms_bx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ms_range", + "name": "Multi-select — range select Shift+click", + "width": 340, + "height": "fit_content(600)", + "x": 400, + "y": 0, + "fill": "#FFFFFF", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "ms_hdr2", + "name": "header", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "alignItems": "center", + "justifyContent": "space_between", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_h2t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" }, + { "type": "frame", "id": "ms_h2icons", "gap": 12, "alignItems": "center", "children": [ + { "type": "text", "id": "ms_h2s", "content": "\uD83D\uDD0D", "fontSize": 14 }, + { "type": "text", "id": "ms_h2p", "content": "+", "fontSize": 16, "fill": "#9B9A97" } + ]} + ] + }, + { + "type": "frame", + "id": "ms_r_annotation", + "name": "annotation", + "width": "fill_container", + "padding": [8, 16], + "fill": "#FFF8E1", + "children": [ + { "type": "text", "id": "ms_ra_t", "content": "Shift+Click: range from first-clicked to here", "fill": "#D9730D", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "ms_r1", + "name": "range-anchor", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#EBF5FF", + "stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_r1t", "content": "Build Laputa App \u2190 anchor (first Cmd+click)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }, + { "type": "text", "id": "ms_r1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_r2", + "name": "range-included", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#EBF5FF", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_r2t", "content": "Facebook Ads Strategy (auto-included in range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_r2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_r3", + "name": "range-end", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#EBF5FF", + "stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_r3t", "content": "Matteo Cellini \u2190 Shift+click target", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }, + { "type": "text", "id": "ms_r3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_r4", + "name": "range-outside", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_r4t", "content": "Kickoff Meeting (outside range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_r4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { "type": "frame", "id": "ms_rspacer", "width": "fill_container", "height": 80 }, + { + "type": "frame", + "id": "ms_rbulkbar", + "name": "bulk-action-bar", + "width": "fill_container", + "height": 48, + "padding": [0, 16], + "fill": "#37352F", + "alignItems": "center", + "justifyContent": "space_between", + "children": [ + { "type": "text", "id": "ms_rbcount", "content": "3 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { + "type": "frame", + "id": "ms_rbactions", + "gap": 8, + "alignItems": "center", + "children": [ + { "type": "frame", "id": "ms_rbarchive", "padding": [6, 12], "cornerRadius": 6, "fill": "#FFFFFF20", "children": [ + { "type": "text", "id": "ms_rba1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" } + ]}, + { "type": "frame", "id": "ms_rbtrash", "padding": [6, 12], "cornerRadius": 6, "fill": "#E03E3E30", "children": [ + { "type": "text", "id": "ms_rba2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" } + ]}, + { "type": "frame", "id": "ms_rbclose", "padding": [6, 8], "children": [ + { "type": "text", "id": "ms_rbx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 } + ]} + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ms_empty", + "name": "Multi-select — empty selection / hover state", + "width": 340, + "height": "fit_content(600)", + "x": 800, + "y": 0, + "fill": "#FFFFFF", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "ms_hdr3", + "name": "header", + "width": "fill_container", + "height": 52, + "padding": [0, 16], + "alignItems": "center", + "justifyContent": "space_between", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_h3t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" }, + { "type": "frame", "id": "ms_h3icons", "gap": 12, "alignItems": "center", "children": [ + { "type": "text", "id": "ms_h3s", "content": "\uD83D\uDD0D", "fontSize": 14 }, + { "type": "text", "id": "ms_h3p", "content": "+", "fontSize": 16, "fill": "#9B9A97" } + ]} + ] + }, + { + "type": "frame", + "id": "ms_e_annot", + "name": "annotation", + "width": "fill_container", + "padding": [8, 16], + "fill": "#F5F5F5", + "children": [ + { "type": "text", "id": "ms_ea_t", "content": "No selection \u2014 Cmd+Click to select, Shift+Click for range", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 } + ] + }, + { + "type": "frame", + "id": "ms_e1", + "name": "item-active", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 13, 14, 16], + "stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } }, + "fill": "#EBF5FF", + "children": [ + { "type": "text", "id": "ms_e1t", "content": "Build Laputa App (current note \u2014 active)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }, + { "type": "text", "id": "ms_e1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_e2", + "name": "item-hover", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "fill": "#F5F5F4", + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_e2t", "content": "Facebook Ads Strategy (hover state)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_e2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_e3", + "name": "item-default", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_e3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_e3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_e4", + "name": "item-default-2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } }, + "children": [ + { "type": "text", "id": "ms_e4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }, + { "type": "text", "id": "ms_e4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "ms_e_nobar", + "name": "no-bar-annotation", + "width": "fill_container", + "padding": [12, 16], + "fill": "#F5F5F5", + "children": [ + { "type": "text", "id": "ms_ena_t", "content": "\u2191 No bulk action bar when nothing is selected", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/design/notes-type-icon-search.pen b/design/notes-type-icon-search.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/notes-type-icon-search.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/properties-panel-header.pen b/design/properties-panel-header.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/properties-panel-header.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/property-value-input.pen b/design/property-value-input.pen new file mode 100644 index 0000000..3f78a1e --- /dev/null +++ b/design/property-value-input.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"content":"Text Input (default editing mode)","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"gfFsr","name":"lbl1","type":"text"},{"alignItems":"center","children":[{"content":"OWNER","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"U7qrk","letterSpacing":1.2,"name":"k1","type":"text"},{"content":"Luca","fill":"$--secondary-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"xus3c","name":"v1","type":"text"}],"cornerRadius":4,"fill":"$--bg-hover-subtle","id":"y19Vl","name":"Property Row — owner","padding":[4,6],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"CADENCE","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"iXGSk","letterSpacing":1.2,"name":"k2","type":"text"},{"children":[{"content":"Weekly","fill":"$--foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"Dp7NE","name":"inpTxt","type":"text"}],"cornerRadius":4,"fill":"$--muted","id":"jDfek","name":"Text Input Active","padding":[4,8],"stroke":{"align":"inside","fill":"$--ring","thickness":1},"type":"frame","width":"fill_container"}],"cornerRadius":4,"id":"lBJ6j","name":"Property Row — cadence (editing)","padding":[4,6],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"DESCRIPTION","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"wNhQR","letterSpacing":1.2,"name":"k3","type":"text"},{"content":"My project notes","fill":"$--secondary-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"TDgEG","name":"v3","type":"text"}],"cornerRadius":4,"id":"rQySL","name":"Property Row — description","padding":[4,6],"type":"frame","width":"fill_container"}],"fill":"$--background","gap":8,"id":"ue2qN","layout":"vertical","name":"Property Value — Text Input (default)","padding":12,"type":"frame","width":300,"x":0,"y":0},{"children":[{"content":"Date Picker (calendar popover on click)","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"U8pRy","name":"lbl2","type":"text"},{"alignItems":"center","children":[{"content":"DEADLINE","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"jVJZw","letterSpacing":1.2,"name":"kD1","type":"text"},{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":12,"iconFontFamily":"lucide","iconFontName":"calendar","id":"9LGQq","name":"calIco","type":"icon_font","width":12},{"content":"Mar 31, 2026","fill":"$--secondary-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"huIJ2","name":"dateTxt","type":"text"}],"cornerRadius":4,"gap":4,"id":"2Nx8W","name":"Date Button","padding":[2,4],"type":"frame"}],"cornerRadius":4,"fill":"$--bg-hover-subtle","id":"gAwzs","name":"Property Row — deadline (date display)","padding":[4,6],"type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"chevron-left","id":"jGsuq","name":"calNav1","type":"icon_font","width":16},{"content":"March 2026","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600","id":"Q0Hbu","name":"calMo","type":"text"},{"fill":"$--muted-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"chevron-right","id":"fo6jZ","name":"calNav2","type":"icon_font","width":16}],"id":"NTfdy","name":"Calendar Header","type":"frame","width":"fill_container"},{"content":"Su Mo Tu We Th Fr Sa","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"normal","id":"5XDoM","letterSpacing":0.5,"name":"calDays","type":"text"},{"children":[{"content":" 1 2 3 4 5 6 7","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":11,"fontWeight":"normal","id":"phwKq","name":"calW1","type":"text"},{"content":" 8 9 10 11 12 13 14","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":11,"fontWeight":"normal","id":"hIyef","name":"calW2","type":"text"},{"content":"15 16 17 18 19 20 21","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":11,"fontWeight":"normal","id":"N9zUH","name":"calW3","type":"text"},{"content":"22 23 24 25 26 27 28","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":11,"fontWeight":"normal","id":"02wVP","name":"calW4","type":"text"},{"content":"29 30 [31]","fill":"$--primary","fontFamily":"IBM Plex Mono","fontSize":11,"fontWeight":"normal","id":"pm7vP","name":"calW5","type":"text"}],"gap":2,"id":"obkXY","layout":"vertical","name":"Day Grid (representation)","type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":12,"iconFontFamily":"lucide","iconFontName":"x","id":"cPTHQ","name":"clearIco","type":"icon_font","width":12},{"content":"Clear date","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"z5fZw","name":"clearTxt","type":"text"}],"gap":4,"id":"T2eel","name":"Clear Date Row","padding":[8,0,0,0],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"cornerRadius":8,"effect":{"blur":12,"color":"#00000015","offset":{"x":0,"y":4},"shadowType":"outer","type":"shadow"},"fill":"$--popover","gap":8,"id":"Ft17I","layout":"vertical","name":"Calendar Popover (representation)","padding":12,"stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"START DATE","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"HNo7P","letterSpacing":1.2,"name":"kD2","type":"text"},{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":12,"iconFontFamily":"lucide","iconFontName":"calendar","id":"kKyym","name":"calIco2","type":"icon_font","width":12},{"content":"Pick a date...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"w5Yim","name":"dateTxt2","type":"text"}],"cornerRadius":4,"gap":4,"id":"FShL0","name":"Date Button — empty","padding":[2,4],"type":"frame"}],"cornerRadius":4,"id":"6y8Ld","name":"Property Row — date (empty value with picker)","padding":[4,6],"type":"frame","width":"fill_container"},{"content":"Calendar opens for any value when display mode is date","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"AOnAh","name":"noteD","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"fill":"$--background","gap":8,"id":"3kOd1","layout":"vertical","name":"Property Value — Date Picker (calendar open)","padding":12,"type":"frame","width":300,"x":400,"y":0},{"children":[{"content":"Boolean Toggle (yes/no click-to-flip)","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"Vi8uD","name":"lbl3","type":"text"},{"alignItems":"center","children":[{"content":"ARCHIVED","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"5zOMO","letterSpacing":1.2,"name":"kB1","type":"text"},{"children":[{"content":"✗ No","fill":"$--secondary-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"2jdhQ","name":"vB1t","type":"text"}],"cornerRadius":4,"fill":"transparent","id":"8aj9u","name":"Boolean Button — No","padding":[2,8],"stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame"}],"cornerRadius":4,"id":"EPVN2","name":"Property Row — archived (false)","padding":[4,6],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"PUBLISHED","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"J4kqn","letterSpacing":1.2,"name":"kB2","type":"text"},{"children":[{"content":"✓ Yes","fill":"$--accent-green","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"PTt7N","name":"vB2t","type":"text"}],"cornerRadius":4,"fill":"transparent","id":"2Kc2p","name":"Boolean Button — Yes","padding":[2,8],"stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame"}],"cornerRadius":4,"fill":"$--bg-hover-subtle","id":"Mx1EU","name":"Property Row — published (true)","padding":[4,6],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"DRAFT","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"y9lFO","letterSpacing":1.2,"name":"kB3","type":"text"},{"children":[{"content":"✓ Yes","fill":"$--accent-green","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"MMOH6","name":"vB3t","type":"text"}],"cornerRadius":4,"fill":"transparent","id":"KEvKa","name":"Boolean Button — Yes (from string)","padding":[2,8],"stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame"}],"cornerRadius":4,"id":"O2K4k","name":"Property Row — draft (true, string)","padding":[4,6],"type":"frame","width":"fill_container"},{"content":"Handles both native boolean and string true/false values","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"9hRyB","name":"note3","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"fill":"$--background","gap":8,"id":"MYYnw","layout":"vertical","name":"Property Value — Boolean Toggle","padding":12,"type":"frame","width":300,"x":0,"y":420},{"children":[{"content":"Status Dropdown (searchable with vault + suggested)","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"4wWho","name":"lbl4","type":"text"},{"alignItems":"center","children":[{"content":"STATUS","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"G0vke","letterSpacing":1.2,"name":"kS1","type":"text"},{"children":[{"content":"ACTIVE","fill":"$--accent-green","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"wZ0aD","letterSpacing":1.2,"name":"statusTxt","type":"text"}],"cornerRadius":16,"fill":"$--accent-green-light","id":"lmfgA","name":"Status Pill — Active","padding":[1,6],"type":"frame"}],"cornerRadius":4,"fill":"$--bg-hover-subtle","id":"EkmCc","name":"Property Row — status","padding":[4,6],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"Type a status...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"EBbCZ","name":"searchTxt","type":"text"}],"id":"lo69T","name":"Search Input","padding":[6,8],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"FROM VAULT","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"WD9o4","letterSpacing":1.2,"name":"vaultTxt","type":"text"}],"id":"aU6V8","name":"Vault Section","padding":[4,8],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"ACTIVE","fill":"$--accent-green","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"ORffY","letterSpacing":1.2,"name":"pill1t","type":"text"}],"cornerRadius":16,"fill":"$--accent-green-light","id":"6X6T5","name":"pill1","padding":[1,6],"type":"frame"}],"cornerRadius":4,"fill":"$--muted","id":"aCzSa","name":"Option — Active (highlighted)","padding":[4,8],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"DONE","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"BNI2j","letterSpacing":1.2,"name":"pill2t","type":"text"}],"cornerRadius":16,"fill":"$--accent-blue-light","id":"dCU52","name":"pill2","padding":[1,6],"type":"frame"}],"id":"RMOKs","name":"Option — Done","padding":[4,8],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"PAUSED","fill":"$--accent-orange","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"JCZHo","letterSpacing":1.2,"name":"pill3t","type":"text"}],"cornerRadius":16,"fill":"$--accent-orange-light","id":"t820y","name":"pill3","padding":[1,6],"type":"frame"}],"id":"xGNr9","name":"Option — Paused","padding":[4,8],"type":"frame","width":"fill_container"},{"fill":"$--border","height":1,"id":"tvMv5","name":"sep1","type":"frame","width":"fill_container"},{"children":[{"content":"SUGGESTED","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"nG95l","letterSpacing":1.2,"name":"sugTxt","type":"text"}],"id":"taNZ2","name":"Suggested Section","padding":[4,8],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"IN PROGRESS","fill":"$--accent-yellow","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"cNzSe","letterSpacing":1.2,"name":"pill4t","type":"text"}],"cornerRadius":16,"fill":"$--accent-yellow-light","id":"LPqKJ","name":"pill4","padding":[1,6],"type":"frame"}],"id":"YZUM8","name":"Option — In Progress","padding":[4,8],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"BLOCKED","fill":"$--accent-red","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"9drsD","letterSpacing":1.2,"name":"pill5t","type":"text"}],"cornerRadius":16,"fill":"$--accent-red-light","id":"ixyZV","name":"pill5","padding":[1,6],"type":"frame"}],"id":"snwbA","name":"Option — Blocked","padding":[4,8],"type":"frame","width":"fill_container"},{"height":4,"id":"nKzOO","name":"pad","type":"frame","width":"fill_container"}],"clip":true,"cornerRadius":8,"effect":{"blur":12,"color":"#00000015","offset":{"x":0,"y":4},"shadowType":"outer","type":"shadow"},"fill":"$--popover","id":"oHRUi","layout":"vertical","name":"Status Dropdown Popover","stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame","width":"fill_container"}],"fill":"$--background","gap":8,"id":"aKgqZ","layout":"vertical","name":"Property Value — Status Dropdown (open)","padding":12,"type":"frame","width":300,"x":400,"y":420}],"variables":{"--accent-blue":{"type":"color","value":"#2383E2"},"--accent-blue-light":{"type":"color","value":"#D3E5EF"},"--accent-green":{"type":"color","value":"#0F7B6C"},"--accent-green-light":{"type":"color","value":"#DBEDDB"},"--accent-orange":{"type":"color","value":"#D9730D"},"--accent-orange-light":{"type":"color","value":"#FADEC9"},"--accent-purple":{"type":"color","value":"#9065B0"},"--accent-purple-light":{"type":"color","value":"#E8DEEE"},"--accent-red":{"type":"color","value":"#E03E3E"},"--accent-red-light":{"type":"color","value":"#FFE2DD"},"--accent-yellow":{"type":"color","value":"#DFAB01"},"--accent-yellow-light":{"type":"color","value":"#FDE68A"},"--background":{"type":"color","value":"#FFFFFF"},"--bg-hover-subtle":{"type":"color","value":"#F5F5F4"},"--border":{"type":"color","value":"#E5E5E2"},"--foreground":{"type":"color","value":"#37352F"},"--muted":{"type":"color","value":"#F5F5F4"},"--muted-foreground":{"type":"color","value":"#91918E"},"--popover":{"type":"color","value":"#FFFFFF"},"--popover-foreground":{"type":"color","value":"#37352F"},"--primary":{"type":"color","value":"#2383E2"},"--ring":{"type":"color","value":"#2383E2"},"--secondary-foreground":{"type":"color","value":"#37352F"}}} \ No newline at end of file diff --git a/design/raw-editor-mode.pen b/design/raw-editor-mode.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/raw-editor-mode.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/relationship-x-cosmetic.pen b/design/relationship-x-cosmetic.pen new file mode 100644 index 0000000..b6abb9d --- /dev/null +++ b/design/relationship-x-cosmetic.pen @@ -0,0 +1,165 @@ +{ + "children": [ + { + "type": "frame", + "id": "rel_x_default", + "name": "Relationship Pill — Default (X hidden)", + "x": 0, + "y": 0, + "width": 280, + "height": "fit_content(40)", + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "rel_x_default_label", + "content": "Relationship Pill — Default State", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "rel_x_pill_default", + "layout": "horizontal", + "gap": 4, + "padding": [ + 4, + 8 + ], + "fill": "$--accent", + "borderRadius": 6, + "width": "fit_content", + "height": "fit_content", + "children": [ + { + "type": "text", + "id": "rel_x_pill_label", + "content": "Note Title", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "id": "rel_x_type_icon", + "width": 14, + "height": 14, + "fill": "$--muted-foreground", + "opacity": 0.5, + "borderRadius": 2 + } + ] + }, + { + "type": "text", + "id": "rel_x_note_default", + "content": "X button not visible in default state", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 10 + } + ] + }, + { + "type": "frame", + "id": "rel_x_hover", + "name": "Relationship Pill — Hover (X visible)", + "x": 320, + "y": 0, + "width": 280, + "height": "fit_content(40)", + "fill": "$--background", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "rel_x_hover_label", + "content": "Relationship Pill — Hover State", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "rel_x_pill_hover", + "layout": "horizontal", + "gap": 6, + "padding": [ + 4, + 8 + ], + "fill": "$--accent", + "borderRadius": 6, + "width": "fit_content", + "height": "fit_content", + "children": [ + { + "type": "text", + "id": "rel_x_pill_label_hover", + "content": "Note Title", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "rel_x_trailing", + "layout": "horizontal", + "gap": 6, + "children": [ + { + "type": "text", + "id": "rel_x_icon", + "content": "×", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "400" + }, + { + "type": "rectangle", + "id": "rel_x_type_icon_hover", + "width": 14, + "height": 14, + "fill": "$--muted-foreground", + "opacity": 0.5, + "borderRadius": 2 + } + ] + } + ] + }, + { + "type": "text", + "id": "rel_x_note_hover", + "content": "X appears inline as flex child, before type icon. Ring border on hover.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 10 + } + ] + } + ] +} \ No newline at end of file diff --git a/design/search-bundle-qmd.pen b/design/search-bundle-qmd.pen new file mode 100644 index 0000000..759adb9 --- /dev/null +++ b/design/search-bundle-qmd.pen @@ -0,0 +1 @@ +{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}} \ No newline at end of file diff --git a/design/search-results-subtitle.pen b/design/search-results-subtitle.pen new file mode 100644 index 0000000..8c33764 --- /dev/null +++ b/design/search-results-subtitle.pen @@ -0,0 +1,665 @@ +{ + "children": [ + { + "type": "frame", + "id": "srs_main", + "name": "Search Results — subtitle with metadata", + "x": 0, + "y": 0, + "width": 540, + "fill": "$--card", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "shadow": [ + { + "x": 0, + "y": 8, + "blur": 32, + "spread": -4, + "fill": "#00000020" + } + ], + "layout": "vertical", + "clip": true, + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "frame", + "id": "srs_inputRow", + "name": "searchInputRow", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 8, + "padding": [14, 16], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "srs_searchIcon", + "width": 18, + "height": 18, + "iconFontName": "magnifying-glass", + "iconFontFamily": "phosphor", + "fill": "$--primary" + }, + { + "type": "text", + "id": "srs_query", + "fill": "$--foreground", + "content": "time management", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "srs_resultsHeader", + "name": "resultsHeader", + "width": "fill_container", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "srs_count", + "fill": "$--muted-foreground", + "content": "3 results \u00b7 45ms", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "srs_result1", + "name": "searchResult1 (selected)", + "width": "fill_container", + "fill": "$--accent-blue-light", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "children": [ + { + "type": "frame", + "id": "srs_r1_titleRow", + "name": "titleRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r1_title", + "fill": "$--foreground", + "content": "Time Management Strategies", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "srs_r1_badge", + "name": "typeBadge", + "fill": "$--accent-green-light", + "cornerRadius": 4, + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "srs_r1_badgeTxt", + "fill": "$--accent-green", + "content": "Evergreen", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_r1_meta", + "name": "metadataRow", + "width": "fill_container", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r1_date", + "fill": "$--muted-foreground", + "content": "2d ago", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r1_sep1", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r1_words", + "fill": "$--muted-foreground", + "content": "1,247 words", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r1_sep2", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r1_links", + "fill": "$--muted-foreground", + "content": "5 links", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_result2", + "name": "searchResult2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "children": [ + { + "type": "frame", + "id": "srs_r2_titleRow", + "name": "titleRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r2_title", + "fill": "$--foreground", + "content": "Weekly Review Process", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "srs_r2_badge", + "name": "typeBadge", + "fill": "$--accent-purple-light", + "cornerRadius": 4, + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "srs_r2_badgeTxt", + "fill": "$--accent-purple", + "content": "Procedure", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_r2_meta", + "name": "metadataRow", + "width": "fill_container", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r2_date", + "fill": "$--muted-foreground", + "content": "5h ago", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_sep1", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_created", + "fill": "$--muted-foreground", + "content": "Created Jan 15", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_sep2", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_words", + "fill": "$--muted-foreground", + "content": "856 words", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_sep3", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r2_links", + "fill": "$--muted-foreground", + "content": "12 links", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_result3", + "name": "searchResult3", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16, 14, 16], + "children": [ + { + "type": "frame", + "id": "srs_r3_titleRow", + "name": "titleRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r3_title", + "fill": "$--foreground", + "content": "Q1 Planning", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "srs_r3_meta", + "name": "metadataRow", + "width": "fill_container", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_r3_date", + "fill": "$--muted-foreground", + "content": "Jan 3", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r3_sep1", + "fill": "$--muted-foreground", + "content": "\u00b7", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_r3_words", + "fill": "$--muted-foreground", + "content": "2,103 words", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_footer", + "name": "searchFooter", + "width": "fill_container", + "fill": "$--muted", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "gap": 16, + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "srs_hint1", + "fill": "$--muted-foreground", + "content": "\u2191\u2193 Navigate", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_hint2", + "fill": "$--muted-foreground", + "content": "\u21b5 Open", + "fontFamily": "Inter", + "fontSize": 11 + }, + { + "type": "text", + "id": "srs_hint3", + "fill": "$--muted-foreground", + "content": "Esc Close", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "srs_states", + "name": "Search Results — subtitle states", + "x": 600, + "y": 0, + "width": 540, + "layout": "vertical", + "gap": 24, + "padding": [24, 24], + "fill": "$--background", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "srs_states_title", + "fill": "$--foreground", + "content": "Metadata Subtitle — States", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "srs_state1", + "name": "State: all metadata present", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "srs_s1_label", + "fill": "$--accent-blue", + "content": "All metadata present (modified \u2260 created)", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "text", + "id": "srs_s1_title", + "fill": "$--foreground", + "content": "Time Management Strategies", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "text", + "id": "srs_s1_meta", + "fill": "$--muted-foreground", + "content": "2d ago \u00b7 Created Jan 15 \u00b7 1,247 words \u00b7 5 links", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "srs_state2", + "name": "State: no created date (same as modified)", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "srs_s2_label", + "fill": "$--accent-blue", + "content": "Modified = Created (skip 'Created' field)", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "text", + "id": "srs_s2_title", + "fill": "$--foreground", + "content": "Quick Note", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "text", + "id": "srs_s2_meta", + "fill": "$--muted-foreground", + "content": "just now \u00b7 342 words \u00b7 2 links", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "srs_state3", + "name": "State: no links (zero outgoingLinks)", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "srs_s3_label", + "fill": "$--accent-blue", + "content": "No links (omitted from metadata)", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "text", + "id": "srs_s3_title", + "fill": "$--foreground", + "content": "Random Thought", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "text", + "id": "srs_s3_meta", + "fill": "$--muted-foreground", + "content": "3h ago \u00b7 128 words", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "srs_state4", + "name": "State: empty note (zero words)", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "srs_s4_label", + "fill": "$--accent-blue", + "content": "Empty note (word count shows 'Empty')", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "text", + "id": "srs_s4_title", + "fill": "$--foreground", + "content": "Untitled Draft", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "text", + "id": "srs_s4_meta", + "fill": "$--muted-foreground", + "content": "5d ago \u00b7 Empty", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "srs_state5", + "name": "State: no date at all", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "children": [ + { + "type": "text", + "id": "srs_s5_label", + "fill": "$--accent-blue", + "content": "No date available (graceful degradation)", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "text", + "id": "srs_s5_title", + "fill": "$--foreground", + "content": "Legacy Import", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "text", + "id": "srs_s5_meta", + "fill": "$--muted-foreground", + "content": "89 words \u00b7 1 link", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/design/search-subtitle-metadata.pen b/design/search-subtitle-metadata.pen new file mode 100644 index 0000000..fe4ea0c --- /dev/null +++ b/design/search-subtitle-metadata.pen @@ -0,0 +1,436 @@ +{"children":[ + { + "type": "frame", + "id": "f_search_meta", + "name": "Search Result — with metadata subtitle", + "x": 0, + "y": 0, + "width": 540, + "height": "fit_content", + "fill": "$--search-bg", + "layout": "vertical", + "padding": [24, 0], + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "f1_heading", + "content": "Search Result Item — with metadata subtitle", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700", + "width": "fill_container", + "padding": [0, 16, 12, 16] + }, + { + "type": "text", + "id": "f1_desc", + "content": "Each search result now shows a metadata line below the snippet: modified date, word count, and link count. The type badge remains next to the title.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "lineHeight": 1.5, + "width": "fill_container", + "padding": [0, 16, 16, 16] + }, + { + "type": "frame", + "id": "f1_countbar", + "width": "fill_container", + "padding": [6, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "text", + "id": "f1_count", + "content": "12 results · 148ms", + "fill": "$--search-highlight", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f1_result1", + "name": "Result 1 — Selected", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "fill": "$--accent", + "children": [ + { + "type": "frame", + "id": "f1_r1_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r1_title", + "content": "How to Manage your Time", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "f1_r1_badge", + "cornerRadius": 3, + "fill": "$--secondary", + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "f1_r1_badgeText", + "content": "Essay", + "fill": "$--search-text-muted", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "f1_r1_snippet", + "content": "...how people can improve their time management skills. They gathered results in this article...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r1_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r1_m1", + "content": "Modified 2h ago · 1,240 words · 7 links", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "f1_result2", + "name": "Result 2 — Normal", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } }, + "children": [ + { + "type": "frame", + "id": "f1_r2_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r2_title", + "content": "Weekly Review Process", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "f1_r2_badge", + "cornerRadius": 3, + "fill": "$--secondary", + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "f1_r2_badgeText", + "content": "Procedure", + "fill": "$--search-text-muted", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "f1_r2_snippet", + "content": "Every Sunday morning, review the week's progress and plan the next week...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r2_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r2_m1", + "content": "Modified 3d ago · 520 words · 12 links", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "f1_result3", + "name": "Result 3 — No links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } }, + "children": [ + { + "type": "frame", + "id": "f1_r3_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r3_title", + "content": "Morning Routine", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "f1_r3_snippet", + "content": "A simple checklist for starting the day with intention...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r3_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r3_m1", + "content": "Modified Jan 15 · 89 words", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "f_notelist_meta", + "name": "NoteList Item — subtitle with link count", + "x": 600, + "y": 0, + "width": 340, + "height": "fit_content", + "fill": "$--card", + "layout": "vertical", + "padding": [24, 0], + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "f2_heading", + "content": "NoteList Item — enhanced subtitle", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700", + "width": "fill_container", + "padding": [0, 16, 12, 16] + }, + { + "type": "text", + "id": "f2_desc", + "content": "Normal NoteList items add a link count after date and word count. The separator is a middle dot (·). When link count is 0, it's omitted.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "lineHeight": 1.5, + "width": "fill_container", + "padding": [0, 16, 16, 16] + }, + { + "type": "frame", + "id": "f2_item1", + "name": "NoteListItem — with links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i1_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i1_title", + "content": "Build Laputa App", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "f2_i1_sub", + "content": "2h ago · 342 words · 5 links", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f2_item2", + "name": "NoteListItem — no links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i2_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i2_title", + "content": "Quick Meeting Notes", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "f2_i2_sub", + "content": "5d ago · 120 words", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f2_item3", + "name": "NoteListItem — empty note", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i3_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i3_title", + "content": "New Untitled Note", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "f2_i3_sub", + "content": "just now · Empty", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } +],"variables":{ + "--search-bg": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1E1E2E"}]}, + "--search-heading": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#e0e0f0"}]}, + "--search-snippet": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#888899"}]}, + "--search-text-dim": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#666680"}]}, + "--search-text-muted": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#8888aa"}]}, + "--search-highlight": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#555570"}]}, + "--search-border": {"type":"color","value":[{"value":"#E9E9E7"},{"theme":{"Mode":"Dark"},"value":"#4c4c6d"}]}, + "--foreground": {"type":"color","value":[{"value":"#1C1C1C"},{"theme":{"Mode":"Dark"},"value":"#F5F5F5"}]}, + "--muted-foreground": {"type":"color","value":[{"value":"#737373"},{"theme":{"Mode":"Dark"},"value":"#A3A3A3"}]}, + "--border": {"type":"color","value":[{"value":"#E5E5E5"},{"theme":{"Mode":"Dark"},"value":"#333333"}]}, + "--card": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1C1C1C"}]}, + "--accent": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]}, + "--secondary": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]} +}} \ No newline at end of file diff --git a/design/smart-property-display.pen b/design/smart-property-display.pen new file mode 100644 index 0000000..482e365 --- /dev/null +++ b/design/smart-property-display.pen @@ -0,0 +1,524 @@ +{ + "children": [ + { + "type": "frame", + "id": "spd-date", + "x": 0, + "y": 0, + "name": "Smart Properties — Date picker open", + "width": 280, + "height": 300, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-date-title", + "content": "Date Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-date-row", + "name": "DateRow", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-date-label", + "content": "DEADLINE", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "text", + "id": "spd-date-value", + "content": "Feb 25, 2026", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-date-picker", + "name": "DatePickerOpen", + "width": "fill_container", + "height": 200, + "fill": "#F5F5F5", + "cornerRadius": 8, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-picker-month", + "content": "February 2026", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "text", + "id": "spd-picker-hint", + "content": "Native opens on click.\nDisplays as 'Feb 25, 2026' when closed.", + "fontSize": 11, + "fill": "#888888" + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-bool", + "x": 300, + "y": 0, + "name": "Smart Properties — Boolean toggle", + "width": 280, + "height": 200, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-bool-title", + "content": "Boolean Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-bool-row-on", + "name": "BooleanRowTrue", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-bool-label-on", + "content": "PUBLISHED", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-bool-toggle-on", + "name": "ToggleOn", + "height": 22, + "cornerRadius": 4, + "fill": "#E8F5E9", + "padding": [2, 8], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-bool-val-on", + "content": "\u2713 Yes", + "fontSize": 12, + "fill": "#2E7D32" + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-bool-row-off", + "name": "BooleanRowFalse", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-bool-label-off", + "content": "ARCHIVED", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-bool-toggle-off", + "name": "ToggleOff", + "height": 22, + "cornerRadius": 4, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "padding": [2, 8], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-bool-val-off", + "content": "\u2717 No", + "fontSize": 12, + "fill": "#888888" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status", + "x": 600, + "y": 0, + "name": "Smart Properties — Status badge", + "width": 280, + "height": 260, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-status-title", + "content": "Status Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-status-row-active", + "name": "StatusActive", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-a", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-active", + "name": "BadgeActive", + "cornerRadius": 16, + "fill": "#E8F5E9", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-active-text", + "content": "ACTIVE", + "fontSize": 10, + "fontWeight": "600", + "fill": "#2E7D32", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status-row-draft", + "name": "StatusDraft", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-d", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-draft", + "name": "BadgeDraft", + "cornerRadius": 16, + "fill": "#FFF8E1", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-draft-text", + "content": "DRAFT", + "fontSize": 10, + "fontWeight": "600", + "fill": "#F9A825", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status-row-done", + "name": "StatusDone", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-dn", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-done", + "name": "BadgeDone", + "cornerRadius": 16, + "fill": "#E3F2FD", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-done-text", + "content": "DONE", + "fontSize": 10, + "fontWeight": "600", + "fill": "#1565C0", + "letterSpacing": 1.2 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-override", + "x": 900, + "y": 0, + "name": "Smart Properties — Display mode override dropdown", + "width": 280, + "height": 300, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-override-title", + "content": "Display Mode Override", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-override-row", + "name": "PropertyWithOverrideHandle", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "fill": "#F5F5F5", + "cornerRadius": 4, + "children": [ + { + "type": "text", + "id": "spd-override-label", + "content": "DEADLINE", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-override-actions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-override-val", + "content": "Feb 25, 2026", + "fontSize": 12, + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-override-handle", + "name": "OverrideHandle", + "width": 16, + "height": 16, + "cornerRadius": 3, + "fill": "#E0E0E0", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "spd-override-icon", + "content": "\u25BE", + "fontSize": 10, + "fill": "#666666" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-dropdown", + "name": "DisplayModeDropdown", + "width": 160, + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "layout": "vertical", + "padding": 4, + "gap": 2, + "children": [ + { + "type": "frame", + "id": "spd-opt-text", + "name": "OptionText", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-text-label", + "content": "Text", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-date", + "name": "OptionDate", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "fill": "#E3F2FD", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-date-label", + "content": "\u2713 Date", + "fontSize": 12, + "fill": "#1565C0" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-bool", + "name": "OptionBoolean", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-bool-label", + "content": "Boolean", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-status", + "name": "OptionStatus", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-status-label", + "content": "Status", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-url", + "name": "OptionURL", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-url-label", + "content": "URL", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/design/sort-picker-properties.pen b/design/sort-picker-properties.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/sort-picker-properties.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/status-color-picker.pen b/design/status-color-picker.pen new file mode 100644 index 0000000..e4ec159 --- /dev/null +++ b/design/status-color-picker.pen @@ -0,0 +1,619 @@ +{ + "children": [ + { + "children": [ + { + "content": "Status Dropdown — Color Dots", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600", + "id": "1wdqT", + "name": "title1", + "type": "text" + }, + { + "content": "Each status option shows a color dot on the left. Clicking the dot opens the color palette picker.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "PsCXR", + "lineHeight": 1.5, + "name": "desc1", + "textGrowth": "fixed-width", + "type": "text", + "width": 272 + }, + { + "children": [ + { + "children": [ + { + "content": "Type a status...", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "VyDsY", + "name": "searchText", + "type": "text" + } + ], + "id": "tim0g", + "name": "searchBar", + "padding": [ + 8, + 10 + ], + "stroke": { + "fill": "$--border", + "thickness": { + "bottom": 1 + } + }, + "type": "frame", + "width": "fill_container" + }, + { + "children": [ + { + "content": "FROM VAULT", + "fill": "$--muted-foreground", + "fontFamily": "IBM Plex Mono", + "fontSize": 9, + "fontWeight": "500", + "id": "Bn3lU", + "letterSpacing": 1.2, + "name": "labelText", + "type": "text" + } + ], + "id": "CB90c", + "name": "sectionLabel", + "padding": [ + 4, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-green", + "height": 10, + "id": "8uSm6", + "name": "dot1", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "ACTIVE", + "fill": "$--accent-green", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "h4OER", + "letterSpacing": 1.2, + "name": "pill1text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-green-light", + "id": "k7sSo", + "name": "pill1", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "IZrMP", + "name": "Status Option - Active", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-red", + "height": 10, + "id": "sSEfx", + "name": "dot2", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "BLOCKED", + "fill": "$--accent-red", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "61jBJ", + "letterSpacing": 1.2, + "name": "pill2text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-red-light", + "id": "ozLQM", + "name": "pill2", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "B3Im8", + "name": "Status Option - Blocked", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-yellow", + "height": 10, + "id": "2mTmp", + "name": "dot3", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "DRAFT", + "fill": "$--accent-yellow", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "evl7l", + "letterSpacing": 1.2, + "name": "pill3text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-yellow-light", + "id": "6Fuxw", + "name": "pill3", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "$--muted", + "gap": 8, + "id": "TwUBA", + "name": "Status Option - Draft (hover)", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + } + ], + "cornerRadius": 8, + "fill": "$--background", + "id": "DT2gn", + "layout": "vertical", + "name": "Dropdown Container", + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame", + "width": "fill_container" + } + ], + "fill": "$--background", + "gap": 16, + "id": "2N2BP", + "layout": "vertical", + "name": "1 — Status Dropdown with Color Dots", + "padding": 24, + "type": "frame", + "width": 320, + "x": 0, + "y": 0 + }, + { + "children": [ + { + "content": "Color Picker — Expanded", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600", + "id": "zPCnI", + "name": "title2", + "type": "text" + }, + { + "content": "Clicking a color dot expands the palette inline. The 8 accent colors from the design system are shown as clickable swatches. A 'Default' option resets to the built-in color.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "TtaTt", + "lineHeight": 1.5, + "name": "desc2", + "textGrowth": "fixed-width", + "type": "text", + "width": 272 + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-yellow", + "height": 10, + "id": "FRKg5", + "name": "dotActive", + "stroke": { + "fill": "$--foreground", + "thickness": 2 + }, + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "DRAFT", + "fill": "$--accent-yellow", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "rA60a", + "letterSpacing": 1.2, + "name": "pillActiveText", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-yellow-light", + "id": "15D2Q", + "name": "pillActive", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "$--muted", + "gap": 8, + "id": "3QplJ", + "name": "Draft Option Row", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "children": [ + { + "content": "ASSIGN COLOR", + "fill": "$--muted-foreground", + "fontFamily": "IBM Plex Mono", + "fontSize": 9, + "fontWeight": "500", + "id": "5yhS8", + "letterSpacing": 1.2, + "name": "paletteLabel", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "content": "Default", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal", + "id": "Q2ZwX", + "name": "defText", + "type": "text" + } + ], + "cornerRadius": 10, + "id": "Sgzjz", + "name": "defBtn", + "padding": [ + 2, + 6 + ], + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame" + }, + { + "fill": "$--accent-red", + "height": 16, + "id": "sOgl3", + "name": "sw1", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-orange", + "height": 16, + "id": "kqj6G", + "name": "sw2", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-yellow", + "height": 16, + "id": "luCEG", + "name": "sw3", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-green", + "height": 16, + "id": "KmBPE", + "name": "sw4", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-blue", + "height": 16, + "id": "IsLDd", + "name": "sw5", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-purple", + "height": 16, + "id": "M6bwU", + "name": "sw6", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-teal", + "height": 16, + "id": "LKPIO", + "name": "sw7", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-pink", + "height": 16, + "id": "bFQCa", + "name": "sw8", + "type": "ellipse", + "width": 16 + } + ], + "gap": 6, + "id": "zwiKV", + "name": "Swatches Row", + "type": "frame" + } + ], + "fill": "$--muted", + "gap": 8, + "id": "ZUL7a", + "layout": "vertical", + "name": "Color Palette", + "padding": [ + 8, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "fill": "$--border", + "height": 1, + "id": "hsnvv", + "name": "divider", + "type": "rectangle", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-green", + "height": 10, + "id": "Mddih", + "name": "otherDot", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "ACTIVE", + "fill": "$--accent-green", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "pVJ5Z", + "letterSpacing": 1.2, + "name": "otherText", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-green-light", + "id": "bHWx0", + "name": "otherPill", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "EpnwC", + "name": "Other Option", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + } + ], + "cornerRadius": 8, + "fill": "$--background", + "id": "f2Adq", + "layout": "vertical", + "name": "Dropdown with Picker", + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame", + "width": "fill_container" + } + ], + "fill": "$--background", + "gap": 16, + "id": "jbCuz", + "layout": "vertical", + "name": "2 — Color Picker Expanded", + "padding": 24, + "type": "frame", + "width": 320, + "x": 360, + "y": 0 + } + ], + "variables": { + "--accent-blue": { + "type": "color", + "value": "#155DFF" + }, + "--accent-blue-light": { + "type": "color", + "value": "#155DFF14" + }, + "--accent-green": { + "type": "color", + "value": "#38A169" + }, + "--accent-green-light": { + "type": "color", + "value": "#38A16919" + }, + "--accent-orange": { + "type": "color", + "value": "#D9730D" + }, + "--accent-orange-light": { + "type": "color", + "value": "#D9730D19" + }, + "--accent-pink": { + "type": "color", + "value": "#D53F8C" + }, + "--accent-pink-light": { + "type": "color", + "value": "#D53F8C19" + }, + "--accent-purple": { + "type": "color", + "value": "#805AD5" + }, + "--accent-purple-light": { + "type": "color", + "value": "#805AD519" + }, + "--accent-red": { + "type": "color", + "value": "#E53E3E" + }, + "--accent-red-light": { + "type": "color", + "value": "#E53E3E19" + }, + "--accent-teal": { + "type": "color", + "value": "#319795" + }, + "--accent-teal-light": { + "type": "color", + "value": "#31979519" + }, + "--accent-yellow": { + "type": "color", + "value": "#D69E2E" + }, + "--accent-yellow-light": { + "type": "color", + "value": "#D69E2E19" + }, + "--background": { + "type": "color", + "value": "#FFFFFF" + }, + "--border": { + "type": "color", + "value": "#E9E9E7" + }, + "--foreground": { + "type": "color", + "value": "#37352F" + }, + "--muted": { + "type": "color", + "value": "#F5F5F4" + }, + "--muted-foreground": { + "type": "color", + "value": "#787774" + } + } +} \ No newline at end of file diff --git a/design/status-property-dropdown.pen b/design/status-property-dropdown.pen new file mode 100644 index 0000000..a0a8a67 --- /dev/null +++ b/design/status-property-dropdown.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"sdClosed","x":0,"y":0,"name":"Status Property — Dropdown closed (pill display)","width":320,"height":48,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdClDesc","fill":"#6b7280","content":"Closed state: status shown as colored pill. Click to open dropdown.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdClRow","name":"Status Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","children":[{"type":"text","id":"sdClLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdClPill","name":"Status Pill — Active","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdClPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]},{"type":"frame","id":"sdOpen","x":0,"y":80,"name":"Status Property — Dropdown open with options","width":320,"height":360,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdOpDesc","fill":"#6b7280","content":"Open state: dropdown popover with colored status chips, search input, vault statuses, and defaults.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdOpRow","name":"Status Row (with open dropdown)","width":"fill_container","layout":"vertical","gap":0,"children":[{"type":"frame","id":"sdOpRowInner","name":"Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","fill":"#f3f4f6","children":[{"type":"text","id":"sdOpLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdOpPill","name":"Status Pill — Active (selected)","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpDropdown","name":"Dropdown Popover","width":"fill_container","fill":"#ffffff","cornerRadius":8,"stroke":{"align":"inside","thickness":1,"fill":"#e5e7eb"},"layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"frame","id":"sdOpSearch","name":"Search Input","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","children":[{"type":"text","id":"sdOpSearchText","fill":"#9ca3af","content":"Type a status...","fontFamily":"Inter","fontSize":12,"fontWeight":"normal"}]},{"type":"frame","id":"sdOpSep1","name":"Separator","width":"fill_container","height":1,"fill":"#e5e7eb"},{"type":"frame","id":"sdOpDefaults","name":"Suggested Statuses","width":"fill_container","layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"text","id":"sdOpDefLabel","fill":"#9ca3af","content":"SUGGESTED","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","letterSpacing":1.2,"padding":[4,8]},{"type":"frame","id":"sdOpOpt1","name":"Option — Not started","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt1Pill","fill":"#e0e7ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt1Text","fill":"#6b7280","content":"NOT STARTED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt2","name":"Option — In progress","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt2Pill","fill":"#f3e8ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt2Text","fill":"#9333ea","content":"IN PROGRESS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt3","name":"Option — Active (hover)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"fill":"#f3f4f6","children":[{"type":"frame","id":"sdOpOpt3Pill","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt3Text","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt4","name":"Option — Done","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt4Pill","fill":"#dbeafe","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt4Text","fill":"#2563eb","content":"DONE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt5","name":"Option — Blocked","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt5Pill","fill":"#fee2e2","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt5Text","fill":"#dc2626","content":"BLOCKED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]},{"type":"frame","id":"sdOpSep2","name":"Separator","width":"fill_container","height":1,"fill":"#e5e7eb"},{"type":"frame","id":"sdOpVault","name":"From Vault","width":"fill_container","layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"text","id":"sdOpVaultLabel","fill":"#9ca3af","content":"FROM VAULT","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","letterSpacing":1.2,"padding":[4,8]},{"type":"frame","id":"sdOpVOpt1","name":"Option — Draft (from vault)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpVOpt1Pill","fill":"#fef9c3","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpVOpt1Text","fill":"#ca8a04","content":"DRAFT","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpVOpt2","name":"Option — Published (from vault)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpVOpt2Pill","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpVOpt2Text","fill":"#16a34a","content":"PUBLISHED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]}]}]}]},{"type":"frame","id":"sdCustom","x":350,"y":0,"name":"Status Property — Custom status typed","width":320,"height":180,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdCuDesc","fill":"#6b7280","content":"User types a custom status name not in suggestions. Press Enter to create it.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdCuRow","name":"Status Row with custom input","width":"fill_container","layout":"vertical","gap":0,"children":[{"type":"frame","id":"sdCuRowInner","name":"Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","fill":"#f3f4f6","children":[{"type":"text","id":"sdCuLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdCuPill","name":"Status Pill — Active","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdCuPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdCuDropdown","name":"Dropdown — with typed input","width":"fill_container","fill":"#ffffff","cornerRadius":8,"stroke":{"align":"inside","thickness":1,"fill":"#e5e7eb"},"layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"frame","id":"sdCuSearch","name":"Search Input (typed)","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"#e5e7eb"},"children":[{"type":"text","id":"sdCuSearchText","fill":"#111827","content":"Needs Review","fontFamily":"Inter","fontSize":12,"fontWeight":"normal"}]},{"type":"frame","id":"sdCuCreate","name":"Create new option","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"sdCuCreateLabel","fill":"#6b7280","content":"Create","fontFamily":"Inter","fontSize":11,"fontWeight":"normal"},{"type":"frame","id":"sdCuCreatePill","fill":"#e0e7ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdCuCreateText","fill":"#6b7280","content":"NEEDS REVIEW","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]}]}]}],"variables":{}} \ No newline at end of file diff --git a/design/sync-conflict-resolution.pen b/design/sync-conflict-resolution.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/sync-conflict-resolution.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/tab-responsive-width.pen b/design/tab-responsive-width.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/tab-responsive-width.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/tags-property-type.pen b/design/tags-property-type.pen new file mode 100644 index 0000000..c85941b --- /dev/null +++ b/design/tags-property-type.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"content":"Tags Property — Display","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"HHQbx","letterSpacing":0.5,"name":"title1","type":"text"},{"alignItems":"center","children":[{"content":"TAGS","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"9BDgi","letterSpacing":1.2,"name":"label1","type":"text"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"React","fill":"$--accent-blue","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"V1Dfn","name":"pillText1","type":"text"},{"content":"×","fill":"$--accent-blue","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"urcjo","name":"pillX1","type":"text"}],"cornerRadius":12,"fill":"$--accent-blue-light","gap":4,"id":"70AK4","name":"pill1","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"TypeScript","fill":"$--accent-green","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"VtpTf","name":"pillText2","type":"text"},{"content":"×","fill":"$--accent-green","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"sw4gt","name":"pillX2","type":"text"}],"cornerRadius":12,"fill":"$--accent-green-light","gap":4,"id":"rCHzS","name":"pill2","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"Tauri","fill":"$--accent-purple","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"OTVWd","name":"pillText3","type":"text"},{"content":"×","fill":"$--accent-purple","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"j8w8E","name":"pillX3","type":"text"}],"cornerRadius":12,"fill":"$--accent-purple-light","gap":4,"id":"U4Dfr","name":"pill3","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"+","fill":"$--accent-blue","fontFamily":"Inter","fontSize":12,"fontWeight":"500","id":"BTPW1","name":"addPlus","type":"text"}],"cornerRadius":10,"height":20,"id":"2A0u4","justifyContent":"center","name":"addBtn","type":"frame","width":20}],"gap":4,"id":"wQwb9","name":"pillWrap","type":"frame"}],"gap":8,"id":"9vZUA","name":"row1","type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"$--background","gap":12,"id":"5Co69","layout":"vertical","name":"Tags Property — Display (pills with color + X)","padding":16,"type":"frame","width":280,"x":0,"y":0},{"children":[{"content":"Tags Dropdown — Multi-Select","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"EYQrz","letterSpacing":0.5,"name":"title2","type":"text"},{"children":[{"content":"Type a tag...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"K4O5T","name":"searchInput","type":"text"}],"id":"FiJYp","name":"searchBox","padding":[8,12],"type":"frame","width":"fill_container"},{"children":[{"content":"FROM VAULT","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"TTrI3","letterSpacing":1.2,"name":"secLabel1","type":"text"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-blue","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"hvC9R","name":"check1","type":"text"},{"children":[{"content":"REACT","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"uafDO","letterSpacing":1.2,"name":"optPillText1","type":"text"}],"cornerRadius":12,"fill":"$--accent-blue-light","id":"fSE56","name":"optPill1","padding":[1,6],"type":"frame"},{"fill":"$--accent-blue","height":14,"id":"hm8BB","name":"optColor1","type":"ellipse","width":14}],"cornerRadius":4,"fill":"$--muted","gap":6,"id":"sEjJ4","name":"opt1","padding":[6,8],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-green","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"xMjpj","name":"check2","type":"text"},{"children":[{"content":"TYPESCRIPT","fill":"$--accent-green","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"QkrlH","letterSpacing":1.2,"name":"optPillText2","type":"text"}],"cornerRadius":12,"fill":"$--accent-green-light","id":"mFD4b","name":"optPill2","padding":[1,6],"type":"frame"},{"fill":"$--accent-green","height":14,"id":"IeC8h","name":"optColor2","type":"ellipse","width":14}],"cornerRadius":4,"gap":6,"id":"jTW08","name":"opt2","padding":[6,8],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"height":14,"id":"VWb7I","name":"spacer3","type":"frame","width":14},{"children":[{"content":"VITE","fill":"$--accent-orange","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"IBEpk","letterSpacing":1.2,"name":"optPillText3","type":"text"}],"cornerRadius":12,"fill":"$--accent-orange-light","id":"juYuA","name":"optPill3","padding":[1,6],"type":"frame"},{"fill":"$--accent-orange","height":14,"id":"aTF8b","name":"optColor3","type":"ellipse","width":14}],"cornerRadius":4,"gap":6,"id":"byNKM","name":"opt3","padding":[6,8],"type":"frame","width":"fill_container"},{"fill":"$--border","height":1,"id":"5sC2Y","name":"divider","type":"rectangle","width":"fill_container"},{"content":"SUGGESTED","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"7vME3","letterSpacing":1.2,"name":"secLabel2","type":"text"}],"id":"KsIE1","layout":"vertical","name":"listArea","padding":[4,0],"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"$--background","id":"aWUZr","layout":"vertical","name":"Tags Property — Input (entering new tag)","type":"frame","width":220,"x":320,"y":0},{"children":[{"content":"Color Picker — Per Tag","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"eUqcb","letterSpacing":0.5,"name":"title3","type":"text"},{"content":"Click the color swatch next to any tag to expand the color picker row","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"9JSxa","name":"desc3","type":"text"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-purple","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"8P4r5","name":"optCheck","type":"text"},{"children":[{"content":"TAURI","fill":"$--accent-purple","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"u9Ljh","letterSpacing":1.2,"name":"optPillTxt","type":"text"}],"cornerRadius":12,"fill":"$--accent-purple-light","id":"KLwLI","name":"optPill","padding":[1,6],"type":"frame"},{"fill":"$--accent-purple","height":14,"id":"XMIpZ","name":"optSwatch","type":"ellipse","width":14}],"cornerRadius":4,"fill":"$--muted","gap":6,"id":"osSzq","name":"optRow","padding":[6,4],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-red","height":16,"id":"4lhx5","name":"c1","type":"ellipse","width":16},{"fill":"$--accent-orange","height":16,"id":"aa0VW","name":"c2","type":"ellipse","width":16},{"fill":"$--accent-yellow","height":16,"id":"Vdkb2","name":"c3","type":"ellipse","width":16},{"fill":"$--accent-green","height":16,"id":"nb62h","name":"c4","type":"ellipse","width":16},{"fill":"$--accent-blue","height":16,"id":"lFU1J","name":"c5","type":"ellipse","width":16},{"fill":"$--accent-purple","height":16,"id":"d7Vkz","name":"c6","type":"ellipse","width":16}],"gap":6,"id":"aHm6t","name":"colorRow","padding":[8,12],"type":"frame"}],"cornerRadius":8,"fill":"$--background","id":"SBYSL","layout":"vertical","name":"Tags Property — Color Picker (selecting tag color)","padding":12,"type":"frame","width":220,"x":580,"y":0}],"variables":{"--background":{"type":"color","value":"#FFFFFF"},"--foreground":{"type":"color","value":"#37352F"},"--muted":{"type":"color","value":"#F0F0EF"},"--muted-foreground":{"type":"color","value":"#787774"},"--border":{"type":"color","value":"#E9E9E7"},"--accent-blue":{"type":"color","value":"#155DFF"},"--accent-blue-light":{"type":"color","value":"#155DFF14"},"--accent-green":{"type":"color","value":"#00B38B"},"--accent-green-light":{"type":"color","value":"#00B38B14"},"--accent-red":{"type":"color","value":"#E03E3E"},"--accent-red-light":{"type":"color","value":"#E03E3E14"},"--accent-purple":{"type":"color","value":"#A932FF"},"--accent-purple-light":{"type":"color","value":"#A932FF14"},"--accent-orange":{"type":"color","value":"#D9730D"},"--accent-orange-light":{"type":"color","value":"#D9730D14"},"--accent-yellow":{"type":"color","value":"#F0B100"},"--accent-yellow-light":{"type":"color","value":"#F0B10014"},"--primary":{"type":"color","value":"#155DFF"},"--font-mono":{"type":"string","value":"IBM Plex Mono, monospace"},"--font-primary":{"type":"string","value":"Inter"}}} \ No newline at end of file diff --git a/design/themes-editable.pen b/design/themes-editable.pen new file mode 100644 index 0000000..747b5ab --- /dev/null +++ b/design/themes-editable.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/design/theming-system.pen b/design/theming-system.pen new file mode 100644 index 0000000..e75de45 --- /dev/null +++ b/design/theming-system.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"alignItems":"center","children":[{"content":"Settings","fill":"#37352F","fontSize":16,"fontWeight":"600","id":"o6HJK","type":"text"},{"fill":"#787774","height":16,"iconFontFamily":"lucide","iconFontName":"x","id":"zgXAf","type":"icon_font","width":16}],"height":56,"id":"mxSw1","justifyContent":"space_between","name":"Header","padding":[0,24],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"Appearance","fill":"#37352F","fontSize":13,"fontWeight":"600","id":"NhMui","type":"text"},{"content":"Choose a theme for your vault. Themes are JSON files in _themes/ — edit them to customize.","fill":"#787774","fontSize":12,"id":"atYQn","lineHeight":1.5,"textGrowth":"fixed-width","type":"text","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"children":[{"fill":"#FFFFFF","height":32,"type":"rectangle","width":20},{"fill":"#F7F6F3","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Default","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"Light theme with warm tones","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"},{"content":"Active","fill":"#155DFF","fontSize":11,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Default (Active)","padding":[0,12],"stroke":{"align":"inside","fill":"#155DFF","thickness":2},"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#1a1a2e","height":32,"type":"rectangle","width":20},{"fill":"#0f0f1a","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Dark","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"Dark variant with deep navy tones","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Dark","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#FAFAFA","height":32,"type":"rectangle","width":20},{"fill":"#111111","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Minimal","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"High contrast, minimal chrome","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Minimal","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":"fill_container"}],"gap":8,"layout":"vertical","name":"Theme List","type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"plus","type":"icon_font","width":14},{"content":"New Theme","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":6,"height":32,"name":"New Theme Button","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"},{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"pencil","type":"icon_font","width":14},{"content":"Open in Editor","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":6,"height":32,"name":"Open in Editor Button","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"}],"gap":8,"name":"Actions","type":"frame","width":"fill_container"}],"gap":20,"layout":"vertical","name":"Body","padding":24,"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Cancel","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"height":32,"padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"},{"alignItems":"center","children":[{"content":"Save","fill":"#FFFFFF","fontSize":13,"type":"text"}],"cornerRadius":6,"fill":"#155DFF","height":32,"padding":[0,16],"type":"frame"}],"gap":8,"height":56,"justifyContent":"end","name":"Footer","padding":[0,24],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"fill":"#FFFFFF","layout":"vertical","name":"Settings — Appearance","type":"frame","width":520,"x":0,"y":0},{"children":[{"alignItems":"center","children":[{"content":"Switch theme...","fill":"#787774","fontSize":15,"type":"text"}],"height":44,"name":"Search","padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"APPEARANCE","fill":"#787774","fontSize":11,"fontWeight":"600","letterSpacing":0.8,"type":"text"},{"alignItems":"center","children":[{"children":[{"fill":"#FFFFFF","height":16,"type":"rectangle","width":10},{"fill":"#F7F6F3","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Default","fill":"#37352F","fontSize":13,"type":"text"},{"fill":"#155DFF","height":14,"iconFontFamily":"lucide","iconFontName":"check","type":"icon_font","width":14}],"cornerRadius":6,"fill":"#EBEBEA","gap":8,"height":36,"name":"Row — Active","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#1a1a2e","height":16,"type":"rectangle","width":10},{"fill":"#0f0f1a","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Dark","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — Dark","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#FAFAFA","height":16,"type":"rectangle","width":10},{"fill":"#111111","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Minimal","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — Minimal","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"#787774","height":16,"iconFontFamily":"lucide","iconFontName":"plus","type":"icon_font","width":16},{"content":"New Theme","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — New Theme","padding":[0,12],"type":"frame","width":"fill_container"}],"layout":"vertical","name":"Results","padding":[4,0],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"↑↓ navigate","fill":"#787774","fontSize":11,"type":"text"},{"content":"↵ select","fill":"#787774","fontSize":11,"type":"text"},{"content":"esc close","fill":"#787774","fontSize":11,"type":"text"}],"gap":16,"height":28,"name":"Footer","padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"cornerRadius":12,"fill":"#FFFFFF","layout":"vertical","name":"Command Palette — Theme Switching","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":520,"x":620,"y":0},{"children":[{"children":[{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"file","type":"icon_font","width":14},{"content":"_themes/default.json","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"gap":8,"height":36,"padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"{","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"name\": \"Default\",","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"colors\": {","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"background\": \"#FFFFFF\",","fill":"#155DFF","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"600","type":"text"},{"content":" \"sidebar-background\": \"#F7F6F3\",","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"accent\": \"#155DFF\"","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" }","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":"}","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"}],"gap":2,"layout":"vertical","padding":16,"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"#FFFFFF","layout":"vertical","name":"Editor — Theme JSON","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":400},{"alignItems":"center","children":[{"fill":"#787774","height":24,"iconFontFamily":"lucide","iconFontName":"arrow-right","type":"icon_font","width":24},{"content":"~300ms","fill":"#787774","fontSize":10,"fontWeight":"500","type":"text"}],"justifyContent":"center","layout":"vertical","name":"Arrow","type":"frame","width":40},{"children":[{"alignItems":"center","children":[{"fill":"#FF5F57","height":8,"type":"ellipse","width":8},{"fill":"#FEBC2E","height":8,"type":"ellipse","width":8},{"fill":"#28C840","height":8,"type":"ellipse","width":8}],"fill":"#F7F6F3","gap":6,"height":28,"padding":[0,8],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"children":[{"cornerRadius":4,"fill":"#EBEBEA","height":20,"type":"frame","width":"fill_container"},{"cornerRadius":4,"height":20,"type":"frame","width":"fill_container"},{"cornerRadius":4,"height":20,"type":"frame","width":"fill_container"}],"fill":"#F7F6F3","gap":4,"height":"fill_container","layout":"vertical","padding":[8,6],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"right":1}},"type":"frame","width":80},{"children":[{"cornerRadius":2,"fill":"#37352F","height":12,"type":"frame","width":160},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":"fill_container"},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":220},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":180}],"gap":8,"height":"fill_container","layout":"vertical","padding":16,"type":"frame","width":"fill_container"}],"height":260,"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"#38A169","height":6,"type":"ellipse","width":6},{"content":"Live — CSS variables update instantly","fill":"#787774","fontSize":10,"type":"text"}],"gap":6,"height":28,"padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"#FFFFFF","layout":"vertical","name":"App Preview","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":380}],"fill":"#F0F0EF","gap":24,"name":"Live Theme Editing Flow","padding":24,"type":"frame","width":900,"x":0,"y":700}],"variables":{}} \ No newline at end of file diff --git a/design/trash-management.pen b/design/trash-management.pen new file mode 100644 index 0000000..ebcc2d6 --- /dev/null +++ b/design/trash-management.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/design/trashed-note-editor.pen b/design/trashed-note-editor.pen new file mode 100644 index 0000000..a604284 --- /dev/null +++ b/design/trashed-note-editor.pen @@ -0,0 +1 @@ +{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"›","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}} \ No newline at end of file diff --git a/design/ui-design-cleanup.pen b/design/ui-design-cleanup.pen new file mode 100644 index 0000000..c0fd64a --- /dev/null +++ b/design/ui-design-cleanup.pen @@ -0,0 +1,329 @@ +{ + "children": [ + { + "id": "cleanup-summary", + "type": "frame", + "name": "ui-design-cleanup — Summary of Changes", + "x": 0, + "y": 0, + "width": 800, + "height": "fit_content(600)", + "fill": "$--background", + "layout": "vertical", + "padding": 32, + "gap": 24, + "children": [ + { + "id": "title", + "type": "text", + "content": "ui-design.pen Cleanup — 2026-02-27", + "fontSize": 28, + "fontWeight": "700", + "color": "$--foreground" + }, + { + "id": "counts", + "type": "frame", + "layout": "horizontal", + "gap": 40, + "children": [ + { + "id": "count-before", + "type": "frame", + "layout": "vertical", + "gap": 4, + "children": [ + { + "id": "cb-label", + "type": "text", + "content": "Frames Before", + "fontSize": 12, + "color": "$--muted-foreground", + "fontFamily": "$--font-mono" + }, + { + "id": "cb-value", + "type": "text", + "content": "113", + "fontSize": 32, + "fontWeight": "700", + "color": "$--foreground" + } + ] + }, + { + "id": "count-after", + "type": "frame", + "layout": "vertical", + "gap": 4, + "children": [ + { + "id": "ca-label", + "type": "text", + "content": "Frames After", + "fontSize": 12, + "color": "$--muted-foreground", + "fontFamily": "$--font-mono" + }, + { + "id": "ca-value", + "type": "text", + "content": "129", + "fontSize": 32, + "fontWeight": "700", + "color": "$--accent-green" + } + ] + }, + { + "id": "count-removed", + "type": "frame", + "layout": "vertical", + "gap": 4, + "children": [ + { + "id": "cr-label", + "type": "text", + "content": "Before Variants Removed", + "fontSize": 12, + "color": "$--muted-foreground", + "fontFamily": "$--font-mono" + }, + { + "id": "cr-value", + "type": "text", + "content": "3", + "fontSize": 32, + "fontWeight": "700", + "color": "$--destructive" + } + ] + }, + { + "id": "count-integrated", + "type": "frame", + "layout": "vertical", + "gap": 4, + "children": [ + { + "id": "ci-label", + "type": "text", + "content": "Frames Integrated", + "fontSize": 12, + "color": "$--muted-foreground", + "fontFamily": "$--font-mono" + }, + { + "id": "ci-value", + "type": "text", + "content": "18", + "fontSize": 32, + "fontWeight": "700", + "color": "$--accent-blue" + } + ] + } + ] + }, + { + "id": "sep1", + "type": "line", + "width": "fill_container", + "stroke": "$--border", + "strokeWidth": 1 + }, + { + "id": "section-removed", + "type": "frame", + "layout": "vertical", + "gap": 8, + "children": [ + { + "id": "sr-title", + "type": "text", + "content": "Removed (before variants)", + "fontSize": 16, + "fontWeight": "600", + "color": "$--foreground" + }, + { + "id": "sr1", + "type": "text", + "content": "- csB01: Sections Header — Before (old design)", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "sr2", + "type": "text", + "content": "- mb001: macOS Border — Before (no border)", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "sr3", + "type": "text", + "content": "- hideBacklinksEmpty_before: Hide Empty Backlinks Label — Before", + "fontSize": 13, + "color": "$--muted-foreground" + } + ] + }, + { + "id": "section-renamed", + "type": "frame", + "layout": "vertical", + "gap": 8, + "children": [ + { + "id": "sn-title", + "type": "text", + "content": "Renamed (dropped After/Before labels)", + "fontSize": 16, + "fontWeight": "600", + "color": "$--foreground" + }, + { + "id": "sn1", + "type": "text", + "content": "- csA01: Sections Header — After (new design) → Sections Header — Redesigned", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "sn2", + "type": "text", + "content": "- mb011: macOS Border — After (with border) → macOS Border — With Border", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "sn3", + "type": "text", + "content": "- hideBacklinksEmpty_after: ... → Hide Empty Backlinks Label", + "fontSize": 13, + "color": "$--muted-foreground" + } + ] + }, + { + "id": "sep2", + "type": "line", + "width": "fill_container", + "stroke": "$--border", + "strokeWidth": 1 + }, + { + "id": "section-integrated", + "type": "frame", + "layout": "vertical", + "gap": 8, + "children": [ + { + "id": "si-title", + "type": "text", + "content": "Integrated from design/*.pen", + "fontSize": 16, + "fontWeight": "600", + "color": "$--foreground" + }, + { + "id": "si1", + "type": "text", + "content": "- add-property-inline.pen (2 frames) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si2", + "type": "text", + "content": "- autocomplete-type-color.pen (2 frames) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si3", + "type": "text", + "content": "- date-picker-shadcn.pen (2 frames) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si4", + "type": "text", + "content": "- design-full-layouts.pen (4 frames) → Full Layouts section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si5", + "type": "text", + "content": "- hide-backlinks-empty.pen (1 after variant) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si6", + "type": "text", + "content": "- smart-property-display.pen (4 frames) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "si7", + "type": "text", + "content": "- status-property-dropdown.pen (3 frames) → Inspector section", + "fontSize": 13, + "color": "$--muted-foreground" + } + ] + }, + { + "id": "sep3", + "type": "line", + "width": "fill_container", + "stroke": "$--border", + "strokeWidth": 1 + }, + { + "id": "section-notes", + "type": "frame", + "layout": "vertical", + "gap": 8, + "children": [ + { + "id": "notes-title", + "type": "text", + "content": "Notes", + "fontSize": 16, + "fontWeight": "600", + "color": "$--foreground" + }, + { + "id": "note1", + "type": "text", + "content": "Dark mode conversion: Not needed — all frames already use CSS variables (light theme)", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "note2", + "type": "text", + "content": "Deduplication: No true duplicates found — unnamed frames are section dividers", + "fontSize": 13, + "color": "$--muted-foreground" + }, + { + "id": "note3", + "type": "text", + "content": "Empty design files (0 frames): auto-pull-vault, git-status-bar, note-subtitle-metadata, note-type-labels, properties-panel-header", + "fontSize": 13, + "color": "$--muted-foreground" + } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/design/vault-agents-md.pen b/design/vault-agents-md.pen new file mode 100644 index 0000000..c3c1c91 --- /dev/null +++ b/design/vault-agents-md.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"alignItems":"center","children":[{"alignItems":"center","children":[{"fill":"$--traffic-red","height":12,"id":"t9oxy","name":"tl1","type":"ellipse","width":12},{"fill":"$--traffic-yellow","height":12,"id":"l5RlH","name":"tl2","type":"ellipse","width":12},{"fill":"$--traffic-green","height":12,"id":"K0bAe","name":"tl3","type":"ellipse","width":12}],"gap":8,"id":"1ph7l","name":"trafficLights","type":"frame"}],"fill":"$--sidebar","height":38,"id":"pNQ1R","name":"macOS Title Bar","padding":[0,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"children":[{"children":[{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"inbox","id":"e3bcH","name":"filterAllIcon","type":"icon_font","width":16},{"content":"All Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"U0SDl","name":"filterAllLabel","type":"text"},{"content":"12","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"3s4eX","name":"filterAllCount","type":"text"}],"cornerRadius":6,"fill":"$--muted","gap":8,"id":"UPibw","name":"filterAll","padding":[6,16],"type":"frame","width":"fill_container"}],"gap":1,"id":"vX4kL","layout":"vertical","name":"Filters","padding":[8,8,16,8],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"fill":"$--accent-purple","height":16,"iconFontFamily":"phosphor","iconFontName":"rocket-launch","id":"303MY","name":"secProjIcon","type":"icon_font","width":16},{"content":"Projects","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"FLQBt","name":"secProjLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"M1fpt","name":"secProjCount","type":"text"}],"gap":8,"id":"HI1le","name":"secProjects","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-blue","height":16,"iconFontFamily":"phosphor","iconFontName":"note","id":"uuuIy","name":"secNotesIcon","type":"icon_font","width":16},{"content":"Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"ShBbX","name":"secNotesLabel","type":"text"},{"content":"4","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"XqaZ6","name":"secNotesCount","type":"text"}],"gap":8,"id":"fu0S4","name":"secNotes","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-green","height":16,"iconFontFamily":"phosphor","iconFontName":"user","id":"GidgQ","name":"secPeopleIcon","type":"icon_font","width":16},{"content":"People","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"8p9jY","name":"secPeopleLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"xkgKZ","name":"secPeopleCount","type":"text"}],"gap":8,"id":"vllAy","name":"secPeople","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-yellow","height":16,"iconFontFamily":"phosphor","iconFontName":"tag","id":"rih06","name":"secTopicsIcon","type":"icon_font","width":16},{"content":"Topics","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"RMMWq","name":"secTopicsLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"aBI8L","name":"secTopicsCount","type":"text"}],"gap":8,"id":"tzibu","name":"secTopics","padding":[6,16],"type":"frame","width":"fill_container"},{"height":8,"id":"QNr5k","name":"spacer","type":"frame","width":"fill_container"},{"content":"VAULT ROOT","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"uLv01","letterSpacing":1,"name":"rootLabel","type":"text"},{"alignItems":"center","children":[{"fill":"$--primary-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"file-text","id":"QFDjK","name":"agentsIcon","type":"icon_font","width":16},{"content":"AGENTS.md","fill":"$--primary-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"u2NF1","name":"agentsLabel","type":"text"}],"cornerRadius":6,"fill":"$--primary","gap":8,"id":"p1FHZ","name":"agentsItem","padding":[6,16],"type":"frame","width":"fill_container"}],"height":"fill_container","id":"5DwQB","layout":"vertical","name":"Sections","padding":[8,0],"type":"frame","width":"fill_container"}],"clip":true,"fill":"$--sidebar","height":"fill_container","id":"t643b","layout":"vertical","name":"Panel: Sidebar","type":"frame","width":250},{"fill":"$--border","height":"fill_container","id":"EHHaL","name":"Divider","type":"rectangle","width":1},{"children":[{"alignItems":"center","children":[{"content":"All Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"600","id":"PJU4E","name":"nlTitle","type":"text"},{"content":"12 notes","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"SA33b","name":"nlCount","type":"text"}],"height":45,"id":"ixPvn","justifyContent":"space_between","name":"NoteList Header","padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"AGENTS.md","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"600","id":"4wQGe","name":"ni1Title","type":"text"},{"content":"Vault Instructions for AI Agents — This is a Laputa vault...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"ywIcy","lineHeight":1.5,"name":"ni1Snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"fill":"$--muted","gap":4,"id":"vJ8oO","layout":"vertical","name":"ni1","padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"Welcome to Laputa","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"500","id":"hBzxZ","name":"ni2Title","type":"text"},{"content":"Welcome to your new knowledge vault! Laputa helps you organize...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"ndPAL","lineHeight":1.5,"name":"ni2Snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"gap":4,"id":"dOhss","layout":"vertical","name":"ni2","padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"Editor Basics","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"500","id":"86Z5D","name":"ni3Title","type":"text"},{"content":"Laputa uses a rich markdown editor. Here are the key formatting...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"FJRW7","lineHeight":1.5,"name":"ni3Snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"gap":4,"id":"R6muw","layout":"vertical","name":"ni3","padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"Sample Project","fill":"$--foreground","fontFamily":"Inter","fontSize":14,"fontWeight":"500","id":"O7zNH","name":"ni4Title","type":"text"},{"content":"This is an example project to show how projects work in Laputa...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"kv72Q","lineHeight":1.5,"name":"ni4Snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"gap":4,"id":"YvP6c","layout":"vertical","name":"ni4","padding":[14,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"}],"clip":true,"height":"fill_container","id":"qeL3K","layout":"vertical","name":"Note Items","type":"frame","width":"fill_container"}],"clip":true,"fill":"$--card","height":"fill_container","id":"tvBvY","layout":"vertical","name":"Panel: NoteList","type":"frame","width":300},{"fill":"$--border","height":"fill_container","id":"tjXh5","name":"Divider 2","type":"rectangle","width":1},{"children":[{"alignItems":"center","children":[{"alignItems":"center","children":[{"fill":"$--foreground","height":14,"iconFontFamily":"lucide","iconFontName":"file-text","id":"ujeOy","name":"tab1Icon","type":"icon_font","width":14},{"content":"AGENTS.md","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"Mu8XO","name":"tab1Label","type":"text"}],"gap":6,"height":"fill_container","id":"2eSEH","name":"tab1","padding":[0,16],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":2}},"type":"frame"}],"fill":"$--sidebar","height":45,"id":"JhTD2","name":"Tab Bar","stroke":{"align":"inside","fill":"$--sidebar-border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"AGENTS.md — Vault Instructions for AI Agents","fill":"$--foreground","fontFamily":"Inter","fontSize":28,"fontWeight":"700","id":"FJTxP","name":"edH1","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"content":"This is a Laputa vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.","fill":"$--foreground","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"KLRkh","lineHeight":1.6,"name":"edIntro","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"content":"Structure","fill":"$--foreground","fontFamily":"Inter","fontSize":22,"fontWeight":"600","id":"StcZL","name":"edH2","type":"text"},{"content":"Files are organized in folders by type:","fill":"$--foreground","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"05oUA","lineHeight":1.6,"name":"edStructIntro","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"children":[{"children":[{"content":"Folder","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600","id":"H11FL","name":"hCol1","type":"text"},{"content":"Type","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600","id":"JD8fz","name":"hCol2","type":"text"},{"content":"Purpose","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"600","id":"uU5Jz","name":"hCol3","type":"text"}],"fill":"$--muted","gap":8,"id":"mwbUg","name":"headerRow","padding":[8,12],"type":"frame","width":"fill_container"},{"children":[{"content":"note/","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"normal","id":"sb2yD","name":"r1c1","type":"text"},{"content":"Note","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"aGztj","name":"r1c2","type":"text"},{"content":"General-purpose documents, research, meeting notes","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"QagHP","name":"r1c3","type":"text"}],"gap":8,"id":"H8GnG","name":"row1","padding":[8,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"project/","fill":"$--accent-purple","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"normal","id":"S7jzm","name":"r2c1","type":"text"},{"content":"Project","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"2DvpL","name":"r2c2","type":"text"},{"content":"Time-bounded efforts with clear goals","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"xOISN","name":"r2c3","type":"text"}],"gap":8,"id":"1X3Ey","name":"row2","padding":[8,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"person/","fill":"$--accent-green","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"normal","id":"Dfb6K","name":"r3c1","type":"text"},{"content":"Person","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"yhjgH","name":"r3c2","type":"text"},{"content":"People — colleagues, collaborators, contacts","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"NL1qV","name":"r3c3","type":"text"}],"gap":8,"id":"sdSbp","name":"row3","padding":[8,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"topic/","fill":"$--accent-yellow","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"normal","id":"t6yAK","name":"r4c1","type":"text"},{"content":"Topic","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"3hrTN","name":"r4c2","type":"text"},{"content":"Subject areas that group related notes","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"S2l0W","name":"r4c3","type":"text"}],"gap":8,"id":"3VN69","name":"row4","padding":[8,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"type/","fill":"$--accent-orange","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"normal","id":"W2m7P","name":"r5c1","type":"text"},{"content":"Type","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"7hzGz","name":"r5c2","type":"text"},{"content":"Type definitions — icon, color, ordering","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"TNj3M","name":"r5c3","type":"text"}],"gap":8,"id":"BNbt7","name":"row5","padding":[8,12],"stroke":{"align":"inside","fill":"$--border","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"clip":true,"cornerRadius":6,"id":"20lr0","layout":"vertical","name":"Structure Table","stroke":{"align":"inside","fill":"$--border","thickness":1},"type":"frame","width":"fill_container"},{"content":"Frontmatter","fill":"$--foreground","fontFamily":"Inter","fontSize":22,"fontWeight":"600","id":"5BCet","name":"edH3","type":"text"},{"content":"YAML frontmatter between --- delimiters defines metadata:","fill":"$--foreground","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"eJvEG","lineHeight":1.6,"name":"edFmDesc","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"children":[{"content":"---","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"tX6oL","name":"codeLine1","type":"text"},{"content":"Is A: Project","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"Q0NAS","name":"codeLine2","type":"text"},{"content":"Status: Active","fill":"$--foreground","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"iAS2l","name":"codeLine3","type":"text"},{"content":"Owner: \"[[person/jane-doe]]\"","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"f9aJW","name":"codeLine4","type":"text"},{"content":"Belongs to: \"[[quarter/24q1]]\"","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"vHqgV","name":"codeLine5","type":"text"},{"content":"---","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":13,"fontWeight":"normal","id":"m4081","name":"codeLine6","type":"text"}],"cornerRadius":6,"fill":"$--muted","gap":2,"id":"spZfl","layout":"vertical","name":"codeBlock","padding":16,"type":"frame","width":"fill_container"}],"clip":true,"gap":16,"height":"fill_container","id":"C7esA","layout":"vertical","name":"Editor Body","padding":[32,80],"type":"frame","width":"fill_container"}],"clip":true,"fill":"$--background","height":"fill_container","id":"koB66","layout":"vertical","name":"Panel: Editor","type":"frame","width":"fill_container"}],"height":"fill_container","id":"sqOO6","name":"Content","type":"frame","width":"fill_container"}],"clip":true,"fill":"$--background","height":900,"id":"iiSrM","layout":"vertical","name":"AGENTS.md — Vault Root + Editor View","type":"frame","width":1440,"x":0,"y":0},{"children":[{"children":[{"alignItems":"center","children":[{"fill":"$--muted-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"layers","id":"tX1IJ","name":"allNotesIcon","type":"icon_font","width":16},{"content":"All Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"h1AvC","name":"allNotesLabel","type":"text"},{"content":"12","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"E50Rv","name":"allNotesCount","type":"text"}],"cornerRadius":6,"gap":8,"id":"cHU7c","name":"filterAllNotes","padding":[6,16],"type":"frame","width":"fill_container"}],"gap":1,"id":"rn991","layout":"vertical","name":"Filters","padding":[8,8,16,8],"stroke":{"align":"inside","fill":"$--border","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"fill":"$--accent-purple","height":16,"iconFontFamily":"phosphor","iconFontName":"rocket-launch","id":"LCxKU","name":"secProjIcon","type":"icon_font","width":16},{"content":"Projects","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"tCo0m","name":"secProjLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"WmcbF","name":"secProjCount","type":"text"}],"gap":8,"id":"ZV8hM","name":"secProjects","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-blue","height":16,"iconFontFamily":"phosphor","iconFontName":"note","id":"9N6hy","name":"secNotesIcon","type":"icon_font","width":16},{"content":"Notes","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"dwLa6","name":"secNotesLabel","type":"text"},{"content":"4","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"pF6Vk","name":"secNotesCount","type":"text"}],"gap":8,"id":"TF7eK","name":"secNotes","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-green","height":16,"iconFontFamily":"phosphor","iconFontName":"user","id":"aeUGs","name":"secPeopleIcon","type":"icon_font","width":16},{"content":"People","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"4Pksr","name":"secPeopleLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"mZkh4","name":"secPeopleCount","type":"text"}],"gap":8,"id":"5AJ0W","name":"secPeople","padding":[6,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-yellow","height":16,"iconFontFamily":"phosphor","iconFontName":"tag","id":"Lu26g","name":"secTopicsIcon","type":"icon_font","width":16},{"content":"Topics","fill":"$--foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"pd7sa","name":"secTopicsLabel","type":"text"},{"content":"1","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"zvf8X","name":"secTopicsCount","type":"text"}],"gap":8,"id":"uCdg1","name":"secTopics","padding":[6,16],"type":"frame","width":"fill_container"},{"height":8,"id":"XZNp8","name":"spacer","type":"frame","width":"fill_container"},{"content":"VAULT ROOT","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"JH4k8","letterSpacing":1,"name":"rootLabel","type":"text"},{"alignItems":"center","children":[{"fill":"$--primary-foreground","height":16,"iconFontFamily":"lucide","iconFontName":"file-text","id":"TeldH","name":"agentsIcon","type":"icon_font","width":16},{"content":"AGENTS.md","fill":"$--primary-foreground","fontFamily":"Inter","fontSize":13,"fontWeight":"500","id":"goqrg","name":"agentsLabel","type":"text"}],"cornerRadius":6,"fill":"$--primary","gap":8,"id":"Vnjwi","name":"agentsItem","padding":[6,16],"type":"frame","width":"fill_container"}],"id":"kZsX6","layout":"vertical","name":"Sections","padding":[8,0],"type":"frame","width":"fill_container"},{"children":[{"content":"AGENTS.md appears in the vault root section of the sidebar, below type sections. It is always present and selected highlights it with the primary accent.","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"gXRLY","lineHeight":1.5,"name":"annotationText","textGrowth":"fixed-width","type":"text","width":"fill_container"}],"fill":"$--muted","id":"bbJjG","name":"Annotation","padding":[12,16],"type":"frame","width":"fill_container"}],"clip":true,"fill":"$--sidebar","id":"omsAY","layout":"vertical","name":"AGENTS.md — Sidebar Vault Root Section","type":"frame","width":250,"x":1540,"y":0}],"variables":{"--accent-blue":{"type":"color","value":"#2383E2"},"--accent-green":{"type":"color","value":"#0F7B6C"},"--accent-orange":{"type":"color","value":"#D9730D"},"--accent-purple":{"type":"color","value":"#9065B0"},"--accent-yellow":{"type":"color","value":"#F0B100"},"--background":{"type":"color","value":"#FFFFFF"},"--border":{"type":"color","value":"#E9E9E7"},"--card":{"type":"color","value":"#FFFFFF"},"--foreground":{"type":"color","value":"#37352F"},"--muted":{"type":"color","value":"#F0F0EF"},"--muted-foreground":{"type":"color","value":"#787774"},"--primary":{"type":"color","value":"#155DFF"},"--primary-foreground":{"type":"color","value":"#FFFFFF"},"--sidebar":{"type":"color","value":"#F7F6F3"},"--sidebar-border":{"type":"color","value":"#E9E9E7"},"--traffic-green":{"type":"color","value":"#28c840"},"--traffic-red":{"type":"color","value":"#ff5f57"},"--traffic-yellow":{"type":"color","value":"#febc2e"}}} diff --git a/design/vault-management-remove.pen b/design/vault-management-remove.pen new file mode 100644 index 0000000..4609e8d --- /dev/null +++ b/design/vault-management-remove.pen @@ -0,0 +1,281 @@ +{ + "children": [ + { + "type": "frame", + "id": "vault_menu_remove", + "name": "Vault Menu — Remove from List", + "x": 0, + "y": 0, + "width": 380, + "height": 320, + "fill": "#F7F6F3", + "layout": "vertical", + "gap": 16, + "padding": [24, 24, 24, 24], + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "vault_menu_title", + "content": "Vault Menu — Remove Action", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "vault_menu_dropdown", + "name": "Vault Dropdown", + "width": "fill_container", + "height": "fit_content", + "fill": "#FFFFFF", + "cornerRadius": [6, 6, 6, 6], + "stroke": "#E9E9E7", + "strokeThickness": 1, + "layout": "vertical", + "padding": [4, 4, 4, 4], + "gap": 0, + "children": [ + { + "type": "frame", + "id": "vault_item_active", + "name": "Active Vault Item", + "width": "fill_container", + "height": 32, + "fill": "#EBEBEA", + "cornerRadius": [4, 4, 4, 4], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "space-between", + "padding": [4, 8, 4, 8], + "children": [ + { + "type": "frame", + "id": "vault_item_active_left", + "layout": "horizontal", + "alignItems": "center", + "gap": 6, + "children": [ + { "type": "text", "id": "vault_check", "content": "✓", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 }, + { "type": "text", "id": "vault_label_active", "content": "My Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "remove_btn_active", + "name": "Remove Button", + "width": 18, + "height": 18, + "cornerRadius": [3, 3, 3, 3], + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { "type": "text", "id": "x_icon_active", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 } + ] + } + ] + }, + { + "type": "frame", + "id": "vault_item_other", + "name": "Other Vault Item", + "width": "fill_container", + "height": 32, + "cornerRadius": [4, 4, 4, 4], + "layout": "horizontal", + "alignItems": "center", + "justifyContent": "space-between", + "padding": [4, 8, 4, 8], + "children": [ + { + "type": "frame", + "id": "vault_item_other_left", + "layout": "horizontal", + "alignItems": "center", + "gap": 6, + "children": [ + { "type": "text", "id": "vault_spacer", "content": " ", "fill": "transparent", "fontFamily": "Inter", "fontSize": 12 }, + { "type": "text", "id": "vault_label_other", "content": "Work Vault", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 } + ] + }, + { + "type": "frame", + "id": "remove_btn_other", + "name": "Remove Button", + "width": 18, + "height": 18, + "cornerRadius": [3, 3, 3, 3], + "layout": "vertical", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { "type": "text", "id": "x_icon_other", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 } + ] + } + ] + }, + { + "type": "frame", + "id": "vault_separator", + "name": "Separator", + "width": "fill_container", + "height": 1, + "fill": "#E9E9E7" + }, + { + "type": "frame", + "id": "vault_open_folder", + "name": "Open Local Folder", + "width": "fill_container", + "height": 32, + "cornerRadius": [4, 4, 4, 4], + "layout": "horizontal", + "alignItems": "center", + "gap": 6, + "padding": [4, 8, 4, 8], + "children": [ + { "type": "text", "id": "folder_icon", "content": "📁", "fontFamily": "Inter", "fontSize": 12 }, + { "type": "text", "id": "open_folder_label", "content": "Open local folder", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 } + ] + } + ] + }, + { + "type": "text", + "id": "vault_menu_note", + "content": "× button removes vault from list without deleting files.\nAvailable when 2+ vaults in list.", + "fill": "#787774", + "fontFamily": "Inter", + "fontSize": 11, + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "cmd_k_vault_commands", + "name": "Cmd+K — Vault Commands", + "x": 420, + "y": 0, + "width": 520, + "height": 320, + "fill": "#F7F6F3", + "layout": "vertical", + "gap": 16, + "padding": [24, 24, 24, 24], + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "cmdk_title", + "content": "Command Palette — Vault Commands", + "fill": "#37352F", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "cmdk_palette", + "name": "Command Palette", + "width": "fill_container", + "height": "fit_content", + "fill": "#FFFFFF", + "cornerRadius": [8, 8, 8, 8], + "stroke": "#E9E9E7", + "strokeThickness": 1, + "layout": "vertical", + "padding": [0, 0, 0, 0], + "gap": 0, + "children": [ + { + "type": "frame", + "id": "cmdk_search", + "name": "Search Input", + "width": "fill_container", + "height": 44, + "layout": "horizontal", + "alignItems": "center", + "padding": [0, 16, 0, 16], + "children": [ + { "type": "text", "id": "cmdk_search_text", "content": "vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14 } + ] + }, + { + "type": "frame", + "id": "cmdk_separator", + "width": "fill_container", + "height": 1, + "fill": "#E9E9E7" + }, + { + "type": "frame", + "id": "cmdk_group_header", + "name": "Settings Group", + "width": "fill_container", + "height": 28, + "layout": "horizontal", + "alignItems": "center", + "padding": [0, 12, 0, 12], + "children": [ + { "type": "text", "id": "group_label", "content": "Settings", "fill": "#B4B4B4", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "cmdk_open_vault", + "name": "Open Vault Command", + "width": "fill_container", + "height": 36, + "layout": "horizontal", + "alignItems": "center", + "padding": [0, 12, 0, 12], + "children": [ + { "type": "text", "id": "cmd_open_vault", "content": "Open Vault…", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 } + ] + }, + { + "type": "frame", + "id": "cmdk_remove_vault", + "name": "Remove Vault Command (highlighted)", + "width": "fill_container", + "height": 36, + "fill": "#E8F4FE", + "layout": "horizontal", + "alignItems": "center", + "padding": [0, 12, 0, 12], + "children": [ + { "type": "text", "id": "cmd_remove_vault", "content": "Remove Vault from List", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 } + ] + }, + { + "type": "frame", + "id": "cmdk_restore_gs", + "name": "Restore Getting Started Command", + "width": "fill_container", + "height": 36, + "layout": "horizontal", + "alignItems": "center", + "padding": [0, 12, 0, 12], + "children": [ + { "type": "text", "id": "cmd_restore_gs", "content": "Restore Getting Started Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 } + ] + } + ] + }, + { + "type": "text", + "id": "cmdk_note", + "content": "• 'Remove Vault from List' removes active vault (disabled if only 1 vault)\n• 'Restore Getting Started Vault' shown when GS vault is hidden\n• Both accessible via Cmd+K search with 'vault' keyword", + "fill": "#787774", + "fontFamily": "Inter", + "fontSize": 11, + "width": "fill_container" + } + ] + } + ], + "variables": {} +} diff --git a/design/zoom-shortcuts.pen b/design/zoom-shortcuts.pen new file mode 100644 index 0000000..78ec492 --- /dev/null +++ b/design/zoom-shortcuts.pen @@ -0,0 +1,202 @@ +{ + "children": [ + { + "type": "frame", + "id": "zs_normal", + "name": "Zoom Shortcuts — StatusBar (100% / default)", + "x": 0, + "y": 0, + "theme": { "Mode": "Light" }, + "width": 800, + "height": 32, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zs_normal_desc", + "name": "description", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "zs_normal_desc_text", + "fill": "$--muted-foreground", + "content": "StatusBar at 100% zoom — zoom indicator is HIDDEN. Cmd+= zooms in, Cmd+- zooms out, Cmd+0 resets to 100%.", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "zs_zoomed", + "name": "Zoom Shortcuts — StatusBar (150% / zoomed in)", + "x": 0, + "y": 60, + "theme": { "Mode": "Light" }, + "width": 800, + "height": 32, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zs_statusbar_zoomed", + "name": "StatusBar — 150% (zoom indicator visible)", + "layout": "horizontal", + "gap": 8, + "alignItems": "center", + "padding": [0, 8], + "children": [ + { + "type": "text", + "id": "zs_wordcount_z", + "fill": "$--muted-foreground", + "content": "1,247 words", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + }, + { + "type": "frame", + "id": "zs_zoom_badge", + "name": "Zoom % Badge", + "fill": "$--muted", + "cornerRadius": 4, + "padding": [2, 6], + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "zs_zoom_label", + "fill": "$--foreground", + "content": "150%", + "fontFamily": "IBM Plex Mono", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_palette", + "name": "Zoom Shortcuts — Command Palette entries", + "x": 0, + "y": 120, + "theme": { "Mode": "Light" }, + "width": 540, + "height": 120, + "fill": "$--popover", + "cornerRadius": 12, + "layout": "vertical", + "gap": 0, + "children": [ + { + "type": "frame", + "id": "zs_cmd_row1", + "name": "Command — Zoom In (⌘=)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "fill": "$--accent", + "children": [ + { + "type": "text", + "id": "zs_cmd_row1_label", + "fill": "$--accent-foreground", + "content": "Zoom In", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row1_shortcut", + "fill": "$--muted-foreground", + "content": "Command+=", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_row2", + "name": "Command — Zoom Out (⌘-)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zs_cmd_row2_label", + "fill": "$--foreground", + "content": "Zoom Out", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row2_shortcut", + "fill": "$--muted-foreground", + "content": "Command+-", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "zs_cmd_row3", + "name": "Command — Reset Zoom (⌘0)", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zs_cmd_row3_label", + "fill": "$--foreground", + "content": "Reset Zoom", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "width": "fill_container" + }, + { + "type": "text", + "id": "zs_cmd_row3_shortcut", + "fill": "$--muted-foreground", + "content": "Command+0", + "fontFamily": "IBM Plex Mono", + "fontSize": 11 + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md new file mode 100644 index 0000000..a35eb91 --- /dev/null +++ b/docs/ABSTRACTIONS.md @@ -0,0 +1,1042 @@ +# Abstractions + +Key abstractions and domain models in Tolaria. + +## Design Philosophy + +Tolaria's abstractions follow the **convention over configuration** principle: standard field names, types, and relationships have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly. + +The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles). + +## Semantic Field Names (conventions) + +These frontmatter field names have special meaning in Tolaria's UI: + +| Field | Meaning | UI behavior | +|---|---|---| +| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically | +| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping | +| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header | +| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel | +| `url:` | External link | Clickable link chip in editor header | +| `date:` | Single date | Formatted date badge | +| `start_date:` + `end_date:` | Duration/timespan | Date range badge | +| `goal:` + `result:` | Progress | Progress indicator in editor header | +| `Workspace:` | Vault context filter | Global workspace filter | +| `belongs_to:` | Parent relationship | Humanized to `Belongs to` in the UI | +| `related_to:` | Lateral relationship | Humanized to `Related to` in the UI | +| `has:` | Contained relationship | Humanized to `Has` in the UI | + +Relationship fields are detected dynamically — any frontmatter field containing `[[wikilink]]` values is treated as a relationship (see [ADR-0010](adr/0010-dynamic-wikilink-relationship-detection.md)). Tolaria's own default relationship vocabulary uses snake_case on disk, but labels are humanized at render time and existing user-authored keys are left untouched. + +### System Properties (underscore convention) + +Any frontmatter field whose name starts with `_` is a **system property**: + +- It is **not shown** in the Properties panel (neither for notes nor for Type notes) +- It is **not exposed** as a user-visible property in search, filters, or the UI +- It **is editable** directly in the raw editor (power users can access it if needed) +- It is used by Tolaria internally for configuration, behavior, and UI preferences + +Examples: +```yaml +_pinned_properties: # which properties appear in the editor inline bar (per-type) + - key: status + icon: circle-dot +_icon: shapes # icon assigned to a type +_color: blue # color assigned to a type +_order: 10 # sort order in the sidebar +_sidebar_label: Projects # override label in sidebar +_width: wide # rich-editor width override for this note +``` + +**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text. + +The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must filter out `_*` fields before passing `properties` to the UI. + +## Document Model + +All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth. + +### Rich-Editor Markdown Serialization + +`src/utils/richEditorMarkdown.ts` is the canonical owner for turning a BlockNote document back into vault Markdown. It restores Tolaria's durable Markdown syntax for wikilinks, math, highlights, attachments, Mermaid, and tldraw blocks before the save path writes bytes to disk. Hot save paths should pass an already-read block snapshot when they have one; a debounced rich-editor flush should not ask BlockNote for `editor.document` twice. + +Real BlockNote editor instances install the experimental direct serializer from `src/utils/blockNoteDirectMarkdown.ts`. Callers should go through `serializeBlockNoteMarkdown()` rather than invoking `blocksToMarkdownLossy()` directly: the helper uses the direct serializer when every block shape is supported and falls back to BlockNote's exporter when a custom or unknown block appears. + +The direct serializer cache is derived state, scoped to the editor instance, and disposable. Bridge helpers that transform blocks for Markdown restoration should preserve object identity when a block, its children, and table cells are unchanged; that lets frequent debounced saves reuse cached Markdown for stable subtrees without treating the cache as the source of truth. + +Opening a Markdown note goes through `resolveBlocksForTarget()` rather than calling BlockNote's parser unconditionally. The resolver first checks exact-source block caches, then uses cheap blank/H1 recognition, then tries Tolaria's worker-backed direct parser for large common Markdown. Unsupported Markdown returns to BlockNote's parser with the same durable-token injection path, preserving correctness while keeping the common large-note path off BlockNote's heavier import lifecycle. Large block sets are then mounted through `applyBlocksToEditorProgressively()`, which locks the editor while chunks append over animation frames and commits the path only after the full document is present. Ordinary BlockNote block wrappers remain fully measurable after mount so side menus and dialogs can preserve viewport position in long documents. + +Hot edit paths should avoid document-wide BlockNote lifecycle work. `richEditorBlockNoteOptions.ts` disables BlockNote's previous-block animation tracker for Tolaria editors because it scans the full old and new ProseMirror documents for every doc-changing transaction. `richEditorDispatchPerformance.ts` wraps the ProseMirror dispatch function once and logs `richEditorDispatch` timings for large or slow transactions without traversing the document. Feature code that subscribes to `editor.onChange` must either be debounced/coalesced or active only while its UI state requires it; collapsed-heading rendering follows this rule by subscribing only while sections are collapsed. + +Block-selection behavior keeps UI/plugin wiring separate from block operations. `richEditorBlockSelectionExtension.ts` owns the ProseMirror plugin, state reducer, decorations, and keyboard/clipboard event dispatch. `richEditorBlockSelectionDocument.ts` owns BlockNote document traversal, nested-selection pruning, collapsed hidden-content operation IDs, and block-move helpers. `richEditorBlockSelectionClipboard.ts` owns Tolaria clipboard MIME data, BlockNote HTML/Markdown fallback parsing, and ID stripping before paste. + +Focused block type changes share the same block-type catalog in `src/utils/richEditorBlockTypes.ts` across the command palette and block handle menu. UI surfaces should call the helpers in `src/components/richEditorBlockTypeCommands.ts` instead of manually constructing BlockNote updates so conversion preserves the current block content, runs inside one editor transaction, and emits the shared `editor_block_type_changed` analytics event. + +### Vault Git Capability + +Git is a per-vault capability, not a prerequisite for the document model. A vault can be: + +| State | Meaning | UI behavior | +|---|---|---| +| Git-backed | The vault path is inside a Git work tree, including a parent repository above the mounted folder | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state | +| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` | + +Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. The setup dialog supports "not now" for a one-time dismissal and "never for this vault" for a local per-vault opt-out from future automatic prompts. Features that depend on Git must check both the vault capability and the installation-local `git_enabled` setting instead of assuming every vault has `.git` or that Git chrome is globally visible. + +The Git executable provider is an installation-local setting layered under that vault capability. Native Git is the default provider. On Windows, users may explicitly select WSL2 Git and a distribution in Settings; command launch then goes through the Rust Git provider abstraction, translates repository paths to WSL form, and never switches away from native Git without the stored provider setting. + +Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead. + +### VaultEntry + +The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`). + +```mermaid +classDiagram + class VaultEntry { + +String path + +String filename + +String title + +String? isA + +String[] aliases + +String[] belongsTo + +String[] relatedTo + +Record~string,string[]~ relationships + +String[] outgoingLinks + +String? status + +String? noteWidth + +Number? modifiedAt + +Number? createdAt + +Number wordCount + +String? snippet + +Boolean archived + +WorkspaceIdentity? workspace + +Boolean trashed ⚠ legacy + +Number? trashedAt ⚠ legacy + +Record~string,VaultPropertyValue~ properties + } + + class TypeDocument { + +String icon + +String color + +Number order + +String sidebarLabel + +String template + +String sort + +Boolean visible + } + + class Frontmatter { + +String type + +String status + +String url + +String[] belongsTo + +String[] relatedTo + +String[] aliases + ...custom fields + } + + VaultEntry --> Frontmatter : parsed from + VaultEntry --> TypeDocument : isA resolves to + VaultEntry "many" --> "1" TypeDocument : grouped by type +``` + +```typescript +// src/types.ts +interface VaultEntry { + path: string // Absolute file path + filename: string // Just the filename + title: string // From first # heading, or filename fallback + isA: string | null // Entity type: Project, Procedure, Person, etc. (from frontmatter `type:` field) + aliases: string[] // Alternative names for wikilink resolution + belongsTo: string[] // Parent relationships (wikilinks) + relatedTo: string[] // Related entity links (wikilinks) + relationships: Record // All frontmatter fields containing wikilinks + outgoingLinks: string[] // All [[wikilinks]] found in note body + status: string | null // Active, Done, Paused, Archived, Dropped + noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width` + modifiedAt: number | null // Unix timestamp (seconds) + // Note: owner and cadence are now in the generic `properties` map + createdAt: number | null // Unix timestamp (seconds) + fileSize: number + wordCount: number | null // Body word count (excludes frontmatter) + snippet: string | null // First 200 chars of body + workspace?: WorkspaceIdentity // Mounted-workspace provenance for cross-vault graph entries + archived: boolean // Archived flag + trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent) + trashedAt: number | null // Kept for backward compatibility (Trash system removed) + properties: Record // Scalar and scalar-array custom properties + fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior +} +``` + +### WorkspaceIdentity + +Mounted workspace provenance is renderer-owned metadata attached to `VaultEntry.workspace` when entries are loaded through the registered workspace set. It is not parsed from note frontmatter and is not written into vault files. + +```typescript +interface WorkspaceIdentity { + id: string + label: string + alias: string // Stable prefix used in cross-workspace wikilinks + path: string // Absolute workspace root + shortLabel: string // Compact note-list badge text + color: string | null + icon: string | null + mounted: boolean + available: boolean + defaultForNewNotes: boolean +} +``` + +The status-bar workspace manager edits installation-local identity and mount state. The alias is the durable user-facing namespace for cross-workspace links such as `[[team/projects/alpha]]`; labels and colors are display affordances only. The default workspace controls where new notes and Type files are created; it is not a claim that only one vault is active. When multiple workspaces are enabled, every mounted available workspace participates in the graph and the active Git repository set. + +Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface. Manual Sync refreshes vault-derived sidebar state and bumps a shared Git history refresh key after successful pulls, including `up_to_date` pulls, while automatic up-to-date checks avoid that heavier reload path. + +`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior. + +### Tolaria Deep Links + +Deep links identify existing vault items with `tolaria:///`. The slug is derived from the registered workspace alias, then label, then path basename; generated links append a stable short hash when two vaults share the same base slug. A manually typed ambiguous base slug is rejected instead of choosing the wrong vault. + +The relative path is encoded per segment, preserving `/` as the separator while allowing spaces, Unicode, and reserved characters inside filenames. Decoding rejects `.`, `..`, encoded slashes, backslashes, empty segments, and any resolved path outside the target vault root. Links keep the file extension so Markdown, text, media, PDFs, and other vault files can all route through the same `VaultEntry` lookup. + +Deep links are navigation-only. Opening one can focus Tolaria, switch to a registered vault, reload the index once, and open an existing item; it never creates missing files, imports external files, or silently falls back to another vault. v1 links are path-based, so renaming or moving a file changes the canonical link. macOS and Windows are the verified v1 desktop targets; Linux registration is best-effort until package-level QA covers the supported desktop environments. + +### File kinds and binary previews + +`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained: + +| `fileKind` | Source files | UI behavior | +|---|---|---| +| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync | +| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics; supported extensions get CodeMirror syntax highlighting | +| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback | + +Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through ``, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. HEIC/HEIF is intentionally handled as an unsupported image import format until Tolaria has a deterministic cross-platform decoder/conversion path; Windows webviews cannot be assumed to decode iPhone HEIC files, so picker uploads and native drops report a localized fallback message and do not save a broken attachment. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects. + +Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print-only rendering, asks for a `.pdf` filesystem destination only when the native capability reports direct save support, and invokes Tauri's native `WKWebView` PDF export command on macOS. Windows/Linux Tauri builds and browser mode keep print-dialog fallback behavior. `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before export, so frontmatter is ignored and the PDF reflects the current rendered editor DOM while leaving the vault file unchanged. + +### Sheet Nodes + +A Markdown note with `_display: sheet` displays in the dedicated sheet editor instead of BlockNote. `type` remains semantic metadata, so a sheet can still be a `Project`, `Responsibility`, or any other Tolaria type. The note remains a plain-text file: frontmatter stores ordinary Tolaria properties plus `_sheet` presentation metadata, while the body stores CSV-like rows containing cell inputs and formulas. + +`_sheet` follows the underscore system-property convention, so it is hidden from normal Properties UI but remains raw-editor editable. It currently stores workbook presentation state such as grid-line visibility and frozen rows/columns, plus column widths, row heights, and cell-level formatting such as number format, bold, italic, font size, color, fill, borders, alignment, and wrapping. + +The renderer adapts this file format to an IronCalc workbook model when the note opens, then serializes the active single sheet back into the same frontmatter-plus-CSV representation after sheet interactions. Serialization is debounced and deferred to idle time when the webview supports it. The prototype intentionally does not expose multiple sheets inside one note; cross-note cell references use Tolaria wikilink syntax such as `[[other-sheet]].B5` instead of IronCalc workbook tabs. Sheet formulas can also read scalar frontmatter properties from a single note with explicit dot notation such as `[[device]].power.watts`; unresolved, ambiguous, or non-scalar property references become spreadsheet `#N/A` errors rather than choosing a note silently. + +For import ergonomics, simple Markdown wrappers in non-formula CSV cells are interpreted as initial cell styles (`**bold**`, `_italic_`, `***bold italic***`, and `~~strike~~`). After the sheet is saved, those styles persist through `_sheet` metadata and the CSV body keeps the unwrapped cell text. + +### Note Content Freshness + +The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog. Note-open entry objects are re-normalized at the tab boundary, so transient reload or bridge payloads with missing display metadata fall back to filename/title defaults before editor chrome renders; entries without a usable path are ignored instead of opening a broken tab. + +`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. + +`scripts/editor-performance-benchmark.mjs` exercises the same renderer note-open path without touching a real vault. It injects synthetic small and large Markdown notes through the browser/Tauri mock boundary, measures editor visibility, first rendered content, full block application, edit frame latency, and DEV-only editor timing logs, then compares medians against `.editor-performance-thresholds.json`. The threshold file is a ratchet: normal runs fail when medians exceed stored `maxMs` values, while `pnpm perf:editor:update` records the current baseline and only tightens existing maxima. + +### Table of Contents Outline + +The editor Table of Contents is derived from the live BlockNote document, not from saved Markdown text. `src/utils/tableOfContents.ts` reads structural `heading` blocks with stable ids and levels, extracts inline text from nested BlockNote content, and nests headings by level while preserving document order. `TableOfContentsPanel` receives a document revision from `Editor`, so rich-editor edits refresh the outline immediately without waiting for autosave or a vault reload. Selecting a heading focuses BlockNote and moves the cursor to that block id, while nested headings can be collapsed independently in panel-local UI state. + +### Entity Types (isA / type) + +Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value. + +Type is determined **purely** from the `type:` frontmatter field — it is never inferred from the file's folder location. All notes live at the vault root as flat `.md` files: + +``` +~/Laputa/ +├── my-project.md ← type: Project (in frontmatter) +├── weekly-review.md ← type: Procedure +├── john-doe.md ← type: Person +├── some-topic.md ← type: Topic +├── AGENTS.md ← canonical Tolaria AI guidance +├── CLAUDE.md ← compatibility shim pointing at AGENTS.md +├── GEMINI.md ← optional Antigravity/Gemini shim pointing at AGENTS.md +├── project.md ← type: Type (definition document) +├── person.md ← type: Type (definition document) +├── ... +``` + +New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. Legacy `type/` and `types/` folders are still scanned like other non-hidden vault folders, so existing type documents in those folders continue to work, but new type documents created by Tolaria are written at the vault root. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root. + +A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root. + +### Types as Files + +Each entity type can have a corresponding **type document**: any markdown note with `type: Type` in its frontmatter. Tolaria creates new type documents at the vault root (e.g., `project.md`, `person.md`) and still reads existing type documents from subfolders. Type documents: + +- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias) +- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility +- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields +- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note +- Serve as the "definition" for their type category + +**Type document properties** (read by Rust and used in the UI): + +| Property | Type | Description | +|----------|------|-------------| +| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") | +| `color` | string | Accent palette key (`red`, `purple`, `blue`, `green`, `yellow`, `orange`, `teal`, `pink`, `gray`) or a valid CSS color value such as `cyan`, `#22d3ee`, or `rgb(34, 211, 238)` | +| `order` | number | Sidebar display order (lower = higher priority) | +| `sidebar_label` | string | Custom label overriding auto-pluralization | +| `template` | string | Markdown template for new notes of this type | +| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc"; bare custom-property form such as "Priority:asc" is accepted and normalized in the UI | +| `view` | string | Default view mode: "all", "editor-list", "editor-only" | +| `visible` | bool | Whether type appears in sidebar (default: true) | + +Type templates can be stored explicitly in the `template` frontmatter property. For hand-edited Type documents, Tolaria also treats the body after the Type note's own matching `# TypeName` heading as a new-note template when that body looks like a template (for example field labels, secondary headings, or checklist starters). Plain descriptive Type bodies are ignored so type documentation does not leak into every new note. + +**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail. + +**Instance schema/defaults**: Custom scalar/scalar-array properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders. + +**UI behavior**: +- Clicking a section group header pins the type document at the top of the NoteList if it exists +- Viewing a type document in entity view shows an "Instances" group listing all entries of that type +- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document + +### Frontmatter Format + +Standard YAML frontmatter between `---` delimiters: + +```yaml +--- +title: Write Weekly Essays +type: Procedure +status: Active +belongs_to: + - "[[grow-newsletter]]" +related_to: + - "[[writing]]" +aliases: + - Weekly Writing +--- +``` + +Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`): +- **String**: `status: Active` +- **Number**: `priority: 5` +- **Bool**: `archived: true` +- **List**: Multi-line ` - item` or inline `[item1, item2]` +- **Null**: `owner:` (empty value) + +Custom frontmatter fields with scalar values are exposed through `VaultEntry.properties`. Custom fields with scalar arrays are also exposed there, unless any array value contains a wikilink; wikilink-bearing fields belong to `VaultEntry.relationships`. Single-item scalar arrays continue to normalize to their scalar value for compatibility, while multi-item scalar arrays remain arrays so saved view filters can match exact elements. + +### Custom Relationships + +The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap: + +```yaml +--- +Topics: + - "[[writing]]" + - "[[productivity]]" +Key People: + - "[[matteo-cellini]]" +--- +``` + +Becomes: `relationships["Topics"] = ["[[writing]]", "[[productivity]]"]` + +This enables arbitrary, extensible relationship types without code changes. + +### Outgoing Links + +All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs. + +### Title / Filename Sync + +Tolaria separates **display title** from the file identifier: + +- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem. +- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter. +- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface. +- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`. +- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive. +- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows. +- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while transient access-denied writes are retried briefly before surfacing failure. The editor keeps the unsaved buffer intact for another attempt. +- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1. + +### Title Surface (UI) + +The BlockNote body is the only title editing surface: + +- The first H1 is the canonical display title. +- There is no separate title row above the editor, even when a note has no H1. +- Notes without an H1 show the editor body and placeholder only. +- Legacy no-H1 notes whose display title differs from the filename show that title as read-only breadcrumb context beside the editable filename, so referenced notes remain identifiable without raw mode. +- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect. + +### Sidebar Selection + +Navigation state is modeled as a discriminated union: + +```typescript +type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse' + +type SidebarSelection = + | { kind: 'filter'; filter: SidebarFilter } + | { kind: 'sectionGroup'; type: string } // e.g. type: 'Project' + | { kind: 'folder'; path: string; rootPath?: string } + | { kind: 'entity'; entry: VaultEntry } // Neighborhood source note + | { kind: 'view'; filename: string } +``` + +`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight. + +- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Inline folder creation carries an optional `FolderCreationParent` (`path` plus `rootPath`) through `App` to the `create_vault_folder` command, so new folders land under the selected folder or selected mounted vault root while preserving the active-vault path boundary. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks. +- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics. +- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry. +- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands. +- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected. +- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection. + +### Collections + +Collections are the renderer-side foundation for note groups plus their presentation configuration. A collection can come from a built-in filter, type section, folder, saved View, or Neighborhood mode. Product terminology can stay simple: users select a collection and Tolaria presents it. + +`SidebarSelection` remains the navigation state for now, but it is adapted into a `CollectionDefinition` by `src/collections/collectionFromSelection.ts`. The collection keeps the original selection for compatibility, adds an `origin` for implementation routing, and normalizes the current presentation to: + +```typescript +type CollectionPresentationConfig = + | { type: 'list'; sort: string | null; properties: string[] } +``` + +`src/collections/resolveCollectionEntries.ts` resolves the selected collection through the existing note-list filtering rules. Changes and Inbox stay caller-supplied transient flows because they depend on git state and inbox-period state outside saved-view YAML. Neighborhood resolves to grouped relationship data instead of a flat row list. + +This layer is intentionally internal and behavior-preserving. It lets future presentations such as board, calendar, table, timeline, or graph consume the same resolved collection model instead of branching directly on sidebar state. Presentation config maps existing note properties; it does not create a separate data model. + +### Saved Views + +Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The renderer treats saved Views as the most configurable persisted Collection artifact. Existing top-level `sort` and `listPropertiesDisplay` fields normalize into the list presentation config, and a future nested `presentation` block may override them in memory when present. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. Filter conditions on scalar-array custom properties, such as `tags: [blues, chicago]`, evaluate `contains`, `any_of`, and related set operators against exact array elements rather than substrings. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior. + +In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault. + +`useAppViewActions()` owns the renderer-side saved View lifecycle: choosing the target workspace, preserving mounted-view identity, saving/deleting YAML definitions, reloading affected vault state, and exposing the available note-list fields for the create/edit dialog. `App.tsx` wires those callbacks into `Sidebar`, `NoteList`, `CreateViewDialog`, and command surfaces without duplicating the persistence rules. + +`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows. + +`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell. + +The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering. + +### Neighborhood Mode + +`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing. + +- The selected `entry` is the neighborhood source note. +- The source note stays pinned at the top of the note list as a standard active row, not a special card. +- Outgoing relationship groups render first using the note's `relationships` map. +- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups. +- Empty groups stay visible with count `0`. +- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections. +- Plain click / `Enter` open the focused note without replacing the current Neighborhood. +- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood. + +## Command Surface + +`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups. + +Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation. + +`useActionHistory` is the renderer-owned stack for reversible app-level actions. It records note-state actions only after persistence succeeds, replays one undo/redo at a time, and reveals the affected note before applying the reversal so editor pending-content flushes stay path-correct. Text editors and text inputs keep their native undo/redo history; app-level Undo/Redo shortcuts are handled only when focus is outside text-editing surfaces. + +## File System Integration + +### Vault Scanning (Rust) + +`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`: + +1. Validates the path exists and is a directory +2. Recursively scans non-hidden files while skipping hidden directories such as `.git/` +3. For each `.md` file, calls `parse_md_file()`: + - Reads content with `fs::read_to_string()` + - Parses frontmatter with `gray_matter::Matter::` + - Extracts title from first `#` heading + - Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder + - Parses dates as ISO 8601 to Unix timestamps + - Extracts relationships, outgoing links, custom properties, word count, snippet +4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind` +5. Sorts by `modified_at` descending +6. Skips unparseable files with a warning log + +All Notes starts from Markdown notes and excludes Markdown files under `attachments/`. `src/utils/allNotesFileVisibility.ts` resolves the installation-local PDF, image, and unsupported-file toggles from app settings; `noteListHelpers` applies that policy only to All Notes filtering and counts. Folder/root browsing continues to show files from the selected folder independently of those All Notes toggles. + +The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row. + +Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, drains stdout while stdin is still being written, and short-circuits root `.gitignore` detection before walking for nested ignore files, so large ignored folder sets cannot deadlock the native UI while preserving Git semantics as closely as the app can reasonably support. + +A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`. + +Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately. + +Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`. That module is the single owner for converting between portable markdown references such as `attachments/image.png`, Tauri asset URLs, and absolute active-vault filesystem paths. Editor markdown rendering, raw-mode serialization, image upload/drop handling, file-block open actions, and parsed image cleanup all call this primitive instead of carrying their own asset URL prefixes, Windows path normalization, or `attachments/` join rules. + +UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary. + +The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. `mcp-server/tool-service.js` owns the shared tool semantics for active-vault resolution, cross-vault lookup/search, note-creation defaults, vault listing, and UI action intents; `mcp-server/index.js` and `mcp-server/ws-bridge.js` remain transport adapters around that service. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, and strips Windows extended-length `\\?\` prefixes from the exported `index.js` argument before handing it to Node, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind. + +### Vault Caching + +`vault::scan_vault_cached(path)` wraps scanning with git-based caching: + +1. Reads cache from `~/.laputa/cache/.json` (external to vault) +2. Compares cache version, vault path, and git HEAD commit hash +3. If cache is valid and same commit → only re-parse uncommitted changed files +4. If different commit → use `git diff` to find changed files → selective re-parse +5. If no cache → full scan +6. Replaces the cache with a temp-file write + rename only if a short-lived writer lock and cache fingerprint check show another scan has not already refreshed it +7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault + +Startup entry hydration calls the cached `list_vault` path first for both the main window and secondary note windows. The main window alone treats an empty cached startup result as suspicious and retries with `reload_vault`; explicit user reloads, watcher refreshes, and Git pull refreshes still use reload paths when disk freshness is required. + +### Frontmatter Manipulation (Rust) + +`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing: + +1. Finds the frontmatter block between `---` delimiters +2. Iterates through lines looking for the target key +3. If found: replaces the value (consuming multi-line list items if present) +4. If not found: appends the new key-value at the end +5. If no frontmatter exists: creates a new `---` block + +The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file. + +### Content Loading + +- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })` +- **Browser mode**: All content loaded at startup from mock data +- Content for backlink detection (`allContent`) is stored in memory as `Record` + +## Git Integration + +Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2). Path-producing commands use `core.quotePath=false` so Unicode note filenames stay as UTF-8 paths across status, history, cache invalidation, and rename detection. Git subprocesses also inherit the user's shell-managed Git identity/config environment when the app process is missing it, including `GIT_AUTHOR_*`, `GIT_COMMITTER_*`, `GIT_CONFIG_*`, `XDG_CONFIG_HOME`, and `EMAIL`. + +### Data Types + +```typescript +interface GitCommit { + hash: string + shortHash: string + message: string + author: string + date: number // Unix timestamp +} + +interface ModifiedFile { + path: string // Absolute path + relativePath: string // Relative to vault root + status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' + addedLines?: number | null + deletedLines?: number | null + binary?: boolean +} + +interface GitRemoteStatus { + branch: string + ahead: number + behind: number + hasRemote: boolean + hasUpstream: boolean + upstream: string | null +} + +interface GitAddRemoteResult { + status: 'connected' | 'already_configured' | 'incompatible_history' | 'auth_error' | 'network_error' | 'error' + message: string +} + +interface PulseCommit { + hash: string + shortHash: string + message: string + date: number + githubUrl: string | null + files: PulseFile[] + added: number + modified: number + deleted: number +} +``` + +### Operations + +| Module | Operation | Notes | +|--------|-----------|-------| +| `history.rs` | File history | `git log` — last 20 commits per file | +| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` | +| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked | +| `file_url.rs` | File URL | Builds a copyable remote URL from the primary remote, current branch, and vault-relative path without exposing remote credentials | +| `author.rs` | Author identity | Resolves the exact commit author Tolaria will use, heals the legacy Tolaria fallback email, and reports when repo-local identity shadows the global Git identity | +| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit | +| `remote.rs` | Pull / Push | Resolves the current branch's configured upstream, then runs explicit pull/push commands against that remote branch; missing upstream and detached HEAD states return actionable sync errors | +| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe | +| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual, and ensure a local author fallback before commit/rebase continuation | +| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes | + +### Auto-Sync + +`useAutoSync` hook handles automatic git sync across every active Git repository: +- Configurable interval (from app settings: `auto_pull_interval_minutes`) +- Pulls the active repository set concurrently on launch, focus, interval, and manual sync +- Budgets automatic launch/focus/interval pulls per repository with a short cooldown so focus or low interval settings do not repeat network Git work immediately after a recent sync; manual sync bypasses this budget +- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it +- Pushes the active repository set during divergence recovery +- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh +- Reopens the clean active tab from disk only when the pull changed that active note, then restores editor focus if the editor owned focus before the remount +- Detects merge conflicts → opens `ConflictResolverModal` +- Tracks aggregate remote status (ahead/behind via `git_remote_status`) +- Tracks the active branch and whether it has an upstream, so status UI can explain non-main branches and missing tracking setup before pull/push +- Handles push rejection (divergence) → sets `pull_required` status +- `pullAndPush()`: pulls then auto-pushes each active repository for divergence recovery +- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs) + +### External Vault Refresh + +External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note when the changed-path list includes that note, and closes the active tab if the file disappeared. Editor focus does not block the clean active note from converging to disk when its own file changed externally; if the active editor owned focus before that remount, the app requests editor focus again after the fresh tab is mounted. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes. + +`useGitRepositories` is the commit-time companion to `useAutoSync`: +- Owns repository picker validation plus `get_modified_files` and `git_remote_status` loading for active Git repositories +- Re-checks the selected repository when the Commit dialog opens and right before submit +- Reloads the selected repository with line stats when the user asks to generate a commit-message draft +- Converts `hasRemote: false` into a local-only commit path +- Keeps the normal push path unchanged for repositories that do have a remote + +`AddRemoteModal` is the explicit recovery path for those local-only vaults: +- Opens from the `No remote` status-bar chip and the command palette +- Calls `git_add_remote` with the current vault path and the pasted repository URL +- Shows auth, network, and incompatible-history failures inline without rewriting the local vault's history + +`useAutoGit` is the checkpoint-time companion to both hooks: +- Consumes installation-local AutoGit settings (`autogit_enabled`, idle threshold, inactive threshold) +- Tracks the last meaningful editor activity plus app focus/visibility transitions +- Triggers `useCommitFlow.runAutomaticCheckpoint()` only when the vault is git-backed, pending changes exist, and no unsaved edits remain +- Shares the same deterministic automatic commit message generator with the bottom-bar Commit button, so timer-driven checkpoints and manual quick commits produce the same `Updated N note(s)` / `Updated N file(s)` messages + +### Frontend Integration + +- **Modified file badges**: Orange dots in sidebar +- **Diff view**: Toggle in breadcrumb bar → shows unified diff +- **Git history**: Shown in Inspector panel for active note +- **Commit dialog**: Triggered from sidebar or Cmd+K; can prefill an editable generated message from current changed-file metadata before any commit runs +- **Generate commit message command**: Cmd+K action that opens the commit dialog, reloads the selected repository with line stats, and fills the message field. Direct configured model targets receive only structured path/status/line-count metadata with a bounded path list; disabled AI, offline AI, agent targets, or model failures use a deterministic changed-file summary instead. +- **Branch indicator**: Current Git branch chip in the bottom bar for Git-backed vaults +- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false` +- **Pulse view**: Activity feed when Pulse filter is selected +- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu +- **Git status popup**: Click sync badge → shows active branch, upstream/missing-upstream state, aggregate ahead/behind, and a Pull button when tracking is configured for the active repository set +- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes + +## BlockNote Customization + +The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative. + +### Custom Wikilink Inline Content + +Defined in `src/components/editorSchema.tsx`: + +```typescript +const WikiLink = createReactInlineContentSpec( + { + type: "wikilink", + propSchema: { target: { default: "" } }, + content: "none", + }, + { render: (props) => ... } +) +``` + +### Code Block Highlighting + +Defined in `src/components/editorSchema.tsx` and styled in `src/components/EditorTheme.css`: + +- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`. +- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM. +- Missing common grammars live in `src/utils/codeBlockLanguageCatalog.ts` and are registered lazily from direct `@shikijs/langs` imports by `src/components/codeBlockOptions.ts`; known aliases such as `ps1` and `vb` normalize to canonical picker values during Markdown import. +- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win. +- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface. + +### Markdown Math + +Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`: + +- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes. +- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note. +- Double-clicking rendered display math edits the math block's `latex` property in-place; Markdown delimiters remain owned by serialization. Inline math can still be reopened as source text for direct editing. +- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots. +- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria. + +### Mermaid Diagrams + +Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`: + +- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body. +- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG. +- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails. +- `serializeDurableEditorBlocks()` wraps the math-aware serializer so math, wikilinks, Mermaid diagrams, and whiteboards share the same Markdown-first save path. +- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state. + +### Tldraw Whiteboards + +Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`: + +- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body. +- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note. +- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file. +- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block. +- The tldraw runtime receives Tolaria's resolved light/dark mode as its user color scheme, so embedded whiteboards follow the app appearance and update while mounted. +- Embedded whiteboards expose a session-only full-window workspace that reuses the same tldraw store and Markdown snapshot; expanding or closing it does not persist camera, tool, or size state. +- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner. +- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts. + +### Formatting Surface Policy + +Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`: + +- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees. +- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling. +- `editorRichCopy.ts` owns rich-editor copy serialization for external apps. Normal selections use BlockNote's external clipboard HTML so tables, lists, checklists, and inline marks paste as rich content outside Tolaria, while `SingleEditorView` still normalizes `text/plain` and keeps fenced code-block selections on raw code text. +- The formatting toolbar only exposes inline controls that persist through Tolaria's Markdown serialization pipeline: bold, italic, strike, inline code, `==highlight==`, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing. +- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction. +- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior. +- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action. +- `createMarkdownHighlightShortcutExtension()` owns the rich-editor Cmd/Ctrl+Shift+M formatting shortcut. It skips IME composition and read-only editors, calls the same highlight style toggle as the formatting toolbar, and records `markdown_highlight_shortcut_used` with keyboard-only metadata. +- `richEditorBlockSelectionExtension.ts` owns Notion-style block selection as an editor-level ProseMirror plugin. A first `Escape` promotes the current caret or native text selection into one or more selected blocks, renders block-level decoration chrome, and keeps arrow/delete/enter handling inside the editor; a second `Escape` clears the block selection without preventing the app-level note-list escape path from taking over. Native drag text selection remains content selection, while block mode uses decoration chrome so empty spacer lines are not treated as selected content. +- `richEditorRecoveryClassifier.ts` is the shared taxonomy for recoverable BlockNote/ProseMirror failures used by render recovery and transform recovery. Missing block IDs plus paragraph and table-row index failures keep one canonical reason across `editor_render_recovered` and `rich_editor_transform_error_recovered`; the two recovery surfaces differ only in retry, repair, and dispatch behavior. +- `richEditorInputTransform.ts` is the shared execution shell for rich-editor Markdown `beforeinput` transforms. It reads the live ProseMirror view, skips IME composition, resets state when a stale view is detected, dispatches transform transactions, prevents native input only after successful dispatch, and reports recoverable editor-transform errors through the shared classifier. Arrow ligatures, inline math conversion, and `==highlight==` keep their syntax-specific matching in their feature files and are composed for the main editor by `richEditorInputTransformExtension.ts`. +- `richEditorTextDirection.ts` uses a BlockNote extension with ProseMirror decorations for per-node RTL quote rendering. It handles callout-marker quotes such as `[!note] כותרת` at the editor render layer, so BlockNote does not strip post-render DOM mutations and quote rails can follow logical inline-start. +- `focusOwnershipGuard.ts` is the shared global focus interception primitive for editor-like surfaces. It owns the single `HTMLElement.prototype.focus` patch, document focus/pointer listeners, outside-target restoration, and cleanup; rich-editor and sheet modules keep only their surface-specific focus-claim policy. +- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path. +- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. +- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. `tolariaSideMenuAlignment.ts` aligns the side menu to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. `tolariaBlockReorder.ts` owns block reordering as a pointer gesture with direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. `tolariaSideMenuBlocks.ts` owns stale-block lookup and mutation guards, so block-handle actions re-resolve the current live BlockNote block before mutating or dragging and note reloads/sync churn cannot leave controls acting on stale block references. +- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation. +- `SingleEditorView` wraps the BlockNote surface in a narrow render-recovery boundary for recoverable BlockNote node-view failures classified by `richEditorRecoveryClassifier.ts`. The boundary retries the BlockNote view once, records `editor_render_recovered`, and marks the recovered error so the React root handler does not send that handled case back to Sentry. Other render errors still propagate through the normal root error path. +- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags. +- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader. +- `tauriEventCleanup.ts` owns safe Tauri event unlisten cleanup. Hooks and stream utilities route listener teardown through it so stale or duplicate native listener removals cannot surface as unhandled promise rejections during fast remounts, window teardown, or stream completion. +- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription used by native drop features. +- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor. + +### Markdown-to-BlockNote Pipeline + +```mermaid +flowchart LR + A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"] + B --> C{"exact-source\nblock cache?"} + C -->|"hit"| D["reuse cached blocks"] + C -->|"miss"| E["preprocess durable blocks,\nwikilinks, math, images"] + E --> F{"large common\nMarkdown?"} + F -->|"yes"| G["worker-backed direct Markdown parser\n→ block tree"] + F -->|"unsupported or small"| H["tryParseMarkdownToBlocks()\n→ BlockNote block tree"] + G --> I["inject wikilinks, math, highlights,\nand durable schema nodes"] + H --> I + D --> J{"large block set?"} + I --> J + J -->|"yes"| K["progressive locked apply\nfirst chunk + frame-yielded appends"] + J -->|"no"| L["single replaceBlocks()\nsmall/fast swap"] + + style A fill:#f8f9fa,stroke:#6c757d,color:#000 + style K fill:#d4edda,stroke:#28a745,color:#000 + style L fill:#d4edda,stroke:#28a745,color:#000 +``` + +> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, tldraw, and standalone file-attachment link placeholders use ASCII sentinels with URI-encoded payloads. + +### BlockNote-to-Markdown Pipeline (Save) + +```mermaid +flowchart LR + A["✏️ BlockNote blocks\n(editor state)"] --> B["restore durable Markdown tokens\nwhile preserving block identity"] + B --> C{"all block shapes\nsupported directly?"} + C -->|"yes"| D["direct serializer\nwith per-editor WeakMap cache"] + C -->|"no"| E["BlockNote Markdown exporter fallback"] + D --> F["prepend frontmatter yaml"] + E --> F + F --> G["invoke('save_note_content')\n→ disk write"] + + style A fill:#cce5ff,stroke:#004085,color:#000 + style G fill:#d4edda,stroke:#28a745,color:#000 +``` + +Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, file-attachment block round-tripping, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path. + +Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save. + +### Wikilink Navigation + +Two navigation mechanisms: + +1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements → `onNavigateWikilink(target)`. +2. **Suggestion menu**: Typing `[[` or `@` triggers `SuggestionMenuController` with the same filtered vault-entry suggestions and inserts normal wikilink inline content. + +Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass matching with global priority: path suffix for path-style targets, filename stem, alias, exact title, then humanized title (kebab-case -> words). In a mounted-workspace graph, unprefixed links prefer the source note's workspace, while links prefixed by a known workspace alias resolve inside that workspace (`[[team/projects/alpha]]`). Cross-workspace canonical link insertion prefixes the target alias only when source and target workspaces differ; same-workspace links stay vault-relative. + +### Raw Editor Mode + +Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command. +`useRawModeWithFlush` owns the rich/raw transition model: pending raw-exit content and raw-mode overrides move together as one content transition, while cursor/scroll restoration moves through one restore-transition ref consumed by `useEditorModePositionSync`. The raw editor should not carry independent pending-content or pending-position refs outside that handoff. +While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state. + +Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax. + +### Rich Editor Width Modes + +Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette. + +Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width. + +### Arrow Ligature Normalization + +Typed ASCII arrow sequences are normalized consistently in both editor modes: + +- Rich editor input mounts `createArrowLigaturesExtension()` (`src/components/arrowLigaturesExtension.ts`) into BlockNote and intercepts typed `beforeinput` events before ProseMirror commits the character. +- Raw editor input uses the CodeMirror `inputHandler` path in `useCodeMirror` so the same ligature rules apply while editing markdown source directly. +- Both paths delegate to the shared `resolveArrowLigatureInput()` helper in `src/utils/arrowLigatures.ts`, which prioritizes `<->` over partial matches, keeps paste literal, and lets escaped forms such as `\\->` and `\\<->` remain ASCII. +- The rich-editor extension treats stale, disconnected, or mid-reload ProseMirror views as a no-op. It never blocks the native input path unless it has already built and dispatched a valid ligature transaction. + +## Styling + +The app uses internal light and dark themes owned by Tolaria, with System as an installation-local preference that follows the OS appearance (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). The previous vault-authored theming system remains removed. + +1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4 +2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme` +3. **Runtime theme bridge**: Resolves the selected preference to `light` / `dark`, applies `data-theme` and `.dark` for shadcn/ui, and subscribes to `prefers-color-scheme` while System is selected +4. **Theme mode commands**: Command-palette actions for Light, Dark, and System call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode` + +## Localization + +App UI strings are resolved through `src/lib/i18n.ts`, with flat JSON catalogs in `src/lib/locales/*.json` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)): + +- `AppLocale`: canonical locale tags such as `'en'`, `'zh-CN'`, `'fr-FR'`, `'es-419'` +- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null` +- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale, including legacy aliases +- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation +- `scripts/validate-locales.mjs`: asserts every checked-in locale catalog matches the English keyset and stays flat-string-only + +`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault. + +## Inspector Abstraction + +The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels: + +1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs: + - **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction. + - **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control. + - **Anchored dropdowns**: Fixed-position property menus and note-list sort menus use `src/components/anchoredDropdown.ts` for anchor measurement, viewport clamping, scroll/resize repositioning, and optional max-height calculations. Property-specific filtering and keyboard navigation stay in `propertyDropdownUtils.ts`. + - **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted. + - **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note. + - **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction. + - Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section. + +2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes. + +3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`. + +4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps. + +## Search + +### Search + +Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading: + +```typescript +interface SearchResult { + title: string + path: string + snippet: string + score: number +} +``` + +### Search Integration + +`SearchPanel` component provides the search UI: +- Real-time results as user types (300ms debounce) +- Click result to open note in editor +- Shows relevance score and snippet + +The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` hits from the visible workspace roots using the command's frontmatter-excluding search option. React stores only matching paths so body-only matches appear in the current list scope without a second content-read pass or rendering private matched text in note rows. + +No indexing step required — search runs directly against the filesystem. + +## Vault Management + +### Vault Switching + +`useVaultSwitcher` hook manages multiple vaults: +- Persists vault list to `$XDG_CONFIG_HOME/com.tolaria.app/vaults.json`, defaulting to `$HOME/.config/com.tolaria.app/vaults.json` on Unix platforms. App config path policy is declared once in `mcp-server/app-config-policy.json` and consumed by both the Rust app helper and Node MCP server: reads check the current Tolaria namespace, then legacy `com.laputa.app`, first in the preferred config root and then in the platform config root when it differs; writes target the current namespace in the preferred root. +- Switching closes all tabs and resets sidebar +- Supports adding, removing, hiding/restoring vaults +- Persists workspace aliases, colors, mount state, and the default new-note destination for the unified graph +- Default vault: public Getting Started starter vault cloned on demand + +Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick-open, keyword search, wikilink navigation, and saved View discovery. Workspace switching remains a focus operation for per-vault capabilities (Git status, folders, AutoGit, watchers, and repair commands), not a graph isolation boundary. + +### Vault Config + +Per-vault settings stored locally and scoped by vault path: +- Managed by `useVaultConfig` hook and `vaultConfigStore` +- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, Git setup prompt preference, AI agent permission mode (`safe` / `power_user`) +- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run +- One-time migration from localStorage (`configMigration.ts`) + +Installation-local layout state that should not sync through a vault stays in localStorage. `useLayoutPanels` stores the clamped sidebar, note-list, and inspector widths under `tolaria:layout-panels` so pane sizing survives app relaunches on the same machine. + +### AI Guidance Files + +Tolaria tracks managed vault-level AI guidance separately from normal note content: +- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents +- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md` +- `GEMINI.md` is an optional Antigravity/Gemini compatibility shim that points Google-backed CLI agents back to `AGENTS.md` +- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom` +- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Antigravity/Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched +- Editing a usable `AGENTS.md`, including changing its frontmatter `type`, makes the file custom rather than broken; broken is reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs +- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken + +Vault guidance is intentionally short and vault-specific. General Tolaria product behavior is delivered through the bundled agent docs resource instead: +- `scripts/build-agent-docs.mjs` compiles the public `site/` Markdown into `src-tauri/resources/agent-docs/` +- `src-tauri/resources/agent-docs/AGENTS.md` orients agents to the generated docs bundle, while `index.md`, section bundles, `all.md`, `search-index.json`, and `pages/` provide fast local lookup +- `get_agent_docs_path` exposes the resolved resource folder to the renderer, and `buildAgentSystemPrompt()` tells every app-managed CLI agent to read vault `AGENTS.md` first, then search the bundled docs for Tolaria behavior + +### Action History + +`useActionHistory` owns renderer-scoped app undo/redo state. It stores explicit action entries with labels plus undo/redo callbacks, suppresses nested recording during replay, and exposes the top labels to command-palette commands. + +- Frontmatter mutations record history only after the write succeeds and only for non-silent user actions. +- Entry state toggles such as archive, favorite, and organized record explicit before/after replay callbacks after persistence succeeds. +- Text inputs, contenteditable surfaces, and editor-owned text history keep native undo/redo first; app-level history runs only when focus is outside text editing. +- Irreversible destructive actions stay outside the stack and continue to use confirmation/destructive affordances. + +### Getting Started / Onboarding + +`useOnboarding` hook detects first launch: +- If vault path doesn't exist → show `WelcomeScreen` +- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning +- After the starter repo clone completes, Tolaria removes every remote so the new vault opens local-only by default +- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback) + +`useGettingStartedClone` encapsulates the non-onboarding Getting Started action: +- Opens the same parent-folder picker used by onboarding +- Derives the final `.../Getting Started` destination path +- Surfaces the resolved path through the app toast after a successful clone + +`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step: +- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key) +- Only shows after vault onboarding has already resolved to a ready state +- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Kiro, and Hermes Agent by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources +- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets +- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path +- Persists dismissal locally once the user continues + +### Remote Git Operations + +Tolaria delegates remote auth to the user's system git setup: +- `CloneVaultModal` captures a supported remote URL (`https://`, `http://`, `ssh://`, or `git@host:path`) and local destination +- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive +- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push +- On Linux AppImage launches, every system-git command and MCP runtime subprocess (Node.js or Bun) removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and the system MCP runtime bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries +- On native Linux Wayland launches, startup environment safeguards set `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already provided it, keeping the broad WebKitGTK DMABUF crash workaround while avoiding the last-resort compositing fallback that can make WebKitGTK windows feel unresponsive. Linux AppImage launches still set both `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless overridden because the sealed AppImage path has the verified rendering failure this fallback protects. +- On Linux AppImage launches, release packaging bundles the GTK3 fcitx immodule into the AppImage and startup environment safeguards write a cache-local `GTK_IM_MODULE_FILE` that points GTK at the mounted module whenever fcitx is configured. If the user has not explicitly chosen a GTK IM module, Tolaria also sets `GTK_IM_MODULE=fcitx`, allowing WebKitGTK editor input to reach fcitx5 on both Wayland and X11 fallback launches without relying on host GTK module cache paths. +- `git_add_remote` uses the same system git path, validates the pasted URL at the Tauri command boundary, and refuses remotes whose history is unrelated or ahead of the local vault +- Shared git subprocess setup rejects the `ext::` transport, inserts an end-of-options separator for clone URLs, disables repo-configured fsmonitor hooks, and ignores repo-configured SSH command overrides +- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input +- No provider-specific token or username is stored in app settings + +## Settings + +App-level settings persisted at `$XDG_CONFIG_HOME/com.tolaria.app/settings.json`, defaulting to `$HOME/.config/com.tolaria.app/settings.json` on Unix platforms. `settings.json` and `vaults.json` share the same `mcp-server/app-config-policy.json` search order used by Rust and the external MCP server, so durable agent registrations resolve mounted workspaces the same way the app resolves installation-local settings: + +```typescript +interface AiWorkspaceConversationSetting { + archived: boolean | null + id: string + target_id: string | null + title: string +} + +interface Settings { + auto_pull_interval_minutes: number | null + autogit_enabled: boolean | null + autogit_idle_threshold_seconds: number | null + autogit_inactive_threshold_seconds: number | null + telemetry_consent: boolean | null + crash_reporting_enabled: boolean | null + analytics_enabled: boolean | null + anonymous_id: string | null + release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed + automatic_update_checks_enabled: boolean | null // null = default true + theme_mode: 'light' | 'dark' | 'system' | null + ui_language: AppLocale | null + date_display_format: 'us' | 'european' | 'friendly' | 'iso' | null + note_width_mode: 'normal' | 'wide' | null + sidebar_type_pluralization_enabled: boolean | null // null = default true + ai_features_enabled: boolean | null // null = default true + git_enabled: boolean | null // null = default true + default_ai_agent: 'claude_code' | 'codex' | 'copilot' | 'opencode' | 'pi' | 'antigravity' | 'kiro' | 'hermes' | null + default_ai_target: string | null // "agent:codex" or "model:/" + ai_model_providers: AiModelProvider[] | null + ai_workspace_conversations: AiWorkspaceConversationSetting[] | null + hide_gitignored_files: boolean | null // null = default true + all_notes_show_pdfs: boolean | null // null = default false + all_notes_show_images: boolean | null // null = default false + all_notes_show_unsupported: boolean | null // null = default false +} +``` + +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `automatic_update_checks_enabled` is installation-local and defaults to `true`; when false, `useUpdater` skips the startup/background update probe while the status-bar/manual "Check for updates" action remains available. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. + +## Telemetry + +### Components +- **`TelemetryConsentDialog`** — First-launch dialog asking user to opt in to anonymous crash reporting. Two buttons: accept (sets `telemetry_consent: true`, generates `anonymous_id`) or decline. +- **`TelemetryToggle`** — Checkbox component in `SettingsPanel` for crash reporting and analytics toggles. + +### Hooks +- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`. + +### Libraries +- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. The same hook drops known benign browser ResizeObserver loop-limit notifications before they become crash issues, while keeping unrelated ResizeObserver failures reportable. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries. +- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors. +- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle. + +### Product Events +- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`. +- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names. +- **Code block copy** — `code_block_copied` records that the rich-editor code-block copy action was used, without sending note paths, languages, or code content. +- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, `ai_agent_response_stopped`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories. +- **AI feature visibility** — `ai_features_visibility_changed` records only whether installation-level AI surfaces were enabled or hidden. +- **Automatic update checks** — `automatic_update_checks_changed` records only whether startup/background update checks were enabled or disabled. +- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state. + +### Tauri Commands +- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting. + +--- + +## Updates & Feature Flags + +### Hooks +- **`useUpdater(releaseChannel, automaticChecksEnabled)`** — Channel-aware updater state machine. When automatic checks are enabled, it checks the selected feed after startup; manual checks always remain available. It surfaces checking/available/downloading/ready states and delegates install work to Rust. +- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union. + +### Frontend helpers +- **`src/lib/releaseChannel.ts`** — Normalizes persisted channel values so legacy or invalid settings fall back to Stable, while Stable serializes back to `null`. +- **`src/lib/appUpdater.ts`** — Thin wrapper around the Tauri updater commands. Keeps the React hook free of endpoint-selection details. + +### Rust +- **`src-tauri/src/app_updater.rs`** — Chooses the correct update endpoint and adapts Tauri updater results into frontend-friendly payloads. Stable uses the public `stable/latest.json` feed. Alpha first resolves the newest non-draft `alpha-vYYYY.M.D-alpha.NNNN` GitHub Release asset named `alpha-latest.json`, then falls back to the public `alpha/latest.json` feed if the release lookup is unavailable. +- **`src-tauri/src/commands/version.rs`** — Formats app build/version labels for the status bar, including calendar alpha labels and legacy release compatibility. + +### Tauri Commands +- **`check_for_app_update`** — Channel-aware update manifest lookup. +- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events. + +### CI/CD +- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Windows job always requires Tauri updater signatures, uses Authenticode signing and `Get-AuthenticodeSignature` verification when Windows certificate secrets are present, and warns while building updater-signed-only artifacts when those secrets are absent. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. Changes to the shared artifact workflow are not ignored by the alpha trigger, so release-pipeline fixes produce a fresh alpha run. macOS release assets use `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds. +- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers plus Tauri-signed updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts selected non-Windows installers without replacing the page with a blank download navigation. Windows visitors see an explicit installer action and managed-device guidance instead of an automatic download. Authenticode publisher signing is added to Windows artifacts when certificate secrets are configured; until then, the workflow warns and publishes updater-signed-only Windows artifacts. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release. +- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6a29442 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,1176 @@ +# Architecture + +Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes. + +## Design Principles + +### Filesystem as the single source of truth + +The vault is a folder of plain markdown files. The app never owns the data — it only reads and writes files. The cache, React state, and any in-memory representation are always derived from the filesystem and must be reconstructible by deleting them. When in doubt, the file on disk wins. + +### Convention over configuration + +Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `belongs_to:`, `related_to:`, `has:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. Relationship defaults are stored in snake_case on disk and humanized in the UI. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them. + +This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup. + +### Where to store state: vault vs. app settings + +When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Tolaria installations — other devices, future platforms (tablet, web)?"** + +| Follows the vault | Stays with the installation | +|-------------------|-----------------------------| +| Type icon, type color | Editor zoom level | +| Pinned properties per type | API keys (OpenAI, Google) | +| Sidebar label overrides | Auto-sync interval | +| Property display order | Window size / position | +| Per-note `_width` rich-editor width override | Default rich-editor note width | +| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files | +| N/A | Whether this installation shows Git features | +| N/A | Git executable provider (`native` vs WSL2 distribution) | +| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility | +| N/A | Per-vault Git setup prompt opt-out | +| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels | +| N/A | Registered workspace labels, aliases, mount state, and default new-note destination | +| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting | + +**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in app config JSON under `$XDG_CONFIG_HOME/com.tolaria.app/` (defaulting to `$HOME/.config/com.tolaria.app/` on Unix platforms), or in localStorage for renderer-only transient layout state. + +Examples: +- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties) +- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity) +- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference) +- ✅ App settings: `zoom: 1.3` (machine-specific preference) +- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language) +- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override) +- ✅ App settings: `date_display_format: "friendly"` (installation-specific date rendering preference) +- ✅ App settings: `sidebar_type_pluralization_enabled: false` (installation-specific sidebar label preference) +- ✅ App settings: `all_notes_show_images: true` (installation-specific All Notes file-category visibility) +- ✅ App settings: `git_provider: "wsl"` plus `git_wsl_distro: "Ubuntu"` (machine-specific Git executable location) + +### No hardcoded exceptions + +No field names, folder paths, or vault-specific values should be hardcoded in the application source code. What can be a convention should be a convention. What needs to be configurable should live in a file. Relationship fields are detected dynamically by checking whether values contain `[[wikilinks]]` — no hardcoded field name lists. + +### AI-first knowledge graph + +Notes are not just documents — they are nodes in a structured graph of people, projects, events, responsibilities, and ideas. Every design decision should ask: "Does this make the knowledge graph easier for a human *and* an AI to navigate?" Conventions that are legible to both are better than conventions that are legible only to one. + +### Three representations, one authority + +Vault data exists in three forms simultaneously: +1. **Filesystem** — the `.md` files on disk. This is the single source of truth. +2. **Cache** — `~/.laputa/cache/.json`, an index for fast startup. Always reconstructible from the filesystem. +3. **React state** — the in-memory `VaultEntry[]` during a session. Always derived from the cache or filesystem. + +These must never diverge permanently. If they do, the filesystem wins and the cache/state are rebuilt. + +```mermaid +flowchart LR + FS["🗂️ Filesystem\n.md files on disk\n(source of truth)"] + Cache["⚡ Cache\n~/.laputa/cache/\n(fast startup index)"] + RS["⚛️ React State\nVaultEntry[]\n(in-memory session)"] + + FS -->|"scan_vault_cached()"| Cache + Cache -->|"useVaultLoader on load"| RS + FS -->|"reload_vault (full rescan)"| RS + RS -.->|"write via Tauri IPC first"| FS + + style FS fill:#d4edda,stroke:#28a745,color:#000 + style Cache fill:#fff3cd,stroke:#ffc107,color:#000 + style RS fill:#cce5ff,stroke:#004085,color:#000 +``` + +#### Ownership rules + +| Layer | Owner | Writes to | Reads from | +|-------|-------|-----------|------------| +| Filesystem | Tauri Rust commands (`save_note_content`, `update_frontmatter`, etc.) | Disk | — | +| Cache | `scan_vault_cached()` in `vault/cache.rs` | `~/.laputa/cache/` | Filesystem + git diff | +| React state | `useVaultLoader` + `useEntryActions` + `useNoteActions` | In-memory `entries` | Cache (on load), filesystem (on reload) | + +#### Invariants + +1. **Disk-first writes**: All functions that change vault data must write to disk (via Tauri IPC) *before* updating React state. This ensures that if the disk write fails, React state remains consistent with what's actually on disk. +2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteCreation`), state may update before disk confirmation — but a failure callback must revert the optimistic state. +3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. Type metadata actions in `useEntryActions` follow this rule — create the missing type document if needed, write frontmatter first, then update state. If missing type creation collides with an existing note filename, the action stops after the existing creation toast instead of applying orphaned state. +4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file. +5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem. +6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape. Large folder filtering runs on the blocking Tokio pool and drains `git check-ignore` output while feeding stdin so broad ignore matches cannot freeze the native UI thread. + +#### External Change Detection + +The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and clean active-editor content refresh under the ADR-0135 unsaved-edit rules. When that clean active-editor remount starts from a focused editor, the main app dispatches a post-replacement editor focus request so typing can continue in the refreshed tab. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads. + +#### Progressive Vault Loading + +Vault opening is allowed to render the main app shell while the entry index is still in flight. Initial startup hydration uses the cached/incremental `list_vault` path; the main window falls back to `reload_vault` only when that cached startup result is empty, while explicit refresh paths still force a fresh reload. `useVaultLoader` keeps `isLoading` true until entries are ready, but folders and saved views load independently so the sidebar can become useful before the note index completes. The status bar uses the vault activity badge during this initial indexing state, while command-palette and editor-shell interactions remain mounted instead of being hidden behind the full app skeleton. The full skeleton is reserved for app-level capability checks such as the initial Git-state probe. + +Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md). + +#### Mounted Workspaces + +The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `vaults.json` under Tolaria's app config directory (`$XDG_CONFIG_HOME/com.tolaria.app/`, defaulting to `$HOME/.config/com.tolaria.app/` on Unix platforms). `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. The default workspace is the write target for new notes and Type documents; it is not the only active vault when multiple workspaces are enabled. + +Vault item deep links use the registered vault list as their resolver namespace. `src/utils/deepLinks.ts` builds `tolaria:///` URLs from workspace aliases, labels, and paths, appending a short stable hash when generated slugs would collide. `useDeepLinks` validates incoming links, switches vaults when required, reloads the vault index once for recently changed files, and opens the matching `VaultEntry` through the normal note-selection path. + +Saved Views participate in that mounted graph as source-scoped chrome. `useVaultLoader` loads view definitions from every mounted vault, annotates each `ViewFile` with its owning `rootPath` and workspace identity, and keeps sidebar selection/persistence keyed by `(rootPath, filename)` so same-named view files from different vaults stay independent. + +Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. Native Git capability checks treat a mounted workspace as Git-backed when it is inside any Git work tree, so included vault folders can reuse a parent repository without creating embedded `.git` directories. Mounted folders outside Git return no changes/no remote for aggregate probes instead of failing workspace-wide sync. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance. + +Git command launch is also installation-local. The Rust Git module resolves a `GitLaunchConfig` from app settings before spawning Git, defaulting to native `git` while allowing Windows installations to explicitly select WSL2 Git and a distribution. WSL-selected launches go through `wsl.exe --exec git` and translate Windows/WSL UNC vault paths before passing repository paths across the command boundary; Tolaria never silently switches providers based on detection alone. + +Renderer git file workflows stay behind `useGitFileWorkflows`. The hook resolves per-note repository paths, queues editor diff requests, opens Pulse history entries including deleted-file previews, and keeps discard/reload handling close to the selected Git surface while `App.tsx` only wires the resulting callbacks into `NoteList`, `PulseView`, and `Editor`. + +Cross-workspace note reads and writes keep the disk-first invariant. When an absolute note path is saved or read without an explicit `vaultPath`, the Tauri boundary resolves the deepest registered vault root that contains the path and validates against that root before touching disk. This lets an editor tab opened from a mounted workspace save back to its source repository while preserving the same path-escape protections as active-vault operations. + +#### Note Opening Fast Path + +Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor. + +The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. On foreground open, block resolution checks the hot tab cache, exact-source parsed-block cache, blank/H1 fast path, and a direct Markdown-to-block parser for large common Markdown before falling back to BlockNote's parser. The direct parser runs in a module worker when available and falls back synchronously only for tests or runtimes without workers. It is conservative: unsupported Markdown constructs return to the BlockNote path rather than changing semantics. Large resolved block sets mount progressively: the editor is locked, the first chunk is applied immediately, remaining chunks append across animation frames, and the same generation/source-content token can abort the partial mount before it is committed. Ordinary BlockNote block wrappers stay fully measurable after mount so hover side menus, slash dialogs, and document-end interactions do not inherit browser lazy-height scroll jumps. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. Development builds log `editorBlockResolve`, `editorBlockApply`, and `parsedBlockPreload` timings for large or slow note opens. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md). + +Rich-editor save serialization stays on the editor fast path but avoids BlockNote's full Markdown exporter when Tolaria knows the block tree is representable directly. Real BlockNote editor instances install `src/utils/blockNoteDirectMarkdown.ts`, which serializes common Tolaria block shapes to Markdown with a per-editor weak cache keyed by stable block object identity and list context. Durable Markdown bridges for wikilinks, math, highlights, files, Mermaid, and tldraw remain owned by `src/utils/richEditorMarkdown.ts`; the bridge helpers preserve unchanged block objects so repeated debounced saves can reuse direct Markdown projections. Stable active-tab rerenders trust the exact-source block cache before doing any full-document Markdown comparison, so unrelated React state changes do not serialize large notes. Unsupported block shapes fall back to BlockNote's lossy serializer, and development builds log `richEditorSerialize` timing/cache/fallback metrics plus rare `editorStabilityCheck` fallback comparisons for large or slow serializations. + +Rich-editor typing keeps BlockNote's per-transaction work minimal. Tolaria creates BlockNote with animation bookkeeping disabled so the `previousBlockType` extension, which scans the old and new ProseMirror documents on every edit, is not installed. Collapsed-heading rendering attaches its mutation observer and `editor.onChange` subscription only while at least one heading/list section is collapsed, then detaches when the final section expands. Development builds wrap ProseMirror dispatch once per editor view and log `richEditorDispatch` timings for large or slow transactions, giving QA a direct signal for edit-time stutter instead of only note-open speed. + +Editor responsiveness is also protected by a synthetic browser benchmark. `pnpm perf:editor` launches an isolated Vite server, injects small and large synthetic Markdown vault entries, opens each note repeatedly, and records medians for editor visibility, first content rendered, full note application, post-open edit frame latency, and development timing logs such as block resolution/application. `.editor-performance-thresholds.json` stores ratcheted local budgets for those medians; `pnpm perf:editor:update` refreshes baselines only when intentionally accepting a new local floor. + +## Tech Stack + +| Layer | Technology | Version | +|-------|-----------|---------| +| Desktop shell | Tauri v2 | 2.10.0 | +| Frontend | React + TypeScript | React 19, TS 5.9 | +| Editor | BlockNote | 0.46.2 | +| Editor render extensions | @tiptap/pm | ProseMirror decorations for rich-editor node presentation | +| Code block highlighting | @blocknote/code-block | 0.46.2 | +| Additional code grammars | @shikijs/langs | 3.23.0 | +| Diagram rendering | Mermaid | 11.14.0 | +| Whiteboard rendering | tldraw | 4.5.10 | +| Raw editor | CodeMirror 6 + official language packages | Markdown, YAML, JSON, Python, SQL, JS/TS | +| Spreadsheet editor | IronCalc workbook + WASM | 0.5.x | +| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 | +| UI primitives | Radix UI + shadcn/ui | - | +| Icons | Phosphor Icons | - | +| Build | Vite | 7.3.1 | +| Backend language | Rust (edition 2021) | 1.77.2 | +| Frontmatter parsing | gray_matter | 0.2 | +| Filesystem watcher | notify | 6.1 | +| AI (workspace) | CLI agent adapters (Claude Code + Codex + GitHub Copilot + OpenCode + Pi + Antigravity + Kiro + Hermes Agent) plus configured local/API model targets | - | +| Search | Keyword (walkdir-based file scan) | - | +| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync | +| MCP | @modelcontextprotocol/sdk | 1.0 | +| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - | +| Package manager | pnpm | - | + +## System Overview + +```mermaid +flowchart TD + subgraph TW["Tauri v2 Window"] + subgraph FE["React Frontend"] + App["App.tsx (orchestrator)"] + WS["WelcomeScreen\n(onboarding)"] + SB["Sidebar\n(navigation + filters + types)"] + NL["NoteList / PulseView\n(filtered list / activity)"] + ED["Editor\n(BlockNote + diff + raw)"] + IN["Right Panel\n(Inspector + TOC)"] + AIW["AiWorkspace\n(docked or native window)"] + AIP["AiPanel\n(transcript + composer)"] + SP["SearchPanel\n(keyword search)"] + ST["StatusBar\n(vault picker + sync + version)"] + CP["CommandPalette\n(Cmd+K launcher)"] + + App --> WS & SB & NL & ED & AIW & SP & ST & CP + ED --> IN + AIW --> AIP + end + + subgraph RB["Rust Backend"] + LIB["lib.rs → Tauri commands"] + VAULT["vault/"] + FM["frontmatter/"] + GIT["git/\n(commit, sync, clone)"] + SETTINGS["settings.rs"] + SEARCH["search.rs"] + CLI["ai_agents.rs\n+ CLI adapters"] + end + + subgraph EXT["External Services"] + CCLI["Claude / Codex / Copilot / OpenCode / Pi / Antigravity / Kiro / Hermes CLI\n(agent subprocesses)"] + MCP["MCP Server\n(ws://9710, 9711)"] + GCLI["git CLI\n(native or selected WSL2 executable)"] + REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"] + end + + FE -->|"Tauri IPC"| RB + CLI -->|"spawn subprocess"| CCLI + LIB -->|"register / monitor"| MCP + GIT -->|"clone / fetch / push / pull via GitLaunchConfig"| GCLI + GCLI -->|"network auth via user config"| REMOTE + end + + style FE fill:#e8f4fd,stroke:#2196f3,color:#000 + style RB fill:#fff8e1,stroke:#ff9800,color:#000 + style EXT fill:#f3e5f5,stroke:#9c27b0,color:#000 +``` + +## Four-Panel Layout + +``` +┌────────┬─────────────┬─────────────────────────┬────────────┐ +│Sidebar │ Note List │ Editor │ Right Panel│ +│(250px) │ (300px) │ (flex-1) │ (280px) │ +│ │ OR │ │ OR │ +│ All │ Pulse View │ [Breadcrumb Bar] │ TOC │ +│ Changes│ │ │ OR │ +│ Pulse │ [Search] │ # My Note │ Properties │ +│ Inbox │ [Sort/Filt] │ │ │ +│ │ │ │ Context │ +│Projects│ Note 1 │ Content here... │ Messages │ +│Experim.│ Note 2 │ (BlockNote or Raw) │ Actions │ +│Respons.│ Note 3 │ │ Input │ +│People │ ... │ │ │ +│Events │ │ │ │ +│Topics │ │ │ │ +├────────┴─────────────┴─────────────────────────┴────────────┤ +│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ Vault: ~/Laputa │ +└──────────────────────────────────────────────────────────────┘ +``` + +- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace. +- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, folder, saved view, or Neighborhood selection is active, the renderer first adapts that navigation state into a Collection (`src/collections/collectionFromSelection.ts`) and then resolves visible entries or relationship groups through `src/collections/resolveCollectionEntries.ts`. The only active collection presentation is currently `list`, so the pane still shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). The renderer normalizes those legacy list fields into `presentation: { type: "list" }` in memory so future saved-view presentation settings can share the same collection model without changing existing YAML. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. +- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Raw CodeMirror chooses syntax highlighting from the file extension for Markdown, YAML, JSON, Python, SQL, JavaScript, and TypeScript files, while unknown text files stay plain. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for raw source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Rich-editor Markdown input transforms for arrows, inline math, and `==highlight==` share one capture-phase `beforeinput` execution path in `src/components/richEditorInputTransform.ts` and are composed by `src/components/richEditorInputTransformExtension.ts`. Navigation history (Cmd+[/]) replaces tabs. + Rich-editor Markdown output is serialized through `src/utils/richEditorMarkdown.ts`, which restores Tolaria's durable Markdown tokens, tries the installed direct BlockNote block serializer, and falls back to BlockNote's Markdown exporter for unsupported shapes. + Rich-editor copy uses BlockNote's external HTML serializer for selected note content so tables, lists, checklists, and inline formatting paste richly into other apps, with Tolaria keeping fenced-code selections as raw code text and normalized plain text on the clipboard. + Rich-editor block selection keeps ProseMirror plugin state, decorations, and key dispatch in `src/components/richEditorBlockSelectionExtension.ts`, while document traversal/collapsed-content operation IDs live in `src/components/richEditorBlockSelectionDocument.ts` and block clipboard serialization/parsing lives in `src/components/richEditorBlockSelectionClipboard.ts`. + Tolaria's BlockNote side menu keeps UI composition in `tolariaBlockNoteSideMenu.tsx`, while collapsed-section rendering and ellipsis hit-testing live in `tolariaCollapsedSections.ts`, pointer block reordering lives in `tolariaBlockReorder.ts`, measured side-menu positioning lives in `tolariaSideMenuAlignment.ts`, and stale-block lookup helpers live in `tolariaSideMenuBlocks.ts`. + Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu. +- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). + +- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior. + +Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. + +The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled and skips min-size mutation while fullscreen or maximized so navigation/sidebar interactions do not unfullscreen or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing. + +The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline. + +Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell. + +Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration. + +Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu. +On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available. + +## Multi-Window (Note Windows) + +Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed. + +The AI workspace can also open in a separate Tauri window through `openAiWorkspaceWindow()`. That window uses `?window=ai-workspace` to boot the lightweight `AiWorkspaceWindowApp` route, receives the active vault context through URL params, opts out of main-window size constraints and startup AI onboarding, and redocks by emitting `tolaria:ai-workspace-dock-requested` to the main window. Its webview is transparent so the rounded workspace shell defines the visible floating-window corners across desktop platforms. + +**Triggers:** +- `Cmd+Shift+Click` on any note in the note list or sidebar +- `Cmd+K` → "Open in New Window" (command palette, requires active note) +- `Cmd+Shift+O` keyboard shortcut +- Note → "Open in New Window" menu bar item + +**Architecture:** +- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`) +- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready +- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload +- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows +- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels + +## AI System + +### AI Agent (AiPanel) + +Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration. + +1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, bundled-docs prompt injection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents +2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters +3. **Shared runtime scaffold** (`cli_agent_runtime.rs` and submodules) — owns the common request shape, prompt wrapping, JSON-line and line-oriented subprocess lifecycle, stdout/stderr/stdin plumbing, normalized error/done handling, version probing, Tolaria stdio MCP server entry generation, and MCP server path resolution used by app-managed CLI agents +4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. GitHub Copilot runs through `copilot -p -s --no-ask-user` from the active vault, streams line-oriented stdout, passes Tolaria MCP through `--additional-mcp-config`, uses Safe mode with only write and Tolaria MCP tools preapproved, and maps Power User to `--allow-all-tools` without `--allow-all`, `--yolo`, or global path/URL bypasses. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Antigravity runs through `agy -p --add-dir `, streams line-oriented stdout, and writes Tolaria MCP into the active vault's `.agents/mcp_config.json`; Safe uses `--sandbox=true --toolPermission=proceed-in-sandbox`, while Power User uses `--sandbox=false --toolPermission=always-proceed` without `--dangerously-skip-permissions`. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Hermes Agent runs through `hermes chat --quiet --source tolaria -q`, streams line-oriented stdout, and uses the user's existing Hermes profile/configuration without mutating `~/.hermes/config.yaml`; setup errors point users to `hermes setup`, `hermes model`, and `hermes doctor`. Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Kiro, and Hermes all launch from the active vault cwd; Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Kiro receive transient Tolaria MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. +5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, GitHub Copilot receives an inline session MCP config through `--additional-mcp-config`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Antigravity receives it through `.agents/mcp_config.json` in the active vault, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault. Hermes uses the user's own Hermes MCP/profile configuration; Tolaria does not rewrite third-party Hermes config files. + +Each `stream_ai_agent` call also receives a request-scoped `ai-agent-stream-*` event name. Desktop runs bind that scoped name to any spawned CLI child through `ai_agent_processes.rs`, and `abort_ai_agent_stream` validates the same scoped name before killing the registered child. Renderer stop controls therefore cancel the local subprocess instead of only ignoring stale events, while natural completion and repeated stop requests remain no-ops. + +CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. Claude Code launches copy a narrow set of exported provider/auth environment variables from the app process or the user's zsh/bash startup files, including Anthropic API/base URL values. OpenCode launches do the same for common provider variables and any `{env:NAME}` placeholders found in OpenCode config files, so company proxy and API-key setups that work in Terminal also work when Tolaria is opened from Finder or Dock. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation. + +CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior. + +Renderer-facing CLI setup errors can carry a `tolaria:i18n-error:` JSON marker with a translation key and primitive interpolation values. `localizedStreamError.ts` resolves that marker through the active app locale before the AI panel appends stream failure text, so native adapters such as Pi can keep CLI diagnostics in Rust while user-facing setup copy remains in the JSON catalogs. + +#### Agent Event Flow + +```mermaid +sequenceDiagram + participant U as User (AiPanel) + participant FE as useCliAiAgent (Frontend) + participant R as ai_agents.rs (Rust) + participant C as Selected CLI Agent + participant V as Vault (MCP) + + U->>FE: sendMessage(text, references) + FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) + FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode, eventName}) + R->>R: pick adapter for claude_code, codex, opencode, pi, antigravity, kiro, or hermes + R->>C: spawn agent with MCP-enabled config + R->>R: register child under scoped eventName + + loop Normalized stream + C-->>R: Claude NDJSON, Codex JSONL, Copilot text, OpenCode JSON, Pi JSON, Antigravity text, Kiro text, or Hermes text events + R-->>FE: emit(eventName, event) + alt TextDelta + FE->>FE: accumulate response (revealed on Done) + else ThinkingDelta + FE->>FE: show reasoning block (collapses on first text) + else ToolStart + FE->>FE: add AiActionCard with spinner + else ToolDone + FE->>FE: update card with output + else Done + FE->>FE: reveal full response + FE->>FE: detect file operations → reload vault if needed + end + end + + opt User stops response + U->>FE: click stop + FE->>R: invoke('abort_ai_agent_stream', {eventName}) + R->>C: kill registered child if still running + FE->>FE: mark response stopped and return composer to idle + end + + C->>V: MCP tool calls (search_notes, read_note, edit_note…) + V-->>C: tool results +``` + +#### File Operation Detection + +When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh. + +### Context Building + +The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the active note and linked entries: + +```json +{ + "activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" }, + "openTabs": [{ "path", "title", "type", "frontmatter" }], + "noteList": [{ "path", "title", "type" }], + "vault": { "types", "totalNotes" }, + "referencedNotes": [{ "title", "path", "type" }] +} +``` + +Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP. + +### Direct Model Targets + +Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints. + +Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication. + +### Authentication + +Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login or user-managed Anthropic/provider environment variables; Codex surfaces a friendly prompt to run `codex login` when needed; GitHub Copilot surfaces a friendly prompt to run `copilot login`, open `copilot` in the vault, and trust the directory when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup. + +## MCP Server + +The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Antigravity CLI, Cursor, or any MCP-compatible client). +The stdio entrypoint and desktop WebSocket bridge share `mcp-server/tool-service.js` for mounted-vault resolution, note lookup/search, note creation defaults, vault listing, and UI action intents; `index.js` and `ws-bridge.js` only adapt those semantics to their transport-specific request and response shapes. + +### Tool Surface + +| Tool | Params | Description | +|------|--------|-------------| +| `open_note` | `path` | Open and read a note by relative path | +| `read_note` | `path` | Read note content (alias for `open_note`) | +| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files | +| `search_notes` | `query, [limit]` | Search notes by title or content substring | +| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions | +| `append_to_note` | `path, text` | Append text to end of existing note | +| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter | +| `delete_note` | `path` | Delete a note file from the vault | +| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter | +| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type | +| `vault_context` / `get_vault_context` | `[vaultPath]` | Get mounted-vault summary: entity types, folders, recent notes, and root `AGENTS.md` instructions | +| `ui_open_note` | `path` | Open a note in the Tolaria UI editor | +| `ui_open_tab` | `path` | Open a note in a new UI tab | +| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) | +| `ui_set_filter` | `type` | Set the sidebar filter to a specific type | + +### Transports + +- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`) +- **WebSocket** — live bridge for Tolaria app integration: + - Port **9710**: Tool bridge — AI/Claude clients call vault tools here + - Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools + +### Explicit External Tool Setup + +Tolaria can register itself as an MCP server in: +- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups) +- `~/.gemini/config/mcp_config.json` (Antigravity CLI) +- `~/.cursor/mcp.json` (Cursor) +- `~/.config/mcp/mcp.json` (generic MCP-compatible clients) +- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key) + +That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Antigravity/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ as fallback) before writing config so external clients are not left pointing at a missing binary, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable and transient client-facing MCP entries convert Windows `mcp-server/index.js` paths from Rust's extended-length `\\?\` spelling back to normal drive or UNC spelling before passing the script argument to Node. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the MCP server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The generated standard entry is exposed as an `mcpServers` manual JSON snippet, while OpenCode gets a separate top-level `mcp` snippet with a `command` array through `get_opencode_mcp_config_snippet`; both appear in the MCP setup dialog and copy through the native clipboard path. In the desktop app, `useMcpStatus` copies those snippets through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Antigravity CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Antigravity sessions use workspace MCP config and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the runtime process instead of keeping it alive in the background. + +### Architecture + +```mermaid +flowchart TD + subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"] + IDX["index.js"] + SVC["tool-service.js\n(vault resolution, note operations,\nUI action intents)"] + VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"] + WSB["ws-bridge.js"] + + IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Antigravity / OpenCode"] + IDX --> SVC + SVC --> VAULT + IDX -.->|"UI action WebSocket client"| WSB + WSB --> SVC + WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"] + WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"] + end + + TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with VAULT_PATHS"| MCP + UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json\n~/.config/opencode/opencode.json"] +``` + +### WebSocket Bridge + +```mermaid +flowchart LR + FE["Frontend\n(useMcpBridge)"] <-->|"ws://localhost:9710"| WSB["ws-bridge.js"] + WSB <--> VAULT["vault.js"] + STDIO["MCP stdio tools"] <-->|"ws://localhost:9711"| FE2["Frontend UI actions\n(useAiActivity)"] +``` + +**Tool bridge protocol** (port 9710): +- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }` +- Response: `{ "id": "req-1", "result": { ... } }` + +**UI bridge protocol** (port 9711): +- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }` +- `useAiActivity` hook receives these and applies them (highlight with 800ms feedback, open note, set filter, etc.) + +### Rust MCP Module + +`src-tauri/src/mcp.rs` manages the MCP server lifecycle: + +| Function | Purpose | +|----------|---------| +| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` env | +| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes | +| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path | +| `register_mcp(vault_path)` | Resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ fallback), resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Antigravity CLI, Cursor, OpenCode, and generic MCP configs on user request | +| `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files | +| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Antigravity CLI, Cursor, OpenCode, and generic MCP configs | +| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) | + +The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens. + +## Search + +Search is keyword-based, using `walkdir` to scan all `.md` files in the vault directory. No external binary or indexing step required. + +- Matches query against file titles and content (case-insensitive) +- Scores results: title matches ranked higher than content-only matches +- Extracts contextual snippets around the first match +- Skips hidden files + +The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score. + +The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend body-content hits use `search_vault` with frontmatter excluded before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row. + +## Vault Cache System + +The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates. + +### Cache File + +`~/.laputa/cache/.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v14 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem. + +`/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash. + +### Three Cache Strategies + +```mermaid +flowchart TD + A([scan_vault_cached]) --> B{Cache exists\nand valid?} + B -->|No / Corrupt| C["🔴 Full Scan\nwalkdir all .md files\n→ full parse"] + B -->|Yes| D{Git HEAD\nmatches cache?} + D -->|Same commit| E["🟢 Cache Hit\ngit status --porcelain\n→ re-parse only uncommitted changes"] + D -->|Different commit| F["🟡 Incremental Update\ngit diff old..new --name-only\n→ selective re-parse of changed files"] + + C --> G[Replace cache if unchanged\nwriter lock + temp file → rename] + E --> G + F --> G + G --> H([VaultEntry list ready]) +``` + +## Styling + +The app uses internal app-owned light and dark themes with an optional System preference (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app. + +1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`. +2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables. +3. **Theme runtime**: Applies resolved `light` / `dark` values to `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode or System-on-dark is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value; `system` subscribes to `prefers-color-scheme` changes at runtime while explicit Light/Dark remain overrides. + +## Localization + +Tolaria's app chrome uses an app-owned localization runtime in `src/lib/i18n.ts`, backed by flat JSON catalogs in `src/lib/locales/` and Lara CLI synchronization through `lara.yaml` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)). `en.json` is the canonical source catalog, locale files are one file per locale, and English remains the fallback for any missing locale file or key. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Legacy stored values such as `zh-Hans` are normalized to canonical locale codes like `zh-CN`. + +`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language. + +`App.tsx` also resolves the installation-local date display format from `settings.date_display_format` and publishes it through `AppPreferencesProvider` in `src/hooks/useAppPreferences.ts`. Note rows, note-list property chips, inspector property cells, note info, table-of-contents metadata, and search result subtitles read that shared preference and render dates through `src/utils/dateDisplay.ts` so the visible style stays consistent. Date picker text entry remains ISO (`YYYY-MM-DD`) to preserve predictable manual input and frontmatter storage. + +## Vault Management + +### Vault List + +Persisted at `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade): +```json +{ + "vaults": [{ "label": "My Vault", "path": "/path/to/vault" }], + "active_vault": "/path/to/vault", + "hidden_defaults": [] +} +``` + +Managed by `useVaultSwitcher` hook. Switching vaults resets sidebar and clears the active note. + +### Vault Config + +Per-vault UI settings stored locally per vault path (currently in browser/Tauri localStorage, not synced via git): +- `zoom`: Float zoom level (0.8–1.5) +- `view_mode`: "all" | "editor-list" | "editor-only" +- `editor_mode`: "raw" | "preview" (persists across note switches and sessions) +- `note_layout`: "centered" | "left" (wide-screen note column alignment for rich and raw editors) +- `tag_colors`, `status_colors`: Custom color overrides +- `property_display_modes`: Property display preferences +- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list +- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list +- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection +- `git_setup_preference`: `"never"` when the user has opted out of future automatic Git setup prompts for that vault + +### Getting Started Vault + +On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options: +- **Create a new vault** → creates an empty git repo in a folder the user chooses +- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode +- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately + +If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault. + +When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git. + +When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures Git can resolve an author identity without overriding one the user configured: it heals the legacy repo-local `vault@tolaria.md` email earlier versions wrote, respects identities resolvable from local, global, or system scope, skips the legacy email wherever it resolves, and only when nothing resolves writes a repo-local `Tolaria ` fallback. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. + +Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Kiro, and Hermes Agent CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. + +`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone. + +The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. Once a user edits a usable `AGENTS.md`, including changing its frontmatter `type`, the status command treats it as custom guidance instead of broken; repair remains reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition. + +After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow. + +### Remote Clone & Auth Model + +Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration. Git subprocesses first honor an installation-local `git_path` in `settings.json` when it points at an existing executable. On macOS, they then prefer the user's login-shell `git` and `PATH`, fall back to standard Apple/Homebrew locations such as `/opt/homebrew/bin/git`, `/usr/local/bin/git`, and `/usr/bin/git`, and only then use the inherited `PATH`. This keeps Homebrew/Xcode Git, Git Credential Manager, and `git-credential-osxkeychain` resolving the same way they do in Terminal even when Tolaria is launched from Finder, Dock, or Homebrew Cask. + +**Flow:** +1. User opens `CloneVaultModal` from onboarding or the vault menu +2. User pastes a supported remote URL (`https://`, `http://`, `ssh://`, or `git@host:path`) and chooses a local destination +3. The `clone_git_repo()` Tauri command validates that URL, then runs `git clone -- ` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones +4. Linux AppImage builds strip AppImage loader variables from system-git and MCP Node subprocesses before spawning them, keeping `git-remote-https` and system `node` on the host library stack +5. `git_push()` / `git_pull()` continue to use the same system git path +6. On macOS, `git_add_remote()` asks Git's credential helper for HTTPS credentials before the first fetch so Keychain can grant access to the same saved credential item the shell uses +7. Every app-managed git subprocess disables the `ext::` transport, keeps file transport at Git's user-initiated policy, disables repo-configured fsmonitor hooks, ignores repo-configured SSH command overrides, and preserves quoted path output +8. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input + +**Auth model:** +- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup +- No provider tokens are stored in Tolaria settings +- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes over HTTPS or SSH + +## Pulse View + +`PulseView` is a git activity feed that replaces the NoteList when the Pulse filter is selected. + +- Groups commits by day ("Today", "Yesterday", or full date) +- Shows commit message, short hash, timestamp, and changed files +- Files have status icons (added/modified/deleted) and are clickable to open in editor +- Links to GitHub commits when `githubUrl` is available +- Infinite scroll pagination (20 commits per page) via Intersection Observer + +Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`. + +## Data Flow + +### Startup Sequence + +```mermaid +sequenceDiagram + participant T as Tauri (Rust) + participant A as App.tsx + participant VL as useVaultLoader + participant MCP as MCP Server + participant U as User + + T->>T: apply Linux WebKit env safeguards
(Wayland/AppImage) + T->>T: start background legacy vault housekeeping
(does not block setup) + T->>MCP: start background initial ws-bridge sync
(if active vault exists) + T->>A: App mounts + + A->>A: useOnboarding — vault exists? + alt Vault missing + A-->>U: WelcomeScreen + else Vault found + A->>VL: useVaultLoader fires + VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached() + T-->>VL: VaultEntry[] + VL->>T: invoke('get_modified_files') + alt Runtime vault path disappears + VL->>T: invoke('check_vault_exists') + VL-->>A: unavailable vault path + cleared stale state + A-->>U: WelcomeScreen (vault missing) + end + A->>T: useMcpStatus — check explicit MCP setup state + A->>T: sync_mcp_bridge_vault(selected path) + VL-->>A: entries ready + end + + U->>A: clicks note in NoteList + A->>T: invoke('get_note_content') + T-->>A: raw markdown + A->>A: resolveBlocksForTarget(path, raw markdown) + alt tab cache or parsed-block cache hit + A->>A: reuse exact-source BlockNote blocks + else large common Markdown + A->>A: direct Markdown-to-block parser + else unsupported Markdown or cold small note + A->>A: tryParseMarkdownToBlocks() + end + A->>A: inject wikilinks, math, and durable schema blocks + A-->>U: Editor renders note +``` + +### Auto-Save Flow + +```mermaid +flowchart LR + A["✏️ Editor content changes"] --> B["useEditorSave\n(debounced)"] + B --> C["restore durable Markdown tokens"] + C --> D["direct block Markdown serializer\nor BlockNote fallback"] + D --> E["invoke('save_note_content')"] + E --> F["💾 Disk write"] + F --> G["Update tab status indicator"] +``` + +### Git Sync Flow + +```mermaid +flowchart TD + AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"] + PULL --> PC{Result?} + PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"] + PC -->|Fast-forward| RV["reload vault + folders/views"] + RV --> TAB{"clean active tab?"} + TAB -->|Yes| RT["replace active tab\nwith fresh disk content"] + TAB -->|No| DONE["idle"] + RT --> RF["restore editor focus\nwhen previously focused"] + RF --> DONE + PC -->|Manual up to date| RV + PC -->|Auto up to date| DONE["idle"] + + MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"] + RS --> RCHK["invoke('git_remote_status')"] + RCHK --> RMODE{Remote configured?} + RMODE -->|No| GC["invoke('git_commit', message)"] + GC --> LOCAL["Local commit only\nNo remote chip + local toast"] + RMODE -->|Yes| GC2["invoke('git_commit', message)"] + GC2 --> GP["invoke('git_push')"] + GP --> PR{Push result?} + PR -->|ok| RM["Reload modified files"] + PR -->|rejected| DIV["syncStatus = pull_required"] + DIV -->|User clicks badge| PAP["pullAndPush()"] + PAP --> PULL2["invoke('git_pull')"] + PULL2 --> GP2["invoke('git_push')"] + GP2 --> RM + + CMD["Cmd+K → Pull\nor Menu → Pull"] --> PULL + STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, upstream, ahead/behind)"] +``` + +Manual Sync resolves the current branch's configured upstream and pulls that remote/branch explicitly, so non-`main` vault branches and local branches that track differently named remote branches follow normal Git tracking configuration instead of assuming `origin/main`. Missing upstreams and detached HEAD states return actionable sync errors while leaving branch creation, checkout, and tracking setup to external Git tooling. Manual Sync still forces a visible-state refresh even when `git_pull` reports `up_to_date`, because the working tree may have already changed through another process while the app still holds stale vault and History state. Updated pulls refresh the vault index, folders, saved views, clean active-editor content, and Git history surfaces; manual up-to-date pulls refresh the vault/sidebar surfaces with unknown changed files and bump the History refresh key without showing a "Pulled 0 updates" toast. Automatic mount/focus/interval up-to-date checks stay cheap and do not reload the vault. + +`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted. + +The manual commit dialog can also draft an editable commit message before submit. `useCommitFlow.generateCommitMessageForDialog()` saves pending edits, reloads the selected repository with `get_modified_files(includeStats: true)`, and fills the textarea without invoking `git_commit`. When the active AI target is a configured direct model and AI features are enabled, Tolaria sends bounded path/status/line-count metadata only; agent targets, unavailable/offline AI, large omitted paths, and model failures fall back to the deterministic changed-file summary. The command palette action "Generate Commit Message from Diff" opens the same dialog and focuses the generated message field for keyboard editing. + +If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders. + +The installation-local `git_enabled` setting is a broader visibility switch. When it is `false`, Tolaria hides Git status-bar entries and Git command-palette actions completely, disables AutoGit controls in Settings, and prevents background Git refresh/sync work even for repositories that are otherwise Git-backed. Settings remains the re-enable path. + +The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command validates the pasted remote URL, ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds. + +`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the default vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. In multiple-workspace mode, that checkpoint reads, commits, and pushes every active repository independently; one failed or rejected repository does not prevent the remaining repositories from being attempted. The manual commit dialog remains single-repository and requires the user to choose the target repository when more than one is active. + +#### Sync States + +| State | Indicator | Color | Trigger | +|-------|-----------|-------|---------| +| `idle` | Synced / Synced Xm ago | green | Successful sync | +| `syncing` | Syncing... | blue | Pull/push in progress | +| `pull_required` | Pull required | orange | Push rejected (divergence) | +| `conflict` | Conflict | orange | Merge conflicts detected | +| `error` | Sync failed | grey | Network/auth error | + +## Vault Module Structure + +The vault backend (`src-tauri/src/vault/`) is split into focused submodules: + +| File | Purpose | +|------|---------| +| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction | +| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` | +| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow | +| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | +| `ignored.rs` | Gitignored-content visibility filtering via batched, pipe-safe `git check-ignore` | +| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames | +| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures | +| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames | +| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` | +| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` | +| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault | + +## Rust Backend Modules + +| Module | Purpose | +|--------|---------| +| `vault/` | Vault scanning, caching, parsing, rename, image, migration | +| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) | +| `git/` | Git operations and shared system-git helpers (`command.rs`, `remote_config.rs`, `commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) | +| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering | +| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch | +| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers | +| `claude_cli.rs`, `codex_cli.rs`, `copilot_cli.rs`, `opencode_cli.rs`, `pi_cli.rs`, `antigravity_cli.rs` | CLI-agent command/config/event adapters | +| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, user-config-seeded transient MCP adapter config, discovery, and JSON stream parsing | +| `mcp.rs` | MCP server spawning + explicit config registration/removal | +| `commands/` | Tauri command handlers (split into submodules) | +| `settings.rs` | App settings persistence | +| `vault_config.rs` | Per-vault UI config | +| `vault_list.rs` | Vault list persistence | +| `menu.rs` | Native desktop menu definitions and command IDs (not mounted on Linux) | + +## Tauri IPC Commands + +### Vault Operations + +| Command | Description | +|---------|-------------| +| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility → `Vec` | +| `get_note_content` | Read note file content | +| `save_note_content` | Write note content to disk | +| `delete_note` | Permanently delete note from disk (with confirm dialog) | +| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts | +| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks | +| `create_vault_folder` | Create a folder relative to the active vault root | +| `list_vault_folders` | Build the folder tree on the blocking Tokio pool, then apply Gitignored-content visibility → `Vec` | +| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths | +| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root | +| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow | +| `batch_archive_notes` | Archive multiple notes | +| `batch_delete_notes` | Permanently delete notes from disk | +| `reload_vault` | Allow the requested vault roots in the runtime asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec` | +| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | +| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app | +| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events | +| `check_vault_exists` | Check if vault path exists | +| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults | +| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean | +| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom | +| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones | + +### Frontmatter + +| Command | Description | +|---------|-------------| +| `update_frontmatter` | Update a frontmatter property | +| `delete_frontmatter_property` | Remove a frontmatter property | + +### Git + +| Command | Description | +|---------|-------------| +| `init_git_repo` | Initialize a local repo, add default `.gitignore`, and create the unsigned setup commit | +| `git_commit` | Stage all + commit | +| `git_pull` | Pull from remote | +| `git_push` | Push to remote | +| `git_remote_status` | Get branch name + ahead/behind counts | +| `git_file_url` | Build a remote-backed browser/git URL for a vault file | +| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it | +| `git_resolve_conflict` | Resolve a merge conflict | +| `git_commit_conflict_resolution` | Commit conflict resolution | +| `get_file_history` | Last N commits for a file | +| `get_modified_files` | `git status` filtered to .md | +| `get_file_diff` | Unified diff for a file | +| `get_file_diff_at_commit` | Diff at a specific commit | +| `get_conflict_files` | List conflicted files | +| `get_conflict_mode` | Get conflict resolution mode | +| `get_vault_pulse` | Git activity feed (paginated) | +| `get_last_commit_info` | Latest commit metadata | +| `clone_repo` | Clone a remote repository into a local folder using system git | + +### Search + +| Command | Description | +|---------|-------------| +| `search_vault` | Keyword search across vault files | + +### Vault Maintenance + +| Command | Description | +|---------|-------------| +| `get_vault_settings` | Read `.laputa/settings.json` | +| `save_vault_settings` | Write vault settings | +| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` | + +### AI & MCP + +| Command | Description | +|---------|-------------| +| `stream_claude_chat` | Claude CLI chat mode (streaming) | +| `check_claude_cli` | Check if Claude CLI is available | +| `get_ai_agents_status` | Check Claude Code + Codex + GitHub Copilot + OpenCode + Pi + Antigravity + Kiro + Hermes Agent availability | +| `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts | +| `stream_ai_agent` | Stream Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Kiro, or Hermes Agent through the normalized agent event layer | +| `abort_ai_agent_stream` | Stop an active app-managed CLI agent stream by its validated request-scoped event name | +| `register_mcp_tools` | Register vault-neutral MCP in Claude/Antigravity/Cursor/OpenCode/generic config | +| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Antigravity/Cursor/OpenCode/generic config | +| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Antigravity/Cursor/OpenCode/generic config | +| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault | +| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path | +| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste | +| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected | + +The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly. + +### Settings & Config + +| Command | Description | +|---------|-------------| +| `get_settings` | Load app settings | +| `save_settings` | Save app settings | +| `load_vault_list` | Load vault list | +| `save_vault_list` | Save vault list | +| `get_vault_config` | Load per-vault UI config | +| `save_vault_config` | Save per-vault UI config | +| `get_default_vault_path` | Get default vault path | +| `get_build_number` | Get app build number | +| `save_image` | Save base64 image to `attachments/` and ensure the vault root is in the runtime asset scope | +| `copy_image_to_vault` | Copy image file to `attachments/` and ensure the vault root is in the runtime asset scope | +| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions | +| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA | +| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes | + +`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, formats calendar alpha builds as `Alpha YYYY.M.D.N`, strips any calendar `-stable.N` suffix back to `YYYY.M.D`, and keeps legacy semver releases readable instead of falling back to `?`. + +## Mock Layer + +When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer: + +```typescript +if (isTauri()) { + result = await invoke('command_name', { args }) +} else { + result = await mockInvoke('command_name', { args }) +} +``` + +The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits. It also tracks per-vault remote state so browser-mode Getting Started and empty-vault flows now behave like the desktop app: local-only until `git_add_remote` succeeds. + +Browser smoke tests can also override `window.__mockHandlers` before the app boots. The AutoGit smoke bridge uses that path directly for seeded saves so the mocked git dirty-state stays synchronized even when the optional browser vault API is serving note content. + +## State Management + +No Redux or global context. State lives in the root `App.tsx` and custom hooks: + +| State owner | State | Purpose | +|-------------|-------|---------| +| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state | +| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data | +| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` | +| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current | +| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` | +| `useAppWindowControls` | view mode, panel visibility, command refs, zoom/build labels | Keeps main-window sizing and editor command ref plumbing out of `App.tsx` | +| `useAppViewActions` | saved-view/type creation and saved-view mutation callbacks | Keeps saved-view persistence and Type auto-creation orchestration out of `App.tsx` | +| `useAiWorkspacePublishedContext` | AI workspace note-list snapshot and BroadcastChannel context publishing | Keeps AI workspace context derivation close to its cross-window publication side effect | +| `useMcpSetupDialogController` | MCP setup dialog state/actions | Keeps MCP status, manual config, and connect/disconnect dialog flow out of `App.tsx` | +| `useAiWorkspaceWindowBridgeEvents` | native AI workspace event subscriptions | Owns Tauri listener setup/cleanup for popped-out workspace window bridge events | +| `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects | +| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback | +| `useNoteCreation` | — | Note/type creation with optimistic persistence | +| `useNoteRename` | — | Note renaming and folder moves with wikilink update | +| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions | +| `useDeepLinks` | Deep-link listener and copy actions | Resolves `tolaria://` links into known vault navigation and clipboard URLs | +| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup | +| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs | +| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch | +| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | +| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | +| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime | +| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode | +| `useAutoSync` | Sync interval, pull/push state | Git auto-sync | +| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints | +| `useCommitFlow` | Commit dialog state, generated commit-message drafts, shared manual/automatic checkpoint runner | Git commit/push orchestration | +| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | +| `useUnifiedSearch` | Query, results, loading state | Keyword search | +| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, Git visibility, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings | +| `useVaultConfig` | Per-vault UI preferences, Git setup prompt preference, AI permission mode | Vault-specific config | +| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands | + +Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`. + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Cmd+K | Open command palette | +| Cmd+P / Cmd+O | Open quick open palette | +| Cmd+N | Create new note | +| Cmd+S | Save current note | +| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it | +| Cmd+Shift+F | Find in vault | +| Cmd+Shift+V | Paste without Formatting into the active supported editing surface | +| Cmd+Shift+M | Toggle Markdown highlight on selected rich-editor text | +| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) | +| Cmd+Z / Cmd+Shift+Z | Undo / Redo | +| Cmd+1–9 | Switch to tab N | +| Cmd+[ / Cmd+] | Navigate back / forward | +| `[[` in editor | Open wikilink suggestion menu | + +Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Remote-backed notes expose "Copy git URL" from the breadcrumb overflow and note-list context menu; the renderer gates the action per note workspace via `git_remote_status`, then asks `git_file_url` to build the copied URL from the primary remote, current branch, and vault-relative path. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths. + +Shortcut routing is explicit: + +- `src/shared/appCommandManifest.json` is the shared command/menu metadata source for command IDs, menu labels, accelerators, native-menu enablement groups, and deterministic QA flags +- `appCommandCatalog.ts` derives renderer command IDs, shortcut lookup maps, Linux menu sections, and QA metadata from that manifest +- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators +- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs +- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path +- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control +- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active +- Rich-editor inline formatting shortcuts that are owned by BlockNote or editor extensions stay local to the editor surface; `createMarkdownHighlightShortcutExtension()` handles Cmd/Ctrl+Shift+M and toggles the durable `==highlight==` style without entering the app-command manifest. +- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions +- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once +- Deterministic QA uses two explicit proof paths from the shared manifest: + - renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()` + - native menu-command proof through `trigger_menu_command` +- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical + +## Auto-Release & In-App Updates + +### Release Pipeline + +Every push to `main` triggers `.github/workflows/release.yml`: + +``` +push to main + → version job: compute calendar alpha version YYYY.M.D-alpha.N + and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN + → use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today + → if stable already uses today, advance alpha to the next calendar day so semver still increases + → build-artifacts job, via `.github/workflows/release-build-artifacts.yml`: + → pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app + → pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app + → upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria__macOS_Silicon and Tolaria__macOS_Intel + → pnpm install, stamp version + → tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage + → verify Linux installer and updater-signature artifacts exist + → upload .deb, .rpm, .AppImage, and signed Linux updater bundles + → pnpm install, stamp version, optionally import the Windows code-signing certificate + → tauri build --target x86_64-pc-windows-msvc --bundles nsis, with Authenticode signing config only when certificate secrets are present + → verify Windows app executable and installer Authenticode signatures when Authenticode signing ran + → upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles + → release job: + → generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs + → publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N + → pages job: + → build VitePress public docs into the GitHub Pages root + → build static HTML release history page at /releases/ + → publish alpha/latest.json + → refresh latest.json + latest-canary.json as compatibility aliases to alpha + → preserve stable/latest.json + → deploy to gh-pages +``` + +Stable promotions trigger `.github/workflows/release-stable.yml`: + +``` +push stable-vYYYY.M.D tag + → version job: validate YYYY.M.D from the tag + → build-artifacts job, via `.github/workflows/release-build-artifacts.yml`: + → pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin + → pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin + → upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria__macOS_Silicon and Tolaria__macOS_Intel + → pnpm install, stamp version + → tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage + → verify Linux installer and updater-signature artifacts exist + → upload .deb, .rpm, .AppImage, and signed Linux updater bundles + → pnpm install, stamp version, optionally import the Windows code-signing certificate + → tauri build --target x86_64-pc-windows-msvc --bundles nsis, with Authenticode signing config only when certificate secrets are present + → verify Windows app executable and installer Authenticode signatures when Authenticode signing ran + → upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles + → release job: + → generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs + → publish GitHub release Tolaria YYYY.M.D + → pages job: + → build VitePress public docs into the GitHub Pages root + → build static HTML release history page at /releases/ + → publish stable/latest.json + → publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, require an explicit Windows installer click with managed-device guidance, and expose RPM as a manual Linux option when the stable release includes one + → preserve alpha/latest.json + → deploy to gh-pages +``` + +Linux AppImage release jobs use Tauri's stock linuxdeploy AppImage output plugin. `scripts/appimage-launcher-tools.mjs` remains available for local experiments with symlink-safe AppRun patching and fcitx module bundling, but release packaging does not pre-seed that shim because linuxdeploy currently exits before sealing the AppImage when the shim replaces the stock output plugin in Tauri's tools cache. + +### Versioning + +- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`. +- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological. +- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches. +- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time. +- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha. + +### In-App Updates + +``` +App startup (3s delay) + → useUpdater.check() + → idle (no update) → no UI + → available → UpdateBanner with release notes + "Update Now" + → downloading → progress bar + → ready → "Restart to apply" + Restart Now + → network error → fail silently +``` + +### Telemetry (Opt-in) + +Anonymous crash reporting (Sentry) and usage analytics (PostHog), both **opt-in only**. + +```mermaid +sequenceDiagram + participant User + participant App + participant Settings + participant Sentry + participant PostHog + + Note over App: First launch or upgrade + App->>User: TelemetryConsentDialog + alt Accept + User->>Settings: telemetry_consent=true, anonymous_id=UUID + Settings->>Sentry: init(DSN, release, anonymous_id) + Settings->>PostHog: init(key, anonymous_id) + else Decline + User->>Settings: telemetry_consent=false + Note over Sentry,PostHog: Zero network requests + end + + Note over App: Settings panel toggle change + User->>Settings: crash_reporting_enabled=false + Settings->>Sentry: teardown() + Settings->>App: reinit_telemetry (Tauri cmd) +``` + +**Privacy guarantees:** +- No vault content, note titles, or file paths in payloads (regex scrubber in `beforeSend`) +- `anonymous_id` is a locally-generated UUID, never tied to identity +- `send_default_pii: false` on both SDKs +- PostHog: `autocapture: false`, `persistence: 'memory'`, no cookies +- Product events use categorical metadata only: file preview kind/action, AI agent id/permission mode/counts/status, update-check preference state, and All Notes visibility category/enabled state. + +**Architecture:** +- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()` +- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled. +- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics. +- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct +- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null` + +### Updates + +Tolaria uses the Tauri updater plugin for automatic updates: + +- `src-tauri/tauri.conf.json` points the default desktop feed at `stable/latest.json` +- `useUpdater(releaseChannel, automaticChecksEnabled)` waits 3 seconds after launch when automatic checks are enabled, then calls Rust commands instead of hard-coding one updater endpoint in the frontend +- `automatic_update_checks_enabled` is an installation-local Settings flag that defaults to enabled; disabling it skips only the startup/background probe, not manual status-bar update checks +- `src-tauri/src/app_updater.rs` maps the selected channel to `alpha/latest.json` or `stable/latest.json` +- `download_and_install_app_update` streams progress events back into `UpdateBanner` + +### Feature Flags (PostHog + Release Channels) + +Feature flags are backed by PostHog and evaluated per release channel: + +- **Alpha**: all features always enabled (no PostHog lookup) +- **Stable** (default): PostHog rules decide which features are enabled +- **Beta cohorts**: modeled in PostHog as tags or person-property targeting, not as a separate updater build or Settings option + +```typescript +import { useFeatureFlag } from './hooks/useFeatureFlag' + +const enabled = useFeatureFlag('example_flag') // boolean +``` + +**Resolution order:** +1. `localStorage` override: key `ff_` with value `"true"` or `"false"` +2. `isFeatureEnabled(flag)` in `telemetry.ts` → Alpha short-circuit, then PostHog, then hardcoded defaults + +**How to add a new flag:** +1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts` +2. Create the flag on PostHog with Stable rollout rules and any optional beta-cohort targeting +3. Use `useFeatureFlag('your_flag')` in components + +Release channel is selectable in Settings as `alpha` or `stable` and passed to PostHog as a person property via `identify()`. Beta targeting is managed in PostHog, not in the updater settings. See ADR-0057. + +## Platform Support — iOS / iPadOS (Prototype) + +Tauri v2 supports iOS as a beta target. The Rust backend cross-compiles to `aarch64-apple-ios-sim` (simulator) and `aarch64-apple-ios` (device) with zero code changes to vault/frontmatter/search logic. + +**Conditional compilation strategy:** + +``` +#[cfg(desktop)] — git CLI, menu bar, MCP server, CLI AI agents, updater +#[cfg(mobile)] — stub commands returning graceful errors or empty results +``` + +Desktop-only modules gated at the crate level: +- `pub mod menu` — macOS menu bar (entire module) + +Desktop-only features gated at the function level in `commands/`: +- Git operations (commit, pull, push, status, history, diff, conflicts) +- Clone-by-URL via system git (`clone_repo`) +- CLI AI agent streaming (Claude, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Kiro) +- MCP registration and status +- Menu state updates + +Features that work on both platforms without changes: +- Vault scan, note read/write, rename, delete, archive +- Frontmatter read/write/delete +- AI chat (Anthropic API via `reqwest`) +- Search (pure Rust in-memory) +- Settings persistence +- Vault list management + +**Capabilities:** `src-tauri/capabilities/default.json` targets desktop; `mobile.json` targets iOS/Android with a minimal permission set. + +**Detailed feasibility report:** `docs/IPAD-PROTOTYPE.md` diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md new file mode 100644 index 0000000..628a158 --- /dev/null +++ b/docs/GETTING-STARTED.md @@ -0,0 +1,534 @@ +# Getting Started + +How to navigate the codebase, run the app, and find what you need. + +## Prerequisites + +- **Node.js** 18+ and **pnpm** +- **Rust** 1.77.2+ (for the Tauri backend) +- **git** CLI (required by the git integration features; Windows users may choose native Git or WSL2 Git in Settings) + +### Linux system dependencies + +If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies first: + +- Arch / Manjaro: + ```bash + sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \ + appmenu-gtk-module libappindicator-gtk3 librsvg + ``` +- Debian / Ubuntu (22.04+): + ```bash + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libsoup-3.0-dev patchelf + ``` +- Fedora 38+: + ```bash + sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \ + libappindicator-gtk3-devel librsvg2-devel + ``` + +### Linux AppImage Wayland troubleshooting + +On some Wayland systems, the Linux AppImage may fail to launch with: + +```text +Could not create default EGL display: EGL_BAD_PARAMETER. Aborting... +``` + +Recent Tolaria Linux builds automatically disable the unstable WebKitGTK DMABUF renderer on native Wayland launches. AppImage launches also disable WebKitGTK compositing as a last-resort sealed-runtime fallback and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround: + +```bash +WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage +``` + +If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning. + +### Linux AppImage packaging checks + +Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin: + +```bash +pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage +``` + +Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. Windows release jobs always require Tauri updater signatures; when Authenticode certificate secrets are configured, they also import the CI code-signing certificate, build NSIS with a generated Tauri Authenticode signing config, and verify the app executable plus installer signatures before upload. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache. + +## Quick Start + +```bash +# Install dependencies +pnpm install + +# Run in browser (no Rust needed — uses mock data) +pnpm dev +# Open http://localhost:5173 + +# Run with Tauri (full app, requires Rust) +pnpm tauri dev + +# Run tests +pnpm test # Vitest unit tests +cargo test # Rust tests (from src-tauri/) + +# Or, run Rust tests from root project directory +cargo test --manifest-path src-tauri/Cargo.toml + +# E2E tests +pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min) +pnpm playwright:regression # Full Playwright regression suite +``` + +## Chunk Sidecar Validation + +The experimental `.chunk/config.json` mirrors the portable parts of the local git hook gate as named validations. Use it for inner-loop checks before running the full pre-push hook: + +```bash +chunk validate --list +chunk validate lint +chunk validate typecheck +chunk validate frontend-coverage +``` + +Remote sidecar validation requires CircleCI authentication: + +```bash +chunk auth set circleci +chunk sidecar setup --name tolaria-hooks +chunk validate --remote lint +``` + +For Playwright smoke, prefer the shared-server shard runner after setup: + +```bash +chunk sidecar ssh --sidecar-id -- 'cd /home/user/tolaria && PLAYWRIGHT_CONCURRENCY=4 bash .chunk/run-playwright-shards.sh 8' +``` + +The pre-push hook uses the faster sidecar path automatically when Chunk is available. It syncs the checkout to three independent sidecars and fans out the automatic gates: + +- `tolaria-hooks-frontend-2`: lint and build first, then frontend coverage. +- `tolaria-hooks-rust`: clippy, rustfmt, and Rust coverage. +- `tolaria-hooks-playwright`: curated Playwright smoke with a shared Vite server and eight shards. + +Set `LAPUTA_PREPUSH_LOCAL=1` to force the local fallback path. If CircleCI has duplicate sidecar names, pin lanes with `SIDECAR_FRONTEND_ID`, `SIDECAR_RUST_ID`, and `SIDECAR_PLAYWRIGHT_ID`. + +The sidecar is Linux-based, so keep native macOS Tauri QA and app-focus screenshot checks on the host machine. The Chunk config is intended for portable frontend, Rust, coverage, and Playwright smoke checks. Avoid starting multiple `chunk validate --remote ...` processes against the same sidecar at once; each validate run syncs the checkout, so concurrent validates can race. + +## Starter Vaults And Remotes + +`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow. + +Linux AppImage builds still use the user's system `git` and `node`. Before Tolaria spawns those Git or MCP Node subprocesses, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers and MCP tooling use the host library stack instead of bundled AppImage libraries. + +On Windows, Settings > Git exposes an explicit Git provider choice. Native Git remains the default. Selecting WSL2 Git stores the chosen distribution in app settings, launches Git through `wsl.exe --exec git`, and translates vault paths before clone, status, commit, sync, and remote operations use them. + +## Multiple Vaults At The Same Time + +The `settings.multi_workspace_enabled` flag turns the registered vault list into a unified graph. When enabled, `useVaultLoader` loads every available mounted vault, annotates entries with workspace provenance, and lets note lists, quick open, keyword search, backlinks, and wikilink navigation span those vaults. + +The selected/default vault remains the write target for new notes and Type documents when `defaultWorkspacePath` points at an available mounted vault. Git status, changes, AutoGit checkpointing, and sync operate across the active mounted repository set, while history, diff, repair, and file operations still resolve explicit repository roots from the selected surface or entry provenance. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault. + +The bottom-left `VaultMenu` exposes quick include/exclude controls and a `Manage vaults` entry. The Vaults settings section owns the full identity controls: display name, short label, read-only alias, accent color, removal, and default destination for new notes. + +## Directory Structure + +``` +tolaria/ +├── src/ # React frontend +│ ├── main.tsx # Entry point (renders ) +│ ├── App.tsx # Root component — wires layout + state hooks +│ ├── App.css # App shell layout styles +│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.) +│ ├── mock-tauri.ts # Mock Tauri layer for browser testing +│ ├── theme.json # Editor typography theme configuration +│ ├── index.css # Semantic app theme variables + Tailwind setup +│ │ +│ ├── components/ # UI components (~100 files) +│ │ ├── Sidebar.tsx # Left panel: filters + type groups +│ │ ├── SidebarParts.tsx # Sidebar subcomponents +│ │ ├── NoteList.tsx # Second panel: filtered note list +│ │ ├── NoteItem.tsx # Individual note item +│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList) +│ │ ├── Editor.tsx # Third panel: editor orchestration +│ │ ├── EditorContent.tsx # Editor content area +│ │ ├── EditorRightPanel.tsx # Right panel toggle +│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type +│ │ ├── RawEditorView.tsx # CodeMirror raw editor +│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships +│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties +│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace orchestration (docked or native window) +│ │ ├── AiWorkspaceChrome.tsx # AI workspace header and vault-guidance chrome +│ │ ├── AiWorkspaceResizeHandles.tsx # AI workspace edge resize handles +│ │ ├── AiWorkspaceSideHeader.tsx # Side-mode AI workspace chat tabs and chrome +│ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode) +│ │ ├── AiMessage.tsx # Agent message display +│ │ ├── AiActionCard.tsx # Agent tool action cards +│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt +│ │ ├── SearchPanel.tsx # Search interface +│ │ ├── SettingsPanel.tsx # App settings +│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync +│ │ ├── CommandPalette.tsx # Cmd+K command launcher +│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions +│ │ ├── WelcomeScreen.tsx # Onboarding screen +│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls +│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands +│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL +│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later +│ │ ├── ConflictResolverModal.tsx # Git conflict resolution +│ │ ├── CommitDialog.tsx # Git commit modal +│ │ ├── CreateNoteDialog.tsx # New note modal +│ │ ├── CreateTypeDialog.tsx # New type modal +│ │ ├── UpdateBanner.tsx # In-app update notification +│ │ ├── inspector/ # Inspector sub-panels +│ │ │ ├── BacklinksPanel.tsx +│ │ │ ├── RelationshipsPanel.tsx +│ │ │ ├── GitHistoryPanel.tsx +│ │ │ └── ... +│ │ └── ui/ # shadcn/ui primitives +│ │ ├── button.tsx, dialog.tsx, input.tsx, ... +│ │ +│ ├── hooks/ # Custom React hooks (~90 files) +│ │ ├── useVaultLoader.ts # Loads vault entries + content +│ │ ├── useVaultSwitcher.ts # Multi-vault management +│ │ ├── useVaultConfig.ts # Per-vault UI settings +│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter +│ │ ├── useNoteCreation.ts # Note/type creation +│ │ ├── useNoteRename.ts # Note renaming + wikilink updates +│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline +│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels +│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Antigravity/Kiro availability polling +│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling +│ │ ├── useAiActivity.ts # MCP UI bridge listener +│ │ ├── useAutoSync.ts # Auto git pull/push +│ │ ├── useConflictResolver.ts # Git conflict handling +│ │ ├── useEditorSave.ts # Auto-save with debounce +│ │ ├── useTheme.ts # Flatten theme.json → CSS vars +│ │ ├── useUnifiedSearch.ts # Keyword search +│ │ ├── useNoteSearch.ts # Note search +│ │ ├── useCommandRegistry.ts # Command palette registry +│ │ ├── useAppCommands.ts # App-level commands +│ │ ├── useAppKeyboard.ts # Keyboard shortcuts +│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata +│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch +│ │ ├── useSettings.ts # App settings +│ │ ├── useGettingStartedClone.ts # Shared Getting Started clone action +│ │ ├── useOnboarding.ts # First-launch flow +│ │ ├── useCodeMirror.ts # CodeMirror raw editor +│ │ ├── useMcpBridge.ts # MCP WebSocket client +│ │ ├── useMcpStatus.ts # Explicit external AI tool connection status + connect/disconnect actions +│ │ ├── useUpdater.ts # In-app updates +│ │ └── ... +│ │ +│ ├── utils/ # Pure utility functions (~48 files) +│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline +│ │ ├── frontmatter.ts # TypeScript YAML parser +│ │ ├── plainTextPaste.ts # Shared Paste without Formatting command target registry +│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers +│ │ ├── ai-agent.ts # Agent stream utilities +│ │ ├── ai-chat.ts # Token estimation utilities +│ │ ├── ai-context.ts # Context snapshot builder +│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting +│ │ ├── wikilink.ts # Wikilink resolution +│ │ ├── configMigration.ts # localStorage → vault config migration +│ │ ├── iconRegistry.ts # Phosphor icon registry +│ │ ├── propertyTypes.ts # Property type definitions +│ │ ├── vaultListStore.ts # Vault list persistence +│ │ ├── vaultConfigStore.ts # Vault config store +│ │ └── ... +│ │ +│ ├── lib/ +│ │ ├── aiAgents.ts # Shared agent registry + status helpers +│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands +│ │ ├── i18n.ts # App-owned localization runtime and locale resolution +│ │ ├── locales/ # JSON locale catalogs (English source + translated locales) +│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers +│ │ └── utils.ts # Tailwind merge + cn() helper +│ │ +│ └── test/ +│ └── setup.ts # Vitest test environment setup +│ +├── src-tauri/ # Rust backend +│ ├── Cargo.toml # Rust dependencies +│ ├── build.rs # Tauri build script +│ ├── tauri.conf.json # Tauri app configuration +│ ├── capabilities/ # Tauri v2 security capabilities +│ ├── src/ +│ │ ├── main.rs # Entry point (calls lib::run()) +│ │ ├── lib.rs # Tauri setup + command registration +│ │ ├── commands/ # Tauri command handlers (split into modules) +│ │ ├── vault/ # Vault module +│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault +│ │ │ ├── cache.rs # Git-based incremental caching +│ │ │ ├── parsing.rs # Text processing + title extraction +│ │ │ ├── rename.rs # Rename + cross-vault wikilink update +│ │ │ ├── image.rs # Image attachment saving +│ │ │ ├── migration.rs # Frontmatter migration +│ │ │ └── getting_started.rs # Getting Started vault clone orchestration +│ │ ├── frontmatter/ # Frontmatter module +│ │ │ ├── mod.rs, yaml.rs, ops.rs +│ │ ├── git/ # Git module +│ │ │ ├── mod.rs, command.rs, remote_config.rs, commit.rs, status.rs +│ │ │ ├── history.rs, clone.rs, connect.rs, conflict.rs, remote.rs, pulse.rs +│ │ ├── telemetry.rs # Sentry init + path scrubber +│ │ ├── search.rs # Keyword search (walkdir-based) +│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch +│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers +│ │ ├── claude_cli.rs # Claude CLI adapter +│ │ ├── codex_cli.rs # Codex CLI adapter +│ │ ├── pi_cli.rs # Pi CLI adapter +│ │ ├── kiro_cli.rs # Kiro CLI adapter +│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal +│ │ ├── app_updater.rs # Alpha/stable updater metadata resolution +│ │ ├── settings.rs # App settings persistence +│ │ ├── vault_config.rs # Per-vault UI config +│ │ ├── vault_list.rs # Vault list persistence +│ │ └── menu.rs # Native macOS menu bar +│ └── icons/ # App icons +│ +├── mcp-server/ # MCP bridge (Node.js or Bun) +│ ├── index.js # MCP server entry (stdio tools) +│ ├── vault.js # Vault file operations +│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711) +│ ├── test.js # MCP server tests +│ └── package.json +│ +├── e2e/ # Playwright E2E tests (~26 specs) +├── tests/smoke/ # Playwright specs (full regression + @smoke subset) +├── design/ # Per-task design files +├── demo-vault-v2/ # Curated local QA fixture for native/dev flows +├── scripts/ # Build/utility scripts +│ +├── package.json # Frontend dependencies + scripts +├── lara.yaml # Lara CLI locale sync configuration +├── vite.config.ts # Vite bundler config +├── tsconfig.json # TypeScript config +├── playwright.config.ts # Full Playwright regression config +├── playwright.smoke.config.ts # Curated pre-push Playwright config +├── ui-design.pen # Master design file +├── AGENTS.md # Canonical shared instructions for coding agents +├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md as an organized Note +└── docs/ # This documentation +``` + +## Key Files to Know + +### Fixtures + +- `demo-vault-v2/` is the small checked-in QA fixture used for native/manual Tolaria flows. It is intentionally curated around a handful of search, relationship, project-navigation, and attachment scenarios. +- `tests/fixtures/test-vault/` is the deterministic Playwright fixture copied into temp directories for isolated integration and smoke tests. +- `python3 scripts/generate_demo_vault.py` generates the larger synthetic vault on demand at `generated-fixtures/demo-vault-large/` for scale/performance experiments. That output is gitignored and should not bloat the normal QA fixture. + +### Start here + +| File | Why it matters | +|------|---------------| +| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how orchestration hooks connect. | +| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. | +| `src-tauri/src/commands/` | Tauri command handlers (split into modules). This is the frontend-backend API surface. | +| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. | + +### Data layer + +| File | Why it matters | +|------|---------------| +| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. | +| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. | +| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. | +| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. | +| `src/hooks/useNoteWindowLifecycle.ts` | Note-window URL opening, asset-scope sync, and window-title updates. | +| `src/hooks/useVaultRenameDetection.ts` | Focus-triggered Git rename detection and wikilink update action wiring. | +| `src/hooks/useStartupScreenState.ts` | Startup-screen and vault-content loading visibility decisions. | +| `src/hooks/useGitFileWorkflows.ts` | Git diff/history/discard wiring and deleted-note preview workflow. | +| `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. | +| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. | + +### Backend + +| File | Why it matters | +|------|---------------| +| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. | +| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. | +| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | +| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). | +| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | +| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. | +| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. | +| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/antigravity_cli.rs`, `src-tauri/src/kiro_cli.rs` | Per-agent command, config, discovery, and event adapters. | +| `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. | + +### Editor + +| File | Why it matters | +|------|---------------| +| `src/components/Editor.tsx` | BlockNote setup, breadcrumb bar, diff/raw toggle. | +| `src/components/SingleEditorView.tsx` | Shared BlockNote shell, Tolaria formatting controllers, and suggestion menus. | +| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. | +| `src/components/tolariaEditorFormatting.tsx` | Markdown-safe formatting toolbar surface for BlockNote. | +| `src/components/tolariaEditorFormattingConfig.ts` | Filters toolbar and slash-menu commands to markdown-roundtrippable actions. | +| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). | +| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. | + +### AI + +| File | Why it matters | +|------|---------------| +| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace orchestration — chat sessions, sidebar tabs, target/permission controls, and dock/pop-out wiring. | +| `src/components/AiWorkspaceChrome.tsx` | Header and vault-guidance chrome shared by docked and popped-out AI workspace modes. | +| `src/components/AiWorkspaceResizeHandles.tsx` | Edge resize affordances for docked/side AI workspace layouts. | +| `src/components/AiWorkspaceSideHeader.tsx` | Side-mode AI workspace chat tabs, rename controls, and compact header chrome. | +| `src/components/aiWorkspaceConversations.ts` | Conversation metadata state, settings persistence, default title generation, and target resolution. | +| `src/components/aiWorkspaceSizing.ts` | AI workspace sizing, localStorage persistence, class names, and layout style helpers. | +| `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. | +| `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. | +| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. | +| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. | +| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. | +| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. | +| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. | +| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. | + +### Styling + +| File | Why it matters | +|------|---------------| +| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes; System mode resolves to one of these at runtime. | +| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). | + +### Settings & Config + +| File | Why it matters | +|------|---------------| +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, Git visibility, auto-sync interval, default note width, sidebar type pluralization, default AI agent). | +| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | +| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | +| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. | +| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, Git setup prompt preference, AI permission mode). | +| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, Git visibility, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. | +| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. | + +## Architecture Patterns + +### Tauri/Mock Branching + +Every data-fetching operation checks `isTauri()` and branches: + +```typescript +if (isTauri()) { + result = await invoke('command', { args }) +} else { + result = await mockInvoke('command', { args }) +} +``` + +This lives in `useVaultLoader.ts` and `useNoteActions.ts`. Components never call Tauri directly. + +### Props-Down, Callbacks-Up + +No global state management (no Redux, no Context). `App.tsx` owns the state and passes it down as props. Child-to-parent communication uses callback props (`onSelectNote`, etc.). + +### Discriminated Unions for Selection State + +```typescript +type SidebarSelection = + | { kind: 'filter'; filter: SidebarFilter } + | { kind: 'sectionGroup'; type: string } + | { kind: 'folder'; path: string } + | { kind: 'entity'; entry: VaultEntry } + | { kind: 'view'; filename: string } +``` + +### Command Registry + +`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. + +Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. + +Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`. + +For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`: + +- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage +- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage + +That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior. + +## Running Tests + +```bash +# Unit tests (fast, no browser) +pnpm test + +# Unit tests with coverage (must pass ≥70%) +pnpm test:coverage + +# Rust tests +cargo test + +# Rust coverage (must pass ≥85% line coverage) +cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85 + +# Playwright core smoke lane (requires dev server) +BASE_URL="http://localhost:5173" pnpm playwright:smoke + +# Full Playwright regression suite +BASE_URL="http://localhost:5173" pnpm playwright:regression + +# Single Playwright test +BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts +``` + +## Common Tasks + +### Add a new Tauri command + +1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.) +2. Add a command handler in `commands/` +3. Register it in the `generate_handler![]` macro in `lib.rs` +4. Call it from the frontend via `invoke()` in the appropriate hook or utility, keeping native-only permission work behind the Tauri command boundary +5. Add a mock handler in `mock-tauri.ts` + +### Add a new component + +1. Create `src/components/MyComponent.tsx` +2. If it needs vault data, receive it as props from the parent +3. Wire it into `App.tsx` or the relevant parent component +4. Add a test file `src/components/MyComponent.test.tsx` + +### Add a new entity type + +1. Create a type document at the vault root: `mytype.md` with `type: Type` frontmatter (icon, color, order, etc.) +2. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true` +3. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog +4. Notes of this type are created at the vault root with `type: MyType` in frontmatter — no dedicated folder needed + +### Add a command palette entry + +1. Register the command in `useAppCommands.ts` via the command registry +2. Add a corresponding menu bar item in `menu.rs` for discoverability +3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar +4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly + +### Modify styling + +1. **Global app/theme variables**: Edit `src/index.css` +2. **Editor typography**: Edit `src/theme.json` + +### Work with the AI agent + +1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string) +2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent +3. **Tool action display**: Edit `src/components/AiActionCard.tsx` +4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts` +5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling. +6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `antigravity_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Antigravity, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Antigravity Safe uses sandboxed `proceed-in-sandbox`, Power User uses `always-proceed` without `--dangerously-skip-permissions`, and workspace MCP config lives in `.agents/mcp_config.json`. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault. +7. **Availability probing**: Edit `src/hooks/useAiAgentsStatus.ts` and `src-tauri/src/ai_agents.rs` for AI-agent install/status detection. Keep renderer probing deferred until after first paint, skip it when AI features or AI surfaces are unavailable, and keep backend per-agent CLI checks parallel so missing tools do not serialize shell startup cost. + +### Work with external MCP setup + +1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must resolve an MCP runtime via `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Client-facing Node script paths strip Windows extended-length `\\?\` prefixes before Tolaria writes durable config or transient agent entries, because stdio MCP clients pass that argument back to Node as the main module path. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the MCP server reading `vaults.json` at tool-call time. +2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the runtime prerequisite (Node.js 18+ or Bun 1+), the exact generated standard `mcpServers` manual config, the exact generated OpenCode top-level `mcp` config, and copy actions before Tolaria writes third-party config files +3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes +4. **Antigravity CLI compatibility**: Keep `~/.gemini/config/mcp_config.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Antigravity sessions still require the user to install and sign in to `agy`, but Tolaria supplies workspace MCP config when Antigravity is selected as the default AI agent +5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry. +6. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist. diff --git a/docs/PUBLIC-DOCS-PLAN.md b/docs/PUBLIC-DOCS-PLAN.md new file mode 100644 index 0000000..d10dd59 --- /dev/null +++ b/docs/PUBLIC-DOCS-PLAN.md @@ -0,0 +1,47 @@ +# Public Docs Plan + +This document records the phase 1 information architecture for public Tolaria documentation. The public docs source lives in `site/`; the existing `docs/` directory remains contributor, architecture, and agent context. + +## Audiences + +| Audience | Needs | Primary location | +|---|---|---| +| New users | Install, first launch, understand the app layout, clone the starter vault | `site/start/` | +| Active users | Learn concrete workflows such as organizing, Git sync, custom views, and AI | `site/guides/` | +| Power users | Understand file layout, frontmatter, filters, release channels, shortcuts, and platform support | `site/reference/` | +| Contributors and agents | Architecture, abstractions, ADRs, development workflow | `docs/`, `AGENTS.md` | + +## Hosting Shape + +The GitHub Pages output should reserve the root for public docs and mount release assets underneath it: + +```text +/ public docs home +/releases/ release history +/download/ latest stable download redirect +/stable/latest.json +/alpha/latest.json +/latest.json compatibility alias for alpha latest +/latest-canary.json compatibility alias for alpha latest +``` + +## Current Coverage + +The phase 1 site now covers post-branch features added after the original April docs snapshot: + +- Windows and Linux release artifacts. +- Stable and Alpha updater channels. +- Direct AI model providers and local/API model setup. +- Claude Code, Codex, OpenCode, Pi, and Antigravity CLI agent targets. +- Explicit MCP setup for external AI tools. +- Table of contents, note width, raw mode, and paste-without-formatting workflows. +- Media/PDF previews, image attachments, All Notes visibility, and Markdown whiteboards. +- System theme mode and sidebar pluralization settings. + +Every user-visible app change should answer: + +```text +Public docs impact: +- updated: +- not needed because: +``` diff --git a/docs/VISION.md b/docs/VISION.md new file mode 100644 index 0000000..fd67a35 --- /dev/null +++ b/docs/VISION.md @@ -0,0 +1,195 @@ +# Tolaria — Product Vision + +*Written by Brian based on conversations with Luca Rossi, Feb–Mar 2026.* +*This is a living document — update it as the vision evolves.* + +--- + +## Why this, why now, why us + +Before the what and how: the why. + +The best projects are built by people who have an unusually strong answer to "why are you the right person to build this?" This is that answer. + +**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels. + +Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Tolaria is an attempt to bottle that system. + +The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Tolaria is not theorized — it's been battle-tested for years at scale. + +**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years. + +This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him. + +**Why Tolaria, in the context of Refactoring.** + +Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI? + +Tolaria answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this. + +This is why Tolaria is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on. + +The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author. + +--- + +## The problem + +Most people who want to work effectively with AI face a version of the same problem: **they don't have their knowledge organized in a way that AI can actually use.** + +They have notes scattered across Notion, Apple Notes, browser bookmarks, and email. Some of it is structured, most of it isn't. Even the people who do maintain a knowledge base discover that AI tools — ChatGPT, Notion AI, others — struggle to navigate it meaningfully. The knowledge is there, but it's not *accessible*. + +The problem has two distinct layers: + +1. **Architectural**: most knowledge tools store data in proprietary formats on remote servers. AI tools can't read them efficiently, can't commit changes back, can't reason over the full structure. The format itself creates a ceiling. + +2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you. + +Tolaria addresses both layers, together. That's what makes it different. + +--- + +## The insight: tool and method, together + +Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up. + +Tolaria's position is different: **we ship the method alongside the tool.** + +The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch. + +This combination — an opinionated method on top of a technically excellent foundation — is what makes Tolaria genuinely useful to people who are stuck, not just people who already know what they're doing. + +--- + +## The method: a framework for knowledge work + +### The knowledge ontology + +Tolaria organizes work around two axes: + +| | **One-time** | **Recurring** | +|---|---|---| +| **Multi-session** | **Project** (has a start and end) | **Responsibility** (no end, measured by KPIs) | +| **Single-session** | *Task* (lives in a task manager) | **Procedure** (checklist, routine) | + +Everything else is context: +- **Notes** — the atomic unit. Any note connects to one or more of the above. +- **Topics** — areas of interest with no performance expectation. A knowledge repository. +- **Events** — things that happened, anchored to a date. +- **People** — contacts and their history. + +Relations between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections that make the knowledge graph navigable. + +This ontology is not arbitrary. It maps cleanly to how both individuals and organizations actually structure their work: companies have projects, responsibilities, procedures, and people. So do independent creators. So do individuals managing their lives. + +### Knowledge has a purpose + +A principle that underlies everything in Tolaria: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something. + +This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself. + +What you *do* with organized knowledge depends on who you are: + +- **Writers and content creators** — the output is articles, essays, posts. Captures become highlights, highlights become **evergreen notes** (small, atomic, timeless ideas), evergreen notes become building blocks for articles. Evergreen notes are a middle layer: not the raw input, not the final output, but the refined reusable units that make writing easier and faster. +- **Builders and project-driven people** — the output is shipped work. Captures feed projects, decisions, and procedures. Evergreen notes matter less; the project knowledge graph matters more. +- **Operators and managers** — the output is better systems and decisions. Captures feed responsibilities (KPIs, workflows) and procedures (how we do things). The value accumulates in the recurring structure, not in individual notes. + +The framework is flexible enough to fit all three — and most people are a mix. What stays constant is the flow: **capture → organize → express**. The *what* of expressing changes; the discipline doesn't. + +### The two-phase workflow: capture and organize + +Notes move through two distinct phases, and the transition between them is intentional. + +**Capture** — fast, frictionless, available everywhere. A thought, a saved article, a Kindle highlight, a voice memo. The cardinal rule: never let friction during capture cause a good idea to be lost. Captured notes land in the vault unconnected — no relationships, no organization. That's fine. That's the point. + +**Organize** — a deliberate, periodic activity (weekly is the natural cadence). You ask: *what is this useful for?* Many things that seemed important when captured won't survive this question — deleting >50% of captures is normal and healthy. For the things that survive: connect them. Link to a Project, a Responsibility, a Topic. Every note should eventually connect to at least one actionable container. If you can't connect something to anything, that's a signal worth paying attention to. + +**The Inbox** is the UI expression of this split: a smart section that shows all unorganized notes — those with no outgoing relationships. The goal is Inbox Zero, reached periodically (weekly). The inbox is not a folder; it's a derived state. Connecting a note to something removes it automatically. + +### Convention over configuration + +The method lives in the app as *conventions*: standard field names and folder structures that have well-defined meanings and trigger specific behavior. + +`status:` shows a colored chip. `Workspace: [[workspace/refactoring]]` assigns a note to a context. `Belongs to:` connects it to its parent. `start_date:` and `end_date:` show a duration badge. The app recognizes these by convention, without any setup. + +Users who want more can override the defaults: `config/relations.md` changes which relationship fields appear by default; `config/semantic-properties.md` controls how fields are rendered. But the defaults work immediately, for everyone. + +This is convention *over* configuration — not convention *instead of* it. + +--- + +## The foundation: architecture that earns trust + +The method is only as good as the system it runs on. Tolaria's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.** + +### Local files, version-controlled with Git + +Every note is a plain Markdown file on your disk. There is no database, no proprietary format, no sync lock-in. The files are readable by any tool that can open a text file — today and in twenty years. + +Git provides version control: every change is tracked, diffable, reversible. You have a full audit trail of what changed, when, and why. Collaboration happens via Git — the same way software teams have collaborated for decades, without any proprietary cloud in between. + +### AI-native by design + +A vault of plain Markdown files, version-controlled with Git, is dramatically more AI-friendly than any SaaS-based system. + +An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it. + +More importantly: the more a vault follows Tolaria's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other. + +### Open and exit-friendly + +The trust between Tolaria and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open. + +--- + +## Why not Obsidian? + +Obsidian is the obvious comparison. The difference is philosophy: + +- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so. +- **Tolaria** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started. + +Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Tolaria, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history. + +--- + +## Who it's for, and where it's going + +### Three stages of adoption + +Tolaria is designed to grow through three natural stages — not pivots, but extensions of the same foundation: + +**Stage 1: Personal PKM + AI context** *(current)* +A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it. + +**Stage 2: Independent knowledge workers** +Content creators, freelancers, consultants. People with maximum incentive *and* maximum agency to build a real system. They have projects, clients, responsibilities — and they work alone or in very small teams. The same ontology applies: a newsletter creator has editorial projects, a subscriber-growth responsibility, and a publishing procedure. AI collaboration deepens: the AI can see not just personal notes but client commitments, content pipelines, recurring workflows. + +**Stage 3: Small teams** +The ontology scales to organizations. Companies have projects, responsibilities, procedures, and people — the same categories, at a larger scale. The access model changes: different people see different subsets of the vault, via workspace filtering and Git-based access control. Version history gives teams a full audit trail. AI agents become shared collaborators on team knowledge, not just personal assistants. + +**What makes this trajectory coherent:** the foundational model — local files, Git-versioned, structured by conventions — doesn't need to be rebuilt at each stage. It extends naturally. + +### The right early adopters + +The first users who will get the most from Tolaria are technically-minded individuals who: +- Are frustrated with Notion's performance, complexity, or lock-in +- Understand or are comfortable with Git +- Want a system that's AI-native by design, not by bolted-on features +- Value owning their data + +Broader audiences will follow as the onboarding experience matures and the conventions become easier to adopt. + +--- + +## Design principles + +1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters +2. **Convention over configuration** — standard field names trigger rich behavior automatically; users can override via vault config files +3. **Git-first** — sync, history, collaboration, and audit trail via Git; no proprietary cloud +4. **AI-native architecture** — local files, open formats, structured by conventions legible to both humans and AI +5. **Zero lock-in** — earn trust daily; the exit door is always open +6. **Capture and organize are separate** — the inbox makes unorganized notes visible; Inbox Zero is the discipline +7. **Relations as first-class citizens** — connections between notes are as important as the notes themselves +8. **Filesystem as the single source of truth** — the app never owns the data; cache and UI state are always derived and reconstructible +9. **Convention over system config files** — app configuration and preferences that belong to a note (e.g. type-level UI preferences) are stored in that note's frontmatter using the `_field` underscore convention, not in separate config files or localStorage. Everything that matters lives in the vault as plain text. diff --git a/docs/adr/0001-tauri-react-stack.md b/docs/adr/0001-tauri-react-stack.md new file mode 100644 index 0000000..c537726 --- /dev/null +++ b/docs/adr/0001-tauri-react-stack.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0001" +title: "Tauri v2 + React as application stack" +status: active +date: 2026-02-14 +--- + +## Context + +Laputa is a desktop app for macOS (with iPad as a future target) that reads and writes a vault of markdown files. The app needs a native feel, filesystem access, git integration, and a rich text editor. A single developer (with AI assistance) is building it. + +## Decision + +Use **Tauri v2** (Rust backend + WebView frontend) with **React + TypeScript** for the UI, **BlockNote** for the editor, and **Vitest + Playwright** for testing. + +## Alternatives considered + +- **Electron**: heavier runtime (~150MB), slower, but more mature ecosystem. Rejected — Tauri is lighter and has better native integration. +- **SwiftUI**: best native macOS/iOS experience, but locks to Apple platforms only, no code sharing with a potential web version, and requires rewriting the entire UI. Rejected for the initial version — revisited in ADR-0005. +- **Flutter**: cross-platform but WebView-based editor would have been poor; Dart ecosystem is thin for markdown tooling. +- **Pure web app**: no filesystem access, no git, would require a backend server. Rejected — offline-first is a core principle. + +## Consequences + +- React frontend can be shared with a future web version +- Rust backend provides safe, fast filesystem/git operations +- Tauri v2 supports iOS (beta) — see ADR-0005 for iPad strategy +- CodeScene code health monitoring applies to both Rust and TypeScript code +- Claude Code can work on both layers without context switching +- Triggers re-evaluation if: Tauri iOS proves unstable for production, or if SwiftUI becomes the primary target platform diff --git a/docs/adr/0002-filesystem-source-of-truth.md b/docs/adr/0002-filesystem-source-of-truth.md new file mode 100644 index 0000000..15f5db6 --- /dev/null +++ b/docs/adr/0002-filesystem-source-of-truth.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0002" +title: "Filesystem as the single source of truth" +status: active +date: 2026-02-14 +--- + +## Context + +Laputa needs a persistence model. The core question: does the app own the data, or does the filesystem? This affects sync, conflict resolution, offline support, portability, and long-term trust with users. + +## Decision + +**The vault is the source of truth.** The app never owns the data — it only reads and writes `.md` files. All cache, React state, and in-memory representations are derived from the filesystem and must be reconstructible by deleting them. When in doubt, the file on disk wins. + +## Alternatives considered + +- **Database-first (SQLite)**: faster queries, easier relationships. Rejected — creates lock-in, makes files unreadable outside the app, complicates sync. +- **Cloud-first (proprietary sync)**: easier multi-device. Rejected — zero lock-in is a core principle; git handles sync. +- **Hybrid (DB + files)**: DB as primary, files as export. Rejected — two sources of truth always diverge. + +## Consequences + +- Notes are plain markdown files, readable and editable by any text editor +- Git provides history, sync, and collaboration for free +- Vault can be opened/edited externally without app corruption +- App rebuilds cache on startup — acceptable cost for integrity guarantees +- No "save" button needed — autosave writes to disk immediately +- Triggers re-evaluation if: vault size grows to millions of files and filesystem scanning becomes a bottleneck diff --git a/docs/adr/0003-single-note-model.md b/docs/adr/0003-single-note-model.md new file mode 100644 index 0000000..d0b368a --- /dev/null +++ b/docs/adr/0003-single-note-model.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0003" +title: "Single note open at a time (no tabs)" +status: active +date: 2026-03-24 +--- + +## Context + +The app originally had a tab bar allowing multiple notes to be open simultaneously (similar to a browser or code editor). After building and shipping it, the tab model was found to add significant UI complexity, state management overhead, and confusion — without a proportional benefit for a notes app. + +## Decision + +**Remove the tab bar. Only one note is open at a time.** Navigation history (Back/Forward with Cmd+[/]) replaces tabs for moving between recently visited notes. Closed tab history and `useTabManagement` are removed. + +## Alternatives considered + +- **Keep tabs**: familiar UX, allows comparing notes side by side. Rejected — adds ~2000 lines of complexity, confusing state (which tab is "active"?), and breaks the "editor is sacred" principle. +- **Tabs + single-note toggle**: configurable per user. Rejected — doubles the state surface and testing burden. +- **Split pane (two notes at once)**: useful for reference. Deferred — can be added later without tabs, via a dedicated split layout. + +## Consequences + +- Removes ~2000 lines of code (`TabBar`, `useClosedTabHistory`, `useEditorTabSwap`, `tabLayout`) +- `handleSelectNote` replaces the current note instead of adding a tab +- Cmd+W (close tab) and Cmd+Shift+T (reopen closed tab) removed from shortcuts +- Back/Forward navigation (Cmd+[/Cmd+]) preserves history without tab state +- Significant simplification of `App.tsx` and editor state +- Triggers re-evaluation if: multi-note workflows become a top user request diff --git a/docs/adr/0004-vault-vs-app-settings-storage.md b/docs/adr/0004-vault-vs-app-settings-storage.md new file mode 100644 index 0000000..40a1ea5 --- /dev/null +++ b/docs/adr/0004-vault-vs-app-settings-storage.md @@ -0,0 +1,42 @@ +--- +type: ADR +id: "0004" +title: "Vault vs app settings for state storage" +status: active +date: 2026-03-24 +--- + +## Context + +As features were added, there was recurring ambiguity about where to persist configuration and state: in the vault (as frontmatter in `.md` files) or in app settings (`~/.config/com.laputa.app/settings.json` / localStorage). Without a clear rule, some decisions were inconsistent. + +## Decision + +**Ask: "Would the user want this to follow the vault across all their installations?"** + +- If **yes** → store in the vault (as frontmatter in the relevant `.md` file, using the `_` convention for system properties) +- If **no** → store in app settings + +Examples: +| Data | Storage | Reason | +|------|---------|--------| +| Note type icon (`_icon`) | Vault frontmatter | Follows the vault everywhere | +| Note type color (`_color`) | Vault frontmatter | Follows the vault everywhere | +| Note sort preference | Vault frontmatter (type file) | Per-vault, consistent across devices | +| API keys (Anthropic, OpenAI) | App settings | Installation-specific | +| GitHub token | App settings | Installation-specific | +| Window size / zoom | App settings | Device-specific | +| Editor zoom level | App settings | Device-specific | +| Telemetry consent | App settings | Installation-specific | + +## Alternatives considered + +- **Everything in localStorage**: simple, but breaks cross-device sync for vault-level config. +- **Everything in vault**: pure, but makes device-specific settings (zoom, window size) propagate to all devices — confusing. + +## Consequences + +- Config that "belongs to a note or type" lives in frontmatter — readable/diffable in git +- The `_` prefix convention (see ABSTRACTIONS.md) distinguishes system properties from user properties +- App rebuilds from vault state on open — no stale config files to manage +- Triggers re-evaluation if: vault files become too polluted with system frontmatter properties diff --git a/docs/adr/0005-tauri-ios-for-ipad.md b/docs/adr/0005-tauri-ios-for-ipad.md new file mode 100644 index 0000000..24a4754 --- /dev/null +++ b/docs/adr/0005-tauri-ios-for-ipad.md @@ -0,0 +1,38 @@ +--- +type: ADR +id: "0005" +title: "Tauri v2 iOS for iPad support (vs SwiftUI rewrite)" +status: active +date: 2026-03-27 +--- + +## Context + +Laputa runs on macOS via Tauri v2. The goal is to also support iPad without changing the stack or redesigning the app from scratch. The core question: extend the existing stack to iOS, or rewrite in SwiftUI for a fully native experience? + +## Decision + +**Use Tauri v2 iOS (beta) for the iPad prototype.** The React frontend stays identical. The Rust backend compiles for iOS with `#[cfg(desktop)]` / `#[cfg(mobile)]` guards for platform-specific features. Desktop-only features (git CLI, macOS menu bar, MCP server, Claude CLI) are stubbed or skipped on mobile. + +The prototype (`feat: add iPad/iOS prototype via Tauri v2 mobile target`, build `b492`) successfully builds and runs on iPad Pro 13" simulator (iOS 18.3.1). + +## Alternatives considered + +- **SwiftUI rewrite**: best native macOS/iPad experience, full App Store integration, native TextKit 2 editor. Rejected for now — would discard all existing React code, Rust backend, 2200+ tests, and Claude Code's accumulated context. Worth revisiting if Laputa becomes iOS-first. +- **Capacitor**: replaces Tauri layer, keeps React, but the Rust backend is lost entirely — git and file operations would need reimplementation in JS or Swift. +- **React Native + WebView**: wraps the React app in a WebView. Too hacky, performance concerns, App Store review risks. + +## Git on iPad + +`git` CLI is unavailable on iOS. Options for production: +- **Option A (recommended)**: `isomorphic-git` — pure JS git implementation, no native dependencies, runs in WebView. Replaces Rust git commands on mobile. +- **Option B (prototype)**: Working Copy as iOS Files provider — user manages git separately. +- **Option C**: iCloud Drive sync — no git history. Not recommended. + +## Consequences + +- Zero frontend changes needed for basic iPad support +- Desktop features (git, MCP, Claude CLI) unavailable on iPad until isomorphic-git is integrated +- Tauri v2 iOS is still beta — production stability unknown +- App Store distribution requires Apple Developer account and TestFlight +- Triggers re-evaluation if: Tauri iOS remains unstable after 6 months, or iPad becomes the primary target (in which case SwiftUI rewrite becomes rational) diff --git a/docs/adr/0006-flat-vault-structure.md b/docs/adr/0006-flat-vault-structure.md new file mode 100644 index 0000000..30f92e0 --- /dev/null +++ b/docs/adr/0006-flat-vault-structure.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0006" +title: "Flat vault structure (no type-based folders)" +status: active +date: 2026-03-15 +--- + +## Context + +Originally, notes were organized into type-based subfolders (`project/`, `person/`, `topic/`, etc.). Changing a note's type required moving it between folders, which broke wikilinks, complicated wikilink resolution (paths vs titles), and created friction for users who wanted to reorganize their knowledge. It also made vault scanning more complex and introduced edge cases around folder creation/deletion. + +## Decision + +**All user notes live as flat `.md` files at the vault root. Type is determined solely from the `type:` frontmatter field — never inferred from folder location.** Only a small set of protected folders exist: `type/` (type definition documents), `config/` (meta-configuration), and `attachments/`. + +## Options considered + +- **Option A** (chosen): Flat vault with frontmatter-only type — simple wikilink resolution (title/filename only), no file moves on type change, vault scanning restricted to root + protected folders. Downside: large vaults may look cluttered in Finder. +- **Option B**: Keep type-based folders — familiar Obsidian-like structure. Downside: type changes require file moves, wikilink resolution needs path awareness, scanning is recursive and slower. +- **Option C**: Hybrid (folders optional, type still from frontmatter) — maximum flexibility. Downside: two ways to do the same thing, confusing for AI agents and automation. + +## Consequences + +- Wikilink resolution is simplified to multi-pass title/filename matching — no path-based matching needed. +- Changing a note's type is a frontmatter edit, not a file move. +- A `flatten_vault` migration command and wizard were added for existing vaults with type folders. +- `vault_health_check` detects stray files in non-protected subfolders. +- `scan_vault` only indexes root-level `.md` files plus protected folders — non-protected subdirectories are ignored. +- Re-evaluation trigger: if users need nested folder hierarchies for non-type organization (e.g., project-specific subdirectories). diff --git a/docs/adr/0007-title-filename-sync.md b/docs/adr/0007-title-filename-sync.md new file mode 100644 index 0000000..3f959fc --- /dev/null +++ b/docs/adr/0007-title-filename-sync.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0007" +title: "Title equals filename (slug sync)" +status: superseded +date: 2026-03-15 +superseded_by: "0044" +--- + +## Context + +With the move to a flat vault structure (ADR-0006), filenames became the primary identifier for notes. Previously, titles were extracted from the first H1 heading, which was fragile (users could delete or change the H1 without realizing it affected the note's identity). A clear, deterministic mapping between title and filename was needed. + +## Decision + +**Every note's filename is `slugify(title).md`. The `title` frontmatter field is the source of truth for the human-readable title. On note open, the system syncs the title field to match the filename if they've diverged (filename wins). On rename, both title and filename are updated atomically.** + +## Options considered + +- **Option A** (chosen): `title = slugify(filename)` with bidirectional sync — deterministic, predictable, wikilinks resolve by title/filename stem. Downside: titles with special characters get simplified in filenames. +- **Option B**: UUID-based filenames with title only in frontmatter — filenames never change. Downside: vault is unreadable in Finder/terminal, breaks the "plain markdown files" principle. +- **Option C**: H1-based title extraction — no explicit title field. Downside: fragile, H1 can be accidentally deleted or changed, decoupled from filename. + +## Consequences + +- `extract_title` reads from frontmatter `title:` field, never from H1. Falls back to `slug_to_title()` (hyphens → spaces, title-case). +- `sync_title_on_open` auto-corrects desynced frontmatter on note open. +- `rename_note` updates both `title:` frontmatter and filename atomically, plus cross-vault wikilink updates. +- The H1 block inside BlockNote is hidden via CSS; a dedicated `TitleField` component above the editor is the primary title editing surface. +- Slug collision detection prevents duplicate filenames. +- Re-evaluation trigger: if users need filenames that don't match titles (e.g., short slugs for long titles). diff --git a/docs/adr/0008-underscore-system-properties.md b/docs/adr/0008-underscore-system-properties.md new file mode 100644 index 0000000..563a1eb --- /dev/null +++ b/docs/adr/0008-underscore-system-properties.md @@ -0,0 +1,42 @@ +--- +type: ADR +id: "0008" +title: "Underscore convention for system properties" +status: active +date: 2026-03-24 +--- + +## Context + +As Laputa added more features that store configuration in note frontmatter (pinned properties, type icons, colors, sidebar labels, sort order), the Properties panel became cluttered with internal fields that users shouldn't normally edit. A convention was needed to distinguish user-visible properties from system-internal ones. + +## Decision + +**Any frontmatter field whose name starts with `_` is a system property. It is hidden from the Properties panel, not exposed in search/filters, but remains editable in the raw editor. The frontmatter parser filters out `_*` fields before passing properties to the UI.** + +## Options considered + +- **Option A** (chosen): Underscore prefix convention (`_icon`, `_color`, `_order`, `_pinned_properties`) — simple, readable in raw files, universal rule. Downside: users must know the convention to access system fields. +- **Option B**: Separate YAML block or nested `_system:` key — cleaner separation. Downside: more complex parsing, breaks flat key-value frontmatter model. +- **Option C**: Store system properties in a separate sidecar file (`.meta.yml`) — complete separation. Downside: doubles the number of files, harder to keep in sync. + +## Consequences + +- All future system-level frontmatter fields must use the `_field_name` convention. +- Both Rust (`vault/mod.rs`) and TypeScript (`utils/frontmatter.ts`) parsers filter `_*` fields before passing `properties` to the UI. +- Power users can still access and edit system properties via the raw editor. +- Type documents use `_icon`, `_color`, `_order`, `_sidebar_label`, `_pinned_properties`. +- Re-evaluation trigger: if the number of system properties grows large enough to warrant a structured sub-object. + +## Normalized system properties + +| Canonical key | Old keys (read with fallback) | Written by | +|---|---|---| +| `_archived` | `Archived`, `archived` | Archive action | +| `_trashed` | `Trashed`, `trashed` | Trash action | +| `_trashed_at` | `Trashed at`, `trashed_at` | Trash action | +| `_favorite` | — | Favorite toggle | +| `_favorite_index` | — | Favorite reorder | + +**Write rule**: always use the canonical `_`-prefixed key. +**Read rule**: accept both canonical and legacy keys (case-insensitive). Do NOT rewrite on read — migration is a separate concern. diff --git a/docs/adr/0009-keyword-only-search.md b/docs/adr/0009-keyword-only-search.md new file mode 100644 index 0000000..f7bb2b2 --- /dev/null +++ b/docs/adr/0009-keyword-only-search.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0009" +title: "Keyword-only search (remove semantic indexing)" +status: active +date: 2026-03-24 +--- + +## Context + +Laputa previously used QMD (a Go binary) for semantic vector indexing, enabling similarity-based search. This added significant complexity: a bundled Go binary requiring code-signing, an indexing step on vault open, status bar progress tracking, auto-install logic, and a separate `tools/qmd/` directory. The semantic search quality did not justify the operational burden, especially as the AI agent (with MCP vault tools) became a more natural way to do exploratory queries. + +## Decision + +**Remove QMD semantic indexing entirely and keep only keyword-based search. Search uses `walkdir` to scan all `.md` files, matching against titles and content with case-insensitive substring matching and relevance scoring.** + +## Options considered + +- **Option A** (chosen): Keyword-only search via `walkdir` — zero dependencies, no indexing step, instant results, no binary to sign/bundle. Downside: no fuzzy or semantic matching. +- **Option B**: Keep QMD semantic search — richer search results, similarity matching. Downside: bundled Go binary, code-signing, indexing latency, maintenance burden. +- **Option C**: Replace QMD with a Rust-native embedding library — no external binary. Downside: large model files, cold start time, still needs indexing. + +## Consequences + +- No external search binary to bundle, sign, or install. +- No indexing step on vault open — search is instant. +- `search_vault` Tauri command scans files directly with `walkdir`, runs in a blocking Tokio task. +- Title matches rank higher than content-only matches; exact title matches rank highest. +- The AI agent (via MCP `search_notes` tool) provides an alternative for exploratory/semantic queries. +- Re-evaluation trigger: if users report keyword search is insufficient for large vaults (9000+ notes). diff --git a/docs/adr/0010-dynamic-wikilink-relationship-detection.md b/docs/adr/0010-dynamic-wikilink-relationship-detection.md new file mode 100644 index 0000000..153c578 --- /dev/null +++ b/docs/adr/0010-dynamic-wikilink-relationship-detection.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0010" +title: "Dynamic wikilink relationship detection" +status: active +date: 2026-03-08 +--- + +## Context + +Laputa needs to support arbitrary relationship types between notes (e.g., `Topics:`, `Key People:`, `Depends on:`). Initially, a hardcoded list `RELATIONSHIP_KEYS` identified which frontmatter fields were relationships. This was fragile — adding a new relationship type required a code change, and users couldn't define their own. + +## Decision + +**The Rust parser dynamically detects relationship fields by scanning all frontmatter keys for values containing `[[wikilinks]]`. Any field with wikilink values is captured in the `relationships` HashMap — no hardcoded field name list needed.** + +## Options considered + +- **Option A** (chosen): Dynamic detection via `[[wikilink]]` presence — zero configuration, extensible, any field name works. Downside: fields with bracket-like content could false-positive (mitigated by the `[[...]]` double-bracket syntax). +- **Option B**: Hardcoded `RELATIONSHIP_KEYS` list — simple, predictable. Downside: inflexible, requires code changes for new relationship types. +- **Option C**: User-configurable relationship field list in vault config — flexible. Downside: configuration burden, doesn't work out of the box. + +## Consequences + +- Users can define arbitrary relationship types by adding wikilink values to any frontmatter field. +- No code change needed when adding new relationship types — convention over configuration. +- All relationship fields appear in the Inspector's RelationshipsPanel automatically. +- The `relationships` HashMap in `VaultEntry` captures all dynamic relationships. +- Standard fields (`belongs_to`, `related_to`) are still recognized for backward compatibility but not privileged. +- Re-evaluation trigger: if false-positive detection becomes a problem (e.g., fields with literal `[[` content that aren't relationships). diff --git a/docs/adr/0011-mcp-server-for-ai-integration.md b/docs/adr/0011-mcp-server-for-ai-integration.md new file mode 100644 index 0000000..517bf49 --- /dev/null +++ b/docs/adr/0011-mcp-server-for-ai-integration.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0011" +title: "MCP server for AI tool integration" +status: active +date: 2026-02-28 +--- + +## Context + +Laputa's AI features (agent panel, chat) need structured access to vault data — searching notes, reading content, editing frontmatter, and steering the UI. Rather than building a bespoke API, the Model Context Protocol (MCP) provides a standardized tool interface that works with Claude Code, Cursor, and any MCP-compatible client. + +## Decision + +**Laputa ships a Node.js MCP server (`mcp-server/`) that exposes vault operations as 14 tools. It runs on stdio for external clients and on two WebSocket ports (9710 for tool calls, 9711 for UI actions) for the embedded Laputa frontend. Tauri spawns the server on startup and auto-registers it in Claude Code and Cursor configs.** + +## Options considered + +- **Option A** (chosen): Node.js MCP server with stdio + WebSocket dual transport — standard MCP compatibility, works with Claude Code/Cursor out of the box, WebSocket enables real-time UI steering. Downside: Node.js dependency, two extra ports. +- **Option B**: Rust-native MCP server — no Node.js dependency. Downside: MCP SDK is JavaScript-first, Rust implementation would be custom and harder to maintain. +- **Option C**: Custom REST/gRPC API — full control. Downside: no compatibility with existing AI tool ecosystems, each client needs a custom integration. + +## Consequences + +- Vault tools (search, read, create, edit, delete, link) are available to any MCP-compatible client. +- Auto-registration in `~/.claude/mcp.json` and `~/.cursor/mcp.json` means zero setup for users. +- The WebSocket bridge enables real-time UI actions (highlight elements, open notes, set filters) from AI tools. +- `mcp-server/` is bundled into release builds and spawned as a child process by `mcp.rs`. +- Port conflicts on 9710/9711 are handled gracefully (EADDRINUSE tolerance). +- Re-evaluation trigger: if MCP SDK gains a Rust implementation that eliminates the Node.js dependency. diff --git a/docs/adr/0012-claude-cli-for-ai-agent.md b/docs/adr/0012-claude-cli-for-ai-agent.md new file mode 100644 index 0000000..9e31cab --- /dev/null +++ b/docs/adr/0012-claude-cli-for-ai-agent.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0012" +title: "Claude CLI subprocess for AI agent (replacing direct API)" +status: active +date: 2026-03-01 +--- + +## Context + +The AI agent panel initially called the Anthropic API directly from Rust, managing tool calling loops manually. This required implementing tool execution, conversation state, and streaming — all complex to maintain. Claude CLI (`claude` binary) handles all of this natively, including MCP tool integration, conversation history, and streaming NDJSON output. + +## Decision + +**The AI agent panel spawns Claude CLI as a subprocess via `claude_cli.rs`, passing messages with `--output-format stream-json` and vault MCP config via `--mcp-config`. The frontend parses the NDJSON event stream (Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Done) for real-time display.** + +## Options considered + +- **Option A** (chosen): Claude CLI subprocess with NDJSON streaming — built-in tool calling, MCP integration, conversation management, no API key needed (CLI handles auth). Downside: requires Claude CLI installed, subprocess management complexity. +- **Option B**: Direct Anthropic API with manual tool loop — full control, no external dependency. Downside: must implement tool calling, retries, conversation state, MCP tool bridging. +- **Option C**: Use Anthropic Agent SDK from Rust — structured agent framework. Downside: SDK is Python/TypeScript, no Rust support. + +## Consequences + +- The AI agent gets full tool access (MCP vault tools + shell access) without custom tool-calling code. +- `claude_cli.rs` manages subprocess lifecycle: spawn, stream events, kill on cancel. +- The frontend (`useAiAgent` hook) processes NDJSON events for reasoning blocks, tool action cards, and response display. +- File operation detection (from Write/Edit tool inputs) triggers automatic vault reload. +- The simpler AI Chat panel still uses the Anthropic API directly for lightweight, no-tools conversations. +- Re-evaluation trigger: if Anthropic releases a Rust Agent SDK or if Claude CLI streaming format changes significantly. diff --git a/docs/adr/0013-remove-theming-system.md b/docs/adr/0013-remove-theming-system.md new file mode 100644 index 0000000..5bd2b92 --- /dev/null +++ b/docs/adr/0013-remove-theming-system.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0013" +title: "Remove vault-based theming system" +status: superseded +date: 2026-03-23 +superseded_by: "0081" +--- + +## Context + +Laputa had a vault-based theming system where themes were markdown notes in `theme/` with `type: Theme` frontmatter. Each property became a CSS variable. This included a `ThemeManager` hook, theme property editor, dark mode detection, live preview on save, and three built-in themes. The system was complex (spanning Rust seed/create/defaults modules, TypeScript hooks, and CSS variable bridging) and added significant maintenance burden for a feature that most users never customized beyond the defaults. + +## Decision + +**Remove the vault-based theming system entirely. The app uses a single, hardcoded light theme defined in CSS variables (`src/index.css`) and editor theme (`src/theme.json`).** The `theme/` folder, `ThemeManager` hook, theme Rust modules, theme property editor, and dark mode support were all deleted. + +## Options considered + +- **Option A** (chosen): Remove theming, ship a single polished light theme — drastically reduced complexity, fewer files to maintain, no theme-related bugs. Downside: no user customization, no dark mode. +- **Option B**: Keep theming but simplify — reduce to light/dark toggle only. Downside: still requires theme loading, CSS variable bridging, and live preview infrastructure. +- **Option C**: Keep the full theming system — maximum flexibility. Downside: high maintenance cost for a rarely-used feature, frequent source of bugs (WKWebView reflow issues, CSS var sync). + +## Consequences + +- Deleted: `src-tauri/src/theme/`, `src/hooks/useThemeManager.ts`, `ThemePropertyEditor.tsx`, theme-related commands, `_themes/` legacy support. +- Single theme defined in `src/index.css` (CSS variables) and `src/theme.json` (editor typography). +- No dark mode support — the app is light-only. +- Protected folders reduced: `theme/` is no longer scanned by `scan_vault`. +- Re-evaluation trigger: if dark mode becomes a hard requirement for accessibility or user demand. diff --git a/docs/adr/0014-git-based-vault-cache.md b/docs/adr/0014-git-based-vault-cache.md new file mode 100644 index 0000000..916863b --- /dev/null +++ b/docs/adr/0014-git-based-vault-cache.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0014" +title: "Git-based incremental vault cache" +status: active +date: 2026-03-08 +--- + +## Context + +Scanning a vault of 9000+ markdown files on every app launch takes several seconds. A caching strategy was needed that could detect which files changed since the last scan and only re-parse those, while remaining correct even after external edits (e.g., from a text editor or git pull). + +## Decision + +**Use git as the change detection mechanism. The cache stores all `VaultEntry` objects in a JSON file at `~/.laputa/cache/.json`. On load, it compares the cached git HEAD commit hash with the current one: if the same, only re-parse uncommitted changed files; if different, use `git diff` to find changed files and selectively re-parse. Full rescan only on cache miss or version bump.** + +## Options considered + +- **Option A** (chosen): Git-based incremental cache — leverages existing git infrastructure, precise change detection, handles both committed and uncommitted changes. Downside: requires git-tracked vault, cache invalidation logic is complex. +- **Option B**: File modification time (`mtime`) based cache — works without git. Downside: unreliable across filesystems (iCloud, Dropbox), clock skew issues. +- **Option C**: File hash (content-based) cache — always correct. Downside: must read every file to compute hash, defeating the purpose of caching. + +## Consequences + +- Cache file stored outside the vault at `~/.laputa/cache/.json` — never pollutes the user's git repo. +- Writes are atomic (write to `.tmp` then rename) to prevent corruption. +- Cache version (v5) is bumped on `VaultEntry` field changes to force full rescan. +- Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run. +- `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. +- Stale cache entries are pruned on vault open (files that no longer exist on disk). +- Re-evaluation trigger: if non-git vaults (e.g., iCloud-only) need to be supported. diff --git a/docs/adr/0015-auto-save-with-debounce.md b/docs/adr/0015-auto-save-with-debounce.md new file mode 100644 index 0000000..5601664 --- /dev/null +++ b/docs/adr/0015-auto-save-with-debounce.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0015" +title: "Auto-save with 500ms debounce" +status: active +date: 2026-03-19 +--- + +## Context + +Manual save (Cmd+S) was the only way to persist editor changes. Users occasionally lost work when switching notes or closing the app without saving. An auto-save mechanism was needed that balanced responsiveness (no perceived lag) with disk I/O efficiency (not writing on every keystroke). + +## Decision + +**Notes auto-save with a 500ms debounce after the last keystroke. The `useEditorSave` hook watches for editor content changes and triggers a save after 500ms of inactivity. The same `save_note_content` Rust command is used for both auto-save and manual save.** + +## Options considered + +- **Option A** (chosen): 500ms debounce auto-save — fast enough to feel instant, slow enough to batch rapid keystrokes. Downside: 500ms window where unsaved changes exist. +- **Option B**: Save on every change (no debounce) — zero data loss risk. Downside: excessive disk writes, poor performance, frequent git diffs. +- **Option C**: Save on note switch / app blur only — minimal disk writes. Downside: data loss if app crashes mid-edit, no live preview of changes in other views. + +## Consequences + +- Users never need to manually save (Cmd+S still works as an immediate save). +- Auto-save triggers vault entry updates, keeping the note list, search, and relationships current. +- The same save path handles wikilink extraction and frontmatter parsing after save. +- Secondary windows (multi-window mode) each have their own auto-save via `useEditorSaveWithLinks`. +- Re-evaluation trigger: if 500ms is too aggressive for low-powered devices or network-synced vaults. diff --git a/docs/adr/0016-sentry-posthog-telemetry.md b/docs/adr/0016-sentry-posthog-telemetry.md new file mode 100644 index 0000000..6713a6b --- /dev/null +++ b/docs/adr/0016-sentry-posthog-telemetry.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0016" +title: "Sentry crash reporting + PostHog analytics with consent" +status: active +date: 2026-03-25 +--- + +## Context + +As Laputa approaches public release, crash reports and usage analytics are needed to identify bugs and understand feature adoption. However, as a personal knowledge management app that handles sensitive data, user privacy is paramount. Any telemetry must be opt-in with clear consent. + +## Decision + +**Integrate Sentry for crash reporting and PostHog for product analytics, both gated behind an explicit consent dialog on first launch. Users can toggle each independently in Settings. No telemetry is sent without affirmative consent.** + +## Options considered + +- **Option A** (chosen): Sentry + PostHog with consent dialog — industry-standard tools, separate crash/analytics toggles, privacy-respecting opt-in. Downside: two external dependencies, two services to manage. +- **Option B**: Self-hosted error tracking — full data control. Downside: operational burden, limited analytics features. +- **Option C**: No telemetry — simplest, most private. Downside: blind to crashes and usage patterns, harder to prioritize features. + +## Consequences + +- `TelemetryConsentDialog` shows on first launch with accept/decline buttons. +- Accepting generates an `anonymous_id` (no PII) and sets `telemetry_consent: true` in settings. +- `useTelemetry` hook reactively initializes/tears down Sentry and PostHog based on settings. +- Both frontend (`src/lib/telemetry.ts`) and Rust backend (`src-tauri/src/telemetry.rs`) have path scrubbers in `beforeSend` hooks to strip vault paths. +- DSN/keys come from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars. +- `reinit_telemetry` Tauri command toggles Rust-side Sentry at runtime. +- Re-evaluation trigger: if a unified telemetry platform (e.g., OpenTelemetry) could replace both services. diff --git a/docs/adr/0018-codescene-code-health-gates.md b/docs/adr/0018-codescene-code-health-gates.md new file mode 100644 index 0000000..68dabb6 --- /dev/null +++ b/docs/adr/0018-codescene-code-health-gates.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0018" +title: "CodeScene code health gates in CI and git hooks" +status: superseded +date: 2026-03-13 +superseded_by: "0064" +--- + +## Context + +Code complexity tends to increase over time, especially in fast-moving projects. Without automated enforcement, hotspot files (most-edited files) degrade in quality, making future changes harder and buggier. A quantitative code health metric was needed to prevent regression. + +## Decision + +**Enforce CodeScene code health scores as mandatory gates in pre-commit and pre-push hooks. Hotspot Code Health must be >= 9.5 and Average Code Health must be >= 9.31 (project-wide). Both gates block commit/push on failure.** The Boy Scout Rule ("leave every file better than you found it") is enforced as part of every task. + +## Options considered + +- **Option A** (chosen): CodeScene with hard gates — quantitative, automated, catches complexity before it merges. Downside: can slow development if scores are borderline, requires CodeScene API access. +- **Option B**: Manual code review for complexity — human judgment. Downside: subjective, inconsistent, doesn't scale. +- **Option C**: Linter-only rules (ESLint complexity, Clippy) — built-in, no external service. Downside: coarser metrics, no hotspot awareness, no project-wide average tracking. + +## Consequences + +- Pre-commit hook runs vitest + CodeScene health check before every commit. +- Pre-push hook runs the same checks plus Playwright smoke tests. +- Developers must fix complexity regressions before committing — even in files they didn't directly modify if changes indirectly affected complexity. +- Never use `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate. +- Common fixes: extract hooks, split large components, reduce function complexity, extract modules. +- `.codesceneignore` excludes `tools/`, `e2e/`, `tests/`, `scripts/` from analysis. +- Re-evaluation trigger: if CodeScene becomes unavailable or a better code health tool emerges. diff --git a/docs/adr/0019-github-device-flow-oauth.md b/docs/adr/0019-github-device-flow-oauth.md new file mode 100644 index 0000000..cae522e --- /dev/null +++ b/docs/adr/0019-github-device-flow-oauth.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0019" +title: "GitHub device flow OAuth for vault sync" +status: active +date: 2026-02-28 +--- + +## Context + +Laputa supports git-backed vaults synced to GitHub. Users need to authenticate with GitHub to clone repos, push changes, and create new vault repositories. In a desktop app, the standard OAuth redirect flow is awkward (no web server to receive the callback). The Device Authorization Flow is designed for exactly this scenario. + +## Decision + +**Use GitHub's Device Authorization Flow (OAuth device code grant) for GitHub authentication. The user sees a code, opens a browser to enter it, and the app polls for the token. Token is persisted in app settings for future git operations.** + +## Options considered + +- **Option A** (chosen): Device Authorization Flow — designed for desktop/CLI apps, no redirect URI needed, secure (user authenticates in their own browser). Downside: requires user to switch to browser and back. +- **Option B**: Personal Access Token (PAT) entry — user generates token on GitHub, pastes it in Settings. Downside: poor UX, users must navigate GitHub settings, token scope management is manual. +- **Option C**: OAuth redirect with local server — spawn a local HTTP server to receive the redirect. Downside: port conflicts, firewall issues, more complex implementation. + +## Consequences + +- `GitHubDeviceFlow` component handles the OAuth flow UI (device code display, polling, success/error states). +- `GitHubVaultModal` enables cloning existing repos or creating new ones. +- Token persisted in `settings.json` under `github_token` / `github_username`. +- `SettingsPanel` shows connection status with disconnect option. +- Uses Tauri opener plugin to launch the browser for user authentication. +- Re-evaluation trigger: if Tauri gains native OAuth redirect support that's simpler than the device flow. diff --git a/docs/adr/0020-keyboard-first-design.md b/docs/adr/0020-keyboard-first-design.md new file mode 100644 index 0000000..5fe8af9 --- /dev/null +++ b/docs/adr/0020-keyboard-first-design.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0020" +title: "Keyboard-first design principle" +status: active +date: 2026-03-01 +--- + +## Context + +Laputa is a productivity tool for knowledge workers who spend most of their time typing. Mouse-heavy interactions interrupt flow. Every feature should be reachable without touching the mouse, and the app must be fully testable via keyboard events (important for Playwright automation and accessibility). + +## Decision + +**Every feature must be reachable via keyboard. Every command palette entry must also appear in the macOS menu bar (File / Edit / View / Note / Vault / Window). This is both a design principle and a QA requirement.** Navigation, note switching, panel toggling, search, and all commands work via keyboard shortcuts or the Cmd+K command palette. + +## Options considered + +- **Option A** (chosen): Keyboard-first with menu bar parity — full keyboard accessibility, menu bar for discoverability, testable via Playwright keyboard events. Downside: more work per feature (must wire shortcut + menu item + command palette entry). +- **Option B**: Mouse-primary with some shortcuts — faster to implement. Downside: poor flow for power users, harder to automate testing. +- **Option C**: Keyboard-only (no menu bar) — simplest. Downside: poor discoverability, macOS HIG violation. + +## Consequences + +- `useCommandRegistry` + `useAppCommands` build a centralized command registry with labels, shortcuts, and handlers. +- `CommandPalette` (Cmd+K) fuzzy-searches all registered commands. +- `menu.rs` defines the native macOS menu bar with accelerators matching keyboard shortcuts. +- `useAppKeyboard` registers global keyboard shortcuts. +- `useMenuEvents` bridges menu bar clicks to command handlers. +- QA uses `osascript` keyboard events for native testing — no mouse, no `cliclick`. +- macOS gotcha: `Option+N` produces special characters — use `e.code` or `Cmd+N` instead. +- Re-evaluation trigger: if a non-macOS platform (Windows, Linux) is supported and needs different menu/shortcut conventions. diff --git a/docs/adr/0021-push-to-main-workflow.md b/docs/adr/0021-push-to-main-workflow.md new file mode 100644 index 0000000..74fdecb --- /dev/null +++ b/docs/adr/0021-push-to-main-workflow.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0021" +title: "Push directly to main (no PRs or branches)" +status: active +date: 2026-03-02 +--- + +## Context + +Initially, the project used feature branches and PRs. With a single developer (assisted by Claude Code), the PR overhead — branch creation, rebase churn, merge conflicts from long-lived branches — slowed development without adding review value. The pre-commit and pre-push hooks already enforce tests, linting, type checking, and code health gates. + +## Decision + +**Push directly to main — no PRs, no feature branches. The pre-push hook runs all quality gates (tests, lint, type check, coverage, CodeScene health). Never use `--no-verify`.** + +## Options considered + +- **Option A** (chosen): Push to main with hook-enforced quality gates — fastest iteration, no rebase churn, hooks provide automated review. Downside: no PR-based review, harder to roll back a batch of changes. +- **Option B**: Feature branches with PRs — standard team workflow, code review. Downside: rebase churn for a solo developer, PR overhead with no reviewer. +- **Option C**: Feature branches without PRs (merge to main locally) — branch isolation without review overhead. Downside: still has merge conflicts, branches diverge. + +## Consequences + +- Commit every 20-30 minutes with conventional commit prefixes (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`). +- Pre-commit hook: vitest + CodeScene health check. +- Pre-push hook: same + Playwright smoke tests. +- No `--no-verify` ever — the hooks are the quality gate. +- Reverting changes requires `git revert` (not force push). +- Re-evaluation trigger: if a second developer joins and needs code review. diff --git a/docs/adr/0022-blocknote-rich-text-editor.md b/docs/adr/0022-blocknote-rich-text-editor.md new file mode 100644 index 0000000..8d866c9 --- /dev/null +++ b/docs/adr/0022-blocknote-rich-text-editor.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0022" +title: "BlockNote as the rich text editor" +status: active +date: 2026-02-15 +--- + +## Context + +Laputa needs a rich text editor that can render markdown with YAML frontmatter, support custom inline content types (wikilinks), and provide a modern editing experience. The editor must handle the markdown-to-blocks-to-markdown round-trip without data loss. + +## Decision + +**Use BlockNote as the primary rich text editor, with CodeMirror 6 as an alternative raw editing mode. Custom wikilink inline content is defined via `createReactInlineContentSpec`. Markdown round-tripping uses a pre/post-processing pipeline with placeholder tokens for wikilinks.** + +## Options considered + +- **Option A** (chosen): BlockNote + CodeMirror 6 raw mode — BlockNote provides modern block-based editing, CodeMirror gives power users direct markdown access. Downside: wikilink round-tripping requires custom preprocessing pipeline. +- **Option B**: ProseMirror directly — maximum control. Downside: much more boilerplate, no block-level abstractions, harder to maintain. +- **Option C**: CodeMirror only (no rich text) — simplest, no round-trip issues. Downside: poor UX for non-technical users, no inline previews. +- **Option D**: Monaco Editor — rich features, VS Code-like. Downside: heavy, designed for code not prose, no block-level structure. + +## Consequences + +- Custom wikilink type defined in `editorSchema.tsx` via `createReactInlineContentSpec`. +- Markdown-to-BlockNote pipeline: `splitFrontmatter()` → `preProcessWikilinks()` → `tryParseMarkdownToBlocks()` → `injectWikilinks()`. +- BlockNote-to-Markdown pipeline: `blocksToMarkdownLossy()` → `postProcessWikilinks()` → prepend frontmatter. +- Placeholder tokens use `‹` and `›` (U+2039/U+203A) to avoid colliding with markdown syntax. +- Raw editor (CodeMirror 6) toggled via Cmd+K → "Raw Editor" or breadcrumb bar button. +- The H1 block is hidden via CSS in favor of a dedicated `TitleField` component. +- Re-evaluation trigger: if BlockNote's markdown round-tripping degrades or a better block editor emerges. diff --git a/docs/adr/0023-repair-vault-auto-bootstrap.md b/docs/adr/0023-repair-vault-auto-bootstrap.md new file mode 100644 index 0000000..4d46606 --- /dev/null +++ b/docs/adr/0023-repair-vault-auto-bootstrap.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0023" +title: "Repair Vault auto-bootstrap pattern" +status: active +date: 2026-03-07 +--- + +## Context + +As Laputa adds features that depend on vault files (type definitions, config files, agents), users with existing vaults would miss these files. Manually creating them is error-prone. Features must work on both new and existing vaults without user intervention. + +## Decision + +**Every feature that depends on vault files must auto-bootstrap: check if file/folder exists on vault open, create with defaults if missing (silent, idempotent). All bootstrap functions are registered with the central `Cmd+K → "Repair Vault"` command for manual re-creation.** + +## Options considered + +- **Option A** (chosen): Auto-bootstrap on vault open + manual Repair Vault command — works for new and existing vaults, idempotent, no user action needed. Downside: vault may accumulate files the user didn't explicitly create. +- **Option B**: Require users to run a setup wizard — explicit, user-controlled. Downside: friction, users forget, new features don't work until setup is run. +- **Option C**: Store defaults in app bundle, not vault — no vault files created. Downside: breaks the "vault as source of truth" principle, custom configs can't override defaults. + +## Consequences + +- Type definitions (`type/project.md`, etc.) are seeded on vault open if missing. +- Config files (`config/agents.md`, etc.) are seeded on vault open if missing. +- `Repair Vault` command (Cmd+K) re-creates all expected files — useful after manual deletion or vault corruption. +- All bootstrap operations are silent and idempotent — running twice has no effect. +- `getting_started.rs` creates the Getting Started demo vault with all expected structure. +- The `vault_health_check` command detects missing or misconfigured vault files. +- Re-evaluation trigger: if the number of auto-created files becomes excessive or confusing for users. diff --git a/docs/adr/0024-cache-outside-vault.md b/docs/adr/0024-cache-outside-vault.md new file mode 100644 index 0000000..a1ac1c1 --- /dev/null +++ b/docs/adr/0024-cache-outside-vault.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0024" +title: "Vault cache stored outside vault directory" +status: active +date: 2026-03-08 +--- + +## Context + +The vault cache was originally stored as `.laputa-cache.json` inside the vault directory. This caused problems: the cache file appeared in git status, polluted the user's repo, and could be accidentally committed. It also confused vault scanning (the cache file was itself a file in the vault). + +## Decision + +**Store the vault cache at `~/.laputa/cache/.json`, outside the vault directory. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Legacy cache files inside the vault are auto-migrated and deleted on first run.** + +## Options considered + +- **Option A** (chosen): External cache at `~/.laputa/cache/` — never pollutes the vault, no git issues, deterministic filename from vault path hash. Downside: separate cleanup needed if vault is deleted. +- **Option B**: Cache inside vault with `.gitignore` — simpler, travels with the vault. Downside: .gitignore can be overridden, users may not have one, still appears in file listings. +- **Option C**: No persistent cache (in-memory only) — simplest, no file management. Downside: full rescan on every app launch, slow for large vaults. + +## Consequences + +- Cache path: `~/.laputa/cache/.json` (e.g., `~/.laputa/cache/12345678.json`). +- Writes are atomic: write to `.tmp` then rename. +- Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted. +- `reload_vault` command deletes the cache file before rescanning. +- The `.laputa/` directory also stores other app data (future: vault metadata, indexes). +- Re-evaluation trigger: if vaults need to be portable between machines (cache would need to travel with the vault or be regenerated). diff --git a/docs/adr/0025-type-field-canonical.md b/docs/adr/0025-type-field-canonical.md new file mode 100644 index 0000000..06f55f7 --- /dev/null +++ b/docs/adr/0025-type-field-canonical.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0025" +title: "type: as canonical field (replacing Is A:)" +status: active +date: 2026-03-08 +--- + +## Context + +The entity type field was originally stored as `Is A:` in frontmatter (e.g., `Is A: Project`), following a natural-language naming convention. This caused problems: the space and colon made it awkward to parse, `is_a` was used internally as the snake_case variant, and `type:` is the standard YAML convention for metadata classification. The field name also confused AI agents that expected standard YAML conventions. + +## Decision + +**Use `type:` as the primary frontmatter field for entity types (e.g., `type: Project`). The legacy `Is A:` field is accepted as an alias for backward compatibility but new notes always use `type:`.** The internal TypeScript/Rust property remains `isA` for backward compatibility. + +## Options considered + +- **Option A** (chosen): `type:` as canonical with `Is A:` as legacy alias — clean, standard YAML convention, AI-readable. Downside: must maintain backward compatibility with existing vaults. +- **Option B**: Keep `Is A:` as canonical — no migration needed. Downside: non-standard, awkward parsing, confusing for AI agents. +- **Option C**: `kind:` or `category:` — avoids potential YAML type conflicts. Downside: less intuitive, still requires migration from `Is A:`. + +## Consequences + +- New notes use `type: Project` (not `Is A: Project`). +- The Rust parser checks `type:` first, falls back to `Is A:` for legacy notes. +- `VaultEntry.isA` property name kept for internal backward compatibility. +- Type documents in `type/` folder use `type: Type` in their own frontmatter. +- Repair Vault migrates legacy `Is A:` fields to `type:` when run. +- Re-evaluation trigger: if YAML reserved word `type` causes parsing issues (not observed so far). diff --git a/docs/adr/0026-props-down-no-global-state.md b/docs/adr/0026-props-down-no-global-state.md new file mode 100644 index 0000000..99a1b6a --- /dev/null +++ b/docs/adr/0026-props-down-no-global-state.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0026" +title: "Props-down callbacks-up (no global state management)" +status: superseded +date: 2026-02-15 +superseded_by: "0115" +--- + +## Context + +React apps commonly adopt global state management libraries (Redux, Zustand, Jotai, Context) to share state across components. For Laputa, the component tree is relatively shallow (App → panels → sub-components), and the data flow is predictable. Adding a state management library would increase complexity without proportional benefit. + +## Decision + +**No global state management (no Redux, no Context for data). `App.tsx` owns the state and passes it down as props. Child-to-parent communication uses callback props (`onSelectNote`, `onCloseTab`, etc.). Local state uses `useState`/`useReducer`.** + +## Options considered + +- **Option A** (chosen): Props-down, callbacks-up — simple, predictable data flow, easy to trace state changes, no library dependency. Downside: prop drilling through deep trees, verbose parent components. +- **Option B**: Redux/Zustand global store — centralized state, easy cross-component access. Downside: boilerplate, indirection, harder to trace state changes, over-engineering for a single-window app. +- **Option C**: React Context for shared state — built-in, no library. Downside: re-renders on any context value change, performance issues with large state objects. + +## Consequences + +- `App.tsx` is the state orchestrator — it holds vault entries, active note, sidebar selection, and all top-level state. +- Components receive data and callbacks as props — no `useContext` for data access. +- Hooks (`useVaultLoader`, `useNoteActions`, `useTabManagement`, etc.) encapsulate state logic but return values consumed by `App.tsx`. +- Prop drilling is mitigated by composing hooks and keeping the component tree shallow. +- Components are easy to test in isolation (just pass props). +- Re-evaluation trigger: if the component tree deepens significantly or cross-cutting state becomes unmanageable with props. diff --git a/docs/adr/0027-dual-ai-architecture.md b/docs/adr/0027-dual-ai-architecture.md new file mode 100644 index 0000000..42eb422 --- /dev/null +++ b/docs/adr/0027-dual-ai-architecture.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0027" +title: "Dual AI architecture (API chat + CLI agent)" +status: superseded +superseded_by: "0028" +date: 2026-03-01 +--- + +## Context + +Laputa needs two distinct AI interaction modes: a lightweight chat for quick questions about the current note (no tool access, fast responses), and a full agent that can search, read, create, and modify vault notes via MCP tools. These have fundamentally different requirements — the chat needs low latency and simple streaming, while the agent needs tool calling, conversation state, and MCP integration. + +## Decision + +**Maintain two separate AI interfaces: AI Chat (AIChatPanel) uses the Anthropic API directly via Rust for simple streaming responses. AI Agent (AiPanel) spawns Claude CLI as a subprocess with MCP vault integration for full tool access.** Both share a context builder (`ai-context.ts`) that provides the active note and linked entries. + +## Options considered + +- **Option A** (chosen): Dual architecture — optimized for each use case. Chat is fast and simple; agent is powerful with tool access. Downside: two codepaths to maintain. +- **Option B**: Single agent for both — always use Claude CLI. Downside: overkill for simple questions, slower startup, unnecessary tool overhead. +- **Option C**: Single API-based chat with manual tool calling — unified codebase. Downside: complex tool-calling loop implementation, no MCP integration. + +## Consequences + +- AI Chat: `AIChatPanel` + `useAIChat` hook → Rust `ai_chat` command → Anthropic API. Default model: Haiku 3.5 (fast, cheap). +- AI Agent: `AiPanel` + `useAiAgent` hook → Rust `claude_cli.rs` → Claude CLI subprocess with MCP config. +- Both panels share a toggle in the breadcrumb bar (Sparkle icon). +- Context builder (`ai-context.ts`) provides structured JSON with active note, linked notes, open tabs, vault metadata. +- Token budget: 60% of 180k context limit (~108k tokens max). +- Chat requires an Anthropic API key in settings; agent uses Claude CLI's own authentication. +- Re-evaluation trigger: if Anthropic releases an SDK that handles both simple chat and tool calling efficiently. diff --git a/docs/adr/0028-cli-agent-only-no-api-key.md b/docs/adr/0028-cli-agent-only-no-api-key.md new file mode 100644 index 0000000..2b579be --- /dev/null +++ b/docs/adr/0028-cli-agent-only-no-api-key.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0028" +title: "CLI agent only — no direct Anthropic API key" +status: active +date: 2026-03-29 +supersedes: "0027" +--- + +## Context + +ADR-0027 introduced a dual AI architecture: a lightweight API-based chat (AIChatPanel) using the Anthropic API directly, and a full CLI agent (AiPanel) spawning Claude CLI as a subprocess with MCP tool access. In practice, the API chat was never shipped to users — the CLI agent covered all use cases and provided a superior experience through tool access and MCP integration. Maintaining two codepaths added complexity, and requiring users to manage an Anthropic API key created friction. + +## Decision + +**Remove the direct Anthropic API integration entirely. AI is available exclusively via CLI agent subprocesses (Claude Code, and in the future Codex or other CLI agents).** No API key field in settings. The CLI agent authenticates via its own mechanism (e.g. `claude` CLI login). + +Removed: +- `AIChatPanel` component, `useAIChat` hook +- Rust `ai_chat` command and `ai_chat.rs` module +- `anthropic_key` field from Settings (Rust and TypeScript) +- Vite dev-server Anthropic API proxy (`aiChatProxyPlugin`, `aiAgentProxyPlugin`) + +Kept: +- `AiPanel` + `useAiAgent` — Claude CLI subprocess with MCP vault integration +- Shared utilities in `ai-chat.ts` (`trimHistory`, `formatMessageWithHistory`, `streamClaudeChat`, etc.) +- `Cmd+I` keyboard shortcut and menu item for toggling the AI panel + +## Options considered + +- **Option A** (chosen): Remove API chat, keep CLI agent only. Simplifies codebase, removes API key management, single codepath. +- **Option B**: Keep both but hide API chat behind feature flag. Adds dead code weight without benefit. +- **Option C**: Replace CLI agent with API chat + manual tool calling. Loses MCP integration and Claude CLI features. + +## Consequences + +- Users no longer need to obtain or manage an Anthropic API key +- Existing saved API keys are silently ignored (the field no longer exists in the Settings struct; serde skips unknown fields on deserialization) +- Future CLI agents (Codex, etc.) can plug into the same `AiPanel` architecture +- If a lightweight chat mode is needed later, it should be built as a CLI agent mode, not a separate API integration diff --git a/docs/adr/0029-domain-command-builder-pattern.md b/docs/adr/0029-domain-command-builder-pattern.md new file mode 100644 index 0000000..675f53c --- /dev/null +++ b/docs/adr/0029-domain-command-builder-pattern.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0029" +title: "Domain command builder pattern for useCommandRegistry" +status: active +date: 2026-03-30 +--- + +## Context + +`useCommandRegistry` was a 224-line "brain method" (CodeScene hotspot) that defined all command palette commands inline: navigation, note actions, git operations, view toggles, settings, type management, and filter controls. This monolithic structure scored 39 on CodeScene's complexity scale (target: ≤9.5 for hotspots), making it increasingly hard to add new commands without touching the central file. + +## Decision + +**Split command definitions into focused domain modules under `src/hooks/commands/`, each exporting a `build*Commands(config)` factory function. `useCommandRegistry` becomes a thin assembler that calls each builder and merges the results.** Domain modules: `navigationCommands`, `noteCommands`, `gitCommands`, `viewCommands`, `settingsCommands`, `typeCommands`, `filterCommands`. Shared types live in `commands/types.ts`; public API re-exported from `commands/index.ts`. + +## Options considered + +- **Option A** (chosen): Domain builder modules — each module owns its command shape and receives typed config. `useCommandRegistry` is pure assembly. All new files score 9.58–10.0. Downside: more files to navigate. +- **Option B**: Split by file but keep one large hook calling sub-hooks — sub-hooks still need shared state passed down, similar coupling. No real complexity win. +- **Option C**: Register commands imperatively via a global registry — decouples callers entirely. Downside: harder to trace, no TypeScript inference at the registration site, over-engineering for current scale. + +## Consequences + +- Adding a new command means editing the relevant domain module (e.g. `noteCommands.ts`) only, not touching the assembler. +- Each domain module receives only the config it needs — explicit, typed interface, no hook dependency. +- `useCommandRegistry` reduced from 224 lines to a thin assembler. +- Pattern is consistent with the Rust commands/ module split (ADR-0030). +- Re-evaluation trigger: if command count grows to the point where the assembler itself becomes a complexity hotspot. diff --git a/docs/adr/0030-rust-commands-module-split.md b/docs/adr/0030-rust-commands-module-split.md new file mode 100644 index 0000000..20f8a1d --- /dev/null +++ b/docs/adr/0030-rust-commands-module-split.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0030" +title: "Rust commands/ module split by domain" +status: active +date: 2026-03-30 +--- + +## Context + +`src-tauri/src/commands.rs` grew to 937 lines as Tauri command handlers accumulated for vault CRUD, git/GitHub sync, AI, system, and window operations. All commands shared a single file with no domain separation, making it hard to navigate, review, and extend. The file was a CodeScene hotspot dragging down overall code health. + +## Decision + +**Replace `commands.rs` with a `commands/` module split by domain: `vault.rs`, `git.rs`, `github.rs`, `ai.rs`, `system.rs`, and `mod.rs` (shared utilities + re-exports).** Each file owns the Tauri command handlers for its domain and the `#[cfg(desktop)]` / `#[cfg(mobile)]` stubs for platform-conditional availability. `mod.rs` is kept thin (≤100 lines) with no command logic — only re-exports and shared helpers (`expand_tilde`, `parse_build_label`). + +## Options considered + +- **Option A** (chosen): Domain-based module split — mirrors the TypeScript `hooks/commands/` pattern (ADR-0029). Each file is independently reviewable and scores well on code health. Downside: more files to navigate. +- **Option B**: Split by platform (`desktop.rs`, `mobile.rs`) — aligns with `#[cfg(...)]` guards but mixes domain concerns. Harder to find a specific command. +- **Option C**: Keep monolith but add section comments — zero file-count cost, but doesn't solve complexity or reviewability. + +## Consequences + +- `github.rs` separates GitHub OAuth/API commands from git sync commands (`git.rs`), matching the underlying Rust module split (`github/` vs `git/`). +- Platform stubs (`#[cfg(mobile)]` error returns) live alongside the desktop implementation in the same domain file. +- `mod.rs` re-exports all command functions so `lib.rs` `invoke_handler!` registration is unchanged. +- New Tauri commands go into the appropriate domain file; if no domain fits, create a new one rather than putting it in `mod.rs`. +- Re-evaluation trigger: if a single domain file (e.g. `vault.rs`) itself grows beyond ~300 lines and becomes a hotspot. diff --git a/docs/adr/0031-full-app-for-note-windows.md b/docs/adr/0031-full-app-for-note-windows.md new file mode 100644 index 0000000..1849052 --- /dev/null +++ b/docs/adr/0031-full-app-for-note-windows.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0031" +title: "Full App instance for secondary note windows" +status: active +date: 2026-03-31 +--- + +## Context + +Laputa supports opening a note in a secondary window ("Open in New Window"). The original implementation used a dedicated `NoteWindow` component — a thin shell that rendered only the editor, duplicating some App-level logic (vault loading, settings, keyboard shortcuts) in a simplified but diverging form. As the main App gained features (properties editing, zoom, command palette, keyboard shortcuts), the `NoteWindow` shell fell behind, requiring ongoing maintenance to keep parity. + +## Decision + +**Remove `NoteWindow` and render the full `App` component in secondary note windows.** The window type is detected at startup via URL query parameters (`?window=note&path=...&vault=...`). When in note-window mode, the App initialises with panels hidden (sidebar collapsed, inspector collapsed) and auto-opens the target note once vault entries load. The window title is kept in sync with the active note title via the Tauri window API. + +## Options considered + +- **Keep `NoteWindow` shell** (status quo): lower initial bundle weight per window, but divergence grows with every main-App feature. Rejected — maintenance cost dominates. +- **Full `App` instance with URL-param mode** (chosen): complete feature parity for free; single code path for all window types. Trade-off: slightly heavier startup for secondary windows (full vault load), acceptable given local filesystem speed. +- **IPC-driven secondary window (no vault reload)**: secondary window subscribes to primary window's vault state via Tauri events. Maximum efficiency, avoids double vault reads. Deferred — requires significant IPC plumbing; can be layered on top later without changing the rendering model. + +## Consequences + +- Removes ~163 lines (`NoteWindow.tsx` deleted entirely) +- Secondary note windows get full feature parity: all keyboard shortcuts, properties panel, zoom, command palette, diff mode, raw editor +- `useLayoutPanels` gains an `initialInspectorCollapsed` option to support the hidden-panel initial state +- A new `src/utils/windowMode.ts` utility encapsulates URL-param detection — single source of truth for window-type logic +- Vault is loaded independently in each note window (no shared state with the main window); writes go to the same filesystem so eventual consistency is maintained via file-watching +- Triggers re-evaluation if: multiple simultaneous note windows cause measurable vault-read contention, or if IPC-driven shared-state windows become a product requirement diff --git a/docs/adr/0032-status-bar-for-git-actions.md b/docs/adr/0032-status-bar-for-git-actions.md new file mode 100644 index 0000000..9accfb1 --- /dev/null +++ b/docs/adr/0032-status-bar-for-git-actions.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0032" +title: 0032 Status Bar For Git Actions +status: active +date: 2026-03-31 +--- +[[Subfolder scanning and folder tree navigation]] + +## Context + +The Laputa sidebar originally surfaced git-related affordances — a "Changes" nav item (visible when modified files > 0), a "Pulse" nav item, and a "Commit & Push" button — alongside the note-type navigation filters and sections. This mixed two concerns in the sidebar: **navigation** (where to go) and **git status / actions** (what changed, what to do). As the sidebar grew, the git items created visual noise and made the nav hierarchy harder to scan. + +## Decision + +**Move Changes, Pulse, and Commit & Push out of the sidebar and into the bottom status bar.** The status bar shows a GitDiff icon with an orange count badge for modified files; a Pulse icon sits next to it. Commit & Push is accessible via an icon button beside the Changes indicator. The sidebar now contains only navigation items (filters and type sections). + +## Options considered + +* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan. +* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom. +* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected. + +## Consequences + +* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only +* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props +* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended +* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state +* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar diff --git a/docs/adr/0033-subfolder-scanning-and-folder-tree.md b/docs/adr/0033-subfolder-scanning-and-folder-tree.md new file mode 100644 index 0000000..8dfc2b1 --- /dev/null +++ b/docs/adr/0033-subfolder-scanning-and-folder-tree.md @@ -0,0 +1,34 @@ +--- +type: ADR +id: "0033" +title: "Subfolder scanning and folder tree navigation" +status: active +date: 2026-03-31 +--- +## Context + +[[0032 Status Bar For Git Actions]] + +Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`). + +Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure. + +## Decision + +**Extend the Rust vault scanner to index **`.md`** files in all visible subdirectories, and expose the vault's folder tree via a new **`list_vault_folders`** Tauri command so the sidebar can render a collapsible FOLDERS section.** + +Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree. + +## Options considered + +* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache. +* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes. +* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories. + +## Consequences + +* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed. +* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching. +* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it. +* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root. +* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected). diff --git a/docs/adr/0034-git-repo-required-for-vault.md b/docs/adr/0034-git-repo-required-for-vault.md new file mode 100644 index 0000000..8da300f --- /dev/null +++ b/docs/adr/0034-git-repo-required-for-vault.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0034" +title: "Git repo required — blocking modal enforces vault prerequisite" +status: active +date: 2026-04-01 +--- + +## Context + +ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users. + +## Decision + +**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).** + +## Options considered + +- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault). +- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features. +- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git. + +## Consequences + +- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy. +- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history). +- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users. +- Browser mode bypasses the check so dev/Storybook workflows are unaffected. +- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting. diff --git a/docs/adr/0035-path-suffix-wikilink-resolution.md b/docs/adr/0035-path-suffix-wikilink-resolution.md new file mode 100644 index 0000000..b855ee0 --- /dev/null +++ b/docs/adr/0035-path-suffix-wikilink-resolution.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0035" +title: "Path-suffix wikilink resolution for subfolder vaults" +status: active +date: 2026-04-01 +--- + +## Context + +ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path. + +The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa". + +## Decision + +**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.** + +## Options considered + +- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins. +- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links. +- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks. + +## Consequences + +- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed). +- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`. +- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex. +- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`). +- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint. diff --git a/docs/adr/0036-external-rename-detection-via-git-diff.md b/docs/adr/0036-external-rename-detection-via-git-diff.md new file mode 100644 index 0000000..e146ae3 --- /dev/null +++ b/docs/adr/0036-external-rename-detection-via-git-diff.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0036" +title: "External rename detection via git diff on focus regain" +status: active +date: 2026-04-01 +--- + +## Context + +Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent. + +The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism. + +## Decision + +**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.** + +## Options considered + +- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not. +- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement. +- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename. + +## Consequences + +- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism. +- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes. +- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates. +- The banner is non-blocking and "Ignore" is always available — the user never loses work. +- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy. diff --git a/docs/adr/0037-codemirror-language-markdown-highlighting.md b/docs/adr/0037-codemirror-language-markdown-highlighting.md new file mode 100644 index 0000000..ea2e62a --- /dev/null +++ b/docs/adr/0037-codemirror-language-markdown-highlighting.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0037" +title: "Language-based markdown syntax highlighting in raw editor" +status: active +date: 2026-04-01 +--- + +## Context + +The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance. + +Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain. + +## Decision + +**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.** + +## Options considered + +- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately. +- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug. +- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope. + +## Consequences + +- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change. +- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting. +- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser. +- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser. +- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text). diff --git a/docs/adr/0038-frontmatter-backed-favorites.md b/docs/adr/0038-frontmatter-backed-favorites.md new file mode 100644 index 0000000..5a79a3c --- /dev/null +++ b/docs/adr/0038-frontmatter-backed-favorites.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0038" +title: "Frontmatter-backed favorites with _favorite and _favorite_index" +status: active +date: 2026-04-02 +--- + +## Context + +Users want to pin frequently-accessed notes to a dedicated FAVORITES section in the sidebar for quick navigation. The app needs a persistence mechanism for which notes are favorited and their display order. + +## Decision + +**Favorites are stored as two system properties in each note's YAML frontmatter: `_favorite: true` and `_favorite_index: `.** + +- `_favorite`: boolean. Present and `true` = favorited. Absent = not favorited. Toggling off deletes the key entirely (no `_favorite: false`). +- `_favorite_index`: integer. Controls display order in the FAVORITES sidebar section (lower = higher). Assigned automatically on favorite, updated on drag-to-reorder. +- Both use the `_` prefix convention (ADR 0008) — they are system-owned and hidden from the Properties panel. + +## Options considered + +- **Frontmatter per-note (chosen)**: Each note carries its own favorite state. Portable across devices (synced via git). No separate metadata file. Cons: two extra frontmatter writes on reorder. +- **Separate `.laputa/favorites.json` file**: Central list of favorite paths. Simpler reorder (one file write). Cons: not portable if `.laputa/` is gitignored; path references break on rename. +- **SQLite/app-level metadata**: Fast queries. Cons: not synced via git; diverges from frontmatter-first data model established in ADR 0008. + +## Consequences + +- Favorites survive vault sync via git — any client that reads frontmatter sees them. +- Reorder writes `_favorite_index` to N files (one per affected note). Acceptable for typical favorites lists (< 20 items). +- If `_favorite: true` exists but `_favorite_index` is absent, the note is appended to the end of the list. +- Re-evaluate if favorites list exceeds ~50 items and reorder writes become a performance concern. diff --git a/docs/adr/0039-git-history-for-note-dates.md b/docs/adr/0039-git-history-for-note-dates.md new file mode 100644 index 0000000..5b9003d --- /dev/null +++ b/docs/adr/0039-git-history-for-note-dates.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0039" +title: "Use git history for note creation and modification dates" +status: active +date: 2026-04-02 +--- + +## Context + +Filesystem metadata (`ctime`/`mtime`) is unreliable for a git-backed vault. After `git clone`, `git pull`, or iCloud sync, files appear "newly created" even when they have years of history. This causes incorrect sort ordering in the note list and wrong dates in the inspector panel. + +## Decision + +**Use `git log` to determine the true creation and modification dates for notes.** A single batch `git log --format="COMMIT %aI" --name-only` command walks the full commit history and extracts: + +- **modified_at** = author date of the most recent commit that touched the file +- **created_at** = author date of the oldest commit that touched the file + +The batch approach runs once per vault scan (not per-file), parsing the log output in a single linear pass. Results are stored in a `HashMap` keyed by vault-relative path and threaded through the existing `parse_md_file` / `scan_vault` / `scan_vault_cached` pipeline. + +### Fallback to filesystem dates + +- **Non-git vaults** (no `.git` directory): all notes use filesystem `mtime`/`ctime`. +- **Uncommitted new files**: not in git log output, so filesystem dates are used automatically. +- **Single-file reloads** (`reload_entry`): use filesystem dates since the file was just saved and the most accurate timestamp is the filesystem one. + +## Options considered + +- **Per-file `git log`**: Correct but O(n) subprocesses. Too slow for vaults with 500+ notes. +- **Frontmatter dates** (e.g., `created: 2025-01-15`): Requires user discipline. Not automatic. Breaks when users forget to set them. +- **Filesystem metadata** (current): Unreliable across clones, pulls, and cloud sync. +- **Single batch `git log`** (chosen): One subprocess, O(n) parsing, correct dates for all committed files. + +## Consequences + +- Note sort-by-created and sort-by-modified now reflect true git history, stable across clones and machines. +- First vault scan runs one `git log` over the full history. For a vault with 1000 files and 500 commits, output is ~100KB and parses in <100ms. +- Renamed files get `created_at` set to the rename commit date (not the original creation). Acceptable trade-off vs. the complexity of rename tracking. +- `CACHE_VERSION` bumped from 9 to 10 to force a full rescan with git dates on upgrade. diff --git a/docs/adr/0040-custom-views-yml-filter-engine.md b/docs/adr/0040-custom-views-yml-filter-engine.md new file mode 100644 index 0000000..794a68d --- /dev/null +++ b/docs/adr/0040-custom-views-yml-filter-engine.md @@ -0,0 +1,59 @@ +--- +type: ADR +id: "0040" +title: "Custom views as .yml files with client-side filter engine" +status: active +date: 2026-04-02 +--- + +## Context + +Users want to save reusable filtered note lists (e.g., "Active Projects", "This Week's Events") as named sidebar items. These views need to persist across sessions, sync via git, and support arbitrary frontmatter conditions. + +## Decision + +**Custom views are stored as `.yml` files in `.laputa/views/` within the vault root.** Each file defines a named view with filter conditions, optional icon/color, and sort preferences. + +### File format + +```yaml +name: Active Projects +icon: rocket +color: blue +sort: "modified:desc" +filters: + all: + - field: type + op: equals + value: Project + - field: status + op: not_equals + value: done +``` + +### Filter engine + +Filters use a tree of AND/OR groups (`all`/`any`) containing conditions. Each condition specifies a `field`, `op` (operator), and optional `value`. Supported operators: `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, `after`. + +Field resolution: built-in fields (`type`, `status`, `title`, `archived`, `trashed`, `favorite`) map to VaultEntry struct fields. Unknown fields fall back to `entry.properties`, then `entry.relationships`. + +Wikilink values like `[[target|Alias]]` are matched by stem (stripping brackets and pipe+alias). + +### Architecture + +- **Rust backend** (`vault/views.rs`): YAML parsing via `serde_yaml`, filter evaluation, file CRUD. Three Tauri commands: `list_views`, `save_view_cmd`, `delete_view_cmd`. +- **Frontend**: Client-side filter evaluation against the already-loaded `VaultEntry[]` array. The Rust `evaluate_view` exists for MCP/CLI access but is not the primary UI path. +- **Sidebar**: VIEWS section between Favorites and Types, hidden when no views exist. + +## Options considered + +- **SQLite views table**: Fast queries, but not portable via git and diverges from the file-first data model. +- **Frontmatter on a special `.md` file**: Overloads the note format for a non-note concept. +- **Standalone `.yml` files (chosen)**: Portable (synced via git), editable by hand or UI, naturally separated from note content. + +## Consequences + +- New dependency: `serde_yaml` crate for YAML parsing. +- `.laputa/views/` directory auto-created on first view save. Already excluded from vault scanning via `HIDDEN_DIRS`. +- Views sync across devices via git. Conflicts resolved by standard git merge (YAML is line-based, merges well). +- Sort persistence: changing sort while a view is selected writes `sort` back to the `.yml` file. diff --git a/docs/adr/0041-filekind-all-files-in-vault-scanner.md b/docs/adr/0041-filekind-all-files-in-vault-scanner.md new file mode 100644 index 0000000..c8b29fe --- /dev/null +++ b/docs/adr/0041-filekind-all-files-in-vault-scanner.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0041" +title: "fileKind field — scan all vault files, not just markdown" +status: active +date: 2026-04-02 +--- + +## Context + +Laputa vaults often contain non-markdown files alongside notes: images, PDFs, YAML configs, JSON exports, scripts, etc. Previously the vault scanner only indexed `.md` files — all other files were invisible to the app. This made the Folder view incomplete: navigating a folder containing a `config.yml` or `photo.png` showed nothing, even though the file was physically there. + +The need arose when adding a Folder tree view that is meant to mirror the actual filesystem structure. Users expect to see all files in a folder, as any file manager would show. + +## Decision + +**The vault scanner now indexes all files (not just `.md`). Every `VaultEntry` carries a `fileKind` field (`"markdown"`, `"text"`, or `"binary"`) that controls how the frontend renders and opens it.** + +- **`"markdown"`**: full Laputa behavior — frontmatter parsing, BlockNote editor, title sync, type system. +- **`"text"`**: filename as title, no frontmatter, opens in raw CodeMirror editor. Covers `.yml`, `.json`, `.ts`, `.py`, `.sh`, etc. +- **`"binary"`**: filename as title, grayed out, non-clickable. Covers images, PDFs, binaries. +- **Hidden files** (starting with `.`) are skipped regardless of extension. +- **Non-folder views** (All Notes, type sections, Custom Views) still show only `"markdown"` entries. +- **Folder view** shows all file kinds. + +## Options considered + +- **Option A** (chosen): Single `VaultEntry` model with a `fileKind` discriminator. All files go through the same pipeline; rendering is gated by `fileKind`. Simple, incremental — existing code paths untouched for markdown files. +- **Option B**: Separate data model for non-markdown files (e.g. `AssetEntry`). Cleaner type hierarchy, but requires duplicating list/filter/sort logic for two types across the codebase. +- **Option C**: Only scan `.md` + explicitly listed extensions (e.g. `.yml`, `.json`). Simpler initial implementation, but requires ongoing maintenance of an allowlist and still misses user files. Abandoned in favor of a deny-list approach (only `.`-prefixed hidden files are excluded). + +## Consequences + +- Non-markdown files are visible in Folder view — the app now behaves like a file manager in that context. +- All views except Folder view continue to show only markdown files (the `isMarkdown` guard in `filterEntries`). +- `countByFilter` / `countAllByFilter` exclude non-markdown entries to keep sidebar counters accurate. +- The vault cache version was bumped to `11` to force a full rescan after this change. +- Binary files have no click action — clicking does nothing (no editor opened). +- Re-evaluation trigger: if users need to preview or edit binary files (e.g. images), a dedicated preview pane would need a separate ADR. diff --git a/docs/adr/0042-posthog-release-channels-feature-flags.md b/docs/adr/0042-posthog-release-channels-feature-flags.md new file mode 100644 index 0000000..98205b8 --- /dev/null +++ b/docs/adr/0042-posthog-release-channels-feature-flags.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0042" +title: "PostHog-based release channels and feature flags" +status: active +date: 2026-04-03 +supersedes: "0017" +--- +## Context + +ADR-0017 introduced canary/stable update channels with localStorage-based feature flags. This worked for local development but lacked remote flag management — promoting a feature from beta to stable required a code change and rebuild. + +## Decision + +**Replace localStorage feature flags with PostHog-based feature flags, evaluated per release channel (alpha/beta/stable). The release channel is a user-selectable setting; PostHog flag rules determine which features are visible for each channel.** + +- **Alpha**: all features always enabled (no PostHog lookup needed, works offline) +- **Beta**: sees features where the PostHog flag targets `release_channel = beta` +- **Stable** (default): sees features where the PostHog flag targets `release_channel = stable` +- Promotion = flipping a PostHog flag on the dashboard. Zero code changes, zero rebuilds. +- `isFeatureEnabled(flagKey)` in `telemetry.ts` is the single evaluation point. +- localStorage overrides (`ff_`) still work for dev/QA testing (checked first). +- Offline: PostHog caches flags in localStorage; alpha always works; first-launch-no-network falls back to hardcoded defaults. + +## Options considered + +* **Option A**: Keep localStorage-only flags (ADR-0017) — no server dependency, but no remote management. +* **Option B** (chosen): PostHog feature flags — we already use PostHog for analytics, so no new dependency. Remote flag management, per-channel targeting, gradual rollouts via PostHog dashboard. +* **Option C**: Dedicated feature flag service (LaunchDarkly, Unleash) — more powerful but adds a new vendor dependency. + +## Consequences + +* `release_channel` added to Settings (persisted via Tauri backend, not vault). +* `useTelemetry` passes `release_channel` as a PostHog person property on identify. +* `isFeatureEnabled()` checks channel → PostHog → hardcoded defaults. +* `useFeatureFlag` hook updated to delegate to `isFeatureEnabled` (after localStorage override check). +* ADR-0017 is superseded — the canary update channel remains, but feature gating moves from localStorage to PostHog. diff --git a/docs/adr/0042-trash-auto-purge-safety-model.md b/docs/adr/0042-trash-auto-purge-safety-model.md new file mode 100644 index 0000000..852abf0 --- /dev/null +++ b/docs/adr/0042-trash-auto-purge-safety-model.md @@ -0,0 +1,56 @@ +--- +type: ADR +id: "0042" +title: "Trash auto-purge safety model" +status: superseded +date: 2026-04-05 +superseded_by: "0045" +--- + +## Context + +The Trash view already shows a "Notes trashed more than 30 days ago will be permanently deleted" warning, but the app never actually enforces this. Users expect trashed notes to be cleaned up automatically after 30 days — if we advertise it, we must implement it. + +This is one of the most dangerous operations in the app: a bug could cause irreversible data loss. The safety model must be explicit and conservative. + +## Decision + +**Auto-purge trashed notes older than 30 days on app launch and window focus (max once per hour), using OS trash (`trash::delete`) for soft-deletion, with mandatory 5-point safety validation per file and an audit log at `.laputa/purge.log`.** + +### Safety checks (all must pass before deleting any file) + +1. `_trashed: true` (or legacy aliases `Trashed`, `trashed`) is present in frontmatter and set to a truthy value +2. `_trashed_at` (or legacy aliases `Trashed at`, `trashed_at`) is present and parseable as a date +3. The parsed date is **strictly more than 30 days ago** (exactly 30 days = skip) +4. The file exists on disk at the expected path +5. The file's canonical path is inside the vault root (prevents path traversal) + +If any check fails, the file is skipped with a warning log. The purge never aborts early — it processes all candidates independently. + +### Deletion method + +Use the `trash` crate (`trash::delete`) to move files to the OS trash (macOS Trash, Windows Recycle Bin) instead of `fs::remove_file`. This gives users a last-resort recovery path. If OS trash fails, fall back to `fs::remove_file` and log a warning. + +### Trigger conditions + +- On app launch (in `run_startup_tasks`) +- On window focus (`WindowEvent::Focused(true)`) — throttled to max once per hour using a `Mutex` timestamp + +### Audit log + +Every purge run appends to `.laputa/purge.log` with timestamp, files checked count, files purged count, and each purged file path. Users can inspect this file to audit what was deleted and when. + +## Options considered + +- **Option A — OS trash via `trash` crate** (chosen): moves to OS trash, user can recover from Trash app. Adds a ~small dependency. Safe default. +- **Option B — `fs::remove_file` (permanent)**: simpler, no dependency, but no recovery path. Too risky for an automatic background operation. +- **Option C — Move to `.laputa/purged/` archive folder**: custom recovery mechanism, but clutters vault directory and users wouldn't know to look there. + +## Consequences + +- Users get the auto-cleanup behavior already advertised in the UI +- Accidentally trashed notes have a second chance via OS Trash +- The `trash` crate adds a platform-specific dependency (macOS: `NSFileManager`, Windows: `IFileOperation`, Linux: freedesktop spec) +- The hourly throttle prevents excessive disk I/O on rapid focus/unfocus cycles +- The purge log provides auditability but will grow over time (acceptable for a text log) +- Re-evaluate if users report OS Trash filling up with vault files diff --git a/docs/adr/0043-reactive-vault-state-on-save.md b/docs/adr/0043-reactive-vault-state-on-save.md new file mode 100644 index 0000000..f99fd20 --- /dev/null +++ b/docs/adr/0043-reactive-vault-state-on-save.md @@ -0,0 +1,63 @@ +--- +type: ADR +id: "0043" +title: "Reactive vault state: editor changes propagate immediately to all UI" +status: active +date: 2026-04-05 +--- +## Context + +When a user edits frontmatter in the raw editor (or BlockNote preserves it), changes to metadata fields like `title`, `type`, `_favorite`, `_archived`, and `sidebar_label` must be reflected immediately across all UI components — sidebar sections, note list, breadcrumb bar, inspector, and tabs. + +Previously, after `save_note_content`, only derived fields (`outgoingLinks`, `snippet`, `wordCount`) were updated in `vault.entries`. Frontmatter-derived fields were stale until a full vault reload. + +## Decision + +**All frontmatter changes are parsed in real-time and applied to `vault.entries` via `updateEntry()` during content editing, not after save.** + +### How it works + +1. **On every content change** (keystroke in raw editor, or BlockNote onChange), `useEditorSaveWithLinks.handleContentChange` is called. +2. It invokes `contentToEntryPatch(content)` which parses frontmatter and maps known keys to `VaultEntry` fields. +3. If the parsed patch differs from the previous one, `updateEntry(path, patch)` merges it into `vault.entries`. +4. All UI components derive from `vault.entries` via React reactivity — they re-render automatically. + +### Mapped fields + +`contentToEntryPatch` maps these frontmatter keys to `VaultEntry` fields: + +| Frontmatter key | VaultEntry field | Notes | +|---|---|---| +| `title` | `title` | | +| `type` / `is_a` | `isA` | | +| `status` | `status` | | +| `_favorite` | `favorite` | | +| `_favorite_index` | `favoriteIndex` | | +| `_archived` / `archived` | `archived` | | +| `_trashed` / `trashed` | `trashed` | | +| `_organized` | `organized` | | +| `color` | `color` | Type entries | +| `icon` | `icon` | Type entries | +| `order` | `order` | Type entries | +| `sidebar_label` | `sidebarLabel` | Type entries | +| `visible` | `visible` | Type entries | +| `template` | `template` | Type entries | +| `sort` | `sort` | Type entries | +| `view` | `view` | Type entries | +| `aliases` | `aliases` | | +| `belongs_to` | `belongsTo` | | +| `related_to` | `relatedTo` | | + +### Inspector operations use a separate, more direct path + +When the user edits frontmatter via the Inspector panel, `runFrontmatterAndApply` calls the Tauri command and immediately applies the result via `updateEntry()`. This path was already reactive before this ADR. + +### View files (.yml) + +View files are not markdown notes — they have no frontmatter delimiters. When a `.yml` file is saved, `onNotePersisted` triggers `reloadViews()` to refresh the sidebar view list. + +## Consequences + +- Any new frontmatter key that should affect the UI must be added to `frontmatterToEntryPatch` and its delete counterpart. +- Components must read note metadata from `vault.entries` (via props), never from local state that could diverge. +- The `reload_vault_entry` Tauri command exists for full re-parsing from disk but is not needed in the normal editing flow — `contentToEntryPatch` handles it client-side. diff --git a/docs/adr/0044-h1-as-title-primary-source.md b/docs/adr/0044-h1-as-title-primary-source.md new file mode 100644 index 0000000..b03acee --- /dev/null +++ b/docs/adr/0044-h1-as-title-primary-source.md @@ -0,0 +1,46 @@ +--- +type: ADR +id: "0044" +title: "H1 as primary title source — filename as stable identifier" +status: active +date: 2026-04-07 +supersedes: "0007" +--- + +## Context + +ADR-0007 established that the `title:` frontmatter field is the source of truth for display titles, with filenames derived from it via slugification and kept in sync bidirectionally. This model had a key assumption: the user explicitly types a title before writing content. + +In practice this created friction: new notes required a title upfront, the TitleField was always visible cluttering the editor, and the "title = filename slug" contract was fragile when users renamed files externally. The team wanted a more natural writing flow where you just start writing — like most text editors — and the title emerges from the document. + +A pair of commits on 2026-04-06 (`377a3f8d`, `7daf6898`) implemented a fundamentally different model. + +## Decision + +**The first `# H1` heading in the note body is the canonical display title. The `title:` frontmatter field is legacy/backward-compat only. New notes are created with filename `untitled-{type}-{timestamp}.md` and no `title:` in frontmatter. On save, if the note has an H1, the file is auto-renamed to a slug derived from it (collision-safe with `-2`, `-3` suffixes).** + +Title resolution priority (Rust `extract_title`): +1. H1 on the first non-empty line of the body +2. Frontmatter `title:` field (legacy, backward-compat) +3. Slug-to-title derivation from filename stem + +The `has_h1: bool` field on `VaultEntry` signals the frontend to hide `TitleField` and the icon picker when an H1 is present, since the H1 serves as the title surface. + +The breadcrumb bar shows the **filename stem** (not display title) so users always know the actual file identifier. + +Auto-rename (`auto_rename_untitled` Tauri command) fires on save for `untitled-*` files that gain an H1, converting them to a human-readable slug. + +## Options considered + +- **Option A — H1 as primary title + auto-rename on save** (chosen): natural writing flow, filename eventually reflects content, TitleField hidden when H1 present. Downside: auto-rename can surprise users; breadcrumb must show filename to stay honest. +- **Option B — Keep `title:` frontmatter as source of truth** (ADR-0007, now superseded): explicit, deterministic. Downside: forces upfront titling, TitleField always visible, friction for quick capture. +- **Option C — UUID-based filenames, title only in H1**: filenames never change, no rename logic needed. Downside: vault unreadable in Finder/terminal, breaks the plain-files principle (ADR-0002). + +## Consequences + +- New notes start as `untitled-note-{timestamp}.md` — the vault may accumulate untitled files if users abandon drafts without writing an H1 +- `TitleField` component is hidden when `has_h1 = true`; icon picker is also hidden (icons only make sense on titled notes) +- Frontmatter `title:` still parsed for backward-compat; existing vaults with explicit titles continue to work +- Auto-rename on save introduces a file rename side-effect during editing — wikilinks pointing to the old filename may break until the rename propagates +- The breadcrumb filename display makes the system more honest but slightly more technical for non-power users +- Re-evaluate if users find auto-rename disorienting or if wikilink breakage during rename becomes a reliability concern diff --git a/docs/adr/0045-permanent-delete-no-trash.md b/docs/adr/0045-permanent-delete-no-trash.md new file mode 100644 index 0000000..843b39e --- /dev/null +++ b/docs/adr/0045-permanent-delete-no-trash.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0045" +title: "Permanent delete with confirm modal — no Trash system" +status: active +date: 2026-04-07 +supersedes: "0042" +--- + +## Context + +ADR-0042 designed a Trash auto-purge safety model (soft-delete with 30-day retention, OS trash via `trash` crate, audit log). This was built on top of a Trash system that treated deletion as a two-phase operation: move to trash → auto-purge after 30 days. + +The Trash system was subsequently identified as unnecessary complexity: it required `trashed`/`trashedAt` frontmatter fields, sidebar filtering, editor banners, inspector components, dedicated smoke tests, and a `trash` crate dependency. The safety guarantee users actually need is a **confirmation prompt before irreversible action**, not a soft-delete buffer — especially given notes live in a git repo (vault git history is already a recovery mechanism per ADR-0034 and ADR-0014). + +Commit `e581ad36` on 2026-04-06 removed the entire Trash system (123 files changed, ~3164 lines deleted). + +## Decision + +**Delete is permanent and immediate, gated only by a confirmation modal (`useDeleteActions`). Notes with `trashed: true` in existing vault frontmatter are treated as normal notes (the flag is ignored by the parser). The `trash` crate dependency is removed.** + +The confirmation modal is the sole safety gate. No soft-delete, no Trash view, no auto-purge scheduler, no `.laputa/purge.log`. + +## Options considered + +- **Option A — Permanent delete + confirm modal** (chosen): simple, honest, no hidden state. Git history provides recovery. Removes ~3000 lines of code and a platform-specific dependency. Downside: no in-app recovery path for users who don't know about git. +- **Option B — OS Trash via `trash` crate** (ADR-0042, now superseded): soft-delete to OS Trash, user can recover from macOS Trash app. Downside: additional dependency, complex auto-purge scheduler, misleading "auto-purge" promise that was never actually implemented. +- **Option C — `.laputa/deleted/` archive folder**: custom recovery mechanism inside vault. Downside: clutters vault, users wouldn't know to look there, still requires manual cleanup. + +## Consequences + +- Users who accidentally delete a note must recover from git history (`git checkout HEAD -- path/to/note.md`) — this is a power-user action +- `trashed`/`trashedAt` frontmatter fields in existing vaults are silently ignored — no migration needed, no data loss +- The `trash` crate is removed from `Cargo.toml` — build times improve marginally +- Smoke tests for trash flows are deleted; delete-related test coverage is now purely the confirm modal behavior +- The Trash view, sidebar filter, note banners, and bulk-trash actions are all gone — simpler UI surface +- Re-evaluate if user feedback shows significant accidental deletion incidents, or if git-based recovery proves too inaccessible for non-technical users diff --git a/docs/adr/0046-starter-vault-cloned-from-github.md b/docs/adr/0046-starter-vault-cloned-from-github.md new file mode 100644 index 0000000..a493f3e --- /dev/null +++ b/docs/adr/0046-starter-vault-cloned-from-github.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0046" +title: "Starter vault cloned from GitHub at runtime — no bundled content" +status: active +date: 2026-04-08 +--- + +## Context + +Laputa ships an optional "Getting Started" vault to help new users understand types, properties, wikilinks, and relationships. Previously, all starter content (markdown files, view YAMLs) was stored inside the app repo under `getting-started-vault/` and written to disk via `create_getting_started_vault()`. This created friction: updating sample content required a new app release, the content grew stale quickly, and the bundled files added noise to the main repo. + +## Decision + +**The Getting Started vault is no longer bundled in the app repo. On first launch, if the user selects "Get started with a template", the app clones the public starter repo (`https://github.com/refactoringhq/laputa-getting-started.git`) into a user-chosen folder using the existing git clone infrastructure.** + +- `getting_started.rs` now holds only the public repo URL constant and delegates to `clone_public_repo()`. +- The `getting-started-vault/` directory has been removed from the app repo. +- `create_getting_started_vault(targetPath)` takes an explicit target path (chosen via folder picker) instead of defaulting to Documents/Getting Started. +- Clone failures show a user-friendly error with an inline "Retry download" button (`canRetryTemplate`, `retryCreateVault`). +- A `clone_public_repo()` function was added to `github/clone.rs` to clone unauthenticated public repos without injecting OAuth tokens or configuring remote auth. + +## Options considered + +- **Option A — Keep bundled content (status quo)**: Simple, works offline. Downside: content tied to app release cycle, repo noise, growing file count. +- **Option B — Clone from GitHub at runtime (chosen)**: Content is always current; starter vault can be updated without an app release; removes ~25 markdown files + YAML from the main repo. Downside: requires network on first use; failure modes need UX handling (retry flow added). +- **Option C — Download a zip archive**: Avoids a git clone, smaller payload. Downside: loses the clean git history in the cloned vault; adds a zip extraction code path. + +## Consequences + +- New users need a network connection when selecting the template option. The empty vault and open-folder paths remain fully offline. +- The starter repo (`laputa-getting-started`) becomes a separate maintenance artifact. +- `LAPUTA_GETTING_STARTED_REPO_URL` env var allows overriding the URL in tests without hitting GitHub. +- Onboarding UX now distinguishes three creation modes: `creatingAction: 'template' | 'empty' | null`, each with distinct button state and status copy. +- Retry UX: `lastTemplatePath` is cached in `useOnboarding` so users can retry a failed clone to the same folder without re-picking it. +- Re-evaluation trigger: if offline-first support becomes a priority, consider bundling a minimal vault again or shipping a fallback zip. diff --git a/docs/adr/0047-regex-mode-for-view-filter-conditions.md b/docs/adr/0047-regex-mode-for-view-filter-conditions.md new file mode 100644 index 0000000..97d6a83 --- /dev/null +++ b/docs/adr/0047-regex-mode-for-view-filter-conditions.md @@ -0,0 +1,34 @@ +--- +type: ADR +id: "0047" +title: "Regex mode for view filter conditions" +status: active +date: 2026-04-08 +--- + +## Context + +The view filter engine (ADR 0040) supports operators like `contains`, `equals`, `not_contains`, `not_equals` with literal string matching. Power users who want pattern-based filtering (e.g., "all notes whose title matches a date pattern", "any property matching a URL regex") cannot express this with literals alone. + +## Decision + +**A `regex: true` flag is added to `FilterCondition`. When set, the `value` field is interpreted as a case-insensitive regular expression (via `regex::RegexBuilder` in Rust, and the native JS `RegExp` in TypeScript) for the operators that support it: `contains`, `equals`, `not_contains`, `not_equals`.** + +- Regex is opt-in: the `regex` field defaults to `false` and is skipped during serialization when false (no noise in existing `.yml` files). +- If the regex fails to compile, the condition evaluates to `false` rather than throwing. +- For relationship fields, the regex is tested against all candidate forms: the raw wikilink string, the inner stem, and the alias (if present). +- The `FilterBuilder` UI gains a regex toggle icon button next to value inputs for supported operators. +- TypeScript `viewFilters.ts` mirrors the same regex logic for client-side evaluation. + +## Options considered + +- **Option A — Add regex operator variants** (`regex_equals`, `regex_contains`): More explicit in YAML. Downside: doubles the operator set; no clear path to combine regex with `not_contains`. +- **Option B — Per-condition `regex: bool` flag (chosen)**: Composable with existing operators; minimal schema change; serialization skips the field when false so existing views are unaffected. +- **Option C — Full query language** (e.g., JMESPath or SQL `WHERE`): Maximum power. Out of scope; would replace rather than extend the filter engine. + +## Consequences + +- New dependency: `regex` crate in Rust (already present for other vault modules; no net new dep). +- Filter YAML files that use `regex: true` require Laputa ≥ this version to evaluate correctly; older versions silently ignore the flag (falling back to `regex: false` default via `#[serde(default)]`). +- Regex evaluation has a small performance cost vs. literal matching. No memoization of compiled regexes per evaluation call — acceptable given vault sizes (< 10k notes). +- Re-evaluation trigger: if regex performance becomes measurable, cache compiled `Regex` objects keyed by pattern string. diff --git a/docs/adr/0048-relative-date-expressions-in-view-filters.md b/docs/adr/0048-relative-date-expressions-in-view-filters.md new file mode 100644 index 0000000..b58dbd0 --- /dev/null +++ b/docs/adr/0048-relative-date-expressions-in-view-filters.md @@ -0,0 +1,46 @@ +--- +type: ADR +id: "0048" +title: "Relative date expressions in view filter conditions" +status: active +date: 2026-04-08 +--- + +## Context + +The view filter engine (ADR 0040) supports `before` and `after` operators but previously compared values as raw strings, meaning users had to write absolute ISO dates (e.g., `2026-04-01`) that became stale immediately. Views like "notes modified in the last 7 days" required updating the date manually every week. + +## Decision + +**The `before` and `after` filter operators now accept relative date expressions in addition to absolute ISO dates. Both the Rust backend and the TypeScript client independently parse the expression before comparing.** + +### Supported syntax + +| Expression | Meaning | +|---|---| +| `today` | Start of the current day (00:00 UTC) | +| `yesterday` | Start of yesterday | +| `tomorrow` | Start of tomorrow | +| `N days ago` / `N weeks ago` / `N months ago` / `N years ago` | Past relative | +| `in N days` / `in N weeks` / `in N months` / `in N years` | Future relative | + +Word-form amounts are also accepted: `one`, `two`, `three`, … `twelve`. + +### Architecture + +- **Rust** (`views.rs`): `parse_date_filter_timestamp()` resolves both field values and condition values to `i64` timestamps before comparing. Falls back gracefully when a value cannot be parsed. +- **TypeScript** (`utils/filterDates.ts`): `parseDateFilterInput()` and `toDateFilterTimestamp()` mirror the same logic for client-side filter evaluation. `date-fns` is used for date arithmetic. +- Both implementations use "start of day UTC" (00:00:00) as the anchor for relative expressions, consistent with how note creation/modification dates are stored. + +## Options considered + +- **Option A — Store and evaluate absolute dates only**: No parsing cost. Downside: views become stale; users must update dates manually. +- **Option B — Relative expressions resolved at evaluation time (chosen)**: Views stay perpetually current ("last 7 days" always means last 7 days). Downside: parallel implementation in Rust and TypeScript must stay in sync. +- **Option C — Pre-resolve relative expressions to absolute dates on save**: Expressions are human-readable when authoring but stored as ISO strings. Downside: view files drift; loses the relative intent. + +## Consequences + +- Relative expressions are evaluated at query time using the server/client clock. A view evaluated at 23:59 and 00:01 may return different results for "today". +- Both parsers share the same resolution anchor (start-of-day UTC). Timezone-sensitive relative expressions (e.g., "yesterday in Tokyo") are not supported. +- Existing `.yml` files with absolute ISO dates continue to work unchanged — the parser first tries ISO format before attempting relative parsing. +- Re-evaluation trigger: if timezone-aware relative dates become a user need, the expression syntax and anchor logic need revisiting. diff --git a/docs/adr/0049-per-note-icon-property.md b/docs/adr/0049-per-note-icon-property.md new file mode 100644 index 0000000..7592978 --- /dev/null +++ b/docs/adr/0049-per-note-icon-property.md @@ -0,0 +1,55 @@ +--- +type: ADR +id: "0049" +title: "Per-note icon property (_icon on individual notes)" +status: active +date: 2026-04-08 +--- + +## Context + +Laputa already supports type-level icons via the `_icon` system property on type documents (ADR 0008). Every note of a given type inherits the type's icon. Users needed a way to give individual notes a distinct visual identity without changing the type — e.g., marking a specific project with a rocket emoji, or a key person with a star icon — without creating a new type just for one note. + +## Decision + +**The `_icon` system property (already used by type documents) is now also supported on regular notes. When a note has an `_icon` value, it overrides the inherited type icon in all UI surfaces. The value may be an emoji, a Phosphor icon name, or an HTTP(S) image URL.** + +### Resolution logic + +`resolveNoteIcon(icon)` in `utils/noteIcon.ts` returns a discriminated union: + +| Kind | Condition | +|---|---| +| `none` | Value is empty/null | +| `emoji` | Value passes `isEmoji()` | +| `image` | Value is an HTTP(S) URL | +| `phosphor` | Value matches a registered Phosphor icon name | + +The `NoteTitleIcon` component renders the correct element for each kind (span, ``, or Phosphor SVG component). + +### UI surfaces updated + +- Editor breadcrumb bar (clicking the icon opens the `_icon` property editor) +- Note list items (`NoteItem`) +- Search panel results +- Relationship chips (shows icons on wikilink chips) +- Sidebar type sections +- Backlinks / ReferencedBy panels +- Inspector pinned area + +### Editing + +A custom event (`laputa:focus-note-icon-property`) is dispatched from the breadcrumb bar click to focus the `_icon` field in the Properties panel without scrolling. The field uses the existing property editor UI. + +## Options considered + +- **Option A — Separate `_note_icon` property**: Avoids ambiguity with the type-level `_icon`. Downside: two names for the same concept depending on context; complicates the resolver. +- **Option B — Reuse `_icon` on notes (chosen)**: Consistent with existing convention (ADR 0008); type docs and note docs follow the same schema. The distinction between type-level and note-level is determined by `is_a: Type` in the frontmatter, not by a different property name. +- **Option C — Inline emoji in note title**: Zero-friction. Downside: title is also the filename (ADR 0044); emojis in filenames cause filesystem/git pain. + +## Consequences + +- `_icon` on a note overrides the type icon everywhere. A note with no `_icon` continues to inherit the type icon (no behavior change for existing vaults). +- The icon resolver (`resolveNoteIcon`) is shared between note icons and type icons; future changes to icon resolution affect both. +- `iconRegistry.ts` grows with any new Phosphor icon additions — currently loaded eagerly. If the icon set grows large, lazy loading or a build-time icon map should be considered. +- Re-evaluation trigger: if users request per-note color (the `_color` system property currently only applies to types), the same resolution pattern can be extended. diff --git a/docs/adr/0050-deterministic-shortcut-command-routing.md b/docs/adr/0050-deterministic-shortcut-command-routing.md new file mode 100644 index 0000000..b8b1f13 --- /dev/null +++ b/docs/adr/0050-deterministic-shortcut-command-routing.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0050" +title: "Deterministic shortcut command routing" +status: active +date: 2026-04-11 +--- + +## Context + +Laputa is keyboard-first, but shortcut execution had split ownership: `useAppKeyboard` handled some shortcuts in the renderer while `menu.rs` owned others as native Tauri menu accelerators. That split made QA unreliable. Browser tests could prove the renderer path, but not the native menu path, and flaky macOS key synthesis made `Cmd+Shift+L`, `Cmd+Shift+I`, and `Cmd+N` regressions easy to miss. + +## Decision + +**Keyboard shortcuts and native menu accelerators now dispatch through the same canonical app command IDs. Renderer-owned shortcuts call the shared dispatcher directly; native menu items emit the same IDs into the frontend, and tests get a deterministic menu-command trigger that exercises that route without relying on synthesized native keystrokes.** + +## Options considered + +- **Option A** (chosen): Shared command IDs plus deterministic menu-command trigger — keeps native desktop UX while making menu-owned commands testable in unit tests, Playwright, and native QA. Downside: one more command layer to maintain. +- **Option B**: Move every shortcut to the renderer — simpler automated testing, but worse macOS menu-bar parity and weaker native UX. +- **Option C**: Keep renderer and native shortcuts separate — lowest code churn, but continues to produce false confidence and shortcut regressions. + +## Consequences + +- `appCommandDispatcher.ts` owns the canonical shortcut command IDs and the shared execution path used by `useAppKeyboard` and `useMenuEvents`. +- Native menu routing remains explicit in `menu.rs`; adding or changing a native shortcut now requires wiring the accelerator and the matching command ID in one place. +- Automated QA can trigger menu-owned commands deterministically through the shared `window.__laputaTest.triggerMenuCommand()` bridge in browser runs and through the native `trigger_menu_command` Tauri command in desktop runs. +- Keyboard QA should prefer real menu selection or the deterministic menu-command trigger for native-owned shortcuts, and reserve synthesized keystrokes for renderer-owned shortcuts or true end-to-end spot checks. +- This decision supersedes the blanket assumption in ADR 0020 that all shortcut verification can be treated as plain keyboard-event testing. diff --git a/docs/adr/0051-shared-shortcut-manifest-for-testable-routing.md b/docs/adr/0051-shared-shortcut-manifest-for-testable-routing.md new file mode 100644 index 0000000..aa2e0d3 --- /dev/null +++ b/docs/adr/0051-shared-shortcut-manifest-for-testable-routing.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0051" +title: "Shared shortcut manifest for testable routing" +status: active +date: 2026-04-11 +--- + +## Context + +ADR 0050 moved renderer shortcuts and native menu events onto the same command dispatcher, but shortcut ownership still drifted across multiple places: `appKeyboardShortcuts.ts`, `appCommandDispatcher.ts`, command-palette metadata, and `menu.rs`. That made shortcut regressions easy to reintroduce because the same facts had to be updated manually in several files. + +The riskiest failures were exactly the native-owned commands that matter most in a keyboard-first app: `Cmd+\` for raw editor, `Cmd+Shift+I` for properties, and `Cmd+Shift+L` for the AI panel. We need one declarative place that says which command owns which shortcut, whether the shortcut is renderer-owned or native-menu-owned, and how tests should trigger it. + +## Decision + +**Shortcut-capable app commands are now defined in a shared frontend manifest that owns command IDs, routing semantics, and shortcut ownership. Renderer keyboard handling resolves commands from that manifest, native menu routing dispatches the same command IDs, and deterministic QA for native-owned shortcuts targets those IDs rather than duplicating shortcut facts in ad hoc code paths.** + +## Options considered + +- **Option A** (chosen): Shared shortcut manifest plus shared dispatcher and deterministic menu-command QA. This reduces drift, improves CodeScene on the command router, and makes native-owned shortcuts provable without flaky macOS key synthesis. Downside: one more manifest to maintain. +- **Option B**: Keep the shared dispatcher from ADR 0050 but continue storing shortcut ownership in separate key maps and menu lists. Lower churn, but it keeps the exact source of the regressions we reopened. +- **Option C**: Move all shortcuts into renderer-only handlers. Easier to test, but weaker macOS menu-bar parity and worse native desktop UX. + +## Consequences + +- `appCommandCatalog.ts` is now the frontend source of truth for shortcut-capable command IDs, ownership, modifier rules, and dispatch kind. +- `appCommandDispatcher.ts` is reduced to route execution instead of carrying a large switch plus duplicated ownership metadata. +- `useAppKeyboard.ts` resolves shortcuts from the shared manifest, including the distinction between `Cmd+Shift+L` (macOS-only) and `CmdOrCtrl+Shift+I/F/O`. +- Native-menu smoke tests should use `window.__laputaTest.triggerMenuCommand()` or the Tauri `trigger_menu_command` bridge to prove the native command path. Renderer-only commands may still be proven with direct keyboard events. +- This ADR supersedes ADR 0050 by replacing “shared command IDs are enough” with “shared command IDs plus shared shortcut ownership metadata are required.” diff --git a/docs/adr/0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md b/docs/adr/0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md new file mode 100644 index 0000000..91430b2 --- /dev/null +++ b/docs/adr/0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0052" +title: "Renderer-first shortcut execution with native-menu dedupe" +status: active +date: 2026-04-11 +--- + +## Context + +ADR 0051 gave Laputa a shared shortcut manifest and shared command IDs, but it still treated many shortcuts as native-menu-owned at execution time. In practice that meant `useAppKeyboard` deferred commands like `Cmd+Shift+I`, `Cmd+Shift+L`, and `Cmd+\` whenever the app ran under Tauri, and automated QA had to prove those flows by injecting menu-command IDs instead of pressing the real keys. + +That is not a strong enough QA story for a keyboard-first app. If a user presses a shortcut while the editor is focused, we need a deterministic way to prove the actual key combo works. At the same time, we still want a native macOS menu bar with working menu items and accelerators. + +## Decision + +**Renderer keyboard handling is now the primary execution path for all shortcut-capable app commands, including commands that also have native menu accelerators. Native menu clicks and accelerators still emit the same command IDs, but the shared dispatcher suppresses the duplicate native/renderer echo from a single keypress so the command runs exactly once.** + +## Options considered + +- **Option A** (chosen): Renderer-first shortcut execution plus native-menu dedupe. This keeps shortcuts testable with real key events in a Tauri-like environment while preserving menu-bar parity and clickable native menu items. Downside: the dispatcher has to understand and suppress paired native/renderer echoes. +- **Option B**: Keep deferring native-owned shortcuts out of the renderer and prove them only through `trigger_menu_command`. Lower implementation churn, but it still leaves the real keystroke path unproven. +- **Option C**: Remove native accelerators entirely and keep shortcuts renderer-only. Simplest to reason about, but weaker desktop UX and poorer macOS menu discoverability. + +## Consequences + +- `appCommandCatalog.ts` remains the single manifest for command IDs and shortcut combos, but keyboard execution no longer depends on a separate owner flag. +- `useAppKeyboard` handles the actual key event for every shortcut-capable command, even in Tauri mode. +- `useMenuEvents` still handles menu clicks and test-triggered native command IDs, but shared dispatcher dedupe prevents a focused keypress from firing twice when the native menu accelerator also echoes back into the renderer. +- Deterministic QA now has two complementary proofs: + - real keyboard events in a Tauri-like environment for the actual shortcut combo + - `trigger_menu_command` for the native menu click/accelerator command path +- This ADR supersedes ADR 0051 by replacing “execution ownership lives in the manifest” with “shortcut combos live in the manifest, while execution is renderer-first and native menu dispatch is deduped.” diff --git a/docs/adr/0053-webview-init-prevention-for-browser-reserved-shortcuts.md b/docs/adr/0053-webview-init-prevention-for-browser-reserved-shortcuts.md new file mode 100644 index 0000000..5c5dfa7 --- /dev/null +++ b/docs/adr/0053-webview-init-prevention-for-browser-reserved-shortcuts.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0053" +title: "Webview-init prevention for browser-reserved shortcuts" +status: active +date: 2026-04-11 +--- + +## Context + +ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked. + +The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct. + +## Decision + +**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.** + +## Options considered + +- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture. +- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them. +- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts. + +## Consequences + +- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus. +- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small. +- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer. +- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds. diff --git a/docs/adr/0054-deterministic-shortcut-qa-matrix.md b/docs/adr/0054-deterministic-shortcut-qa-matrix.md new file mode 100644 index 0000000..92293b8 --- /dev/null +++ b/docs/adr/0054-deterministic-shortcut-qa-matrix.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0054" +title: "Deterministic shortcut QA matrix" +status: active +date: 2026-04-11 +--- + +## Context + +ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy: + +- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA +- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell +- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use + +That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven. + +## Decision + +**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.** + +## Options considered + +- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS. +- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely. +- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today. + +## Consequences + +- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command. +- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA. +- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis. +- Native menu-command behavior can be verified deterministically through the Tauri command bridge. +- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords. diff --git a/docs/adr/0055-h1-is-the-only-editor-title-surface.md b/docs/adr/0055-h1-is-the-only-editor-title-surface.md new file mode 100644 index 0000000..02c2c70 --- /dev/null +++ b/docs/adr/0055-h1-is-the-only-editor-title-surface.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0055" +title: "H1 is the only editor title surface" +status: superseded +date: 2026-04-11 +supersedes: "0044" +superseded_by: "0068" +--- + +## Context + +ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone. + +The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body. + +## Decision + +**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.** + +Display-title behavior stays: +1. First H1 in the body +2. Legacy frontmatter `title:` +3. Filename-derived fallback + +But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder. + +Filename operations remain explicit: +- untitled notes still auto-rename from H1 on save +- manual filename rename/sync remains in the breadcrumb + +## Options considered + +- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model. +- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape. +- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce. + +## Consequences + +- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body. +- `TitleField` and the title-section render path are removed from the frontend. +- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body. +- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage. diff --git a/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md b/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md new file mode 100644 index 0000000..a1d0a8b --- /dev/null +++ b/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md @@ -0,0 +1,50 @@ +--- +type: ADR +id: "0056" +title: "System git auth only — no provider-specific OAuth or repo APIs" +status: active +date: 2026-04-12 +supersedes: "0019" +--- + +## Context + +Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management: + +- GitHub Device Flow OAuth +- persisted `github_token` / `github_username` settings +- GitHub-only clone/create UI +- GitHub API calls for repo listing and creation + +That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI. + +## Decision + +**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.** + +Concretely: + +- remove GitHub Device Flow commands and UI +- remove persisted GitHub auth fields from app settings +- remove GitHub repo list/create API integration +- keep `clone_repo`, but make it a generic system-git clone command +- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly + +## Options considered + +- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git. +- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction. +- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app. + +## Consequences + +- `CloneVaultModal` accepts any git URL and local destination path. +- `clone_repo` shells out to system git without injecting provider tokens. +- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr. +- `SettingsPanel` no longer contains a GitHub connection section. +- Tolaria no longer stores git-provider credentials in `settings.json`. +- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path. +- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools. +- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import. + +Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app. diff --git a/docs/adr/0057-alpha-stable-release-channels-and-beta-cohorts.md b/docs/adr/0057-alpha-stable-release-channels-and-beta-cohorts.md new file mode 100644 index 0000000..c309af8 --- /dev/null +++ b/docs/adr/0057-alpha-stable-release-channels-and-beta-cohorts.md @@ -0,0 +1,45 @@ +--- +type: ADR +id: "0057" +title: "Alpha/stable release channels with PostHog beta cohorts" +status: superseded +date: 2026-04-12 +superseded_by: "0066" +--- + +## Context + +Tolaria's updater and release docs still described a canary branch, a beta updater channel, and a single `latest.json` feed. That no longer matched the desired product model: + +- `main` should continuously publish **alpha** builds. +- **Stable** should be promoted manually by pushing `stable-vX.Y.Z` tags. +- "Beta" users should be modeled in PostHog for targeting and analysis, not as a separate binary or updater feed. + +The updater also needed semver-safe versioning when a user switches between Stable and Alpha. A date-based alpha version below the latest stable release would cause the updater to ignore newer alpha builds after a stable promotion. + +This ADR supersedes ADR-0017's canary-branch updater model. + +## Decision + +**Tolaria exposes exactly two updater channels: `stable` and `alpha`. Stable is the default feed, while every push to `main` publishes a prerelease alpha build to `alpha/latest.json`, and manually promoted `stable-vX.Y.Z` tags publish stable builds to `stable/latest.json`. Beta audiences are handled in PostHog and are not a third updater channel.** + +## Options considered + +- **Option A** (chosen): Two updater channels (`stable`, `alpha`) plus PostHog beta cohorts. Pros: matches the product requirement, keeps CI simple, keeps updater semantics understandable, and separates release distribution from experimentation audiences. Cons: requires semver-aware alpha versioning and a small migration for legacy channel settings. +- **Option B**: Keep the canary branch / canary channel model. Pros: no workflow redesign. Cons: no longer matches how releases are actually promoted and forces distribution strategy to depend on a long-lived branch. +- **Option C**: Add a third updater channel for beta builds. Pros: direct binary segmentation. Cons: extra CI complexity, extra updater endpoints, and unnecessary duplication because beta targeting is already better handled by PostHog. + +## Consequences + +- `release.yml` now publishes alpha prereleases from every push to `main`. +- `release-stable.yml` publishes stable releases only from `stable-v*` tags. +- `src-tauri/src/app_updater.rs` selects `alpha/latest.json` or `stable/latest.json` at runtime. +- `release_channel` stays an app setting, but only `alpha` is stored explicitly; Stable serializes to the default `null` value. +- Legacy or invalid persisted channel values fall back to Stable. +- Alpha versions are prereleases of the next stable patch version (for example `1.2.4-alpha.202604122135.7` after stable `1.2.3`) so semver ordering remains valid across channel switches. +- The legacy GitHub Pages aliases `latest.json` and `latest-canary.json` continue to mirror alpha for backward compatibility. +- Beta rollouts and internal-user targeting are done in PostHog using person properties or cohorts rather than updater manifests. + +## Advice + +If a future release process needs more than two binary distribution rings, re-evaluate this decision only when PostHog cohorting is no longer sufficient and the extra operational cost of another updater feed is justified. diff --git a/docs/adr/0058-claude-code-first-launch-onboarding-gate.md b/docs/adr/0058-claude-code-first-launch-onboarding-gate.md new file mode 100644 index 0000000..368c19a --- /dev/null +++ b/docs/adr/0058-claude-code-first-launch-onboarding-gate.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0058" +title: "Claude Code first-launch onboarding gate" +status: superseded +superseded_by: "0062" +date: 2026-04-12 +--- + +## Context + +Tolaria's AI features depend on the `claude` CLI being installed on the user's machine. New users arriving with no prior context could open the app, try AI-powered workflows, and get silent failures with no explanation. + +A dedicated first-launch prompt was needed to: +- Surface whether the `claude` CLI is already present. +- Guide users to the install page if it is missing. +- Not block experienced users who want to skip the check. + +The existing `useOnboarding` hook already handles vault setup and resolves to a `ready` state, but it had no mechanism for a post-vault, pre-app step. + +## Decision + +**A one-time `ClaudeCodeOnboardingPrompt` is shown immediately after vault onboarding resolves to `ready`, before the main app shell renders. Dismissal is persisted in `localStorage` via `useClaudeCodeOnboarding`, so the gate appears exactly once per install.** + +## Options considered + +- **Option A** (chosen): Full-screen gate after vault onboarding, dismissed once and persisted in `localStorage`. Pros: cannot be missed on first launch, reuses `useClaudeCodeStatus` for live detection, zero impact on returning users. Cons: adds one extra render phase to the boot sequence. +- **Option B**: Inline banner inside the main app. Pros: less intrusive. Cons: easy to ignore, harder to surface install link prominently. +- **Option C**: Check at feature use time (show error when AI action fails). Pros: no new screen. Cons: poor UX — silent failure or cryptic error at the moment the user needs AI. + +## Consequences + +- The app boot sequence now has four phases: loading → welcome (if needed) → Claude Code check (once) → main shell. +- `useClaudeCodeOnboarding(enabled)` takes a boolean so the gate is skipped entirely in note windows and before vault onboarding completes. +- The dismissal key (`tolaria:claude-code-onboarding-dismissed`) must be pre-set in Playwright storage state so smoke tests bypass the gate. +- Re-evaluation warranted if the `claude` CLI gains an in-app auto-install path, making the manual prompt unnecessary. diff --git a/docs/adr/0059-local-only-git-commits-without-remote.md b/docs/adr/0059-local-only-git-commits-without-remote.md new file mode 100644 index 0000000..9bd946c --- /dev/null +++ b/docs/adr/0059-local-only-git-commits-without-remote.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0059" +title: "Local-only git commits for vaults without a remote" +status: active +date: 2026-04-12 +--- + +## Context + +ADR-0034 mandates a git repo for every vault, but never required a remote. In practice, the commit flow always attempted a `git push` after staging and committing. Users with purely local vaults (no remote configured) would hit a push error on every commit. + +The fix required distinguishing between two commit modes at the point of user action: +- **Push mode**: repo has a remote → commit then push (existing behavior). +- **Local mode**: repo has no remote → commit only, no push attempted. + +## Decision + +**`useCommitFlow` detects the vault's remote status before opening the commit dialog and at commit time. When `hasRemote === false`, it commits locally and skips the push step entirely, showing "Committed locally (no remote configured)" as the confirmation toast.** + +## Options considered + +- **Option A** (chosen): Runtime detection via a new `useGitRemoteStatus` hook + `CommitMode` type (`push` | `local`). Pros: transparent to the user, no configuration needed, adapts if a remote is added later. Cons: adds an async remote-status check to the commit open flow. +- **Option B**: Require all vaults to have a remote (keep blocking behavior). Pros: simpler model. Cons: breaks the valid use case of a local-only knowledge base; contradicts ADR-0056 which removed provider-specific OAuth. +- **Option C**: Let the push fail silently and always show success. Pros: no new logic. Cons: misleading feedback; users wouldn't know the push was skipped vs. succeeded. + +## Consequences + +- `useGitRemoteStatus` is a new hook that exposes `remoteStatus` and `refreshRemoteStatus`; it is called both when opening the commit dialog and after each commit. +- `CommitDialog` now receives a `commitMode` prop and adjusts its CTA label accordingly (`Commit & Push` vs `Commit`). +- The `commitAndPush` callback in `CommitFlowConfig` is replaced by `resolveRemoteStatus` + `vaultPath`; the actual git operations (`git_commit`, `git_push`) are invoked directly inside `useCommitFlow`. +- Local-only commits fire `trackEvent('commit_made')` the same as push commits for analytics continuity. +- Re-evaluation warranted if a remote is later added to a previously-local vault and the UX should prompt the user to push accumulated commits. diff --git a/docs/adr/0060-network-aware-ui-gating-for-remote-features.md b/docs/adr/0060-network-aware-ui-gating-for-remote-features.md new file mode 100644 index 0000000..bcc9292 --- /dev/null +++ b/docs/adr/0060-network-aware-ui-gating-for-remote-features.md @@ -0,0 +1,28 @@ +--- +type: ADR +id: "0060" +title: "Network-aware UI gating for remote-dependent features" +status: active +date: 2026-04-13 +--- + +## Context + +Some app features require an active internet connection (e.g., cloning the Getting Started vault template from GitHub). Prior to this decision, the UI would attempt the operation and surface a generic error only after failure. Users on first launch in offline environments got a confusing error when trying to use the template. + +## Decision + +**Introduce a `useNetworkStatus` hook that tracks `navigator.onLine` via `online`/`offline` DOM events, and use it to proactively gate UI surfaces that require a network.** Features that depend on remote access (clone, sync) show an explanatory message and disable their action button when the device is offline, rather than failing silently at execution time. + +## Options considered + +- **Option A** (chosen): `useNetworkStatus` hook + proactive UI disable — disables the action before the user tries it, with inline copy explaining the offline state. +- **Option B**: Attempt and catch — let the operation run and surface the error in a toast. Simpler, but poor UX for first-launch users who don't know what went wrong. +- **Option C**: Check connectivity with a ping on demand — more accurate but adds latency and complexity; `navigator.onLine` is sufficient for the use case. + +## Consequences + +- Positive: cleaner first-run experience for offline users; no misleading error messages. +- Positive: `useNetworkStatus` is a reusable hook for future remote-gated features. +- Negative: `navigator.onLine` can return `true` on a captive-portal / no-internet network — the hook reflects OS-level connectivity, not end-to-end reachability. The operation may still fail with a network error, which must still be handled. +- Re-evaluate if the app adds more remote features that need finer-grained reachability checks. diff --git a/docs/adr/0061-ai-prompt-bridge-event-bus.md b/docs/adr/0061-ai-prompt-bridge-event-bus.md new file mode 100644 index 0000000..1f22d3d --- /dev/null +++ b/docs/adr/0061-ai-prompt-bridge-event-bus.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0061" +title: "AI prompt bridge — module-level event bus for cross-component prompt routing" +status: active +date: 2026-04-13 +--- + +## Context + +The AI panel is a sibling subtree to the command palette in the component tree. When the user submits a prompt from the command palette's AI mode, the AI panel (mounted elsewhere) needs to receive it and start processing. Props-down / callbacks-up wiring between the two would require threading state through multiple layers of unrelated components. + +## Decision + +**Introduce `aiPromptBridge.ts` as a module-level singleton event bus.** The bridge exposes `queueAiPrompt(text, references)` (write path) and `takeQueuedAiPrompt()` (consume path), backed by a module variable and a `CustomEvent` on `window` (`tolaria:ai-prompt-queued`). The command palette enqueues a prompt; the AI panel listens for the event, consumes the prompt via `takeQueuedAiPrompt`, and dispatches it to the agent. A companion `requestOpenAiChat()` function fires a separate `tolaria:open-ai-chat` event to open the panel before the prompt is sent. + +## Options considered + +- **Option A** (chosen): module-level singleton + `window` events — zero dependencies, no new global state manager, consistent with the existing `window.dispatchEvent` pattern already used for menu-command bridging. +- **Option B**: Lift AI panel state to a shared ancestor (e.g., `App.tsx`) and pass `onPrompt` callback down — would require `App.tsx` to own AI agent state, bloating it further; conflicts with ADR-0026 (props-down principle). +- **Option C**: Zustand / Jotai global store atom — adds a dependency and architecture overhead for a narrow, two-participant channel. + +## Consequences + +- Positive: decouples command palette from AI panel with no shared ancestor coupling. +- Positive: any future surface (e.g., wikilink context menu, note action bar) can call `queueAiPrompt` without tree-level wiring. +- Negative: module-level mutable state is harder to test in isolation; tests must call `takeQueuedAiPrompt` to drain state between runs. +- Negative: the event is fire-and-forget — if the AI panel is not mounted when the event fires, the prompt is silently dropped (currently not an issue as the panel is always mounted). +- Re-evaluate if the number of AI entry points grows large enough to warrant a proper state management solution. diff --git a/docs/adr/0062-selectable-cli-ai-agents.md b/docs/adr/0062-selectable-cli-ai-agents.md new file mode 100644 index 0000000..bd334a8 --- /dev/null +++ b/docs/adr/0062-selectable-cli-ai-agents.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0062" +title: "Selectable CLI AI agents with a shared panel architecture" +status: active +date: 2026-04-13 +--- + +## Context + +Tolaria's AI panel, onboarding flow, and status surfaces were built around a single CLI dependency: Claude Code. That worked for the first release, but it made every UI and backend seam agent-specific. Adding Codex as a second supported CLI agent would have duplicated large parts of the app: separate availability checks, a second onboarding path, another status badge, and yet another streaming hook. + +The product direction is broader than a single vendor. Tolaria needs one AI panel that can target multiple local CLI agents while preserving the same MCP-backed vault tooling, the same note-context assembly, and a single install-local preference for which agent should be used by default. + +## Decision + +**Introduce a shared CLI-agent abstraction for Tolaria's AI surfaces.** The frontend now treats agents as a small registry (`claude_code`, `codex`) with labels, install URLs, availability state, and a persisted `default_ai_agent` setting. The AI panel, onboarding gate, command palette, and status bar all read from that shared model. On the backend, `ai_agents.rs` owns agent detection and streaming, dispatching to per-agent adapters: Claude still flows through `claude_cli.rs`, while Codex is launched through `codex exec --json` with Tolaria's MCP server injected via transient config flags. + +## Options considered + +- **Option A** (chosen): shared agent registry + backend adapter layer — one panel, one preference, one onboarding path, and a clear place to add future CLI agents. +- **Option B**: keep the UI Claude-specific and bolt on Codex as a second special case — lowest short-term cost, but every new agent multiplies the number of bespoke checks, prompts, and command handlers. +- **Option C**: split the product into separate per-agent panels — clearer ownership per integration, but fragments the UX and makes command-palette / status-bar interactions inconsistent. + +## Consequences + +- Positive: new CLI agents can be added by implementing one backend adapter and registering one frontend definition. +- Positive: onboarding and settings now explain the AI capability of the app at the product level rather than assuming Claude Code is the only valid path. +- Positive: the default agent is installation-local, matching ADR-0004's rule that machine-specific tool preferences belong in app settings rather than the vault. +- Negative: event normalization is now Tolaria-owned; backend adapters must translate each CLI's stream format into a common event model. +- Negative: some user guidance becomes agent-specific again at the edge, such as install links and authentication errors (`claude` login vs `codex login`). +- Re-evaluate if one agent needs capabilities the shared panel cannot express cleanly, or if Tolaria ever moves from CLI subprocesses to a dedicated local SDK/runtime. diff --git a/docs/adr/0063-blocknote-code-block-package-for-editor-highlighting.md b/docs/adr/0063-blocknote-code-block-package-for-editor-highlighting.md new file mode 100644 index 0000000..7d843b5 --- /dev/null +++ b/docs/adr/0063-blocknote-code-block-package-for-editor-highlighting.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0063" +title: "BlockNote code-block package for editor syntax highlighting" +status: active +date: 2026-04-13 +--- + +## Context + +Tolaria uses BlockNote for rich-text editing. Fenced code blocks already render on BlockNote's dark `pre > code` surface, but they were missing syntax highlighting and inherited the muted inline-code chip background from the global `code` selector in `EditorTheme.css`. The QA expectation is a dark code block with highlighted tokens and light code text, without regressing inline-code styling elsewhere in the editor. + +BlockNote documents syntax highlighting as a schema concern: replace the default `codeBlock` spec with `createCodeBlockSpec(...)` and provide a Shiki highlighter. Tolaria also needs to preserve the existing default behavior for unlabeled code blocks, which should stay plain text instead of defaulting to JavaScript. + +## Decision + +**Tolaria overrides the default BlockNote `codeBlock` spec with `@blocknote/code-block`, keeps `defaultLanguage: "text"`, and scopes the muted inline-code chip styling away from fenced code blocks.** + +## Options considered + +- **Use `@blocknote/code-block`** (chosen): first-party BlockNote path, ships supported language aliases and a bundled Shiki highlighter, renders `.shiki` token spans in-editor, and avoids maintaining a parallel ProseMirror plugin integration. +- **Use a custom `createCodeBlockSpec({ createHighlighter })` bundle**: also valid, but Tolaria does not need a custom language/theme bundle beyond BlockNote's packaged setup right now. +- **Keep BlockNote defaults and only fix CSS**: removes the nested gray chip bug, but leaves fenced code blocks unhighlighted and fails the product requirement. + +## Consequences + +Tolaria's highlighting now lives in the editor schema instead of an editor-side plugin hook. `src/components/editorSchema.tsx` swaps in `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })`, which adds BlockNote's language selector plus Shiki token spans for supported fenced blocks. `EditorTheme.css` continues to keep the `pre > code` background transparent so BlockNote's dark code-block shell remains intact. + +The tradeoff is one new first-party dependency and BlockNote's bundled language menu inside code blocks. If Tolaria later needs a narrower bundle, custom themes, or export-time highlighting parity, this ADR should be superseded with a custom Shiki bundle decision. diff --git a/docs/adr/0064-ratcheted-codescene-thresholds.md b/docs/adr/0064-ratcheted-codescene-thresholds.md new file mode 100644 index 0000000..6f63379 --- /dev/null +++ b/docs/adr/0064-ratcheted-codescene-thresholds.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0064" +title: "Ratcheted CodeScene thresholds as the quality gate baseline" +status: active +date: 2026-04-14 +--- + +## Context + +ADR-0018 established CodeScene code-health gates so Tolaria could block regressions before code reached `main`. Since then, the codebase has improved materially and the tracked baseline in `.codescene-thresholds` has been ratcheted above the original 9.50 / 9.31 minimums. + +Leaving ADR-0018 active would make the architecture record stale: the enforced thresholds are now stricter than the decision document says, and the current workflow intentionally tightens them as the project's sustained health improves. + +## Decision + +**Supersede ADR-0018 and treat `.codescene-thresholds` as the ratcheted policy baseline for Tolaria's CodeScene gate.** The current required minimums are `HOTSPOT_THRESHOLD=9.84` and `AVERAGE_THRESHOLD=9.45`. Thresholds move upward only when the repository can sustain a stricter baseline without immediately regressing. + +## Options considered + +- **Ratchet the enforced thresholds and document the new baseline** (chosen): keeps the ADRs aligned with the real gate, preserves the Boy Scout Rule, and makes code-health expectations stricter as the codebase improves. +- **Keep ADR-0018 active and treat higher thresholds as an implementation detail**: lower documentation churn, but the active ADR would no longer describe the actual CI and hook policy. +- **Remove numeric thresholds from ADRs entirely**: more durable on paper, but loses the explicit quality bar that developers are expected to maintain. + +## Consequences + +- `.codescene-thresholds` is now the authoritative location for the current numeric gate values. +- ADRs must be superseded again if Tolaria makes another meaningful policy jump in CodeScene thresholds. +- Pre-push and related quality checks now enforce a stricter floor than ADR-0018 described. +- The quality gate remains intentionally one-way: relaxing thresholds would require an explicit architectural reversal, not a quiet config edit. diff --git a/docs/adr/0065-root-managed-ai-guidance-files.md b/docs/adr/0065-root-managed-ai-guidance-files.md new file mode 100644 index 0000000..9955c91 --- /dev/null +++ b/docs/adr/0065-root-managed-ai-guidance-files.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0065" +title: "Root-managed AI guidance files with Claude shim" +status: active +date: 2026-04-14 +--- + +## Context + +Tolaria now supports multiple local CLI agents, but vault-level guidance still carried legacy assumptions. Existing vault bootstrap and repair flows centered on `config/agents.md`, while modern coding agents expect instructions at the vault root. That mismatch made managed guidance harder to reason about, left Claude Code compatibility implicit, and gave the UI no reliable way to distinguish between Tolaria-managed files that can be repaired and user-authored custom guidance that must be preserved. + +## Decision + +**Tolaria manages vault AI guidance at the vault root.** `AGENTS.md` is the canonical shared guidance file, `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`, and Tolaria classifies both files as `managed`, `missing`, `broken`, or `custom` so repair flows restore only Tolaria-managed guidance without overwriting custom user files. + +## Options considered + +- **Root `AGENTS.md` as canonical plus a root `CLAUDE.md` shim** (chosen): matches current agent expectations, keeps one source of truth for shared instructions, and makes repair status explicit. +- **Keep managed guidance under `config/agents.md`**: preserves the older structure, but hides a user-facing integration contract behind legacy config paths and keeps Claude compatibility indirect. +- **Maintain separate full instruction files for each agent**: simple per tool, but duplicates instructions and increases drift risk whenever guidance changes. + +## Consequences + +- New and repaired vaults now seed `AGENTS.md` and `CLAUDE.md` at the vault root. +- Legacy `config/agents.md` content is migrated forward when safe, then the obsolete file is removed. +- The status bar and command palette can expose a first-class restore action because backend guidance state is normalized. +- Custom root guidance files are preserved instead of being silently overwritten by repair flows. +- Tolaria keeps a single shared guidance document even while supporting multiple CLI agents. +- Re-evaluate if supported agents stop relying on root-level files or if future agent integrations require materially different vault instructions instead of a shared source of truth. diff --git a/docs/adr/0066-calendar-semver-versioning-for-alpha-and-stable-releases.md b/docs/adr/0066-calendar-semver-versioning-for-alpha-and-stable-releases.md new file mode 100644 index 0000000..b18b1a5 --- /dev/null +++ b/docs/adr/0066-calendar-semver-versioning-for-alpha-and-stable-releases.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0066" +title: "Calendar-semver versioning for alpha and stable releases" +status: active +date: 2026-04-16 +supersedes: "0057" +--- + +## Context + +ADR-0057 kept Tolaria on two updater channels and used "next stable patch" semver for alpha builds. That preserved ordering, but it no longer matched the agreed product naming: + +- Alpha should display as `Alpha YYYY.M.D.N` +- Alpha should ship the technical version `YYYY.M.D-alpha.N` +- Stable should ship and display as `YYYY.M.D` + +The naming change still needs to stay semver-safe when users switch between Stable and Alpha. A pure same-day calendar alpha would become older than a same-day stable promotion, so the workflow needs a monotonicity guard in addition to cleaner display strings. + +## Decision + +**Tolaria keeps exactly two updater channels (`stable` and `alpha`), but both now use calendar-semver release numbers.** Stable promotions use `stable-vYYYY.M.D` tags and stamp the technical version `YYYY.M.D`. Every push to `main` publishes an alpha build with technical version `YYYY.M.D-alpha.N` and display label `Alpha YYYY.M.D.N`. + +If the latest stable tag already uses the current UTC calendar date, the alpha workflow advances to the next calendar day before assigning `-alpha.N`. That keeps alpha semver-newer than the most recent stable build even after a same-day promotion. + +## Options considered + +- **Calendar semver with a next-day safeguard** (chosen): matches the agreed naming, keeps user-facing labels clean, and preserves updater ordering across channel switches. +- **Calendar semver without a safeguard**: simplest display model, but alpha can become semver-older than Stable after a same-day promotion. +- **Keep ADR-0057's next-patch prerelease numbering**: semver-safe, but it does not match the agreed release naming or the product surfaces that should show calendar-based versions. + +## Consequences + +- Release workflows now compute both a technical version and a display version. +- User-facing version surfaces strip technical prerelease noise into clean labels (`Alpha YYYY.M.D.N` or `YYYY.M.D`). +- Stable promotions must use `stable-vYYYY.M.D` tags instead of patch-based semver tags. +- Alpha sequence numbers are scoped to a calendar core date and remain compatible with the updater manifests. diff --git a/docs/adr/0067-autogit-idle-and-inactive-checkpoints.md b/docs/adr/0067-autogit-idle-and-inactive-checkpoints.md new file mode 100644 index 0000000..1d71f96 --- /dev/null +++ b/docs/adr/0067-autogit-idle-and-inactive-checkpoints.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0067" +title: "AutoGit idle and inactive checkpoints" +status: active +date: 2026-04-17 +--- + +## Context + +Tolaria already had explicit git actions in the status bar (ADR-0032) and a remote-aware manual commit flow (ADR-0059), but git-backed vaults still depended on the user remembering to create checkpoints. That worked for deliberate commits, yet it left a gap for ordinary writing sessions where the app had already saved all note content but no git checkpoint had been recorded. + +The new checkpointing behavior needed to stay conservative: + +- never run for non-git vaults +- never commit unsaved editor buffers +- reuse the same remote detection and local-only fallback as the manual commit flow +- avoid drift between timer-driven checkpoints and the status-bar quick commit action + +## Decision + +**Tolaria introduces installation-local AutoGit settings plus a dedicated `useAutoGit` hook that triggers a shared `useCommitFlow.runAutomaticCheckpoint()` path after configurable idle or inactive thresholds.** The checkpoint runs only when the current vault is git-backed, there are pending saved changes (or local commits waiting to push), and no unsaved edits remain. + +`useCommitFlow.runAutomaticCheckpoint()` is now the single checkpoint runner for both AutoGit and the status-bar quick commit action. That shared path generates deterministic automatic commit messages (`Updated N note(s)` / `Updated N file(s)`), commits locally when no remote exists, and can also do a push-only retry when commits already exist locally. + +## Options considered + +- **Option A** (chosen): A shared checkpoint runner used by both AutoGit timers and the quick commit action. Pros: one git policy, one message generator, one remote-handling path. Cons: adds another cross-cutting settings-driven hook. +- **Option B**: A separate background AutoGit implementation. Pros: could evolve independently from the manual commit flow. Cons: high risk of drift in commit messages, push behavior, and remote handling. +- **Option C**: Commit on every save. Pros: simplest trigger model. Cons: far too noisy for git history, especially with Tolaria's autosave model. + +## Consequences + +- App settings now persist `autogit_enabled`, `autogit_idle_threshold_seconds`, and `autogit_inactive_threshold_seconds` in installation-local settings storage. +- `useAutoGit` tracks editor activity plus app focus/visibility state and triggers checkpoints after the configured thresholds. +- Automatic checkpoints are blocked while unsaved edits exist, so AutoGit only records content that is already flushed through the normal save pipeline. +- The bottom-bar quick commit action now reuses the same checkpoint runner after forcing a save, keeping manual and automatic checkpoint behavior aligned. +- Vaults without a remote still benefit: AutoGit uses the existing local-only commit behavior from ADR-0059 instead of treating missing remotes as an error. +- Re-evaluate if users need per-vault policy instead of installation-local policy, or if timer-driven checkpoints create too much git noise in real-world use. diff --git a/docs/adr/0068-h1-only-title-surface-with-optional-untitled-auto-rename.md b/docs/adr/0068-h1-only-title-surface-with-optional-untitled-auto-rename.md new file mode 100644 index 0000000..e507ec1 --- /dev/null +++ b/docs/adr/0068-h1-only-title-surface-with-optional-untitled-auto-rename.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0068" +title: "H1-only title surface with optional untitled auto-rename" +status: active +date: 2026-04-17 +supersedes: "0055" +--- + +## Context + +ADR-0055 removed the legacy title row and made the editor body the only title surface. That ADR also kept one strong filename behavior from ADR-0044: untitled notes would auto-rename from their first H1 on save. + +That always-on rename rule turned out to be too rigid. Some users want the H1 to drive the displayed title immediately, but prefer to keep the synthetic `untitled-*` filename stable until they explicitly rename it from the breadcrumb bar. The product needed to preserve the H1-only editing model without forcing every installation into automatic filename changes. + +## Decision + +**Tolaria keeps the editor body as the only title surface, but untitled-note auto-rename from the first H1 becomes an installation-local setting (`initial_h1_auto_rename_enabled`) that defaults to enabled.** + +When the setting is enabled, untitled notes continue to auto-rename on save as soon as a real H1 title exists. When disabled, Tolaria still treats the H1 as the canonical display title, but it leaves the filename unchanged until the user explicitly renames it through the breadcrumb controls. + +## Options considered + +- **Option A** (chosen): Keep the current auto-rename behavior as the default, but make it an installation-local preference. Pros: preserves the fast path for most users while allowing opt-out for users who want stable temporary filenames. Cons: different installs can behave differently. +- **Option B**: Keep auto-rename mandatory, as assumed by ADR-0055. Pros: one simple filename policy. Cons: surprises users who want title editing without immediate file renames. +- **Option C**: Turn auto-rename off for everyone. Pros: filenames only change on explicit user action. Cons: leaves more `untitled-*` files around and adds friction to the common case. + +## Consequences + +- App settings now persist `initial_h1_auto_rename_enabled` in installation-local settings storage. +- The save pipeline consults that setting before scheduling untitled-file renames. +- Disabling untitled auto-rename does not restore any legacy title field or alternate title UI. H1 remains the only editor title surface. +- When the setting is off, display title and filename can diverge for longer: the note may show a human H1 while the file remains `untitled-*` until explicit rename. +- Settings UI and command/search affordances now expose this filename policy as a user preference rather than a hardcoded rule. +- Re-evaluate if users later need this policy to be per-vault instead of installation-local, or if longer-lived untitled filenames create too much Finder/git noise. diff --git a/docs/adr/0069-neighborhood-mode-for-note-list-relationship-browsing.md b/docs/adr/0069-neighborhood-mode-for-note-list-relationship-browsing.md new file mode 100644 index 0000000..a7d9988 --- /dev/null +++ b/docs/adr/0069-neighborhood-mode-for-note-list-relationship-browsing.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0069" +title: "Neighborhood mode for note-list relationship browsing" +status: active +date: 2026-04-19 +--- + +## Context + +Tolaria already had a relationship-browsing state behind `SidebarSelection.kind === 'entity'`, but the product language and interaction model were still fuzzy. The pinned source note rendered as a special card instead of a normal note row, grouped relationship results were deduplicated across sections, and Cmd-click behaved like a legacy "open separately" affordance rather than a clear graph-navigation action. + +The new note-list flow needed an explicit product concept for browsing related notes around a source note, plus keyboard semantics that matched the mouse flow. The team also wanted the list to preserve graph truth instead of collapsing overlapping relationships away when a note legitimately belonged to multiple groups. + +## Decision + +**Tolaria formalizes `SidebarSelection.kind === 'entity'` as Neighborhood mode.** The note list now treats the selected note as the neighborhood source, pins it at the top using the standard active note-row styling, shows outgoing relationship groups first and inverse/backlink groups after, keeps empty groups visible with count `0`, and allows the same note to appear in multiple groups when multiple relationships are true. + +**Neighborhood navigation is a distinct pivot action.** Plain click and plain `Enter` open the focused note without replacing the current neighborhood. Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood. + +## Options considered + +- **Reuse the existing `entity` selection as Neighborhood mode** (chosen): keeps the state model localized, avoids a second nearly-identical note-list mode, and lets sidebar navigation exit Neighborhood by selecting any other sidebar target. Cons: code still uses the historical `entity` name internally. +- **Add a new `neighborhood` selection variant**: clearer internal naming, but it duplicates the same source-note payload and would force wider selection-handling churn across the app for little product gain. +- **Keep the old implicit entity-browsing behavior**: lowest short-term engineering effort, but it leaves the product terminology inconsistent and preserves interaction mismatches like deduped groups and non-pivot Cmd-click behavior. + +## Consequences + +- Product, tests, and docs now refer to Neighborhood as a first-class note-list browsing mode. +- The note list preserves overlapping graph evidence: one note can appear in multiple groups when multiple relationships are true. +- Keyboard-only browsing now matches the pointer flow: arrow keys/open keep the current neighborhood, while Cmd/Ctrl-`Enter` pivots it. +- Sidebar navigation remains the exit path from Neighborhood because the app still models the mode through the existing selection union. +- Internal code still uses the `entity` discriminator, so future refactors should treat "entity selection" and "Neighborhood mode" as the same concept unless a broader navigation redesign justifies a new selection shape. diff --git a/docs/adr/0070-starter-vaults-local-first-with-explicit-remote-connection.md b/docs/adr/0070-starter-vaults-local-first-with-explicit-remote-connection.md new file mode 100644 index 0000000..d53a67f --- /dev/null +++ b/docs/adr/0070-starter-vaults-local-first-with-explicit-remote-connection.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0070" +title: "Starter vaults are local-first with explicit remote connection" +status: active +date: 2026-04-19 +--- + +## Context + +ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error. + +That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started. + +## Decision + +**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette. + +**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo. + +## Options considered + +- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step. +- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state. +- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story. + +## Consequences + +- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later. +- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows. +- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote. +- The starter repo remains a distribution source only, not an ongoing sync destination. +- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step. diff --git a/docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md b/docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md new file mode 100644 index 0000000..1a9ef9f --- /dev/null +++ b/docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md @@ -0,0 +1,49 @@ +--- +type: ADR +id: "0071" +title: "External vault updates reload derived state and reopen the clean active note" +status: superseded +date: 2026-04-21 +superseded_by: "0111" +--- + +## Context + +ADR-0002 makes the filesystem the source of truth, and ADR-0043 keeps locally edited frontmatter reactive inside the running UI. But external vault mutations still had a gap. A `git pull` or an AI agent edit could change notes on disk while the app kept showing stale note-list state, stale derived relationships, or an editor surface that still rendered the pre-refresh BlockNote document. + +The fix needed to satisfy a few constraints at once: + +- refresh all vault-derived UI, not just the main note list +- preserve unsaved local edits instead of clobbering them with disk state +- reopen the active note from disk when it is safe, even if another file changed, because backlinks, inverse relationships, and other derived surfaces can depend on the whole vault +- handle the native editor case where an in-place file update requires a full tab reopen to show the fresh document reliably + +## Decision + +**All external vault mutations now reconcile through one shared refresh path that reloads vault-derived state and then conditionally reopens the active note from disk.** + +Tolaria now routes post-pull refreshes and AI-agent file modifications through the same `refreshPulledVaultState()` helper. + +That shared path does the following: + +1. Reload `vault.entries`, folders, and saved views together. +2. If there is no active note, stop after the reload. +3. If the active note has unsaved local edits, keep the current editor buffer and do not replace it from disk. +4. Otherwise, find the refreshed `VaultEntry` for the active note and replace the active tab with freshly loaded disk content. +5. If the active file itself changed in place during the external update, close the tab before reopening it so BlockNote fully remounts onto the new document. +6. If the active file no longer exists after the reload, close the open tab state instead of leaving a stale editor behind. + +## Options considered + +- **Shared external-refresh reconciler** (chosen): one policy for pulls and agent edits, consistent vault-derived UI, and explicit protection for unsaved local edits. Cons: more coupling between sync flows and tab management. +- **Patch only the changed surfaces ad hoc**: smaller individual fixes, but high risk of drift between pull handling, agent handling, and future external-write paths. +- **Always force a full app-level reload**: simplest correctness story, but too disruptive and more likely to throw away user context unnecessarily. + +## Consequences + +- Any workflow that mutates the vault externally, such as git pulls or agent writes, should go through the shared refresh reconciler rather than reloading a single surface in isolation. +- Clean active notes now converge back to on-disk truth automatically after external updates. +- Unsaved local edits remain protected from external refreshes, even when the rest of the vault reloads. +- Folder, saved-view, backlink, and inverse-relationship surfaces stay aligned with the refreshed vault, not just the editor tab. +- Tolaria now treats "refresh after external mutation" as a first-class synchronization concern rather than a per-feature fix. +- Re-evaluate if the editor gains a reliable in-place document reset API, because that could remove the need for the close-and-reopen step when the active file itself changed. diff --git a/docs/adr/0072-confirmed-vault-paths-gate-startup-state.md b/docs/adr/0072-confirmed-vault-paths-gate-startup-state.md new file mode 100644 index 0000000..2d97fbd --- /dev/null +++ b/docs/adr/0072-confirmed-vault-paths-gate-startup-state.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0072" +title: "Confirmed vault paths gate startup state" +status: active +date: 2026-04-22 +--- + +## Context + +Tolaria's startup path was assuming that any incoming `vaultPath` was authoritative immediately. In practice, boot can pass through transient empty paths and stale paths that no longer correspond to the persisted active vault. That produced two classes of regressions: + +1. `useVaultLoader` fired `reload_vault` and `get_modified_files` before a real vault path existed, generating avoidable warnings and backend calls for `""`. +2. On fresh install or other non-persisted startup cases, a missing path could incorrectly render `vault-missing` instead of the intended welcome flow. + +Tolaria's onboarding and vault-loading surfaces need the same invariant: only a confirmed vault identity should drive startup side effects or missing-vault error UI. + +## Decision + +**Tolaria now treats a vault path as authoritative at startup only after it is confirmed.** Vault-loading side effects no-op until the path is non-empty, and the `vault-missing` onboarding state is shown only when the missing path was the persisted active vault recorded in `load_vault_list`. Otherwise, startup falls back to `welcome`. + +## Options considered + +- **Option A** (chosen): gate startup effects and missing-vault UI on confirmed vault identity. This keeps boot deterministic, avoids empty-path backend calls, and preserves the product rule that fresh installs should land in Welcome rather than an error state. +- **Option B**: treat any startup `vaultPath` as authoritative immediately. Simpler branching, but it keeps the existing race where transient or stale paths trigger warnings and the wrong onboarding state. +- **Option C**: special-case each startup surface independently. Lower immediate churn, but it would duplicate boot logic and let `useOnboarding` and `useVaultLoader` drift again. + +## Consequences + +- `useVaultLoader` must guard all startup work behind a real non-empty vault path. +- `useOnboarding` must consult persisted vault state before deciding that a missing path represents a deleted active vault. +- Fresh installs, cleared vault lists, and other startup flows without a confirmed active vault should resolve to `welcome`, even if an initial path probe fails. +- Re-evaluate if Tolaria introduces deeper startup routing (for example multiple launch intents or restored workspaces) that needs a richer boot-state model than the current confirmed-path gate. diff --git a/docs/adr/0073-persistent-linkify-protocol-registry-across-editor-remounts.md b/docs/adr/0073-persistent-linkify-protocol-registry-across-editor-remounts.md new file mode 100644 index 0000000..e0bd6b6 --- /dev/null +++ b/docs/adr/0073-persistent-linkify-protocol-registry-across-editor-remounts.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0073" +title: "Persistent linkify protocol registry across editor remounts" +status: active +date: 2026-04-22 +--- + +## Context + +Tolaria keeps a single editor shell alive while users swap notes and toggle between BlockNote and raw mode. The upstream BlockNote/Tiptap link stack assumes linkify protocol registration is effectively one-shot per editor lifetime, and Tiptap's link extension resets that registry on destroy. + +In Tolaria's lifecycle, that behavior caused duplicate `linkifyjs: already initialized` warnings during note-open and editor-remount flows. The problem is cross-cutting: BlockNote and Tiptap both participate, and the failure only disappears when protocol registration survives teardown/remount cycles instead of being repeated opportunistically. + +## Decision + +**Tolaria patches the upstream BlockNote and Tiptap link packages so custom linkify protocols are pre-registered once per app runtime and are not reset on editor teardown.** The patched packages coordinate through `globalThis` flags, and Tolaria tracks them via `pnpm` patched dependencies rather than ad hoc runtime monkey-patching inside app code. + +## Options considered + +- **Option A** (chosen): maintain explicit `pnpm` patches for the affected upstream packages, pre-register the needed protocols once, and preserve the registry across remounts. This matches Tolaria's persistent editor shell and keeps the behavior deterministic in both dev and packaged builds. +- **Option B**: keep upstream behavior and tolerate or suppress the warnings locally. Lower maintenance, but it leaves editor lifecycle correctness dependent on noisy duplicate initialization and makes future regressions harder to reason about. +- **Option C**: add Tolaria-side runtime monkey-patches around editor mount/unmount. Avoids vendoring patches, but spreads dependency-specific lifecycle logic into application code and is more fragile across package upgrades. + +## Consequences + +- `pnpm-workspace.yaml` now treats the relevant BlockNote and Tiptap link packages as patched dependencies, so upgrades must preserve or consciously replace those patches. +- Editor teardown in Tolaria must not assume ownership of the global linkify protocol registry. +- Smoke coverage for note open, editor remount, and raw-mode toggling must stay in place because the failure mode is lifecycle-specific rather than feature-specific. +- Re-evaluate this ADR if upstream BlockNote/Tiptap gains a supported lifecycle-safe protocol-registration model that makes the Tolaria patches unnecessary. diff --git a/docs/adr/0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md b/docs/adr/0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md new file mode 100644 index 0000000..8591d81 --- /dev/null +++ b/docs/adr/0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0074" +title: "Explicit external AI tool setup and least-privilege desktop scope" +status: active +date: 2026-04-22 +--- + +## Context + +Tolaria's first MCP integration optimized for zero setup: desktop startup auto-registered the Tolaria MCP server in Claude Code and Cursor config files, the Tauri asset protocol allowed every local path, and app-managed Codex sessions launched with the CLI's dangerous bypass flag. That made the product feel convenient, but it also widened trust by default in places that users could not see or consent to clearly. + +The product direction now favors least-privilege defaults. Fresh installs should not silently edit third-party config files, external AI tool setup must be intentional and reversible, and the desktop shell should only expose the filesystem paths that the active vault actually needs. + +## Decision + +**Tolaria now treats external AI tool wiring as an explicit user action and keeps the desktop shell scoped to the active vault.** + +- The app still spawns its local MCP WebSocket bridge on desktop startup, but it no longer auto-registers third-party MCP config files. +- External MCP registration is exposed through a keyboard-accessible setup flow reachable from the command palette and status surfaces. Confirming the flow upserts Tolaria's MCP entry for the current vault; cancel leaves external config untouched; disconnect removes Tolaria's entry again. +- The Tauri asset protocol remains enabled for local vault images, but its static config scope is empty. Tolaria grants recursive asset access only to the active vault at runtime when that vault is reloaded. +- App-managed Codex sessions use the CLI's normal approval and sandbox path by default instead of opting into the dangerous bypass mode automatically. + +## Options considered + +- **Explicit setup + runtime vault-only scope** (chosen): aligns with least-privilege defaults, keeps command-palette discoverability, preserves image loading and external-tool support, and makes every privileged step visible and reversible. +- **Keep startup auto-registration and global asset scope**: lowest friction, but it silently mutates third-party config and leaves the desktop shell effectively open to every local file path. +- **Disable external MCP registration entirely**: safest on paper, but it removes a valuable workflow for Claude Code, Cursor, and other MCP-compatible tools that Tolaria intentionally supports. + +## Consequences + +- Fresh installs no longer modify `~/.claude/mcp.json` or `~/.cursor/mcp.json` until the user confirms setup. +- Switching vaults does not silently retarget external MCP clients; users reconnect explicitly when they want a different vault exposed. +- Desktop asset access is constrained to the active vault instead of all filesystem paths, while note images and attachments continue to load normally. +- The command palette and status bar now expose an explicit external AI tools setup/remove flow that supports keyboard-only QA. +- Codex agent sessions are safer by default, at the cost of relying on the CLI's normal approval path instead of bypassing it automatically. diff --git a/docs/adr/0075-crash-safe-note-rename-transactions.md b/docs/adr/0075-crash-safe-note-rename-transactions.md new file mode 100644 index 0000000..649e430 --- /dev/null +++ b/docs/adr/0075-crash-safe-note-rename-transactions.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0075" +title: "Crash-safe note rename transactions" +status: active +date: 2026-04-22 +--- + +## Context + +Tolaria's note rename path used a simple "write new file, then delete old file" flow. That was easy to implement, but it had three integrity problems called out by issue #205: it could leave a visible duplicate note if the app crashed between those steps, destination-path selection depended on check-then-use races, and backlink rewrite failures were collapsed into a generic updated-files count that made partial success look clean. + +Rename is a core vault integrity operation. The app needs a flow that preserves a trustworthy visible state even when the process dies mid-rename, and it needs to surface any partial backlink rewrite failures clearly enough that users are not told everything updated when some linked files still need manual repair. + +## Decision + +**Tolaria now stages note renames through a hidden per-vault transaction directory and recovers unfinished transactions on the next vault scan.** + +- A rename that changes the file path first writes a transaction manifest plus a hidden backup path inside `/.tolaria-rename-txn/`. +- Tolaria moves the old note into that hidden backup, persists the new file with a no-clobber destination write, and then deletes the backup/manifest only after the new note exists. +- If the process crashes before the new note is committed, the next `scan_vault` restores the hidden backup back to the original path before listing entries. +- Manual filename renames keep their explicit conflict semantics, but the final destination is now claimed with a no-clobber write instead of relying on an existence check. +- Backlink rewrites now return both the number of successful updates and the number of failed updates so the UI can warn about partial success instead of reporting a clean rename. + +## Options considered + +- **Hidden transaction directory + scan-time recovery** (chosen): keeps in-flight rename artifacts out of the visible vault model, gives Tolaria a deterministic recovery point after crashes, and lets the final destination use no-clobber persistence. +- **Rename in place without transaction metadata**: simpler, but it cannot recover a half-finished rename reliably after process death and still leaves either duplicate or missing-note windows. +- **Best-effort duplicate cleanup with no recovery path**: lowest implementation cost, but it leaves the user-visible vault state dependent on exact crash timing and does not meet the trustworthiness goal for rename operations. + +## Consequences + +- Every vault can now contain a hidden `.tolaria-rename-txn/` directory managed by Tolaria; scan and folder UI continue to ignore it because hidden directories are already excluded. +- Rename results are richer: the frontend must treat `failed_updates > 0` as a warning state even when the rename itself succeeded. +- Future changes to vault scanning or note rename behavior must preserve transaction recovery before entry listing, otherwise crash safety regresses. +- The rename path no longer silently overwrites a destination discovered via a stale existence check; title-driven renames retry with suffixed filenames, while explicit filename renames fail cleanly on collision. diff --git a/docs/adr/0076-note-retargeting-separates-type-and-folder-moves.md b/docs/adr/0076-note-retargeting-separates-type-and-folder-moves.md new file mode 100644 index 0000000..256751d --- /dev/null +++ b/docs/adr/0076-note-retargeting-separates-type-and-folder-moves.md @@ -0,0 +1,34 @@ +--- +type: ADR +id: "0076" +title: "Note retargeting separates type changes from folder moves" +status: active +date: 2026-04-22 +--- + +## Context + +ADR-0025 made `type:` the canonical classification field, and ADR-0033 reopened subfolders as a valid way to organize files in the vault. Once Tolaria exposed both type sections and the folder tree in the sidebar, note reorganization had an unresolved ambiguity: does retargeting a note mean changing its semantic type, moving its file, or both? + +Without an explicit model, drag-and-drop and command-palette flows would need to duplicate their own validation and persistence logic, and Tolaria could easily drift back toward the old type-folder coupling that ADR-0006 deliberately removed. + +## Decision + +**Tolaria treats note retargeting as one shared interaction model with two distinct mutation paths: types change metadata, folders change file paths.** + +- Retargeting a note to a type updates only the note's `type:` frontmatter. The file stays where it is. +- Retargeting a note to a folder preserves the current filename and `type:` value, and moves the file through the same crash-safe rename transaction pipeline used for backend rename commands. +- Drag/drop targets and command-palette actions both route through the same frontend retargeting abstraction so validation, dialogs, collision handling, and success/error behavior stay consistent. + +## Options considered + +- **Shared retargeting model with separate type-vs-folder semantics** (chosen): preserves ADR-0025/ADR-0006's decoupling of type from path, lets folder moves reuse ADR-0075's crash-safe rename guarantees, and keeps multiple UI surfaces behaviorally aligned. +- **Treat folders as the source of truth for note type**: simpler mental model for some vaults, but it reintroduces path-based type inference and makes type changes depend on file moves again. +- **Implement drag/drop and command-palette retargeting as separate flows**: lower short-term coordination cost, but it duplicates mutation rules and makes consistency regressions likely. + +## Consequences + +- Type sections are semantic targets only; they must never imply a filesystem move. +- Folder targets are physical move operations; they must preserve filename/title behavior, reject collisions, and rewrite path-based wikilinks through the shared rename pipeline. +- Future note-retargeting surfaces should reuse the shared retargeting abstraction instead of introducing another mutation path. +- Re-evaluate this ADR if Tolaria later supports bulk retargeting, folder rules that intentionally infer type, or another organization primitive that needs different semantics. diff --git a/docs/adr/0077-concurrent-safe-vault-cache-replacement.md b/docs/adr/0077-concurrent-safe-vault-cache-replacement.md new file mode 100644 index 0000000..5eef747 --- /dev/null +++ b/docs/adr/0077-concurrent-safe-vault-cache-replacement.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0077" +title: "Concurrent-safe vault cache replacement" +status: active +date: 2026-04-24 +--- + +## Context + +ADR-0014 and ADR-0024 established Tolaria's git-based persistent vault cache and moved it outside the vault directory. That cache was still being rewritten with a simple temp-file-and-rename flow. + +Once Tolaria started reopening the same vault from multiple windows/processes more often, that write model became too optimistic: two scans could both build valid cache payloads from different moments in time, and the slower writer could still atomically replace a fresher cache written by another window. The cache needed cross-window safety without introducing a long-lived coordinator process or making vault open dependent on heavyweight IPC. + +## Decision + +**Tolaria now treats vault-cache replacement as a best-effort compare-and-swap operation instead of an unconditional atomic overwrite.** + +- Each scan still builds the next cache payload in memory and writes it to a temp file first. +- Before replacing the real cache file, Tolaria acquires a short-lived lock file for that vault cache path. +- After the lock is acquired, Tolaria rechecks the on-disk cache fingerprint and only renames the temp file into place if another window/process has not already refreshed the cache. +- If the cache changed underneath the current scan, Tolaria skips the replace and keeps the newer on-disk cache. +- Stale cache-write locks are garbage-collected after a short timeout so a crashed writer does not block future refreshes. + +## Options considered + +- **Lock + fingerprint guarded replacement** (chosen): keeps the cache file external and file-based, avoids overwriting fresher cache state from another Tolaria window, and preserves graceful fallback to filesystem rescans. Cons: cache writes become best-effort rather than guaranteed after every scan. +- **Keep unconditional temp-file + rename**: simplest implementation, but concurrent windows can regress the cache to an older view even though each individual replace is atomic. +- **Centralized cache service or long-lived process mutex**: strongest coordination story, but too much operational complexity for a local desktop app and would create new failure modes around boot, process lifetime, and IPC. + +## Consequences + +- Tolaria's cache correctness model is now "latest successful guarded replace wins," not "every scan must write a cache file." +- Cache refreshes must tolerate a skipped write when another window/process already produced a fresher cache. +- Temp-file writes and renames still provide corruption resistance, but freshness is protected separately by the writer lock and fingerprint check. +- Cache-write failures remain non-fatal: Tolaria logs them and falls back to rebuilding from the filesystem when needed. +- Re-evaluate if Tolaria later needs stronger cross-process coordination than lock-file + fingerprint checks can provide. diff --git a/docs/adr/0078-scoped-unsigned-fallback-for-app-managed-git-commits.md b/docs/adr/0078-scoped-unsigned-fallback-for-app-managed-git-commits.md new file mode 100644 index 0000000..c9d2e90 --- /dev/null +++ b/docs/adr/0078-scoped-unsigned-fallback-for-app-managed-git-commits.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0078" +title: "Scoped unsigned fallback for app-managed git commits" +status: active +date: 2026-04-24 +--- + +## Context + +ADR-0021, ADR-0059, and ADR-0070 all assume Tolaria can create and advance a local git-backed vault without asking users to debug git internals first. In practice, inherited `commit.gpgsign` settings were breaking that promise: a missing or misconfigured GPG/SSH signing helper could block the initial `Initial vault setup` commit during onboarding and could also strand later app-triggered commits behind opaque signing failures. + +Tolaria needed a policy that kept signed workflows intact when the user's signing setup actually works, while still ensuring app-managed git operations do not become unusable just because a desktop environment cannot reach the signing helper. + +## Decision + +**Tolaria uses a scoped unsigned fallback for app-managed commits instead of requiring signing to succeed unconditionally.** + +- The onboarding/setup commit (`Initial vault setup`) always runs with `commit.gpgsign=false` for that single git invocation. +- Normal app-managed `git_commit` calls still honor the user's existing git signing configuration first. +- If a commit fails and Git's error matches a signing-helper failure, Tolaria retries that same app-managed commit once with signing disabled. +- Tolaria does not rewrite the user's git config and does not broaden the retry to unrelated commit failures. + +## Options considered + +- **Scoped unsigned fallback for app-managed commits** (chosen): keeps onboarding and local commit flows resilient while still preserving signed commits when the user's environment supports them. Cons: some Tolaria-created commits may be unsigned on machines with broken signing setups. +- **Require signing to succeed for every commit**: simplest policy, but it turns missing desktop GPG/SSH helpers into app-breaking failures during onboarding and normal use. +- **Disable signing for all Tolaria-triggered commits**: maximally robust, but it would silently bypass working signing setups and weaken users' expected git security posture. + +## Consequences + +- New vault creation is no longer blocked by inherited signing settings that only fail in Tolaria's app context. +- Users with healthy signing setups still get signed Tolaria commits after the first normal attempt succeeds. +- Signing-failure detection must stay narrow so Tolaria does not mask unrelated git errors behind an unsigned retry. +- Tolaria's git integration now explicitly prefers "complete the app-managed commit safely" over "preserve signing at all costs" when the signing helper is unavailable. +- Re-evaluate if Tolaria later exposes per-vault git policy controls or needs a richer user-facing explanation for when a fallback unsigned commit was used. diff --git a/docs/adr/0079-linux-window-chrome-and-menu-reuse.md b/docs/adr/0079-linux-window-chrome-and-menu-reuse.md new file mode 100644 index 0000000..7a07577 --- /dev/null +++ b/docs/adr/0079-linux-window-chrome-and-menu-reuse.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0079" +title: "Linux window chrome and menu reuse" +status: active +date: 2026-04-24 +--- + +## Context + +Tolaria's desktop shell was designed around macOS window chrome. `titleBarStyle: "Overlay"` and `hiddenTitle: true` give the app a clean single-surface titlebar on macOS, but Linux ignores those flags and draws native GTK decorations and a native menu bar on top of the React UI. That creates a double-titlebar effect, mismatched theming, and inconsistent behavior between the main window and detached note windows. + +We still need Linux to reuse Tolaria's existing command palette, shortcut manifest, and deterministic menu-command routing instead of inventing a Linux-only command path. + +## Decision + +**Tolaria uses custom React-rendered window chrome on Linux and routes its menu through the existing shared command IDs.** + +- The main Tauri window disables server-side decorations on Linux during app setup. +- Detached note windows set `decorations: false` when Linux chrome is active. +- `LinuxTitlebar` renders the drag region, resize handles, and window controls for Linux windows. +- `LinuxMenuButton` mirrors the app's File/Edit/View/Go/Note/Vault/Window menus, but dispatches the existing command IDs through `trigger_menu_command`. +- The native Tauri menu bar is not mounted on Linux; macOS and other existing desktop targets keep the native menu. +- Shared shortcuts remain defined in `appCommandCatalog.ts`, including `Cmd+Shift+L` on macOS and `Ctrl+Shift+L` on Linux through the same command manifest. + +## Options considered + +- **React-rendered Linux chrome with shared command IDs** (chosen): keeps Linux visually aligned with Tolaria's existing shell and preserves one command-routing model across keyboard shortcuts, menu clicks, and QA helpers. Cons: Tolaria now owns Linux window chrome behavior directly. +- **Keep native GTK decorations and menu bar on Linux**: cheaper to ship, but it breaks visual consistency and produces overlapping titlebar/menu surfaces that do not match the rest of the app. +- **Introduce Linux-only command wiring for the custom menu**: would allow a Linux-specific implementation, but it would fork the shortcut/menu architecture and weaken deterministic QA. + +## Consequences + +- Linux main windows and detached note windows now present one consistent titlebar surface controlled by Tolaria. +- Menu commands, command palette actions, and deterministic QA still share the same command IDs, which limits platform-specific drift. +- Linux packaging and CI must install WebKit2GTK 4.1 dependencies and produce Linux bundles explicitly. +- Tolaria now owns Linux resize handles, maximize/minimize/close behavior, and titlebar drag-region behavior in the renderer, so regressions in those surfaces require direct tests. diff --git a/docs/adr/0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md b/docs/adr/0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md new file mode 100644 index 0000000..d0f73cc --- /dev/null +++ b/docs/adr/0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0080" +title: "Cross-platform desktop release artifacts and portable vault names" +status: active +date: 2026-04-24 +--- + +## Context + +Tolaria's release pipeline and file validation rules were still biased toward macOS. Alpha/stable releases only produced first-class macOS artifacts, stable download redirects assumed a DMG-only world, and vault file/folder validation allowed names that work on macOS/Linux but break on Windows clones and sync targets. + +Shipping Windows as a supported desktop target requires both distribution and data portability to become explicit. A Windows installer is not enough if shared vault content can still produce invalid filenames on that platform, and cross-platform updater manifests must keep Tauri's signed updater artifact separate from the user-facing installer download. + +## Decision + +**Tolaria ships first-class macOS, Windows x64, and Linux x64 desktop artifacts, and its vault-facing filename rules are portable across those platforms by default.** + +- Alpha and stable release workflows build and publish macOS, Windows x64, and Linux x64 artifacts from the same release tag/version computation. +- `latest.json` manifests continue to point Tauri updater clients at signed updater artifacts through `url`, while manual installer/download links are exposed separately via platform-specific fields such as `dmg_url` and `download_url`. +- The stable download page resolves the best current platform download from that manifest plus release assets, instead of assuming macOS-only DMG delivery. +- Note filename renames, folder creation/rename flows, and custom view filenames all share one portable validation rule set that rejects Windows reserved device names, invalid characters, and trailing dot/space suffixes. +- Shortcut labels shown in the UI are derived from the shared command manifest so non-macOS builds display `Ctrl`-style accelerators instead of macOS glyphs. + +## Options considered + +- **Cross-platform artifacts + portable filename rules** (chosen): makes Windows support real instead of nominal, keeps updater behavior compatible with Tauri, and prevents cross-OS vault breakage at the point of write. Cons: more CI matrix surface area and more platform-specific packaging constraints. +- **Ship Windows installers but keep existing filename validation**: lowers immediate implementation cost, but Windows users would still hit invalid vault content created elsewhere and trust in sync portability would stay weak. +- **Keep macOS-first updater/download metadata and infer other platforms from release assets only**: cheaper in the short term, but it weakens in-app update guarantees and makes the public download page depend on ad hoc asset naming rather than an explicit manifest contract. + +## Consequences + +- Tolaria's release CI now owns packaging and artifact validation on three desktop platforms instead of one. +- The public stable download page can redirect Windows/Linux users to real installers without special-case manual curation. +- Vault content created through Tolaria stays portable across macOS, Linux, and Windows, which reduces sync-time surprises and broken clones. +- Any future platform addition now needs both a release-artifact contract and an explicit portable-filename review instead of piggybacking on macOS assumptions. diff --git a/docs/adr/0081-internal-light-dark-theme-runtime.md b/docs/adr/0081-internal-light-dark-theme-runtime.md new file mode 100644 index 0000000..1162092 --- /dev/null +++ b/docs/adr/0081-internal-light-dark-theme-runtime.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0081" +title: "Internal light and dark theme runtime" +status: active +date: 2026-04-24 +supersedes: "0013" +--- + +## Context + +ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility. + +The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines. + +## Decision + +**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.** + +The v1 theme runtime is deliberately smaller than a general theming system: + +- Themes are defined by the app, not by vault-authored notes. +- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui. +- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions. +- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names. +- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred. + +## Options considered + +- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage. +- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data. +- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive. +- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components. + +## Consequences + +- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states. +- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell. +- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers. +- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference. +- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings. +- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly. +- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement. diff --git a/docs/adr/0082-markdown-durable-math-notes.md b/docs/adr/0082-markdown-durable-math-notes.md new file mode 100644 index 0000000..bd556ca --- /dev/null +++ b/docs/adr/0082-markdown-durable-math-notes.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0082" +title: "Markdown-durable math in notes" +status: active +date: 2026-04-26 +--- + +## Context + +Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format. + +BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source. + +## Decision + +**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.** + +The initial implementation: + +- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math. +- Converts math source to temporary placeholders before BlockNote parses Markdown. +- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency. +- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode. +- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note. + +## Options considered + +- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes. +- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract. +- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect. +- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement. + +## Consequences + +- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math. +- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source. +- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria. +- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model. +- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior. diff --git a/docs/adr/0083-dual-architecture-macos-release-artifacts.md b/docs/adr/0083-dual-architecture-macos-release-artifacts.md new file mode 100644 index 0000000..3d4a047 --- /dev/null +++ b/docs/adr/0083-dual-architecture-macos-release-artifacts.md @@ -0,0 +1,38 @@ +--- +type: ADR +id: "0083" +title: "Dual-architecture macOS release artifacts" +status: active +date: 2026-04-26 +supersedes: "0080" +--- + +## Context + +ADR-0080 made Tolaria's desktop release pipeline cross-platform, but the macOS leg still shipped only Apple Silicon artifacts. That left Intel Mac users without a compatible build in both the alpha feed and stable releases, even though Tauri and Rust can produce `x86_64-apple-darwin` bundles from the same release workflow. + +The updater manifest also needs to distinguish macOS CPU architectures. A generic `darwin` or macOS-only entry would make it too easy for an Intel installation to see an Apple Silicon updater bundle, and browser user agents cannot reliably tell Apple Silicon Macs apart from Intel Macs. + +## Decision + +**Tolaria publishes macOS release artifacts for both Apple Silicon (`darwin-aarch64`) and Intel (`darwin-x86_64`) in every alpha and stable release.** + +- Alpha and stable workflows build the macOS matrix for `aarch64-apple-darwin` and `x86_64-apple-darwin`. +- Alpha manifests include signed updater tarballs for `darwin-aarch64` and `darwin-x86_64`. +- Stable manifests include both macOS updater tarballs and both manual DMG downloads, alongside the existing Windows x64 and Linux x86_64 entries. +- Release jobs normalize macOS artifact filenames with the architecture suffix before publishing so GitHub release assets stay unambiguous. +- The stable download page exposes separate Apple Silicon and Intel Mac links. When both Mac links exist, a generic macOS browser is not auto-redirected because user-agent architecture detection is unreliable. +- The cross-platform filename portability decisions from ADR-0080 remain in force. + +## Options considered + +- **Publish separate Apple Silicon and Intel Mac artifacts** (chosen): gives each updater client an architecture-specific manifest key and gives users explicit manual download links. Cons: doubles the macOS release matrix and signing/notarization surface. +- **Publish a universal macOS binary**: gives users one download, but requires lipo/re-sign/notarize coordination and reintroduces the artifact-combining complexity the release pipeline intentionally avoided. +- **Keep Apple Silicon-only macOS releases**: keeps CI cheaper, but leaves Intel Mac users unsupported and makes the release artifacts inconsistent with Tolaria's desktop support goals. + +## Consequences + +- macOS release jobs now run one matrix entry per CPU architecture. +- Release manifest consumers must treat `darwin-aarch64` and `darwin-x86_64` as distinct platform keys. +- Stable manual downloads show two Mac choices instead of pretending browser detection can select the right CPU architecture. +- Future macOS release changes must validate both updater and manual-download artifacts for both architectures. diff --git a/docs/adr/0084-app-localization-foundation.md b/docs/adr/0084-app-localization-foundation.md new file mode 100644 index 0000000..e606f4c --- /dev/null +++ b/docs/adr/0084-app-localization-foundation.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0084" +title: "App-owned localization foundation" +status: active +date: 2026-04-26 +--- + +## Context + +Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure. + +## Decision + +Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`. + +- English is the canonical fallback locale. +- Simplified Chinese (`zh-Hans`) is the first additional locale. +- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English". +- Missing translation keys fall back to English. +- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context. +- Language switching is exposed in Settings and through command-palette actions. + +## Alternatives considered + +- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow. +- **Store language in the vault**: rejected because UI language is an installation preference, not content structure. +- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive. + +## Consequences + +- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale. +- Partially translated locales remain usable because English is the fallback for missing keys. +- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault. +- Larger feature surfaces can migrate to the shared localization module incrementally. diff --git a/docs/adr/0085-non-git-vault-support.md b/docs/adr/0085-non-git-vault-support.md new file mode 100644 index 0000000..ce96fc4 --- /dev/null +++ b/docs/adr/0085-non-git-vault-support.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0085" +title: "Non-git vaults open with explicit later Git initialization" +status: active +date: 2026-04-26 +supersedes: "0034" +--- + +## Context + +ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory. + +Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration. + +## Decision + +**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action. + +While a vault is not Git-backed: + +- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled. +- Background auto-sync and AutoGit checkpoints do not run. +- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally. + +`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit. + +## Options considered + +- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow. +- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows. +- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata. + +## Consequences + +- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior. +- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant. +- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA. +- Future Git-dependent features must check the current vault's Git state before registering commands or running background work. diff --git a/docs/adr/0086-in-app-image-file-preview.md b/docs/adr/0086-in-app-image-file-preview.md new file mode 100644 index 0000000..672032c --- /dev/null +++ b/docs/adr/0086-in-app-image-file-preview.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0086" +title: "In-app image previews for binary vault files" +status: active +date: 2026-04-26 +supersedes: "0041" +--- + +## Context + +ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model. + +That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault. + +## Decision + +**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.** + +- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type. +- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read. +- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk. +- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically. +- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface. +- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model. + +## Consequences + +- The existing `VaultEntry` model and cache version do not need to change. +- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes. +- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them. +- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations. diff --git a/docs/adr/0087-json-catalogs-and-lara-cli-localization.md b/docs/adr/0087-json-catalogs-and-lara-cli-localization.md new file mode 100644 index 0000000..44b73d2 --- /dev/null +++ b/docs/adr/0087-json-catalogs-and-lara-cli-localization.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0087" +title: "JSON locale catalogs with Lara CLI synchronization" +status: active +date: 2026-04-27 +supersedes: + - "0084" +--- + +## Context + +ADR-0084 established an app-owned localization layer in `src/lib/i18n.ts` with English fallback and hand-maintained TypeScript dictionaries. That was enough for the first localized UI surface, but it does not scale well to a broader locale matrix or machine-assisted translation workflows. + +We now want Tolaria to support a wider set of locales and to automate translation updates with Lara CLI while keeping the runtime dependency-light and preserving the existing English fallback behavior. + +## Decision + +Tolaria will keep its app-owned runtime localization layer, but the translation source-of-truth moves to flat JSON catalogs in `src/lib/locales/`. + +- `src/lib/locales/en.json` is the canonical source catalog. +- Additional locale files use one JSON file per locale code (for example `zh-CN.json`, `fr-FR.json`). +- `src/lib/i18n.ts` keeps fallback, interpolation, locale resolution, and props-down locale wiring, but it now loads locale catalogs from JSON files instead of TypeScript objects. +- Lara CLI configuration lives in `lara.yaml`, and translation runs happen through repo scripts (`pnpm l10n:translate`, `pnpm l10n:translate:force`). +- `scripts/validate-locales.mjs` verifies that every locale catalog present in the repo matches the English keyset and only contains flat string values. +- Legacy stored preferences such as `zh-Hans` are normalized to the canonical `zh-CN` locale. + +## Alternatives considered + +- **Keep TypeScript dictionaries and point Lara at `.ts` files**: possible, but JSON is the more standard interchange format for translation tooling and keeps diffs simpler for translators and reviewers. +- **Adopt a full frontend i18n framework now**: rejected because Tolaria already has working locale propagation and fallback behavior, and the immediate need is better content management plus translation automation. +- **Store translated strings outside the app repo**: rejected because Tolaria's chrome localization should stay versioned with the app code that consumes it. + +## Consequences + +- Translators and automation tools now work against plain JSON catalogs instead of editing source code. +- The runtime keeps English fallback behavior, so a missing locale file or missing key does not break app chrome. +- Locale additions become a data/config change first: add the locale metadata, run Lara, review JSON output, then ship. +- Localization work now has a dedicated validation step that can run in CI or before commit. diff --git a/docs/adr/0088-markdown-durable-mermaid-diagrams.md b/docs/adr/0088-markdown-durable-mermaid-diagrams.md new file mode 100644 index 0000000..6aaeeb6 --- /dev/null +++ b/docs/adr/0088-markdown-durable-mermaid-diagrams.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0088" +title: "Markdown-durable Mermaid diagrams in notes" +status: active +date: 2026-04-27 +--- + +## Context + +Tolaria notes are plain Markdown files, while the rich editor uses BlockNote and raw mode uses CodeMirror. Users need fenced `mermaid` blocks to render as diagrams in the note surface without changing the canonical file format or hiding the source from raw editing. + +BlockNote can parse fenced code blocks, but a generic highlighted code block does not provide diagram rendering. Rendering Mermaid directly from the Markdown fence also has to preserve the original fence source when notes are saved, copied through raw mode, closed, and reopened. + +## Decision + +**Tolaria will support Mermaid diagrams through a Markdown placeholder round-trip owned by the editor pipeline and rendered with the `mermaid` package.** + +The implementation: + +- Converts fenced `mermaid` blocks to temporary placeholders before BlockNote parses Markdown. +- Replaces placeholders with a `mermaidBlock` schema block that stores both the original fenced source and the diagram body. +- Renders the block through Mermaid in the rich editor. +- Serializes `mermaidBlock` nodes back to their stored fenced Markdown before save, raw-mode entry, and editor-position snapshots. +- Shows the original source as an inline fallback when Mermaid cannot render a diagram. + +## Options considered + +- **Tolaria-owned placeholder round-trip with Mermaid rendering** (chosen): matches the existing wikilink and math architecture, keeps Markdown as the source of truth, and gives Tolaria explicit control over serialization. +- **Render all `mermaid` code blocks by overriding the generic code-block renderer**: smaller surface, but it couples diagram behavior to the code-highlighting package and makes exact source preservation harder. +- **Raw-mode-only Mermaid support**: preserves source but fails the enhanced note reading experience users expect. +- **Store parsed diagram metadata outside the Markdown body**: enables richer future editing, but violates the files-first model. + +## Consequences + +- `src/utils/mermaidMarkdown.ts` is the canonical parser/serializer bridge for note diagrams. +- Rich mode renders diagrams as schema-backed blocks; raw mode remains the direct source editor. +- Invalid Mermaid source remains visible instead of breaking the editor surface. +- `mermaid` is now a runtime dependency and should be upgraded deliberately with rendering regression coverage. +- Future diagram controls, such as copy source or expand, can attach to the same `mermaidBlock` without changing storage. diff --git a/docs/adr/0089-active-vault-filesystem-watcher.md b/docs/adr/0089-active-vault-filesystem-watcher.md new file mode 100644 index 0000000..e8f607a --- /dev/null +++ b/docs/adr/0089-active-vault-filesystem-watcher.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0089" +title: "Active vault filesystem watcher" +status: active +date: 2026-04-27 +--- + +## Context + +Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale. + +ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path. + +## Decision + +**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.** + +The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths. + +The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback. + +## Options considered + +- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state. +- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation. +- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits. +- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows. + +## Consequences + +- External writes converge automatically into the visible vault state after a short debounce. +- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected. +- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop. +- The status bar can show reload progress for manual and automatic refreshes. +- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists. diff --git a/docs/adr/0090-pi-cli-agent-adapter.md b/docs/adr/0090-pi-cli-agent-adapter.md new file mode 100644 index 0000000..f98151a --- /dev/null +++ b/docs/adr/0090-pi-cli-agent-adapter.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0090" +title: "Pi CLI agent adapter" +status: active +date: 2026-04-28 +--- + +## Context + +Tolaria already supports Claude Code, Codex, and OpenCode as local CLI agents in the AI panel. The next provider request is Pi Coding Agent support with the same first-class availability, settings, status, streaming, and MCP vault access. + +Pi exposes non-interactive JSON events through `pi --mode json`, but Pi core intentionally does not include built-in MCP. Its supported MCP path is the `pi-mcp-adapter` extension, which reads MCP server definitions from Pi-compatible config files. + +## Decision + +Tolaria adds Pi as a supported CLI agent id (`pi`) and launches app-managed Pi sessions through a dedicated adapter module. + +The adapter runs `pi --mode json --no-session` from the active vault cwd, closes stdin, and points `PI_CODING_AGENT_DIR` at a temporary directory containing Tolaria's `mcp.json`. That config loads the Tolaria MCP server through `pi-mcp-adapter`, pins `VAULT_PATH` to the selected vault, sets `WS_UI_PORT=9711`, uses lazy server lifecycle, and exposes the small Tolaria tool set directly. + +Pi availability follows the existing desktop pattern: check the inherited `PATH`, the user's login shell, and common local/toolchain install locations. Pi authentication remains owned by the Pi CLI; Tolaria only surfaces setup errors and does not store provider API keys. + +## Options Considered + +- **Transient Pi adapter config** (chosen): gives app-launched Pi sessions Tolaria MCP access without mutating user or vault config files. Cons: requires the Pi MCP adapter extension path to be available to Pi. +- **Write `.mcp.json` into the active vault**: simple for Pi discovery, but creates project files as a side effect of a chat session and can dirty user vaults. +- **Rely on global `~/.pi/agent/mcp.json`**: matches Pi documentation, but silently retargets or depends on user-global state and conflicts with Tolaria's explicit integration boundary. +- **Skip MCP for Pi**: easier to implement, but creates a weaker agent than the existing providers and violates the requirement for first-class MCP support. + +## Consequences + +- The AI panel, settings, command palette, status bar, onboarding, and stream normalization now treat Pi as a first-class supported agent. +- App-launched Pi sessions can use Tolaria vault MCP tools while remaining scoped to the selected vault. +- Pi support stays isolated in Pi-specific modules instead of expanding the shared `ai_agents.rs` hotspot. +- Users still need Pi itself, a configured Pi model/provider, and the Pi MCP adapter extension path available to the Pi CLI. diff --git a/docs/adr/0091-gemini-cli-external-ai-setup.md b/docs/adr/0091-gemini-cli-external-ai-setup.md new file mode 100644 index 0000000..db3c56e --- /dev/null +++ b/docs/adr/0091-gemini-cli-external-ai-setup.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0091" +title: "Gemini CLI external AI setup" +status: active +date: 2026-04-28 +--- + +## Context + +Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance. + +Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions. + +## Decision + +Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`. + +Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten. + +The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys. + +## Options Considered + +- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context. +- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini. +- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients. +- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server. + +## Consequences + +- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files. +- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance. +- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini. +- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths. diff --git a/docs/adr/0092-vault-ai-agent-permission-modes.md b/docs/adr/0092-vault-ai-agent-permission-modes.md new file mode 100644 index 0000000..aba5464 --- /dev/null +++ b/docs/adr/0092-vault-ai-agent-permission-modes.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0092" +title: "Vault-scoped AI agent permission modes" +status: active +date: 2026-04-28 +--- + +## Context + +ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault. + +The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming. + +## Decision + +**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.** + +The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker. + +Adapter mappings remain conservative: +- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`. +- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes. +- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories. +- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config. + +## Options Considered + +- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault. +- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults. +- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision. +- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about. + +## Consequences + +- Vault config normalization owns the safe default for old vaults and malformed values. +- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping. +- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests. +- Any future dangerous mode requires a new ADR and separate UI language. diff --git a/docs/adr/0093-shared-cli-agent-runtime-adapters.md b/docs/adr/0093-shared-cli-agent-runtime-adapters.md new file mode 100644 index 0000000..274625f --- /dev/null +++ b/docs/adr/0093-shared-cli-agent-runtime-adapters.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0093" +title: "Shared CLI agent runtime adapters" +status: active +date: 2026-04-29 +--- + +## Context + +Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution. + +That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter. + +## Decision + +**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.** + +The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping. + +Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream. + +## Options Considered + +- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior. +- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity. +- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel. +- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test. + +## Consequences + +- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`. +- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local. +- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape. +- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests. diff --git a/docs/adr/0094-gitignored-content-visibility-boundary-filter.md b/docs/adr/0094-gitignored-content-visibility-boundary-filter.md new file mode 100644 index 0000000..af06e5e --- /dev/null +++ b/docs/adr/0094-gitignored-content-visibility-boundary-filter.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0094" +title: "Gitignored content visibility as a command-boundary filter" +status: active +date: 2026-04-29 +--- + +## Context + +Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`. + +Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface." + +## Decision + +**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.** + +- `hide_gitignored_files` is an installation-local app setting and defaults to `true`. +- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical. +- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled. +- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format. +- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result. + +## Options considered + +- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence. +- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling. +- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules. + +## Consequences + +- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata. +- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter. +- The cache can still support future visibility changes without a data migration. +- Users can reveal ignored content again immediately by disabling the setting. +- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output. diff --git a/docs/adr/0095-saved-view-order-field.md b/docs/adr/0095-saved-view-order-field.md new file mode 100644 index 0000000..8b69c41 --- /dev/null +++ b/docs/adr/0095-saved-view-order-field.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0095" +title: "Saved views use an explicit YAML order field" +status: active +date: 2026-04-29 +--- + +## Context + +Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls. + +The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference. + +## Decision + +**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.** + +- Lower `order` values render earlier in the sidebar and other Saved View lists. +- Views without `order` sort after ordered views and then fall back to filename ordering for stability. +- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames. +- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions. + +## Options considered + +- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model. +- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming. +- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version. + +## Consequences + +- Saved View ordering becomes part of the vault and syncs naturally through Git. +- Existing views remain valid; unordered files keep a stable fallback sort until reordered. +- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence. +- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store. diff --git a/docs/adr/0096-root-created-type-documents.md b/docs/adr/0096-root-created-type-documents.md new file mode 100644 index 0000000..49d43ac --- /dev/null +++ b/docs/adr/0096-root-created-type-documents.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0096" +title: "Root-created type documents" +status: active +date: 2026-04-29 +--- + +## Context + +Tolaria identifies type definitions by markdown frontmatter (`type: Type`), not by filesystem location. Older documentation and UI creation flows still treated `type/` as the canonical destination for new type documents, with a compatibility fallback for vaults that already used `types/`. + +That folder-based creation policy conflicted with the broader vault model: notes are scanned from all non-hidden folders, type identity comes from metadata, and root `type.md` / `note.md` definitions are already used by repair and bootstrap flows. + +## Decision + +**Tolaria creates new type documents at the vault root.** + +- A type document is any markdown note with `type: Type` in frontmatter. +- New UI-created type documents use `{vault}/{slug}.md`. +- Existing type documents in `type/`, `types/`, or other scanned folders remain valid and continue to drive templates, icons, colors, visibility, sorting, and sidebar grouping. +- Creation does not silently migrate or move existing type documents. +- Root filename collisions are handled as file collisions; Tolaria must not overwrite an existing note when creating a type document. + +## Options considered + +- **Root-created type documents** (chosen): matches the metadata-first model, removes special casing from creation, and aligns new types with root-managed default type scaffolding. +- **Canonical `type/` folder**: avoids root filename collisions, but makes path special even though type identity is already defined by frontmatter. +- **Preserve existing folder convention dynamically**: minimizes change for plural `types/` vaults, but creates inconsistent behavior across vaults and leaves `types/` only partially supported. + +## Consequences + +- Users can inspect and edit type documents as ordinary root notes by default. +- Existing vaults with `type/` or `types/` type documents remain readable because vault scanning already includes non-hidden subdirectories. +- If a root note with the same slug already exists, type creation fails with a collision message instead of writing into a fallback folder. +- Legacy `type/` may remain hidden from the folder tree so old type documents do not duplicate the Types sidebar section. +- Re-evaluate if users need a guided migration from folder-based type documents to root type documents. diff --git a/docs/adr/0097-gemini-cli-agent-adapter.md b/docs/adr/0097-gemini-cli-agent-adapter.md new file mode 100644 index 0000000..ff7e1b9 --- /dev/null +++ b/docs/adr/0097-gemini-cli-agent-adapter.md @@ -0,0 +1,42 @@ +--- +type: ADR +id: "0097" +title: "Gemini CLI agent adapter" +status: active +date: 2026-04-29 +--- + +## Context + +ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi. + +Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions. + +## Decision + +Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`. + +The desktop backend adds a Gemini adapter that: + +- discovers `gemini` through the process path, login shell, and common local/toolchain install locations +- runs `gemini --output-format json --approval-mode --prompt ` from the active vault +- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH` +- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]` +- uses Power User mode with `yolo` and a trusted Tolaria MCP entry +- maps Gemini JSON responses into Tolaria's existing AI panel stream events + +The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config. + +## Options Considered + +- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped. +- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel. +- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage. +- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder. + +## Consequences + +- Gemini appears anywhere users can choose, install, or switch local AI agents. +- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance. +- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating. +- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel. diff --git a/docs/adr/0098-in-app-image-and-pdf-file-previews.md b/docs/adr/0098-in-app-image-and-pdf-file-previews.md new file mode 100644 index 0000000..d6ee947 --- /dev/null +++ b/docs/adr/0098-in-app-image-and-pdf-file-previews.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0098" +title: "In-app image and PDF previews for binary vault files" +status: superseded +date: 2026-04-29 +supersedes: "0086" +superseded_by: "0110" +--- + +## Context + +ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria. + +## Decision + +**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.** + +- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`. +- Supported images render with `` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`. +- The Tauri CSP permits scoped asset URLs in `object-src` so PDF objects can load vault-backed files without broadening script, connect, or image policy. +- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch. +- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable. +- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior. + +## Consequences + +- PDFs do not become notes and do not get Markdown editor semantics. +- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape. +- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions. diff --git a/docs/adr/0099-cumulative-vault-asset-scope.md b/docs/adr/0099-cumulative-vault-asset-scope.md new file mode 100644 index 0000000..bbd96fb --- /dev/null +++ b/docs/adr/0099-cumulative-vault-asset-scope.md @@ -0,0 +1,37 @@ +--- +type: ADR +id: "0099" +title: "Cumulative vault asset scope for previews" +status: active +date: 2026-04-29 +supersedes: "0074 asset-protocol runtime scoping" +--- + +## Context + +ADR-0074 moved the desktop asset protocol away from broad filesystem access and toward runtime vault scoping. The implementation tried to keep only the active vault in scope by calling Tauri's `forbid_directory` for vault roots that were no longer active. + +Tauri's filesystem scope treats forbidden paths as permanent precedence rules: a forbidden path is denied even if it is later allowed again. After a user switched away from a vault and back, image and PDF previews could keep producing `403 Forbidden` responses for valid vault files until the app restarted. + +## Decision + +**Tolaria accumulates Tauri asset protocol access for vault roots loaded during the current app session and never forbids a previously loaded vault root at runtime.** + +- `sync_vault_asset_scope` adds the canonical vault root and requested vault root when they are missing from the runtime asset scope. +- The runtime asset scope remains narrower than global filesystem access because only vault roots that Tolaria has loaded are added. +- Command paths still enforce the active vault boundary through the Rust command layer before reads, writes, external opens, and attachment imports. +- Asset scope revocation is deferred to process exit, because Tauri does not expose a safe runtime unallow operation for directories. + +## Options considered + +- **Cumulative runtime vault scope** (chosen): keeps previews reliable after vault switches while preserving vault-only access in the current process. +- **Continue forbidding previous vaults**: appears stricter, but Tauri forbids are not reversible and valid previews fail after switching back. +- **Allow all filesystem paths**: avoids preview failures but returns to the broad asset protocol access that ADR-0074 intentionally removed. +- **Replace `convertFileSrc` with a custom protocol**: could support exact active-vault revocation, but it would be a larger cross-cutting migration for editor images, file previews, and PDF rendering. + +## Consequences + +- Images and PDFs from any vault loaded in the current session can keep rendering after vault switches. +- The app process, not each vault switch, is the revocation boundary for asset URL access. +- Active-vault command validation remains the primary guard for mutations and default-app opens. +- Re-evaluate this if Tauri adds a public runtime unallow operation for asset protocol directories. diff --git a/docs/adr/0100-synthetic-vault-root-folder-row.md b/docs/adr/0100-synthetic-vault-root-folder-row.md new file mode 100644 index 0000000..8dfe5e8 --- /dev/null +++ b/docs/adr/0100-synthetic-vault-root-folder-row.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0100" +title: "Synthetic vault-root row in folder navigation" +status: active +date: 2026-04-30 +--- + +## Context + +ADR-0033 introduced subfolder scanning and a collapsible folder tree backed by `list_vault_folders`, but the sidebar still had no first-class way to select the vault root itself. That left root-level files outside the folder-navigation model and pushed the UI toward one-off handling for the opened vault path. + +The new sidebar behavior needs to show root-level files when the user clicks the vault name, while preserving the existing folder rename/delete model for real folders only. + +## Decision + +**Represent the vault root in the sidebar as a synthetic frontend-owned folder row rather than as a mutable backend folder.** + +- `FolderTree` wraps backend folder nodes in a root row with `path: ""` and `rootPath` set to the opened vault path. +- `SidebarSelection` keeps using `kind: 'folder'`, but root selection is encoded as the empty folder path plus `rootPath` metadata. +- Root-level file filtering is handled in note-list helpers as a dedicated root case instead of pretending the vault root is an ordinary folder. +- Rename/delete remain available only for real folders; the vault root row is navigable, not mutable. + +## Options considered + +- **Option A** (chosen): Synthetic vault-root row in the renderer — keeps `list_vault_folders` focused on real folders, avoids backend schema churn, and reuses the existing folder-selection mental model. +- **Option B**: Add a pseudo-folder to backend folder results — would couple presentation-only root behavior to command data and blur the distinction between the vault itself and mutable folders. +- **Option C**: Keep root files outside folder navigation entirely — simpler, but leaves the sidebar with an incomplete navigation model and special cases elsewhere in the UI. + +## Consequences + +- Folder navigation now has a single model for root and nested folder browsing. +- Backend folder APIs stay unchanged: they describe actual folders, not UI-only rows. +- Selection handling must treat `path: ""` as the vault-root case and use `rootPath` when computing direct-root file membership. +- Re-evaluate if folder actions ever need to operate on the vault root itself, because that would likely require a separate command model instead of extending the synthetic row. diff --git a/docs/adr/0101-categorical-product-analytics-events.md b/docs/adr/0101-categorical-product-analytics-events.md new file mode 100644 index 0000000..ec0f992 --- /dev/null +++ b/docs/adr/0101-categorical-product-analytics-events.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0101" +title: "Categorical product analytics events" +status: active +date: 2026-04-30 +--- + +## Context + +ADR-0016 established opt-in PostHog analytics and Sentry crash reporting, but new feature telemetry now spans file previews, inline image lightbox opens, AI agent session lifecycle events, and All Notes visibility toggles. Instrumenting those flows ad hoc would make it easy to leak vault-specific data such as note paths, filenames, prompt text, or image URLs. + +Tolaria needs richer product telemetry for feature behavior while preserving the privacy bar expected from a local-first notes app. + +## Decision + +**Route product analytics through dedicated helper functions that emit only coarse categorical metadata.** + +- Product events live behind `src/lib/productAnalytics.ts` instead of scattering raw `trackEvent()` payloads across feature code. +- Allowed payloads are constrained to categories and counts such as preview kind, action kind, AI agent id, permission mode, response/tool counts, status, and All Notes visibility category/enabled state. +- Product events must not include vault content, note titles, file paths, filenames, image URLs, prompt text, or other user-authored data. +- When a feature needs telemetry, it should add or extend a typed helper rather than sending free-form payloads inline. + +## Options considered + +- **Option A** (chosen): Typed categorical wrappers for product events — preserves useful product signals while keeping privacy constraints explicit and reviewable in one place. +- **Option B**: Let each feature call `trackEvent()` directly with whatever fields seem useful — faster short term, but inconsistent and too easy to let sensitive data leak into analytics. +- **Option C**: Avoid new product events entirely — safest from a privacy standpoint, but leaves the team blind to whether preview, AI, and list-visibility features are actually being used or failing. + +## Consequences + +- Telemetry reviews become easier because the allowed product-event surface is centralized. +- Future product analytics work should start by defining categorical event helpers, not by attaching raw note/file context. +- Some debugging detail is intentionally sacrificed; deep diagnosis should rely on consented crash reporting and local reproduction rather than richer analytics payloads. +- Re-evaluate if Tolaria ever needs a broader telemetry taxonomy or stronger compile-time guarantees around event schemas. diff --git a/docs/adr/0102-low-end-safe-autosave-idle-window.md b/docs/adr/0102-low-end-safe-autosave-idle-window.md new file mode 100644 index 0000000..5094793 --- /dev/null +++ b/docs/adr/0102-low-end-safe-autosave-idle-window.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0102" +title: "Low-end-safe autosave idle window" +status: active +date: 2026-04-30 +supersedes: "0015" +--- + +## Context + +ADR-0015 chose a 500ms autosave debounce so normal edits would persist quickly without writing on every keystroke. GitHub issue #443 showed that this window is too aggressive on very weak Windows CPUs: if typing intervals exceed 500ms or a save takes long enough to overlap continued typing, Tolaria can start disk and derived-state work while the user is still entering text. + +## Decision + +**Tolaria autosaves after a 1.5s idle window and treats stale in-flight autosaves as obsolete when newer content arrives before they resolve.** Manual saves, note switches, raw-mode entry, and destructive actions still flush pending editor content immediately. + +## Options Considered + +- **Option A — 1.5s idle window plus stale-save protection** (chosen): reduces mid-typing saves on slow CPUs while keeping ordinary autosave behavior fast enough for reliability. It also prevents an older slow save from clearing or repainting over newer pending text. +- **Option B — Keep 500ms and only fix stale saves**: preserves the previous timing but still triggers repeated saves for slower typists and weaker machines. +- **Option C — Save only on blur or navigation**: minimizes background work but increases crash-loss risk during longer writing sessions. + +## Consequences + +- Autosave is less likely to compete with active typing on low-end Windows hardware. +- The unsaved window grows from roughly 500ms to roughly 1.5s after Tolaria receives the latest content change. +- Explicit flush paths remain immediate, so navigation, manual save, raw-mode transitions, and destructive actions still preserve pending edits before proceeding. +- Future changes to autosave timing should keep weak-CPU responsiveness and stale in-flight save behavior in the same test surface. diff --git a/docs/adr/0103-adapter-specific-ai-permission-semantics.md b/docs/adr/0103-adapter-specific-ai-permission-semantics.md new file mode 100644 index 0000000..4368c2b --- /dev/null +++ b/docs/adr/0103-adapter-specific-ai-permission-semantics.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0103" +title: "Adapter-specific AI permission semantics" +status: active +date: 2026-04-30 +supersedes: "0092" +--- + +## Context + +ADR-0092 introduced per-vault Vault Safe / Power User modes, but the first implementation left too much room for adapter drift. Some agents can directly deny or allow Bash, some expose only a sandbox/approval profile, and Pi currently has no narrower app-managed switch beyond Tolaria's transient MCP configuration. The shared UI still needs a consistent product contract: Vault Safe must not encourage shell execution, while Power User should keep shell execution available across repeated agent turns where the selected adapter supports it. + +## Decision + +**Tolaria treats the permission mode as a product contract first and maps it conservatively per adapter.** + +- Shared AI system prompts are mode-aware on every turn, including turns with note context snapshots. +- Vault Safe tells agents not to use or advertise shell, terminal, Bash, script execution, git, or command-line tools. +- Power User tells shell-capable agents that local shell commands are available for the active vault and should remain scoped to that vault. +- Claude Code Safe excludes Bash; Power User includes and pre-approves Bash without dangerous bypass flags. +- Codex Safe uses the CLI's read-only sandbox plus untrusted approval policy; Power User uses workspace-write plus never-ask approval so shell-capable Codex turns remain low-friction across the session. +- OpenCode Safe denies bash and external directories; Power User allows bash while still denying external directories. +- Pi keeps the same conservative transient MCP config in both modes until the Pi CLI exposes a reliable app-managed shell permission switch. The prompt must not promise shell for Pi Power User. +- Gemini Safe excludes `run_shell_command`; Gemini Power User intentionally uses `yolo` with trusted transient Tolaria MCP settings. + +## Consequences + +- Mode behavior is no longer described solely by generic UI copy; adapter docs and tests define the exact mapping. +- Codex Vault Safe remains a best-effort safe profile rather than a true built-in-tools-off mode, because Codex CLI currently exposes sandbox and approval controls but not a dedicated switch to remove shell tooling while preserving MCP. +- Future adapters must either implement both modes explicitly or document that Power User maps to the same conservative behavior. +- If Tolaria adds a stronger warning or dangerous mode later, it needs a separate ADR and UI language. diff --git a/docs/adr/0104-tauri-frontend-readiness-watchdog.md b/docs/adr/0104-tauri-frontend-readiness-watchdog.md new file mode 100644 index 0000000..d64956f --- /dev/null +++ b/docs/adr/0104-tauri-frontend-readiness-watchdog.md @@ -0,0 +1,48 @@ +--- +type: ADR +id: "0104" +title: "Tauri frontend readiness watchdog" +status: active +date: 2026-05-01 +--- + +## Context + +Tolaria already keeps heavy filesystem and subprocess work off the Tauri window-creation path, but that alone does not protect against a different startup failure mode: the desktop WebView can render the static HTML shell while the React app never becomes interactive. + +On macOS this showed up as an inert window that looked launched but never finished mounting the real app. The failure boundary is cross-layer: + +- `index.html` can paint before React commits +- React root errors can happen before the app reports itself ready +- a plain reload is acceptable as a one-time recovery, but an automatic reload loop is not +- browser/mock runs should not inherit desktop-only recovery behavior + +Tolaria needs a startup contract that distinguishes “HTML painted” from “frontend actually became interactive”, and a bounded recovery path when that contract is not satisfied. + +## Decision + +**Tolaria uses a Tauri-only frontend-readiness watchdog that reloads the WebView at most once if React never reports startup readiness.** + +Concretely: + +- `index.html` installs a Tauri-only startup timer before React loads +- React dispatches a readiness signal from a mounted effect after the app shell commits +- if readiness never arrives before the timeout, the WebView reloads once +- the same one-shot reload path is available to React root error handling before readiness is marked +- `sessionStorage` tracks whether the startup reload was already attempted so Tolaria does not loop forever +- browser/mock environments keep using ordinary browser clipboard/storage behavior and do not enable this desktop startup recovery path + +## Options considered + +- **Tauri-only readiness watchdog with one-shot reload** (chosen): directly addresses the inert-startup failure mode, keeps recovery local to the frontend, and avoids permanent reload loops. Cost: startup now depends on a small cross-layer contract between HTML bootstrap and React. +- **Do nothing and rely on manual relaunch**: simplest implementation, but leaves users stranded in a broken-looking app state with no automatic recovery. +- **Reload on any React root error without a readiness gate**: more aggressive, but too noisy; post-startup runtime errors should not trigger surprise reloads. +- **Move recovery entirely into native Rust window/bootstrap logic**: possible, but the failure signal lives in the frontend lifecycle, so native code would still need a readiness handshake. + +## Consequences + +- Tolaria now distinguishes successful frontend startup from merely rendering the HTML shell. +- Desktop startup recovery is bounded to a single retry per session, reducing the chance of trapping users in reload loops. +- `index.html`, `src/main.tsx`, and `src/utils/frontendReady.ts` form a shared startup contract that future bootstrap refactors must preserve. +- Any future change that delays app-shell mount beyond the watchdog timeout must re-evaluate the timeout and readiness trigger. +- If a startup failure persists after one retry, Tolaria still surfaces the broken state instead of hiding a deeper bug behind repeated reloads. diff --git a/docs/adr/0105-editor-correctness-and-responsiveness-contract.md b/docs/adr/0105-editor-correctness-and-responsiveness-contract.md new file mode 100644 index 0000000..2cde082 --- /dev/null +++ b/docs/adr/0105-editor-correctness-and-responsiveness-contract.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0105" +title: "Editor correctness and responsiveness contract" +status: active +date: 2026-05-01 +--- + +## Context + +Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note. + +The editor must optimize for the product priorities in this order: + +1. no crashes +2. no stale content, race-condition overwrites, or lost edits +3. responsive typing and cursor movement +4. fast note-list-to-editor loading + +## Decision + +**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle. + +## Options considered + +- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed. +- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics. +- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated. +- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default. + +## Consequences + +- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote. +- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process. +- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget. +- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it. +- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits. +- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing. +- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote. diff --git a/docs/adr/0106-shared-app-command-manifest.md b/docs/adr/0106-shared-app-command-manifest.md new file mode 100644 index 0000000..b5093a2 --- /dev/null +++ b/docs/adr/0106-shared-app-command-manifest.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0106" +title: "Shared app command manifest" +status: active +date: 2026-05-02 +--- + +## Context + +Tolaria command metadata was split across several runtime surfaces: TypeScript owned shortcut lookup and command-palette shortcut display, Rust owned native menu IDs, labels, accelerators, aliases, and enablement groups, and the Linux titlebar fallback menu duplicated another command list. Adding or changing a command required carefully editing multiple files that could drift while still compiling. + +The existing renderer-first shortcut model and native-menu dedupe remain correct, but they need a single source for metadata that must be identical across those surfaces. + +## Decision + +**Tolaria stores cross-runtime app command metadata in `src/shared/appCommandManifest.json`, and both the renderer and Tauri native menu derive their command/menu IDs, accelerators, menu labels, menu aliases, enablement groups, and deterministic QA metadata from it.** Context-sensitive command-palette builders still own availability and execution callbacks, and OS-native menu entries remain local to the native menu implementation. + +## Options considered + +- **Shared JSON manifest included by TypeScript and Rust** (chosen): works in both runtimes without code generation, keeps menu metadata reviewable, and lets tests validate drift directly. +- **Generate TypeScript and Rust constants from a schema**: gives stronger compile-time types but adds a build step and a generated-file maintenance burden for a small manifest. +- **Keep duplicated constants with more tests**: reduces immediate refactor scope, but still forces every command change through parallel manual edits. + +## Consequences + +- New app commands that appear in native menus or shortcut QA must be added to `src/shared/appCommandManifest.json`. +- `appCommandCatalog.ts` is responsible for turning the manifest into typed renderer helpers such as `APP_COMMAND_IDS`, shortcut lookup maps, Linux menu sections, and deterministic QA definitions. +- `src-tauri/src/menu.rs` includes the same manifest JSON, builds custom menu items from it, maps overridden menu item IDs such as `file-quick-open-alias` back to their primary command IDs, and resolves state-dependent enablement groups from manifest entries. +- Platform-native menu items such as Undo, Redo, Copy, Paste, Select All, Services, Quit, and Window controls stay in Rust because they are OS affordances, not Tolaria app commands. +- Command-palette builders continue to own dynamic labels, filtering, enabled state, and callbacks where those depend on current app state. diff --git a/docs/adr/0107-markdown-durable-tldraw-whiteboards.md b/docs/adr/0107-markdown-durable-tldraw-whiteboards.md new file mode 100644 index 0000000..703f9ca --- /dev/null +++ b/docs/adr/0107-markdown-durable-tldraw-whiteboards.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0107" +title: "Markdown-durable tldraw whiteboards in notes" +status: active +date: 2026-05-03 +--- + +## Context + +Tolaria notes are durable Markdown files, while whiteboard editing needs an interactive canvas with structured shape data. tldraw provides a mature React whiteboard runtime and exposes snapshot APIs that can persist the document without using browser-local `persistenceKey` storage. + +The storage decision needs to preserve Tolaria's filesystem source-of-truth rule: deleting local caches must not lose a board, raw mode must expose the canonical source, and Git should track the board together with the surrounding note. + +## Decision + +**Tolaria will support whiteboards as Markdown-durable fenced `tldraw` blocks backed by tldraw document snapshots.** + +The implementation: + +- Converts fenced `tldraw` blocks into temporary placeholders before BlockNote parses Markdown. +- Replaces those placeholders with `tldrawBlock` schema blocks that store a stable board id and tldraw document snapshot JSON. +- Renders each block with the `tldraw` package inside the rich editor. +- Debounces tldraw document changes into BlockNote block props so Tolaria's normal autosave writes the snapshot into the `.md` file. +- Serializes `tldrawBlock` nodes back to fenced Markdown before save, raw-mode entry, and editor-position snapshots. +- Adds a `/whiteboard` slash command that inserts the same block format. + +Session state such as camera position, selected shapes, and selected tools is not persisted into the note. Preview images are intentionally omitted from the initial design; they may be introduced later as derived cache artifacts for note lists, search results, or graph cards. + +## Options considered + +- **Markdown fenced block with tldraw document snapshot** (chosen): keeps the board in the note file, matches Tolaria's Mermaid/math round-trip model, and works for both full whiteboard notes and embedded boards inside larger notes. +- **tldraw `persistenceKey` / IndexedDB**: simplest app integration, but violates the vault-as-source-of-truth rule and would lose boards when browser storage is cleared. +- **Separate `.tldr` files embedded from Markdown**: keeps JSON out of prose notes, but fragments note ownership, makes embedded boards harder to move with their parent note, and complicates Git history for small board edits. +- **Preview-image-first storage**: useful for thumbnails, but not editable source. It would make the PNG an attractive but stale source of truth. + +## Consequences + +- `src/utils/tldrawMarkdown.ts` is the canonical parser/serializer bridge for whiteboard blocks. +- `src/components/TldrawWhiteboard.tsx` owns the tldraw runtime integration and only persists document snapshots. +- Raw mode remains a direct source editor for the fenced JSON. +- Embedded whiteboards and future full-note whiteboard templates share the same storage format. +- Asset support is deferred; when added, asset bytes should live in vault-relative attachment paths referenced from the tldraw asset records. diff --git a/docs/adr/0107-pointer-owned-editor-block-reordering.md b/docs/adr/0107-pointer-owned-editor-block-reordering.md new file mode 100644 index 0000000..03663fc --- /dev/null +++ b/docs/adr/0107-pointer-owned-editor-block-reordering.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0107" +title: "Pointer-owned editor block reordering" +status: active +date: 2026-05-02 +--- + +## Context + +Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor. + +Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls. + +## Decision + +**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block. + +## Options considered + +- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote. +- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri. +- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling. +- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow. + +## Consequences + +- Internal block reordering must not depend on `DataTransfer` or `draggable=true`. +- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge. +- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress. +- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning. +- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests. diff --git a/docs/adr/0108-direct-model-ai-targets.md b/docs/adr/0108-direct-model-ai-targets.md new file mode 100644 index 0000000..81da914 --- /dev/null +++ b/docs/adr/0108-direct-model-ai-targets.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0108" +title: "Direct model AI targets alongside coding agents" +status: active +date: 2026-05-03 +--- + +## Context + +Tolaria's AI panel originally targeted desktop coding-agent CLIs only. That works well for tool-capable vault editing, but it excludes users who run local model servers, users who prefer OpenAI or Anthropic APIs, and future mobile builds where desktop subprocesses cannot run. + +## Decision + +**Tolaria models AI selection as an AI target.** Targets can be desktop coding agents or direct model endpoints. Coding agents keep the existing Safe / Power User permission modes and tool access. Direct model targets run in Chat mode: they receive note context and conversation history, but they do not get vault-write tools or shell access. + +Direct model provider metadata is stored in app settings. Provider API secrets are not stored in settings; hosted providers can either save a key in Tolaria's local app-data secrets file or read a key from a named environment variable. The local secrets file is outside the vault, outside project worktrees, and written with owner-only file permissions on Unix platforms. Local providers such as Ollama and LM Studio can run without a key. + +## Options Considered + +- **AI target abstraction** (chosen): supports agents, local models, hosted APIs, and mobile-compatible model runtimes without pretending all AI backends have the same capabilities. +- **Treat custom providers as OpenCode configuration only**: lower implementation cost on desktop, but does not help mobile and keeps API users dependent on a coding-agent install. +- **Direct API runtime with write tools immediately**: powerful, but would require Tolaria-owned tool loops, confirmations, retries, and safety semantics before the basic chat value is proven. + +## Consequences + +- Settings owns durable provider setup and default target selection. +- The status bar becomes a quick target switcher across agents and configured model targets. +- The AI panel explains capability differences: agents show Safe / Power User; direct model targets show Chat mode. +- Future work can migrate local secrets to OS keychain storage and add read/write tool loops without changing the top-level target model. diff --git a/docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md b/docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md new file mode 100644 index 0000000..9ff30d3 --- /dev/null +++ b/docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md @@ -0,0 +1,25 @@ +# 0108. Sanitized Rendered Markup and Safe User Regex + +Date: 2026-05-03 + +## Status + +Accepted + +## Context + +Tolaria renders generated SVG/HTML from trusted libraries such as Mermaid and KaTeX, and it allows users to opt into regex matching in filters and editor find/replace. Codacy SRM flagged the raw markup insertion and direct regex construction as Critical XSS/DoS risks. + +## Decision + +Add direct runtime dependencies on `dompurify` and `safe-regex2`. + +Rendered Mermaid SVG and KaTeX HTML must be sanitized before insertion and mounted through DOM nodes rather than React `dangerouslySetInnerHTML`. User-provided regex sources must be length-bounded and checked with `safe-regex2` before compilation. + +SCA-reported vulnerable transitive dependencies are pinned through package-manager overrides so Codacy resolves patched floors until upstream dependencies adopt them naturally. This includes `protobufjs`, the MCP SDK web-server stack, and Vite's parser/build transitive stack. Rust lockfile-only updates keep OpenSSL, rustls-webpki, and tar on patched versions without changing public Tauri APIs. + +## Consequences + +Markup rendering now has an explicit sanitizer boundary that is shared by Mermaid and math rendering. User regex features remain available, but unsafe or overly large expressions fail validation instead of running in the UI thread. + +The overrides should be removed once the dependency graph no longer pulls the vulnerable versions. diff --git a/docs/adr/0109-debounced-worker-derived-editor-indexes.md b/docs/adr/0109-debounced-worker-derived-editor-indexes.md new file mode 100644 index 0000000..c52504e --- /dev/null +++ b/docs/adr/0109-debounced-worker-derived-editor-indexes.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0109" +title: "Debounced worker-derived editor indexes" +status: active +date: 2026-05-04 +--- + +## Context + +Right side panels can need derived indexes of the active note, such as the Table of Contents hierarchy. These indexes are useful while editing, but rebuilding them synchronously during note opening or on every keystroke competes with the editor's main-thread work and violates ADR-0105's responsiveness contract. + +The Table of Contents also needs live BlockNote block IDs for navigation, while the fastest and most stable source for the outline itself is the active note's Markdown content. Binding the outline rebuild directly to BlockNote document mutations makes typing and note swaps more expensive than necessary. + +## Decision + +**Derived editor indexes that are not required for the editor surface itself must be lazy, debounced, and built off the main thread when they can be derived from Markdown.** The Table of Contents does not build while its panel is closed; once opened, it uses a Web Worker to build its Markdown-derived H1/H2/H3 tree after a debounce, while live BlockNote block IDs are resolved only at click time for navigation. + +## Options considered + +- **Lazy debounced Web Worker for Markdown-derived indexes** (chosen): avoids any TOC work while the panel is closed, keeps outline parsing away from typing and note-opening work once opened, cancels stale panel updates, and lets the rendered editor remain the only editor surface. Cons: adds a small worker/client path and a title-only interim state. +- **Main-thread deferred rebuild with `setTimeout`**: avoids blocking the first render, but still runs on the UI thread and can still rebuild too often during active edits. +- **Synchronous rebuild from the BlockNote document**: simplest and gives immediate block IDs, but makes every BlockNote document update a potential side-panel rebuild. +- **Never update the TOC while editing**: safest for typing performance, but stale outlines make the panel misleading for active authoring. + +## Consequences + +- TOC tree state is driven by note identity plus debounced Markdown content, not by BlockNote document churn. +- Closing the TOC panel unmounts the panel and cancels pending debounce callbacks; no worker request is scheduled while the panel is closed. +- The TOC may briefly show only the note title after a note switch or edit burst; the full tree appears when the debounced worker result returns. +- Navigation remains tied to the live editor: block IDs are resolved from the current BlockNote document at click time and scrolled/focused then. +- Future derived side-panel indexes should follow the same pattern when they parse or scan note content and are not needed to render the editor itself. diff --git a/docs/adr/0110-in-app-media-and-pdf-file-previews.md b/docs/adr/0110-in-app-media-and-pdf-file-previews.md new file mode 100644 index 0000000..2a5fe6a --- /dev/null +++ b/docs/adr/0110-in-app-media-and-pdf-file-previews.md @@ -0,0 +1,44 @@ +--- +type: ADR +id: "0110" +title: "In-app media and PDF previews for binary vault files" +status: superseded +date: 2026-05-05 +supersedes: "0098" +superseded_by: "0121" +--- + +## Context + +ADR-0098 extended Tolaria's file-first preview model from images to PDFs while keeping binary files as ordinary `VaultEntry` records. In practice, vaults also carry voice notes, interview recordings, screen captures, and short clips that users need to inspect in context without round-tripping through another app. + +The existing binary preview architecture already had the important constraints in place: + +- previewability should stay a renderer concern inferred from filename extension rather than a persisted schema field +- preview access should stay inside Tauri's scoped asset protocol instead of broad filesystem reads +- external-open actions must still re-enter the active-vault command boundary before delegating to the OS + +Audio and video support should extend that same model rather than introducing a separate asset or media subsystem. + +## Decision + +**Tolaria previews supported image, audio, video, and PDF files in the editor pane while keeping them as ordinary binary vault files.** + +- The scanner keeps the coarse `fileKind: "binary"` representation; `src/utils/filePreview.ts` infers preview support from safe extension allow-lists. +- `FilePreview` remains the single renderer-owned preview surface for supported binary files. +- Images continue to render through ``, PDFs through the webview PDF object renderer, and audio/video through native HTML media controls, all backed by Tauri asset URLs from `convertFileSrc`. +- The Tauri CSP must allow scoped asset URLs in `media-src` for audio/video and in `object-src` for PDFs without broadening script or network permissions. +- Note-list rows for previewable media stay clickable and use file-specific affordances; unsupported binaries remain ordinary files with explicit fallback/open-external paths. + +## Alternatives considered + +- **Extend the existing FilePreview model to media** (chosen): keeps one binary-preview surface, reuses scoped asset access, and avoids new persisted file categories. Cons: native media controls are intentionally minimal. +- **Open audio and video only in the default app**: simpler implementation, but breaks in-context review for media-heavy vaults. +- **Introduce dedicated persisted media file kinds or a separate media library**: could support richer metadata later, but adds schema and scanner complexity for files that should remain normal vault entries. + +## Consequences + +- Audio and video do not become notes and do not get special persistence semantics. +- Tolaria's binary preview surface now covers the common safe media formats without changing cache shape, scanner output, or the filesystem-first model. +- Scoped runtime asset access and active-vault command validation remain the security boundary for binary previews and external-open actions. +- Re-evaluate this decision if Tolaria later needs editing, waveform/timeline tooling, subtitles, or transcoding, because those would justify a richer media-specific subsystem. diff --git a/docs/adr/0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md b/docs/adr/0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md new file mode 100644 index 0000000..98dda08 --- /dev/null +++ b/docs/adr/0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md @@ -0,0 +1,49 @@ +--- +type: ADR +id: "0111" +title: "Path-aware external vault refresh with focused-editor preservation" +status: active +date: 2026-05-05 +supersedes: "0071" +--- + +## Context + +ADR-0071 established a shared reconciliation path for external vault mutations so git pulls, AI-agent writes, and other non-local edits would reload vault-derived state and protect unsaved local changes. That policy was directionally right, but the original "reopen the clean active note" rule was too broad once Tolaria added a native filesystem watcher and more editor-mounted integrations. + +Two problems emerged: + +- unrelated external updates could remount a clean active editor even when the active file itself did not change +- remounting while focus was inside the rich or raw editor surface could drop cursor/focus state and disrupt native input flows even when the vault refresh itself was otherwise safe + +Tolaria still needs refreshed entries, folders, views, backlinks, and other derived state after external writes. But it should only pay the cost of an active-editor remount when the changed-path batch actually requires one and the user is not actively focused inside the editor. + +## Decision + +**External vault refreshes now reload shared vault-derived state eagerly, but only remount the active editor when the active file itself changed and the editor is clean and unfocused.** + +The shared `refreshPulledVaultState()` path now applies these rules: + +1. Reload vault entries, folders, and saved views together for every external change batch. +2. If there is no active note, stop after the shared reload. +3. If the active note changed during the async reload, stop rather than reopening stale context. +4. If the active note has unsaved local edits, keep the current editor buffer mounted. +5. If focus is currently inside the rich or raw editor surface, keep that editor mounted even for otherwise clean notes. +6. If the active file disappeared, close the tab instead of leaving a stale editor behind. +7. Only close and reopen the active tab when the changed-path batch includes that active file and the previous guards did not apply. + +Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches should continue to converge through this single reconciliation helper instead of inventing separate reload policies. + +## Alternatives considered + +- **Path-aware refresh with focused-editor preservation** (chosen): keeps derived vault state fresh while avoiding unnecessary remounts and focus loss. Cons: a focused clean editor can temporarily lag on-disk content until a later safe remount. +- **Always reopen the clean active note after every external refresh**: strongest immediate convergence, but causes visible churn and drops editor focus for unrelated changes. +- **Skip shared reloads whenever the editor is focused**: preserves focus, but leaves folders, views, backlinks, and other derived state stale. + +## Consequences + +- Unrelated external vault updates no longer remount the active editor just because the note is clean. +- Focused rich/raw editor sessions preserve cursor and native input state across watcher- or agent-driven vault refreshes. +- The changed-path batch is now part of the external-refresh contract; callers should pass the best available file list instead of treating all refreshes as full active-note invalidations. +- A focused clean editor may intentionally continue showing pre-refresh content until a later safe reopen, trading immediate active-note convergence for editing continuity. +- `refreshPulledVaultState()` remains the single place to evolve external-refresh policy; future features should extend that helper rather than layering ad hoc editor reload behavior. diff --git a/docs/adr/0112-system-theme-mode.md b/docs/adr/0112-system-theme-mode.md new file mode 100644 index 0000000..06bba85 --- /dev/null +++ b/docs/adr/0112-system-theme-mode.md @@ -0,0 +1,41 @@ +--- +type: ADR +id: "0112" +title: "System theme mode" +status: active +date: 2026-05-05 +--- + +## Context + +ADR-0081 introduced Tolaria's internal app-owned light and dark theme runtime and deliberately deferred system-follow mode. That kept the first dark-mode release small, but users now need Tolaria to match the operating system appearance automatically, including scheduled macOS light/dark changes. + +The previous constraints still apply: themes are app-owned, not vault-authored; the renderer must avoid startup flashes; shadcn/ui, Tailwind variables, editor chrome, and secondary windows must keep sharing the same resolved light/dark contract. + +## Decision + +**Tolaria now treats `system` as a persisted theme preference that resolves to the current OS light/dark appearance at runtime.** + +The selected preference can be `light`, `dark`, or `system`: + +1. `settings.theme_mode` remains the source of truth for the installation-local preference. +2. The localStorage mirror stores the selected preference, including `system`, so the `index.html` prepaint script can resolve the correct appearance before React mounts. +3. `data-theme` and the shadcn `.dark` class always receive the resolved app theme, `light` or `dark`; they never receive `system`. +4. When `system` is selected, the renderer subscribes to `prefers-color-scheme` changes and reapplies the resolved theme without reopening the app. +5. Explicit `light` and `dark` choices remain overrides and do not follow OS changes. + +Command-palette theme actions and the Settings panel both save the same preference path. Product analytics record preference changes with the selected mode only, without sending vault or note content. + +## Alternatives considered + +- **Persist `system` and resolve it into the existing light/dark runtime** (chosen): keeps ADR-0081's small app-owned theme surface while adding OS-follow behavior. +- **Store the resolved OS theme in settings**: avoids a third stored value, but silently converts System users into explicit Light/Dark users after every save. +- **Set `data-theme="system"` and branch in CSS**: would require every theme consumer to understand a third state and would break existing Tailwind/shadcn dark-mode assumptions. +- **Rely only on CSS `prefers-color-scheme` media queries**: helps static CSS, but does not update JavaScript consumers, command state, editor integrations, or the localStorage startup mirror consistently. + +## Consequences + +- Startup still avoids a light flash when the stored preference is `system` and the OS is dark. +- Secondary windows that mount the shared theme hook receive the same resolved appearance and update on OS changes. +- Code that reads `document.documentElement.dataset.theme` must treat it as a resolved `light` or `dark` value, not as the stored user preference. +- Future theme variants should preserve this split between selected preference and resolved app theme rather than widening `data-theme` to non-renderable preference values. diff --git a/docs/adr/0113-shared-renderer-attachment-path-normalization.md b/docs/adr/0113-shared-renderer-attachment-path-normalization.md new file mode 100644 index 0000000..12cdf11 --- /dev/null +++ b/docs/adr/0113-shared-renderer-attachment-path-normalization.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0113" +title: "Shared renderer attachment path normalization" +status: active +date: 2026-05-07 +--- + +## Context + +Tolaria already treats vault attachments as ordinary files under `attachments/`, and ADRs around previews and asset scoping rely on Tauri asset URLs to render them safely. In practice, attachment handling had started to fragment across the renderer: some flows joined `vaultPath + attachments/...`, some decoded `convertFileSrc` URLs directly, some handled Windows separators ad hoc, and some only worked for one editor surface. + +That duplication turned attachment behavior into a drift risk. Opening file blocks, following editor links, serializing raw-mode Markdown, rewriting image URLs after vault switches, and copying dropped files into the vault all needed the same three representations to stay in sync: + +- portable markdown references such as `attachments/report.pdf` +- Tauri asset URLs used by the renderer +- absolute filesystem paths inside the active vault + +## Decision + +**Tolaria centralizes attachment path conversion in a single renderer-owned primitive and keeps portable `attachments/...` references as the canonical cross-surface representation.** + +Specifically: + +1. `src/utils/vaultAttachments.ts` is the single owner for converting between portable attachment references, Tauri asset URLs, and active-vault filesystem paths. +2. Editor rendering, raw-mode serialization, image upload/drop flows, file-block open actions, and parsed-media cleanup must call that shared primitive instead of carrying local path/URL conversion logic. +3. Renderer code may derive absolute paths only relative to the current active vault and must reject asset URLs or relative paths that fall outside that boundary. +4. Tauri asset URLs remain a transport/rendering detail, not a persisted vault format. + +## Alternatives considered + +- **Shared renderer attachment-path primitive with portable persisted refs** (chosen): keeps behavior consistent across media rendering, editor actions, and vault switching while preserving Markdown portability. +- **Per-feature helpers for each attachment surface**: simpler locally, but repeats Windows/path-normalization rules and lets editor actions drift apart. +- **Persist absolute paths or Tauri asset URLs in Markdown/editor state**: would couple notes to one machine or one runtime session and make vault content less portable. +- **Push all attachment conversion into backend commands**: could reduce renderer logic, but the renderer still needs a shared local model for in-memory markdown rewriting, link activation, and preview URL handling. + +## Consequences + +- Attachment behavior becomes consistent across previews, editor links, toolbar/file-block opens, drag-drop imports, and markdown serialization. +- Vault content stays portable because persisted references remain `attachments/...` paths rather than machine-specific absolute paths or session-specific asset URLs. +- Cross-platform edge cases such as Windows separators, encoded asset URLs, and vault-switch rewrites now have one place to harden and test. +- The Rust command layer remains the write/read security boundary; this ADR only centralizes renderer-side normalization before those commands are called or asset URLs are rendered. +- Future attachment/media features should extend `vaultAttachments.ts` rather than introducing new ad hoc conversion helpers. diff --git a/docs/adr/0114-mounted-workspaces-unified-graph.md b/docs/adr/0114-mounted-workspaces-unified-graph.md new file mode 100644 index 0000000..e6d4d69 --- /dev/null +++ b/docs/adr/0114-mounted-workspaces-unified-graph.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0114" +title: "Mounted workspaces unified graph" +status: active +date: 2026-05-07 +--- + +## Context + +Tolaria users can already register multiple vaults, but switching vaults historically replaced the active graph. That model breaks down when separate Git repositories represent different workspaces that still need to reference each other: search, quick-open, wikilink navigation, and note lists should see one graph, while Git status, folders, saved views, and sync controls remain scoped to the repository currently in focus. + +The app also needs a stable way to disambiguate same-named notes across repositories without writing machine-specific paths into Markdown. A full storage migration or database-backed graph would conflict with Tolaria's filesystem-first model and make separate Git histories harder to reason about. + +## Decision + +**Tolaria treats the registered vault list as an installation-local mounted-workspace set and annotates loaded entries with workspace provenance.** + +Specifically: + +1. `vaults.json` persists workspace identity (`label`, stable `alias`, color, mount flag) and the default workspace path for newly created notes. +2. `useVaultLoader` loads entries from every available mounted workspace and attaches `WorkspaceIdentity` to each `VaultEntry` before React consumes the combined graph. +3. Active-vault switching remains the focus control for Git, folder tree, saved views, watchers, repair, and other per-repository operations. +4. Wikilinks stay Markdown-first. Same-workspace links remain vault-relative; cross-workspace canonical links are prefixed with the target workspace alias. +5. Note reads and writes for absolute paths can resolve the deepest registered vault root at the Tauri boundary when no explicit `vaultPath` is supplied, preserving path-containment validation across mounted workspaces. + +## Alternatives considered + +- **Mounted workspace provenance on `VaultEntry` with alias-prefixed links** (chosen): preserves filesystem/Git independence while letting UI graph surfaces operate across repositories. +- **Merge separate repositories into one vault**: avoids cross-root resolution, but forces users to collapse unrelated Git histories and permissions into one repo. +- **Persist absolute paths in wikilinks**: disambiguates locally, but makes notes non-portable and leaks machine paths into user data. +- **Store a global graph database**: could make cross-workspace queries faster, but violates the cache-is-disposable rule and adds a new source of truth. + +## Consequences + +- Search, quick-open, note lists, and wikilink navigation can operate across mounted workspaces. +- UI surfaces that show ambiguous note names should display compact workspace provenance only when more than one workspace is present. +- New notes and Type files are created in the configured default workspace, falling back to the active workspace if the default is unavailable or unmounted. +- Backend command boundaries must continue validating every disk operation against a registered root; mounted workspaces do not loosen filesystem access. +- Future per-workspace features should distinguish graph-wide behavior from active-repository behavior before adding state or commands. diff --git a/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md b/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md new file mode 100644 index 0000000..6a41967 --- /dev/null +++ b/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md @@ -0,0 +1,28 @@ +--- +type: ADR +id: "0115" +title: "Scoped React Context for shared UI preferences" +status: active +date: 2026-05-12 +--- + +## Context + +Laputa has relied on props-down callbacks-up state flow since ADR-0026 because most renderer state is orchestrated in `App.tsx` and the component tree stays understandable. Today's `date_display_format` refactor exposed a narrow exception: the same installation-local rendering preference now needs to reach note rows, property chips and cells, inspector surfaces, table-of-contents metadata, search subtitles, and date-editing controls across multiple branches of the tree. Continuing to thread that value through intermediate components would add noisy prop plumbing to components that do not conceptually own the preference. + +## Decision + +**Use a scoped React context for shared UI preferences that are read in many renderer leaves but still sourced from `App.tsx`. `AppPreferencesProvider` publishes the current installation-local preference values, and leaf components consume them through focused hooks such as `useDateDisplayFormat`; writes still flow through the existing settings/update path rather than through context mutations.** + +## Alternatives considered +- **Scoped app-preferences context** (chosen): removes prop forwarding for cross-cutting rendering preferences while keeping the source of truth in `App.tsx` and avoiding a general-purpose global store. +- **Continue prop drilling from `App.tsx`**: preserves the old rule literally, but keeps widening component signatures and couples intermediate components to preferences they do not use. +- **Adopt a broader global state/store solution**: centralizes access, but introduces more indirection and policy surface than this renderer-only preference case needs. + +## Consequences + +Leaf components can read shared formatting preferences directly, so `date_display_format` stays consistent across note-list, inspector, search, and metadata surfaces without forwarding props through unrelated layers. + +This narrows ADR-0026's blanket "no Context for data" rule. The replacement rule is: mutable application/domain state still lives in `App.tsx` plus focused hooks, while React context is allowed only for tightly scoped, cross-cutting UI preferences whose canonical value still originates from that same top-level state. + +Future additions to `AppPreferencesProvider` should stay small, renderer-local, and read-focused. If Laputa starts moving writable domain state, async workflows, or large derived objects into context, that needs a new ADR rather than quietly expanding this pattern. diff --git a/docs/adr/0116-rich-raw-transition-and-serialization-ownership.md b/docs/adr/0116-rich-raw-transition-and-serialization-ownership.md new file mode 100644 index 0000000..081aa6e --- /dev/null +++ b/docs/adr/0116-rich-raw-transition-and-serialization-ownership.md @@ -0,0 +1,44 @@ +--- +type: ADR +id: "0116" +title: "Rich/raw transition and serialization ownership" +status: active +date: 2026-05-13 +--- + +## Context + +Tolaria already relies on BlockNote as the rich editor and on a Markdown-first save path, but the rich/raw boundary had started to split that contract across multiple local helpers. Autosave and tab-swap logic serialized rich-editor content in one place, raw-mode entry rebuilt Markdown in another, and raw-mode toggles carried pending content and pending cursor/scroll restore state through separate refs. + +That fragmentation created two drift risks in one of the most correctness-sensitive parts of the app: + +- rich-editor Markdown output could diverge across autosave, tab switches, and raw-mode entry, especially around wikilink restoration, durable schema-node serialization, frontmatter preservation, and portable attachment paths +- raw/rich mode switches could desynchronize pending content from pending position restoration, making stale transition state harder to reason about and harder to harden + +Tolaria's editor contract already prioritizes no crashes, no stale overwrites, and minimal per-keystroke work. The rich/raw boundary needs the same single-owner discipline. + +## Decision + +**Tolaria centralizes BlockNote-to-Markdown serialization for editor flows in one shared owner and treats raw/rich mode handoff as explicit transition state with a single owner per concern.** + +Specifically: + +1. `src/utils/richEditorMarkdown.ts` is the canonical owner for rich-editor body/document serialization used by autosave, tab-swap, and raw-mode entry. +2. Raw-mode content handoff is modeled as one content transition object, so pending raw-exit content and raw-mode overrides move together. +3. Cursor/scroll restoration across rich/raw toggles is modeled as one restore-transition ref consumed by the editor-mode position sync path. +4. Editor surfaces should not keep independent ad hoc pending-content or pending-position refs outside those shared owners. + +## Options considered + +- **Shared serialization owner plus explicit transition owners** (chosen): keeps the Markdown contract and mode-switch lifecycle consistent across editor flows, while still allowing debounced work and focused testing. +- **Per-flow local serializers and pending refs**: simpler inside each hook, but lets autosave, tab-swap, and raw-mode entry drift apart over time. +- **Separate raw-mode-specific serialization and restore logic**: would preserve local autonomy, but duplicates correctness-sensitive rules at the exact boundary where stale state bugs are hardest to diagnose. +- **Always rebuild all content/position state synchronously on every toggle**: reduces retained transition state, but increases work at toggle time and does not solve ownership drift in shared serialization logic. + +## Consequences + +- Autosave, tab-switch flushing, and raw-mode entry now share one Markdown serialization contract. +- Wikilink restoration, durable editor-node serialization, frontmatter preservation, and portable attachment-path rewriting have one place to evolve. +- Rich/raw mode toggles become easier to reason about because content transition state and restore transition state each have a single owner. +- Future editor features that need rich-editor Markdown output or mode-transition bookkeeping should extend these shared owners rather than introducing local one-off refs or serializers. +- Re-evaluation is warranted if Tolaria adopts a different editor runtime or if rich/raw mode stops being a first-class bidirectional workflow. diff --git a/docs/adr/0117-appimage-fcitx-gtk3-frontend-bundle.md b/docs/adr/0117-appimage-fcitx-gtk3-frontend-bundle.md new file mode 100644 index 0000000..f779352 --- /dev/null +++ b/docs/adr/0117-appimage-fcitx-gtk3-frontend-bundle.md @@ -0,0 +1,24 @@ +# ADR-0117: Bundle the fcitx GTK3 frontend in Linux AppImages + +## Status + +Accepted + +## Context + +Linux AppImages run WebKitGTK through the GTK3 input-method stack. Users with fcitx5 can export `GTK_IM_MODULE=fcitx`, `QT_IM_MODULE=fcitx`, and `XMODIFIERS=@im=fcitx`, but the AppImage still cannot load the host GTK immodule reliably because the GTK module cache and library paths are isolated from the mounted AppDir. + +The previous AppImage startup fallback set `GTK_IM_MODULE=fcitx` when fcitx was detected, but it did not make the `im-fcitx5.so` module available inside the AppImage. That left Chinese/Japanese/Korean input dependent on host paths that GTK may not search from a sealed AppImage. + +## Decision + +Linux release jobs install `fcitx5-frontend-gtk3` and the AppImage output-plugin shim copies the GTK3 fcitx immodule plus its client library into the AppDir before the AppImage is sealed. At runtime, AppImage startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache that points GTK at the bundled module whenever fcitx is configured explicitly or through common fcitx environment hints. + +The sealed AppImage validation step extracts every produced AppImage and fails the release if the symlink-safe AppRun resolver, the bundled fcitx immodule, or the fcitx client library is missing. + +## Consequences + +- fcitx5 input works in AppImage launches without relying on the host GTK immodule cache path. +- X11 fallback launches with explicit `GTK_IM_MODULE=fcitx` use the same bundled module path as Wayland launches. +- Linux AppImage builds now depend on the distro package that provides the GTK3 fcitx frontend. +- If the Ubuntu package path changes, the AppImage validation step fails before publishing a broken bundle. diff --git a/docs/adr/0118-entry-scoped-note-windows-without-vault-index-scans.md b/docs/adr/0118-entry-scoped-note-windows-without-vault-index-scans.md new file mode 100644 index 0000000..a58f411 --- /dev/null +++ b/docs/adr/0118-entry-scoped-note-windows-without-vault-index-scans.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0118" +title: "Entry-scoped note windows without vault index scans" +status: active +date: 2026-05-14 +--- + +## Context + +ADR-0031 kept secondary note windows on the full `App` shell so they would inherit the same editor capabilities as the primary window. That decision also accepted a full vault load per secondary window as the simpler trade-off. + +In practice, repeated note-window opens were paying that full-vault scan cost even when the window only needed one known note path. The startup path loaded the vault index and passed related entries into the editor even though note-window mode renders a single-note surface. That extra work increased window-open cost and made every secondary window depend on repository-wide entry hydration for a workflow that is intentionally scoped to one note. + +Tolaria still wants note windows to reuse the main App architecture rather than reviving a separate `NoteWindow` shell. The missing decision was how far the shared vault loader should go when the window contract is already narrowed to a single entry. + +## Decision + +**Secondary note windows continue to render the full `App` shell, but they no longer load the full vault index during startup.** In note-window mode, Tolaria skips the shared vault-entry scan, reloads only the requested note entry, and scopes editor entry props to that active note instead of passing repository-wide visible entries. + +This keeps the architectural benefit of ADR-0031 (one window architecture, one editor surface) while changing the data-loading contract for secondary windows from vault-scoped to entry-scoped. + +## Alternatives considered + +- **Full `App` shell with entry-scoped loading** (chosen): preserves feature parity in the shared shell while removing unnecessary full-vault scans for a one-note window. Trade-off: note windows should not assume vault-index-derived context is available by default. +- **Full `App` shell with full vault scan**: simplest continuation of ADR-0031, but repeats avoidable repository-wide I/O every time a note window opens. +- **Dedicated `NoteWindow` shell**: could be lighter still, but reintroduces the architectural drift and duplicated feature work that ADR-0031 intentionally removed. + +## Consequences + +- Opening a secondary note window no longer requires `list_vault`/full entry hydration before the editor can render the requested note. +- Repeated note-window opens avoid redundant vault scans and stay aligned with the product contract that these windows are single-note work surfaces. +- Features inside note-window mode must treat vault-index-derived data as opt-in; they cannot assume related entries are already loaded just because the full `App` shell is mounted. +- ADR-0031 remains directionally valid for shared window architecture, but its original "full vault load per secondary window" trade-off is no longer the operating model. +- Re-evaluate if note windows later need immediate repository-wide browsing context, or if future profiling shows the remaining single-entry reload path is still too expensive. diff --git a/docs/adr/0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md b/docs/adr/0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md new file mode 100644 index 0000000..a239285 --- /dev/null +++ b/docs/adr/0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md @@ -0,0 +1,35 @@ +# 0119. Vault-Neutral MCP Registration With Mounted Workspace Guidance + +Status: active + +Date: 2026-05-14 + +## Context + +Tolaria used to register external MCP clients with a durable `VAULT_PATH` environment variable. That made the copied config easy to inspect, but it also meant Claude Code, Cursor, Gemini CLI, and generic MCP clients stayed pinned to whichever vault was active at setup time. + +Domenico Lupinetti's dynamic-vault MCP proposal in PR #603 pointed in the right direction: MCP clients should follow Tolaria's current workspace state instead of requiring users to reconnect after each vault change. The current app model has also moved from one selected vault toward mounted workspaces, so the MCP server needs to operate on every active mounted vault and load local agent guidance from each vault. + +## Decision + +Durable external MCP registration is vault-neutral. Tolaria still writes an explicit stdio MCP entry, but that entry contains only the Node command, `mcp-server/index.js`, and `WS_UI_PORT=9711`. It no longer writes `VAULT_PATH`. + +The Node MCP entrypoints resolve vaults at tool-call time: + +- Explicit `VAULT_PATH` and `VAULT_PATHS` environment variables continue to win for app-owned bridge launches and legacy/manual launches. +- When those env vars are absent, the MCP server reads Tolaria's `vaults.json`. +- `active_vault` is returned first. +- Every workspace in `vaults[]` is included unless it is explicitly marked `mounted: false`. +- Paths are deduplicated and blank paths are ignored. + +Vault context now checks each active mounted workspace root for `AGENTS.md` and returns those instructions with the vault summary. The MCP server also exposes `list_vaults` so agents can discover the active workspace set and whether each vault has root guidance. + +We are not adding a session-local `switch_vault` tool. A switch tool would create a second source of truth inside the MCP process, while Tolaria already owns mounted workspace state. + +## Consequences + +External MCP config survives vault switches and mounted-workspace changes without reconnecting. + +Agents can work across all active mounted vaults and receive the per-vault `AGENTS.md` instructions needed to respect local rules. + +Manual users can still override the resolved workspace set with `VAULT_PATH` or `VAULT_PATHS` when they intentionally want a static or scripted MCP session. diff --git a/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md b/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md new file mode 100644 index 0000000..b1bab70 --- /dev/null +++ b/docs/adr/0120-stable-appimage-mcp-server-path-with-opencode-registration.md @@ -0,0 +1,37 @@ +# 0120. Stable AppImage MCP Server Path With OpenCode Registration + +Status: active + +Date: 2026-05-14 + +## Context + +Domenico Lupinetti's PR #600 identified two gaps in durable external MCP setup: + +- Linux AppImage launches expose bundled resources through a mount path that can change between app starts, so external clients can keep a stale `mcp-server/index.js` path. +- OpenCode uses `~/.config/opencode/opencode.json` with a different MCP schema from Claude Code, Cursor, Gemini, and generic `mcpServers` clients. + +ADR-0119 made durable MCP registration vault-neutral, so PR #600 could not be merged directly: its registered entries still carried `VAULT_PATH`. The stable-path and OpenCode work is still valid, but it has to preserve the current mounted-workspace resolution model. + +## Decision + +On Linux AppImage startup, Tolaria extracts the bundled `mcp-server/` directory to `~/.local/share/tolaria/mcp-server/`. The extracted directory is version-gated by a `.tolaria-version` marker. Extraction runs on first launch or after an app version change, uses a staging directory plus rename, and uses a process lock so concurrent app launches do not write the stable directory at the same time. + +Durable external registration prefers the stable extracted server directory when it is ready. Otherwise it falls back to the packaged resource resolver. + +OpenCode is added to durable MCP registration and removal. Tolaria writes an OpenCode-specific entry under the top-level `mcp` key using: + +- `type: "local"` +- `command: [node, index.js]` +- `enabled: true` +- `environment.WS_UI_PORT = "9711"` + +OpenCode registration remains vault-neutral. It does not write `VAULT_PATH`; the Node MCP server resolves active mounted workspaces from Tolaria state per ADR-0119. + +## Consequences + +Linux AppImage users can register external MCP clients once and keep a valid `index.js` path across restarts and updates. + +OpenCode participates in the same connect, disconnect, and status flow as Claude Code, Cursor, Gemini, and generic MCP clients while preserving its own config schema. + +The stable path fixes the packaging lifecycle without reintroducing static vault pinning. diff --git a/docs/adr/0121-appimage-external-fallback-for-audio-and-video-previews.md b/docs/adr/0121-appimage-external-fallback-for-audio-and-video-previews.md new file mode 100644 index 0000000..b327f73 --- /dev/null +++ b/docs/adr/0121-appimage-external-fallback-for-audio-and-video-previews.md @@ -0,0 +1,34 @@ +--- +type: ADR +id: "0121" +title: "AppImage external fallback for audio and video previews" +status: active +date: 2026-05-15 +supersedes: "0110" +--- + +## Context + +ADR-0110 standardized in-app previews for image, audio, video, and PDF vault files through the shared `FilePreview` surface and Tauri asset URLs. In practice, Linux AppImage builds run audio and video playback through WebKitGTK, and that runtime has proven unstable enough that mounting the same in-webview media controls is not a reliable default for packaged Linux releases. + +Tolaria still needs one binary-preview model across platforms: previewability should remain renderer-inferred from filename extensions, binary files should remain ordinary vault entries, and external-open actions must continue to re-enter the active-vault command boundary before the OS opens a file. + +## Decision + +**Tolaria keeps in-app image and PDF previews everywhere, but Linux AppImage builds fall back to external-open controls for audio and video instead of mounting in-webview media playback.** + +- `FilePreview` remains the single renderer-owned surface for supported binary vault files. +- The preview policy is runtime-owned: the renderer asks the native runtime whether external media fallback is required before rendering audio or video elements. +- Linux AppImage builds return `true` for that runtime check and suppress in-webview audio/video previews; other targets keep the existing native HTML media controls. +- Editor-embedded BlockNote audio/video blocks follow the same runtime gate so binary preview behavior stays consistent between note bodies and file previews. + +## Alternatives considered +- **Runtime-gated external fallback on Linux AppImage** (chosen): keeps one preview architecture while containing a platform-specific runtime instability. Cons: AppImage users lose inline playback for audio/video. +- **Keep in-app audio/video previews on every platform**: preserves feature parity, but continues shipping a known unstable playback path on AppImage. +- **Disable all binary previews on Linux**: simpler policy, but unnecessarily removes stable image/PDF previews and weakens the file-first editor experience. + +## Consequences + +Tolaria now treats audio/video preview as a runtime capability decision rather than a universal guarantee of the binary preview system. Linux AppImage users see explicit external-open fallback controls for audio and video, while other platforms keep the richer in-app playback path. + +This keeps the filesystem-first binary model, scoped asset access, and active-vault validation boundary intact without introducing persisted media types or a separate media subsystem. Re-evaluate this decision if AppImage media playback becomes stable enough to restore inline playback without special handling, or if other packaged runtimes need their own preview capability gates. diff --git a/docs/adr/0122-scalar-array-frontmatter-properties.md b/docs/adr/0122-scalar-array-frontmatter-properties.md new file mode 100644 index 0000000..48c2c57 --- /dev/null +++ b/docs/adr/0122-scalar-array-frontmatter-properties.md @@ -0,0 +1,33 @@ +# 0122. Scalar Array Frontmatter Properties + +Status: active + +Date: 2026-05-15 + +## Context + +Saved Views filter against `VaultEntry.properties` in the renderer and in the Rust view evaluator. Before this decision, Tolaria preserved custom scalar frontmatter values as properties but dropped multi-element non-wikilink arrays during a full vault scan. That made a view such as `tags / contains / blues` unstable: optimistic renderer state could see a changed array for a while, but reload, view switch, or restart rebuilt the entry without the array-backed property. + +Relationship arrays already have separate semantics because wikilink-bearing fields are stored in `VaultEntry.relationships`. Plain scalar arrays need to stay queryable as custom properties without being treated as relationship fields. + +## Decision + +**Tolaria preserves custom scalar-array frontmatter fields in `VaultEntry.properties`, while wikilink-bearing arrays remain relationships.** Single-item scalar arrays continue to normalize to a scalar value for compatibility; multi-item scalar arrays remain arrays. + +Saved View filters evaluate scalar-array properties with set semantics. `contains` and `any_of` match exact case-insensitive elements, not substrings inside an element. Scalar properties keep their existing case-insensitive text matching behavior. + +The vault cache version is bumped so existing caches that dropped array properties are rebuilt from disk. + +## Options considered + +- **Option A (chosen): Preserve scalar arrays as properties** - keeps YAML frontmatter expressive, fixes reload/restart behavior, and avoids hardcoded fields such as `tags`. The cost is widening `VaultEntry.properties` from scalar-only to scalar-or-array. +- **Option B: Flatten arrays to comma-delimited strings** - keeps the old property type, but cannot distinguish exact elements from substrings and makes filters ambiguous. +- **Option C: Treat every array as a relationship** - reuses existing relationship matching, but non-wikilink values such as tags are not graph edges and should not appear as relationships. + +## Consequences + +Views can filter custom scalar arrays consistently across save, reload, view switches, and app restart. + +Property chips and sorting must tolerate property arrays. The note-list chip resolver already expands array values; custom-property sorting falls back to string comparison for arrays. + +Any future custom-property logic must handle `VaultPropertyValue` rather than assuming every property is a scalar. diff --git a/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md new file mode 100644 index 0000000..6ad8754 --- /dev/null +++ b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0123" +title: "Full vault graph for secondary note windows" +status: active +date: 2026-05-25 +supersedes: "0118" +--- + +## Context + +ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window. + +The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract. + +## Decision + +**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled. + +`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`. + +## Alternatives considered + +- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract. +- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load. +- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck. + +## Consequences + +- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations. +- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan. +- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities. diff --git a/docs/adr/0124-cached-secondary-note-window-startup.md b/docs/adr/0124-cached-secondary-note-window-startup.md new file mode 100644 index 0000000..a37f70d --- /dev/null +++ b/docs/adr/0124-cached-secondary-note-window-startup.md @@ -0,0 +1,27 @@ +--- +type: ADR +id: "0124" +title: "Cached secondary note window startup" +status: active +date: 2026-05-26 +supersedes: "0123" +--- + +## Context + +ADR-0123 restored secondary note windows to the normal `App` path so they retain the full vault/workspace graph required by Properties, quick open/search, wikilinks, and workspace-aware note actions. + +That parity is still the product contract, but forcing a fresh Tauri `reload_vault` during every secondary-window mount invalidates the backend cache. Opening several note windows can therefore repeat expensive full-vault scans even when the main window has already warmed the cache. + +## Decision + +**Secondary note windows keep the full vault graph, but their initial vault load uses the cached/incremental `list_vault` path instead of the forced `reload_vault` path.** + +Normal main-window startup continues to force a fresh initial reload. Explicit refresh paths, watcher-driven refreshes, and user-initiated reloads still use reload commands where they need disk freshness. + +## Consequences + +- Secondary note windows remain capable full app windows rather than reduced editor shells. +- Repeated note-window opens can reuse the backend vault cache instead of invalidating it on every startup. +- First open after a cold cache still scans the vault, then warms the shared backend cache for later windows. +- If the cached scan path is stale, the existing backend cache update logic remains responsible for incremental freshness. diff --git a/docs/adr/0126-renderer-action-history.md b/docs/adr/0126-renderer-action-history.md new file mode 100644 index 0000000..bce527f --- /dev/null +++ b/docs/adr/0126-renderer-action-history.md @@ -0,0 +1,29 @@ +--- +id: "0126" +title: "Renderer action history for app-level undo and redo" +status: "active" +date: "2026-05-26" +supersedes: + - "0106" +--- + +# ADR-0126: Renderer action history for app-level undo and redo + +## Context + +Tolaria already lets native text surfaces keep their own undo stacks, but app-level state changes such as frontmatter edits, archive toggles, favorite toggles, and organization toggles did not share a consistent undo/redo model. Routing all Undo and Redo through the native menu items left these app actions one-way while also making command-palette discoverability inconsistent. + +## Decision + +Introduce a renderer-owned `useActionHistory` stack for app-level actions. Supported actions record explicit undo and redo callbacks only after the write succeeds, clear redo after new user actions, and suppress nested recordings while a history entry is replaying. + +The Edit menu and command manifest now route Undo and Redo to renderer commands. Focused text-editing controls still receive native text history first through `document.execCommand('undo' | 'redo')`, so editor/input undo behavior remains separate from the app action stack. + +Destructive actions that are not safely reversible remain outside this stack and continue to rely on confirmation/destructive UX instead of pretending to be undoable. + +## Consequences + +- App-level history is scoped to the active renderer session and is not persisted across launches. +- Undo/redo labels can be surfaced in the command palette because the top stack entries expose labels. +- Menu accelerators and keyboard shortcuts use the shared command manifest instead of Tauri's native Undo/Redo menu builders. +- ADR-0106 remains valid for the broader menu ownership model, but its native Undo/Redo exception is superseded by this renderer action-history route. diff --git a/docs/adr/0127-native-ai-workspace-window.md b/docs/adr/0127-native-ai-workspace-window.md new file mode 100644 index 0000000..18aeeb5 --- /dev/null +++ b/docs/adr/0127-native-ai-workspace-window.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0127" +title: "Native AI workspace window" +status: active +date: 2026-05-26 +--- + +## Context + +The AI panel used to behave like another right-side editor panel. That kept the agent UI inside the main Tolaria window even when the user undocked it, so the "floating" surface could not be moved to another macOS space or placed beside Tolaria as a real window. + +The AI surface also needed to support multiple chat sessions, per-chat target selection, and a single header that does not duplicate the old panel title and permission controls. + +## Decision + +**The AI surface is a renderer-owned `AiWorkspace` that can run either docked in the main app or in a dedicated native Tauri webview window labeled `ai-workspace`.** + +The docked and native-window modes share the same React workspace component. The native window boots the normal `App` path with `?window=ai-workspace`, skips main-window size constraints, and uses macOS overlay traffic lights. Close and minimize requests from that window emit a dock request back to the main window before destroying the pop-out window. + +## Options considered + +- **Native Tauri window** (chosen): gives macOS users real window movement, traffic lights, and normal desktop window management; requires route/window-mode plumbing and explicit dock events. +- **CSS floating panel inside the main window**: simple and preserves component state in one renderer, but it cannot leave the main window bounds and fails the expected macOS behavior. +- **Separate full AI app shell**: isolates the workspace, but would duplicate vault loading and settings flows more than necessary. + +## Consequences + +- The status-bar AI affordance opens the workspace; target selection now belongs in the workspace header. +- `AiWorkspace` owns multi-chat sidebar state and filters target choices to installed local agents plus configured local/API model providers. +- The old `AiPanel` remains the reusable transcript/composer surface, but its header and prompt/focus effects can be disabled when mounted inside workspace sessions. +- Pop-out/dock currently transfers the workspace at the window level; future persistence can promote active conversations into a shared store if users need exact in-flight chat reparenting across renderer instances. diff --git a/docs/adr/0128-lightweight-ai-workspace-window.md b/docs/adr/0128-lightweight-ai-workspace-window.md new file mode 100644 index 0000000..1c842d5 --- /dev/null +++ b/docs/adr/0128-lightweight-ai-workspace-window.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0128" +title: "Lightweight AI workspace window" +status: active +date: 2026-05-26 +supersedes: "0127" +--- + +## Context + +ADR-0127 moved the AI workspace into a native Tauri window, but the first version booted the full `App` shell and used macOS overlay traffic lights. That made pop-out feel slow, duplicated startup work, and left the chat route dependent on main-window vault loading before agent turns could run. + +The AI workspace also needs installation-local chat metadata so user-facing chat titles and archive state survive dock/pop-out transitions without writing to a vault. + +## Decision + +**The AI workspace pop-out uses a lightweight renderer route backed by app settings metadata.** + +`openAiWorkspaceWindow()` opens the `ai-workspace` Tauri webview with `?window=ai-workspace` plus active vault context in URL params. `App` routes that window directly to `AiWorkspaceWindowApp`, which loads settings, AI agent status, and vault guidance without mounting the full vault/editor shell. The window is undecorated and transparent, and it relies on `AiWorkspace` headers for drag regions plus separate close and dock controls. Close only closes the pop-out; dock emits the main-window dock request before closing the pop-out. + +`settings.ai_workspace_conversations` stores only chat sidebar metadata: conversation id, title, archive state, and explicit target override. Prompt text, transcripts, note content, model credentials, and vault-local configuration stay out of app settings. + +## Options considered + +- **Lightweight AI route** (chosen): keeps pop-out startup focused on AI state and passes explicit vault context to the agent controller. +- **Full `App` route**: preserves maximum feature parity by default, but repeats vault/editor startup work and delays a window that should contain only the AI workspace. +- **Vault-stored chat metadata**: would travel with a vault, but chat labels and archive state are installation UI preferences rather than vault content. + +## Consequences + +- Pop-out startup avoids full note graph loading and should be close to instant after the Tauri webview is created. +- The dedicated AI window has no native traffic lights; users close or redock it through separate workspace header controls, and the rounded workspace shell defines the visible floating-window corners. +- Chat titles, archived state, and target overrides persist at the installation level in `settings.json`. +- Future transcript persistence must use a separate storage decision; `ai_workspace_conversations` is intentionally metadata-only. diff --git a/docs/adr/0129-tolaria-vault-item-deep-links.md b/docs/adr/0129-tolaria-vault-item-deep-links.md new file mode 100644 index 0000000..bbf44a0 --- /dev/null +++ b/docs/adr/0129-tolaria-vault-item-deep-links.md @@ -0,0 +1,45 @@ +--- +type: ADR +id: "0129" +title: "Tolaria vault item deep links" +status: active +date: 2026-05-27 +--- + +# ADR-0129: Tolaria vault item deep links + +## Context + +Users need durable links they can paste into calendars, task managers, chats, and other apps to return to a Tolaria vault item. The link needs to identify a registered vault, preserve the file extension so non-Markdown files can be opened, and fail clearly when the vault or item is unavailable. Links must not create or import files implicitly. + +Mounted workspaces make vault naming non-trivial. A readable slug is useful, but two vaults can share a label, alias, or folder name. URL parsing also cannot rely only on the browser URL implementation because dot-segment normalization can hide path traversal attempts before validation runs. + +## Decision + +Tolaria deep links use this shape: + +```text +tolaria:/// +``` + +The vault slug is generated from the registered workspace alias, then label, then path basename. When two vaults would share the same base slug, generated links append a stable short hash derived from the normalized vault path. A handwritten ambiguous base slug is rejected instead of picking an arbitrary vault. + +The path component is encoded per segment with `encodeURIComponent`, so spaces, Unicode, and reserved characters are preserved while `/` remains the path separator. Parsing rejects empty path segments, `.`, `..`, decoded separators inside a segment, unsafe Windows separators, and resolved paths outside the selected vault root. + +`src/utils/deepLinks.ts` owns URL building, parsing, and vault resolution. `src/hooks/useDeepLinks.ts` owns renderer integration: it receives Tauri deep-link events, validates them against the registered vault list, switches vaults when needed, reloads once if the target file is not in the current index yet, opens existing Markdown/text/binary entries, reports localized errors, and emits safe PostHog outcomes. Deep links are navigation-only; they never create missing files, import external content, or infer a fallback vault. + +The desktop shell registers the `tolaria` scheme through `tauri-plugin-deep-link` and keeps second launches focused through `tauri-plugin-single-instance`. Windows and Linux also call runtime `register_all()` as a repair step. macOS uses bundle scheme registration. Linux runtime registration is best-effort and is not part of the verified v1 support target. + +Copy surfaces are shared actions: + +- Breadcrumb overflow: `Copy note deeplink` +- Command palette: `Copy deep link to current item` +- File preview header: copy action for non-Markdown vault files + +## Consequences + +Path-based links are understandable and support every vault file kind, but a file rename changes the old link. A future stable item-id layer could supersede this URL shape while preserving path links as a readable fallback. + +Collision handling keeps generated links deterministic without exposing full local paths. Ambiguous handwritten slugs fail clearly, which is safer than opening the wrong vault. + +Renderer-owned resolution keeps the navigation logic close to mounted-workspace state and note selection. Native plugins stay responsible only for scheme registration, event delivery, and focusing the existing app instance. diff --git a/docs/adr/0130-windows-authenticode-release-signing.md b/docs/adr/0130-windows-authenticode-release-signing.md new file mode 100644 index 0000000..6b9507d --- /dev/null +++ b/docs/adr/0130-windows-authenticode-release-signing.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0130" +title: "Windows Authenticode signing for release installers" +status: active +date: 2026-05-27 +--- + +## Context + +Tolaria's Windows release job already produced Tauri updater signatures, but those signatures are not the Windows trust signal used by SmartScreen, Smart App Control, Defender, or WDAC policies when a user downloads and runs an installer from the browser. A managed Windows 11 user reported that the stable NSIS installer was blocked by Windows Security with no bypass option. + +Microsoft's current guidance is that unsigned public installers can be fully blocked by enterprise policy, while signed installers at least carry a publisher identity and can build reputation across releases. Store distribution would provide the strongest SmartScreen outcome, but Tolaria does not currently publish a Microsoft Store package. + +## Decision + +**Tolaria release CI must Authenticode-sign Windows app executables and installers before publishing them.** + +- Alpha and stable Windows release jobs import a CI-provided code-signing certificate from GitHub secrets. +- The workflow generates a temporary Tauri config that sets `bundle.windows.certificateThumbprint`, `digestAlgorithm`, and `timestampUrl`, then passes that config to `pnpm tauri build`. +- The Windows job verifies the produced app executable and installer artifacts with `Get-AuthenticodeSignature` and fails before upload if any signature is missing, invalid, or signed by an unexpected certificate. +- The public stable download page requires an explicit Windows installer click and tells managed-device users that IT may need to approve the Tolaria publisher before first install. + +## Options considered + +- **CI-enforced Authenticode signing** (chosen): gives Windows users and enterprise admins a real publisher identity, lets certificate reputation transfer across releases, and blocks accidental publication of unsigned installers. Cons: release jobs now depend on code-signing secrets and a valid certificate. +- **Documentation-only SmartScreen warning**: cheaper, but it leaves managed-device users with no supported path when policy removes the bypass option. +- **Microsoft Store distribution only**: strongest SmartScreen behavior, but it requires a separate packaging, submission, and release-management path that Tolaria does not yet own. +- **Portable ZIP fallback**: still downloads executable content from the browser and can remain subject to SmartScreen, Mark-of-the-Web, Smart App Control, or WDAC policy. + +## Consequences + +- Windows release failures caused by missing or expired code-signing credentials are intentional release blockers. +- Tauri updater signatures remain required for in-app updates, but they are treated as separate from Windows Authenticode trust. +- Enterprise-managed Windows installs can be documented around a stable Tolaria publisher identity instead of asking users to disable security policy. +- A future Microsoft Store/MSIX distribution path can supersede or supplement this policy if Tolaria decides to support Store-managed installs. diff --git a/docs/adr/0131-reusable-release-artifact-build-workflow.md b/docs/adr/0131-reusable-release-artifact-build-workflow.md new file mode 100644 index 0000000..59ce0a9 --- /dev/null +++ b/docs/adr/0131-reusable-release-artifact-build-workflow.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0131" +title: "Reusable release artifact build workflow" +status: active +date: 2026-05-28 +--- + +## Context + +Tolaria's alpha and stable release workflows both need to build the same platform artifact set: dual-architecture macOS updater bundles, optional stable macOS DMGs, Linux bundles, and signed Windows installers/updater bundles. Keeping those build jobs copied into both release workflows made platform fixes and validation changes easy to apply in one channel while accidentally leaving the other channel behind. + +The release workflows still differ in how they compute versions, create releases, and publish alpha vs. stable metadata, but the artifact build contract is shared. + +## Decision + +**Tolaria centralizes release artifact production in `.github/workflows/release-build-artifacts.yml`, invoked by alpha and stable release workflows through `workflow_call`.** Channel-specific workflows own versioning and publishing; the shared workflow owns platform build, signing, validation, and artifact upload behavior. + +## Alternatives considered + +- **Reusable artifact workflow** (chosen): keeps alpha and stable artifact behavior aligned while preserving separate channel-specific release orchestration. Cons: release behavior is split across one caller workflow and one called workflow, so debugging requires following both files. +- **Keep duplicated jobs in each release workflow**: makes each workflow self-contained, but every platform build fix must be applied twice and drift is likely. +- **Merge alpha and stable releases into one workflow**: reduces workflow count, but couples different trigger/version/publishing semantics and makes the release pipeline harder to reason about. + +## Consequences + +Alpha and stable releases now share one platform artifact contract, including macOS, Linux, and Windows validation. Changes to signing, cache keys, bundle validation, or platform matrices should happen in the reusable artifact workflow unless they are genuinely channel-specific. + +The architecture documentation should describe the release pipeline as channel orchestration plus shared artifact production, not as independent duplicated build job sets. diff --git a/docs/adr/0132-alpha-authenticode-soft-gate.md b/docs/adr/0132-alpha-authenticode-soft-gate.md new file mode 100644 index 0000000..b9b9925 --- /dev/null +++ b/docs/adr/0132-alpha-authenticode-soft-gate.md @@ -0,0 +1,25 @@ +--- +type: ADR +id: "0132" +title: "Alpha Authenticode soft gate" +status: active +date: 2026-05-28 +amends: "0130" +--- + +## Context + +ADR 0130 made Windows Authenticode signing mandatory for release installers. That is still the right requirement for stable promotions, but the repository does not yet have the Windows code-signing certificate secrets needed by CI. Because alpha releases run on every push to `main`, requiring those secrets there broke the continuous alpha channel before the certificate provisioning work was complete. + +## Decision + +**Alpha Windows artifacts keep building when Authenticode certificate secrets are absent; stable Windows artifacts still require Authenticode signing.** + +- The shared release artifact workflow accepts `require_windows_authenticode`. +- Alpha passes `false`, emits a workflow warning when certificate secrets are absent, and still requires Tauri updater signatures. +- Stable passes `true` and fails before building Windows artifacts unless certificate and password secrets are configured. +- When certificate secrets are present, both channels use the generated Tauri Authenticode config and verify Windows executable/installer signatures before upload. + +## Consequences + +The alpha updater channel remains live while Windows certificate provisioning is underway. Stable releases continue to enforce the Windows trust policy from ADR 0130 before public promotion. Once the certificate secrets are configured, alpha builds automatically regain Authenticode signing without another workflow change. diff --git a/docs/adr/0133-request-scoped-ai-stream-events.md b/docs/adr/0133-request-scoped-ai-stream-events.md new file mode 100644 index 0000000..12b9f6d --- /dev/null +++ b/docs/adr/0133-request-scoped-ai-stream-events.md @@ -0,0 +1,27 @@ +--- +type: ADR +id: "0133" +title: "Request-scoped AI stream event channels" +status: active +date: 2026-05-29 +--- + +## Context + +AI agent streams and direct model streams used shared Tauri event names (`ai-agent-stream` and `ai-model-stream`). That worked while only one stream of each kind was active, but the side AI workspace and pop-out window can make concurrent or rapidly reused AI sessions more likely. Shared channels risk routing deltas, tool events, or completion events to the wrong renderer listener. + +## Decision + +**Each native AI stream uses a request-scoped Tauri event channel generated by the renderer and validated by the backend.** + +The renderer creates a unique event name with the stream's stable base prefix, listens on that channel, and passes it to the `stream_ai_agent` or `stream_ai_model` command as `event_name`. The Rust command accepts only scoped names that match the expected base prefix and safe character set; invalid or missing names fall back to the legacy shared channel. + +## Alternatives considered + +- **Request-scoped renderer channel** (chosen): isolates simultaneous streams without changing the stream event payload shape, while preserving backward-compatible command defaults. +- **Keep shared static event names**: simpler, but concurrent agent/model sessions can cross-deliver stream events between workspaces or chats. +- **Backend-generated channel names**: centralizes validation, but requires a setup handshake before the renderer can subscribe and complicates the current fire-and-stream command flow. + +## Consequences + +Concurrent AI streams can run without contaminating each other's renderer callbacks. The backend keeps a narrow validation boundary for externally supplied event names. Any future AI streaming command should follow the same base-prefix plus scoped-suffix convention instead of adding another process-wide static channel. diff --git a/docs/adr/0134-direct-shiki-language-registrations.md b/docs/adr/0134-direct-shiki-language-registrations.md new file mode 100644 index 0000000..5eeb8d1 --- /dev/null +++ b/docs/adr/0134-direct-shiki-language-registrations.md @@ -0,0 +1,27 @@ +--- +type: ADR +id: "0134" +title: "Direct Shiki language registrations for code blocks" +status: active +date: 2026-05-29 +--- + +## Context + +Tolaria uses `@blocknote/code-block` for rich-editor fenced-code highlighting. The bundled BlockNote highlighter covers the common web and systems languages already in the editor menu, but it does not include several common Shiki grammars such as PowerShell, VBScript, Dart, Dockerfile, Terraform/HCL, and TOML. Users still expect imported fences like `powershell`, `ps1`, `vb`, and `vbscript` to highlight, show a valid language picker state, and serialize back to a stable Markdown fence. + +## Decision + +**Tolaria keeps BlockNote's code-block integration and adds direct, lazy `@shikijs/langs` registrations for missing common languages and aliases.** + +## Options considered + +- **Keep only the BlockNote bundle**: simplest, but leaves PowerShell/VBScript and other common fences unsupported. +- **Register selected `@shikijs/langs` grammars lazily** (chosen): preserves BlockNote's schema and parser path while adding only the extra grammars users need. +- **Replace BlockNote's highlighter with a full custom Shiki bundle**: more control, but a larger structural change than the current requirement needs. + +## Consequences + +`src/components/codeBlockOptions.ts` remains the owner of the BlockNote highlighter configuration, but extra grammar modules are now imported directly from `@shikijs/langs` only when a matching fence or picker value needs highlighting. `src/utils/codeBlockLanguageCatalog.ts` owns the supported extra language labels and aliases, and `src/utils/codeBlockLanguage.ts` normalizes known imported aliases such as `ps1` and `vb` to the canonical picker language. + +The language menu grows, but unsupported aliases still fail safely by staying as plain explicit fence names. If Tolaria later needs a generated language bundle, export-time highlighting, or a substantially smaller menu, this ADR should be superseded by a custom Shiki packaging decision. diff --git a/docs/adr/0134-sheet-nodes-with-plain-text-workbook-storage.md b/docs/adr/0134-sheet-nodes-with-plain-text-workbook-storage.md new file mode 100644 index 0000000..b82e541 --- /dev/null +++ b/docs/adr/0134-sheet-nodes-with-plain-text-workbook-storage.md @@ -0,0 +1,49 @@ +# ADR 0134: Sheet Nodes With Plain-Text Workbook Storage + +## Status + +Experimental + +## Context + +Tolaria should support spreadsheet-style content without losing its file-first, local-first, offline-first, and Git-friendly model. A sheet must remain inspectable and editable as plain text, while the interactive editor should behave like a spreadsheet and avoid a custom grid implementation owned by Tolaria. + +## Decision + +Notes whose frontmatter resolves to `_display: sheet` are displayed with a dedicated sheet editor instead of the block note editor. `type` stays semantic and organizational metadata, so sheets can belong to any Tolaria type. The body of the note is CSV-like plain text containing cell inputs and formulas. Sheet presentation metadata is stored in the same note frontmatter under `_sheet`. + +Example: + +```yaml +--- +type: Project +_display: sheet +_sheet: + frozen_rows: 1 + frozen_columns: 1 + columns: + B: + width: 180 + cells: + C6: + num_fmt: "0.00%" + bold: true + font_size: 15 + border_top: "thin #ff0000" +--- +Metric,January,February +Revenue,1200,=B2*1.1 +``` + +The prototype uses IronCalc's workbook package for the spreadsheet UI and formula engine. Tolaria adapts between the plain-text note representation and the IronCalc workbook model on load/save. + +## Consequences + +- Spreadsheet data remains legible and diffable in Git. +- Common workbook UI behavior, including selection, keyboard navigation, formatting controls, and copy/paste, is delegated to IronCalc. +- Tolaria-owned code is limited to format routing, the plain-text adapter, persistence safeguards, product-specific control hiding, and formula autocomplete. It should not grow into a custom spreadsheet grid. +- The current prototype is intentionally single-sheet. Cross-note cell references remain a future Tolaria extension, not an IronCalc multi-sheet feature. +- `_sheet` stores common presentation state as plain YAML, including column widths, row heights, grid-line visibility, frozen rows/columns, number formats, borders, and basic cell styling. +- Metadata extraction is bounded and save serialization is debounced, with an idle-time pass when available, to avoid unbounded autosave scans. Larger workbooks may need incremental dirty-range tracking before this becomes production-ready. +- Formula autocomplete is a small Tolaria-side enhancement over IronCalc's input surface; broader spreadsheet UI behavior should stay delegated to the workbook package. +- Simple Markdown wrappers in imported non-formula CSV cells can seed initial bold, italic, and strike cell styles, but saved sheet styling is represented in `_sheet` metadata rather than inline Markdown markers. diff --git a/docs/adr/0135-clean-active-note-refresh-after-external-edit.md b/docs/adr/0135-clean-active-note-refresh-after-external-edit.md new file mode 100644 index 0000000..ca134be --- /dev/null +++ b/docs/adr/0135-clean-active-note-refresh-after-external-edit.md @@ -0,0 +1,44 @@ +--- +type: ADR +id: "0135" +title: "Clean active notes refresh immediately after external edits" +status: active +date: 2026-05-30 +supersedes: "0111" +--- + +## Context + +ADR-0111 made external vault refreshes path-aware and preserved focused editor mounts so unrelated watcher events would not disrupt cursor state. That avoided needless remount churn, but it also meant a clean active note edited by Codex or another external process could remain visibly stale while the editor stayed focused. Because no later safe-remount trigger was guaranteed, users could see the old content until a full app restart. + +Tolaria's filesystem-first model requires clean in-memory editor state to converge to the file on disk during the current session. Unsaved local editor buffers still need protection, but editor focus alone is not enough reason to keep showing stale content when the changed-path batch identifies the active file. + +## Decision + +**External vault refreshes now remount a clean active note immediately when the external changed-path batch includes that note, regardless of editor focus.** + +The shared `refreshPulledVaultState()` path applies these rules: + +1. Reload vault entries, folders, and saved views together for every external change batch. +2. If there is no active note, stop after the shared reload. +3. If the active note changed during the async reload, stop rather than reopening stale context. +4. If the active note has unsaved local edits, keep the current editor buffer mounted. +5. If the active file disappeared, close the tab instead of leaving a stale editor behind. +6. If the changed-path batch includes the clean active file, close and reopen the active tab from disk even when focus is inside the rich or raw editor. +7. Unknown or unrelated change batches refresh vault-derived state without remounting the active editor. + +Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches continue to converge through this single reconciliation helper instead of adding separate reload policies. + +## Alternatives considered + +- **Immediate clean active-note remount** (chosen): restores filesystem convergence for Codex and other external note edits while preserving unsaved local edits. Cons: a focused clean editor can lose cursor state when its own file changes externally. +- **Keep focused-editor preservation from ADR-0111**: avoids cursor disruption, but can leave the active editor stale indefinitely. +- **Defer active-note reload until blur**: reduces focus disruption, but adds another pending-refresh state machine and still allows the active editor to show stale disk content for an unbounded editing session. + +## Consequences + +- External edits to the currently open clean note become visible without restarting Tolaria. +- Unsaved local content remains authoritative and is not replaced by watcher, pull, or agent refreshes. +- The changed-path batch remains part of the external-refresh contract; callers should pass specific file paths whenever available. +- Unrelated watcher events still avoid active-editor remounts, so broad vault churn does not disturb the editor unless the active file itself changed. +- ADR-0111 is superseded by this stronger filesystem-convergence rule. diff --git a/docs/adr/0136-macos-webview-pdf-export.md b/docs/adr/0136-macos-webview-pdf-export.md new file mode 100644 index 0000000..cb329a2 --- /dev/null +++ b/docs/adr/0136-macos-webview-pdf-export.md @@ -0,0 +1,31 @@ +# ADR-0136: macOS Webview PDF Export + +## Status + +Accepted + +## Context + +The first note PDF export implementation reused the native webview print command. On macOS that opens the full printer dialog, which is not the product behavior expected from "Export note as PDF"; users should choose a filesystem destination and get a PDF directly. + +Tolaria already renders the exportable note in the live BlockNote DOM and applies print-only CSS so math, Mermaid, images, code blocks, tables, links, and custom blocks follow the same rendering path users see in the editor. Introducing a second Markdown-to-PDF renderer would duplicate that rendering logic and create drift. + +## Decision + +Use the existing Tauri webview and WebKit's own print operation to save the current webview directly to a chosen PDF path. The renderer remains responsible for export preparation: + +- exit raw/diff modes +- apply the PDF export body class +- ask the user for a `.pdf` destination +- invoke the native `export_current_webview_pdf` command + +The native command uses direct `objc2`, `objc2-app-kit`, `objc2-foundation`, and `objc2-web-kit` dependencies on macOS only. These crates are already part of Tauri's platform stack; declaring them directly lets Tolaria ask `WKWebView` for a WebKit-aware `NSPrintOperation` without adding a separate PDF rendering engine. + +Windows, Linux, and browser mode keep print-dialog fallback behavior because they do not have the macOS WebKit/AppKit direct PDF save path yet. The renderer checks the native capability before opening a filesystem save dialog, so unsupported platforms do not ask for a destination that cannot be used. + +## Consequences + +- The macOS path opens a save-file dialog, not the printer dialog. +- The exported PDF keeps using the rendered note DOM, so frontmatter stays excluded by the existing rich-editor body extraction. +- The feature depends on macOS WebKit/AppKit behavior for direct PDF output. Other desktop platforms use the existing native print dialog until they get a platform-specific direct PDF path. +- The new direct dependencies must stay target-scoped to macOS so Linux and Windows builds do not compile AppKit crates. diff --git a/docs/adr/0137-shared-rich-editor-input-transforms.md b/docs/adr/0137-shared-rich-editor-input-transforms.md new file mode 100644 index 0000000..d3c3a0d --- /dev/null +++ b/docs/adr/0137-shared-rich-editor-input-transforms.md @@ -0,0 +1,58 @@ +--- +type: ADR +id: "0137" +title: "Shared rich-editor input transforms" +status: active +date: 2026-06-07 +--- + +## Context + +Tolaria has several Markdown-style conveniences in the rich BlockNote editor: +typed arrows become ligatures, completed inline math becomes a math node, and +completed `==highlight==` syntax becomes the durable highlight mark. + +These features were added incrementally as separate `beforeinput` extensions. +Each extension repeated the same lifecycle work: reading the live ProseMirror +view, skipping IME composition, guarding stale views, dispatching transactions, +preventing native input only after a successful transform, and recovering known +BlockNote/ProseMirror transform failures. The syntax matchers differed, but the +execution shell was parallel enough that each new Markdown affordance risked a +slightly different edge-case policy. + +## Decision + +**Tolaria routes rich-editor Markdown input transforms through one shared +`beforeinput` execution path.** + +`src/components/richEditorInputTransform.ts` owns the common lifecycle, +dispatch, and recoverable-error behavior. Feature files such as +`arrowLigaturesExtension.ts`, `mathInputExtension.ts`, and +`markdownHighlightInputExtension.ts` expose small transform objects that only +decide whether the current input event should produce a transaction. + +`src/components/richEditorInputTransformExtension.ts` composes the Markdown +transform set used by the main editor and hidden editor probe. + +## Options Considered + +- **Shared transform primitive with feature-owned matchers** (chosen): removes + duplicate listener, dispatch, composition, and recovery code while keeping each + syntax rule local and testable. +- **One monolithic Markdown input extension**: reduces listener count, but mixes + unrelated syntax rules in one file and makes each future input affordance + harder to test independently. +- **Keep one extension per feature**: preserves local ownership, but keeps the + repeated edge-case shell and invites divergence as more transforms are added. + +## Consequences + +- `Editor` mounts one Markdown input-transform extension rather than separate + arrow, math, and highlight `beforeinput` listeners. +- Feature-specific files remain responsible for their syntax matching and + transaction construction only. +- Recoverable transform errors use the same telemetry event and fallback policy + across Markdown input transforms. +- New rich-editor Markdown input affordances should plug into the shared + transform primitive instead of adding another capture-phase `beforeinput` + extension. diff --git a/docs/adr/0138-authenticode-required-for-all-release-channels.md b/docs/adr/0138-authenticode-required-for-all-release-channels.md new file mode 100644 index 0000000..a1f9bf0 --- /dev/null +++ b/docs/adr/0138-authenticode-required-for-all-release-channels.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0138" +title: "Require Authenticode signing for all Windows release channels" +status: active +date: 2026-06-09 +supersedes: "0132" +amends: "0130" +--- + +## Context + +ADR 0132 temporarily allowed alpha Windows artifacts to build without Authenticode when the repository did not yet have Windows code-signing certificate secrets. That kept the alpha channel moving during certificate provisioning, but it also normalized unsigned Windows artifacts and made stable promotion exceptions easy to repeat. + +At the same time, CI coverage uploads started failing because the pinned Codecov action still fetched Codecov's retired Keybase public-key account while the current Codecov CLI signatures use the original key from `codecovsecops`. That failure is unrelated to Windows Authenticode, but it blocked the same mainline quality lane and made release-readiness harder to reason about. + +## Decision + +**All Windows release artifacts must be Authenticode-signed before upload, for both alpha and stable channels.** + +- The reusable release artifact workflow no longer accepts a soft-gate input for Windows Authenticode. +- The Windows build validates that Tauri updater signing secrets and Windows Authenticode certificate/password secrets are present before packaging starts. +- The Windows build always passes the generated Authenticode config to `pnpm tauri build`. +- The Windows build always verifies the app executable and installer signatures with `Get-AuthenticodeSignature` before artifact upload. +- Codecov uploads use the patched `codecov-action` release that imports Codecov's current public key source, preserving CLI integrity validation instead of skipping it. + +## Consequences + +- Missing, expired, partial, or invalid Windows code-signing credentials fail alpha and stable release artifact builds. +- The repository cannot publish unsigned Windows installers as a convenience fallback. +- A trusted Windows code-signing certificate still has to come from a certificate authority or signing service; generating a local self-signed certificate is not an acceptable substitute for release artifacts. +- If Tolaria later adopts Microsoft Trusted Signing, Store packaging, or another signing provider, that integration should replace the PFX secret import path while preserving mandatory verification before upload. diff --git a/docs/adr/0139-temporary-windows-authenticode-soft-gate.md b/docs/adr/0139-temporary-windows-authenticode-soft-gate.md new file mode 100644 index 0000000..1fa4150 --- /dev/null +++ b/docs/adr/0139-temporary-windows-authenticode-soft-gate.md @@ -0,0 +1,26 @@ +--- +type: ADR +id: "0139" +title: "Temporary Windows Authenticode soft gate" +status: active +date: 2026-06-09 +supersedes: "0138" +amends: "0130" +--- + +## Context + +ADR 0138 made Authenticode mandatory for every Windows release artifact. That is the desired end state, but the repository still does not have a trusted Windows code-signing certificate or signing service configured. Enforcing Authenticode before provisioning is complete blocks the alpha and stable release pipelines even though the Tauri updater signatures are available and still protect updater integrity. + +## Decision + +**Windows Authenticode signing is temporarily optional for both alpha and stable release builds.** + +- Windows builds still require `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_KEY_PASSWORD`, so updater artifacts remain signed. +- When `WINDOWS_CODE_SIGNING_CERTIFICATE`/`WINDOWS_CERTIFICATE` and the matching password secret are present, CI imports the certificate, builds with the generated Authenticode config, and verifies executable and installer signatures with `Get-AuthenticodeSignature`. +- When both Windows Authenticode certificate and password secrets are absent, CI emits a warning and builds Windows artifacts without Authenticode. +- Partial Authenticode configuration remains a hard error because it is ambiguous and easy to misread as a signed release. + +## Consequences + +Windows releases can continue while certificate provisioning is pending. This does not provide the Windows publisher identity needed by SmartScreen, Smart App Control, Defender, or WDAC-managed environments; those policies can still block the installer until a trusted Authenticode certificate or signing service is configured. Reinstating a hard gate should be a small workflow change once the certificate path exists. diff --git a/docs/adr/0140-extension-based-raw-text-syntax-highlighting.md b/docs/adr/0140-extension-based-raw-text-syntax-highlighting.md new file mode 100644 index 0000000..637f891 --- /dev/null +++ b/docs/adr/0140-extension-based-raw-text-syntax-highlighting.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0140" +title: "Extension-based raw text syntax highlighting" +status: active +date: 2026-06-17 +--- + +## Context + +Tolaria already indexes UTF-8 non-Markdown vault files as `fileKind: "text"` and opens them in the raw CodeMirror editor. Discussion #872 asked for those files to receive syntax highlighting by file extension, especially `.sql`, `.json`, `.py`, and `.yaml`, matching the expectation set by highlighted fenced code blocks in Markdown notes. + +The raw editor previously installed the Markdown language extension for every raw file. That made Markdown notes work, but `.sql`, `.json`, `.py`, and `.yaml` files rendered as effectively plain text or Markdown-shaped text instead of using their own grammars. + +## Decision + +Tolaria maps raw editor file extensions to CodeMirror language packages at editor creation time: + +- Markdown files keep the existing frontmatter-aware Markdown path. +- YAML, JSON, Python, SQL, JavaScript, and TypeScript-like files use the official CodeMirror language packages for those grammars. +- Unknown text files stay plain instead of inheriting Markdown highlighting. +- Markdown-only frontmatter decorations and warnings stay scoped to Markdown files. + +## Options considered + +- **Use official CodeMirror language packages** (chosen): keeps raw editing inside CodeMirror, provides maintained incremental parsers, and avoids a second rendering surface. +- **Reuse Shiki from BlockNote code blocks**: visually closer to rich-editor code blocks, but Shiki is a static highlighter and would require a parallel CodeMirror decoration pipeline. +- **Keep Markdown highlighting for every raw file**: no new dependencies, but fails the requested behavior and treats non-Markdown files as Markdown. + +## Consequences + +- New runtime dependencies: `@codemirror/lang-javascript`, `@codemirror/lang-json`, `@codemirror/lang-python`, and `@codemirror/lang-sql`. +- `src/utils/rawEditorLanguage.ts` owns extension-to-language mapping. +- `src/extensions/rawEditorLanguage.ts` owns the CodeMirror extension selection and keeps Markdown frontmatter highlighting scoped to Markdown files. +- Additional languages should be added by extending the mapping and using official CodeMirror language packages where available. diff --git a/docs/adr/0141-scoped-linux-webkit-rendering-safeguards.md b/docs/adr/0141-scoped-linux-webkit-rendering-safeguards.md new file mode 100644 index 0000000..d9f8242 --- /dev/null +++ b/docs/adr/0141-scoped-linux-webkit-rendering-safeguards.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0141" +title: "Scoped Linux WebKit rendering safeguards" +status: active +date: 2026-06-18 +--- + +## Context + +Tolaria needs Linux WebKitGTK startup safeguards because some Wayland/AppImage environments crash before the app can render. The existing startup path treated native Linux Wayland launches and sealed Linux AppImage launches the same way by setting both `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless the user had already provided either variable. + +That broad fallback protected unstable AppImage launches, but it also applied the last-resort compositing disablement to native Wayland sessions. Native Wayland still needs the DMABUF crash workaround, but disabling WebKit compositing there can make windows feel unresponsive. The sealed AppImage runtime remains the verified environment that needs both rendering overrides. + +## Decision + +Tolaria scopes Linux WebKit rendering safeguards by launch environment: + +- Native Linux Wayland launches set `WEBKIT_DISABLE_DMABUF_RENDERER=1` by default, while preserving WebKit compositing unless the user explicitly disables it. +- Linux AppImage launches continue to set both `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` by default because the sealed AppImage path has the verified rendering failure this fallback protects. +- User-provided environment values remain authoritative per variable, so advanced users and distro-specific workarounds can override either safeguard. + +## Alternatives considered + +- **Scope the fallback by launch environment** (chosen): keeps the proven AppImage protection while avoiding a heavier native Wayland workaround that hurts responsiveness. Cons: the startup policy now distinguishes AppImage from native Linux sessions. +- **Keep both overrides for all Wayland and AppImage launches**: simplest and maximally conservative for crash avoidance, but applies the last-resort compositing workaround beyond the environment that actually needs it. +- **Remove the WebKit rendering overrides entirely**: restores default WebKitGTK behavior, but would reopen known Linux startup crashes in AppImage/Wayland environments. + +## Consequences + +Native Linux Wayland users keep the broad DMABUF crash workaround without losing WebKit compositing by default. AppImage users keep the sealed-runtime fallback that has been validated against the startup crash class. + +Future Linux rendering work should treat `linux_appimage.rs` startup overrides as a capability/policy boundary, not as a single global Linux switch. Re-evaluate this decision if WebKitGTK or the AppImage runtime no longer requires these environment safeguards, or if another packaged Linux runtime develops a distinct rendering failure mode. diff --git a/docs/adr/0142-rich-editor-prosemirror-decoration-dependency.md b/docs/adr/0142-rich-editor-prosemirror-decoration-dependency.md new file mode 100644 index 0000000..9cac056 --- /dev/null +++ b/docs/adr/0142-rich-editor-prosemirror-decoration-dependency.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0142" +title: "Rich editor ProseMirror decoration dependency" +status: active +date: 2026-06-22 +--- + +## Context + +Tolaria's rich editor needs per-node RTL rendering for quote blocks whose Markdown begins with an Obsidian callout marker such as `[!note]`. Browser `dir="auto"` sees the Latin marker first and resolves the quote as LTR, leaving the quote rail on the left even when the title/body are Hebrew or Arabic. + +External DOM patching is not reliable here because BlockNote/ProseMirror owns those nodes and can replace them after mutations. The styling decision must be expressed through the editor render pipeline. + +## Decision + +Add `@tiptap/pm` as a direct dependency and use ProseMirror decorations from a BlockNote extension for rich-editor text-direction overrides. + +The extension decorates RTL quote nodes with Tolaria-specific direction attributes/classes. CSS then uses those stable decoration attributes to move quote rails to the logical start side. + +## Alternatives considered + +- **ProseMirror decorations via `@tiptap/pm`** (chosen): uses the editor's supported render layer and avoids DOM reconciliation fights. Cons: makes a transitive ProseMirror facade dependency direct. +- **MutationObserver DOM patching**: avoids a direct dependency, but ProseMirror strips or replaces externally mutated editor nodes. +- **Pure CSS logical properties only**: works when the element's direction is already correct, but cannot ignore leading callout marker syntax when computing direction. + +## Consequences + +Rich-editor RTL quote and callout-marker rendering can be tested deterministically through the existing BlockNote render path. Future per-node editor presentation rules should prefer ProseMirror decorations over post-render DOM mutation when the target node is owned by BlockNote. diff --git a/docs/adr/0143-shared-focus-ownership-guard.md b/docs/adr/0143-shared-focus-ownership-guard.md new file mode 100644 index 0000000..47a3e06 --- /dev/null +++ b/docs/adr/0143-shared-focus-ownership-guard.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0143" +title: "Shared focus ownership guard" +status: active +date: 2026-06-23 +--- + +## Context + +Tolaria has multiple renderer surfaces that intentionally reject programmatic focus while another surface owns keyboard input. The rich editor must not steal focus back from the Properties panel, and the sheet editor must not let IronCalc autofocus reclaim keyboard capture after focus moves to app chrome or dialogs. + +The editor and sheet implementations previously installed separate `HTMLElement.prototype.focus` patches and separate document focus listeners. That duplicated lifecycle made unmount order matter: removing one surface guard could restore the native focus method while another guard was still mounted. + +## Decision + +Use one shared focus ownership registry for global focus interception. + +`src/hooks/focusOwnershipGuard.ts` owns the single `HTMLElement.prototype.focus` patch, document focus/pointer listeners, outside-target memory, blocked-focus restoration, and cleanup. Surface modules register scoped ownership policy: + +- `src/hooks/editorFocusOwnership.ts` decides when rich-editor focus is suspended or resumed. +- `src/components/sheet-editor/useGuardedWorkbookFocus.ts` decides when workbook focus requires active sheet keyboard capture and no external focus surface. + +## Alternatives considered + +- **Shared global registry with surface-owned policy** (chosen): removes duplicate patch/listener lifecycle while preserving editor and sheet behavior locally. +- **Keep stacked surface-specific patches**: minimizes immediate movement but keeps cleanup-order bugs and duplicated outside-focus restoration. +- **Move all editor and sheet focus policy into one module**: centralizes more code, but mixes unrelated surface rules and makes future policy changes harder to review. + +## Consequences + +Only the shared guard may patch `HTMLElement.prototype.focus` or install document-level focus ownership listeners. New editor-like surfaces should register a scoped policy through the shared guard instead of adding another prototype patch. diff --git a/docs/adr/0144-collections-and-presentations.md b/docs/adr/0144-collections-and-presentations.md new file mode 100644 index 0000000..86e689a --- /dev/null +++ b/docs/adr/0144-collections-and-presentations.md @@ -0,0 +1,48 @@ +--- +type: ADR +id: "0144" +title: "Collections carry presentation configuration" +status: active +date: 2026-06-24 +--- + +## Context + +Tolaria already has several ways to select a group of notes: built-in sidebar filters such as All Notes and Inbox, type sections, folder rows, saved Views, and Neighborhood mode around one note. Product-wise, these are all collections of notes. The current implementation still routes most of them through a component named `NoteList`, which makes the list presentation look like the domain model. + +Spreadsheets also introduced a parallel single-note concern: a note can keep the same durable identity while choosing a different display mode through `_display`. Collections need the same separation between the notes being selected and how those notes are presented. Future presentations such as boards, calendars, tables, timelines, and graphs need presentation-specific field mappings, such as board column field, calendar start/end fields, or table columns, without inventing a parallel data model. + +## Decision + +**Tolaria treats a Collection as the internal representation of a selected group of notes plus its presentation configuration.** A collection can be built from a saved View YAML file, a type section, a built-in sidebar filter, a folder, or Neighborhood mode. The first supported presentation is `list`, preserving current behavior. + +Saved Views remain the most configurable persisted collection artifact. Existing top-level saved-view fields (`sort`, `listPropertiesDisplay`, `filters`, `order`, `name`, `icon`, `color`) remain valid. The renderer normalizes them into an in-memory collection presentation: + +```yaml +presentation: + type: list + sort: modified:desc + properties: + - status + - owner +``` + +Future saved-view YAML may store nested `presentation` configuration. For compatibility, Tolaria reads legacy top-level list fields and lets nested `presentation.type: list` override them in memory. The current implementation does not rewrite existing YAML into the nested shape. + +`SidebarSelection` remains the navigation input for now. Renderer code adapts it to `CollectionDefinition` through `src/collections/collectionFromSelection.ts`, and resolves visible entries through `src/collections/resolveCollectionEntries.ts`. This is an implementation bridge, not a new user-visible concept. + +## Options considered + +- **Use one Collection concept with nested presentation config** (chosen): keeps the product model small, matches saved-view YAML, and lets built-in sections and type sections behave like generated collections without exposing separate "source" terminology. +- **Separate CollectionSource and CollectionPresentation concepts everywhere**: precise internally, but adds vocabulary and wiring before Tolaria has multiple presentations. It remains a possible implementation detail later, not a product concept now. +- **Keep adding one-off branches to NoteList/App.tsx**: fastest for the next feature, but makes boards, calendars, and graph-like surfaces compete with list-specific assumptions. +- **Use `kind` on saved views**: already explored in prior Kanban work, but `kind` is vague and "view" is overloaded across saved Views, app view mode, and note display. `presentation.type` is clearer and leaves room for presentation-specific config. + +## Consequences + +- Current UI behavior remains unchanged: every existing collection still renders as the list presentation. +- New collection presentations should consume resolved notes and presentation config instead of reimplementing sidebar filtering. +- Presentation config maps existing note properties; it must not create a separate data store. A board groups notes by a property, a calendar maps notes to date properties, and a table chooses visible property columns. +- Saved-view YAML remains portable and Git-syncable. Nested presentation config should be added compatibly and only written when a user edits presentation settings. +- Note display modes remain a separate per-note concern owned by `_display` and ADR-0134. Collection presentations operate across notes; note display modes interpret one note. +- Community plugin surfaces should wait until at least two internal collection presentations exist, so the host API can be based on proven capabilities rather than speculative extension points. diff --git a/docs/adr/0145-xdg-backed-app-config-path.md b/docs/adr/0145-xdg-backed-app-config-path.md new file mode 100644 index 0000000..eb557d5 --- /dev/null +++ b/docs/adr/0145-xdg-backed-app-config-path.md @@ -0,0 +1,44 @@ +--- +type: ADR +id: "0145" +title: "XDG-backed app config path" +status: active +date: 2026-06-27 +--- + +## Context + +Tolaria stores installation-local state such as settings, registered workspaces, window state, AI workspace session metadata, and local AI provider secrets outside the vault. The product rule is still that vault-shaped content belongs in the vault, while machine- and installation-specific preferences stay in app config. + +Users also want this app config to be portable through dotfile backup workflows. The existing implementation documented `~/.config/com.tolaria.app`, but the Rust path resolver used the platform config directory directly and duplicated that decision in settings and vault-list code. That made `$XDG_CONFIG_HOME` support unclear and increased the risk that new app config files would pick a different path. + +## Decision + +**Tolaria resolves app-owned config files through one Rust helper that follows the XDG config location on Unix platforms and keeps the platform config directory as a read fallback.** + +All app-owned config JSON remains under the `com.tolaria.app` namespace: + +```text +${XDG_CONFIG_HOME:-$HOME/.config}/com.tolaria.app/settings.json +${XDG_CONFIG_HOME:-$HOME/.config}/com.tolaria.app/vaults.json +${XDG_CONFIG_HOME:-$HOME/.config}/com.tolaria.app/window-state.json +${XDG_CONFIG_HOME:-$HOME/.config}/com.tolaria.app/ai-provider-secrets.json +``` + +If `XDG_CONFIG_HOME` is missing on Unix platforms, Tolaria uses `$HOME/.config`. If `XDG_CONFIG_HOME` is relative, Tolaria ignores it and falls back to the next valid config root. Windows keeps using the platform config directory unless the user sets an absolute `XDG_CONFIG_HOME`. + +Reads check the preferred Tolaria namespace first, then the legacy `com.laputa.app` namespace, and then the previous platform config directory when it differs from the XDG location. Writes always go to the Tolaria namespace under the preferred config root. The helper lives in `src-tauri/src/app_config.rs`, and app config consumers should call it instead of joining their own config root. + +## Options considered + +- **Use the XDG config home on Unix and preserve platform read fallback** (chosen): makes dotfile-backed config explicit without changing the JSON file formats or stranding existing platform-config installs. +- **Always force `~/.config` on every platform**: maximizes visible dotfile portability, but ignores OS conventions for users who have not opted into XDG config. +- **Keep per-module path helpers**: preserves the status quo, but makes future config files easy to place inconsistently. +- **Move installation-local state into the vault**: would sync more data, but would mix device preferences, credentials, and window state into user content. + +## Consequences + +- Users can back up Tolaria's app config JSON alongside other dotfile-managed tools. +- Existing installs keep working because reads still check `com.laputa.app` and the previous platform config directory when no preferred XDG/Tolaria file exists. +- Relative `XDG_CONFIG_HOME` values are ignored so Tolaria does not write config relative to an arbitrary process working directory. +- New app-owned config files should use the shared Rust helper and document whether they are safe to back up. Secrets stay outside vaults and worktrees, but users who back up their XDG directory need to treat those files as sensitive. diff --git a/docs/adr/0146-cached-main-window-startup-with-empty-reload-recovery.md b/docs/adr/0146-cached-main-window-startup-with-empty-reload-recovery.md new file mode 100644 index 0000000..56b0e83 --- /dev/null +++ b/docs/adr/0146-cached-main-window-startup-with-empty-reload-recovery.md @@ -0,0 +1,30 @@ +--- +type: ADR +id: "0146" +title: "Cached main-window startup with empty reload recovery" +status: active +date: 2026-06-28 +supersedes: "0124" +--- + +## Context + +ADR-0124 allowed secondary note windows to use the cached/incremental `list_vault` path, but kept normal main-window startup on a forced `reload_vault`. + +That forced reload invalidates the cache and runs a full filesystem scan before the main window finishes indexing. On large local macOS vaults this can make startup look hung for tens of seconds, with the status bar stuck in the vault reloading state even when a healthy cached index is available. + +Tolaria still needs the recovery behavior that motivated the fresh reload path: if a startup cache returns an empty result for a vault that should contain notes, the app should recover with a fresh scan instead of leaving the user with an empty graph. + +## Decision + +**Main-window startup uses the cached/incremental `list_vault` path first, just like secondary note windows. The main window performs a `reload_vault` fallback only when that initial cached result is empty.** + +Explicit user reloads, watcher/external-edit refreshes, Git pull refreshes, and other freshness-critical paths continue to call `reload_vault` through the existing reload abstractions. + +## Consequences + +- Healthy cached starts avoid invalidating the vault cache and do not pay a full rescan just to mount the main window. +- Empty-cache or stale-empty startup regressions still recover through one fresh `reload_vault` pass in the main window. +- Secondary note windows keep the ADR-0124 behavior and do not use startup empty-result recovery, avoiding surprise full scans when opening many note windows. +- The backend cache remains responsible for incremental freshness when `list_vault` returns non-empty cached entries. +- Future startup performance changes must preserve the distinction between initial cached hydration and explicit freshness reloads. diff --git a/docs/adr/0147-antigravity-cli-agent-adapter.md b/docs/adr/0147-antigravity-cli-agent-adapter.md new file mode 100644 index 0000000..6723b42 --- /dev/null +++ b/docs/adr/0147-antigravity-cli-agent-adapter.md @@ -0,0 +1,42 @@ +--- +type: ADR +id: "0147" +title: "Antigravity CLI agent adapter" +status: active +date: 2026-06-28 +supersedes: + - "0091" + - "0097" +--- + +## Context + +ADR-0091 and ADR-0097 made Gemini CLI the Google-backed CLI agent path for durable external MCP setup and app-managed AI panel sessions. Antigravity CLI is now the maintained successor path, with official one-shot execution through `agy -p ... --cwd`, dedicated Antigravity MCP configuration in `~/.gemini/config/mcp_config.json` or workspace `.agents/mcp_config.json`, and continued support for `GEMINI.md` / `AGENTS.md` workspace rules during Gemini CLI migration. + +Keeping `gemini` as Tolaria's product-facing local-agent id would leave users pointed at deprecated setup docs and the old `~/.gemini/settings.json` MCP shape. + +## Decision + +Tolaria replaces the app-managed Gemini CLI agent with an Antigravity CLI adapter. + +The product-facing local agent id is `antigravity`. Existing stored `gemini` default-agent values and legacy status payloads normalize to `antigravity` so older settings do not get dropped. + +The desktop backend: + +- discovers `agy` through PATH, login-shell lookup, and common local install locations, including `~/.local/bin/agy` +- runs `agy -p --cwd ` from the active vault +- writes transient workspace MCP config to `.agents/mcp_config.json` for app-managed sessions +- maps Safe mode to `--sandbox=true --toolPermission=proceed-in-sandbox` +- maps Power User mode to `--sandbox=false --toolPermission=always-proceed` +- avoids `--dangerously-skip-permissions` +- streams line-oriented stdout into Tolaria AI panel text events and reports setup/auth failures with Antigravity-specific recovery copy + +Durable external MCP setup now writes the standard Tolaria MCP entry to `~/.gemini/config/mcp_config.json` for Antigravity CLI instead of the legacy Gemini CLI `~/.gemini/settings.json` path. The optional vault-root `GEMINI.md` compatibility shim remains valid because Antigravity CLI continues to parse Gemini-compatible context files during migration. + +## Consequences + +- Users see Antigravity CLI in agent pickers, onboarding, command registry, and install links. +- Older `default_ai_agent: "gemini"` settings continue to select the Google-backed CLI path after migration. +- Existing standalone Gemini CLI global config is no longer mutated by Tolaria's durable MCP setup. +- Antigravity end-to-end native QA requires `agy` to be installed and authenticated; local tests use fake executables for deterministic command/config coverage. +- Future Antigravity CLI flag changes must update the adapter tests and this ADR rather than silently falling back to dangerous bypass flags. diff --git a/docs/adr/0148-cancellable-ai-agent-streams.md b/docs/adr/0148-cancellable-ai-agent-streams.md new file mode 100644 index 0000000..8010d52 --- /dev/null +++ b/docs/adr/0148-cancellable-ai-agent-streams.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0148" +title: "Cancellable AI agent streams" +status: active +date: 2026-06-28 +supersedes: +--- + +## Context + +ADR-0133 gave every AI stream a request-scoped event name so concurrent or rapidly reused renderer sessions do not share Tauri event channels. Users can still start long-running CLI-agent work that spawns local subprocesses, and closing or ignoring the frontend stream only suppresses stale renderer callbacks. Without a backend cancellation path, the selected CLI can keep running after the user has decided to stop the response. + +The stop control needs to work across the shared JSON-line runtime and line-oriented adapters such as Antigravity, Kiro, and Hermes without giving the renderer access to arbitrary process ids. + +## Decision + +Tolaria treats the request-scoped AI-agent event name as the cancellation handle for app-managed CLI-agent streams. + +The desktop backend wraps `stream_ai_agent` execution in an AI-agent stream scope. When an adapter spawns a child process, it registers that child under the current scoped stream id through a shared process registry. The renderer can then call `abort_ai_agent_stream(event_name)` with the same scoped id used for event delivery. The command validates the `ai-agent-stream-*` prefix and safe character set before killing the registered child, and returns `false` when no active child is present. + +Direct AI-model streams are not registered in this process registry because they do not spawn local CLI children. + +## Consequences + +- The stop button can abort an in-flight app-managed CLI process instead of only ignoring late stream events. +- New CLI-agent adapters that spawn subprocesses must register their child after taking stdout/stderr handles and wait through the registered wrapper. +- The renderer never receives or supplies OS process ids; cancellation remains scoped to validated AI-agent stream names. +- A stop can race with process startup or natural completion, so both frontend and backend stop paths are idempotent. diff --git a/docs/adr/0149-shared-app-config-policy-manifest.md b/docs/adr/0149-shared-app-config-policy-manifest.md new file mode 100644 index 0000000..b9ed178 --- /dev/null +++ b/docs/adr/0149-shared-app-config-policy-manifest.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0149" +title: "Shared app config policy manifest" +status: active +date: 2026-07-02 +--- + +## Context + +Tolaria resolves app-owned configuration such as settings, vault registries, window state, AI workspace sessions, and local AI provider secrets outside vault content. ADR 0145 established the XDG-backed app config location and a Rust helper for app-owned config paths, while the external Node MCP server also needs to resolve durable mounted workspace state from the same app config files when launched vault-neutrally. + +Keeping the namespace names, readable files, migration fallback order, and write target duplicated in Rust and Node makes the durable MCP registration path fragile. A new config file such as `settings.json` or `vaults.json` can silently diverge between the app and the external MCP server, especially across the current `com.tolaria.app` namespace, legacy `com.laputa.app` namespace, and previous platform config directory fallback. + +## Decision + +**Tolaria declares app config path policy once in `mcp-server/app-config-policy.json`, and both the Rust app helper and Node MCP server consume that manifest for namespace and file resolution.** + +The policy manifest names the current and legacy app namespaces, lists shared app config files, defines the read order across preferred and platform config roots, and keeps writes targeting the current Tolaria namespace in the preferred config root. The Rust helper still owns platform/XDG root discovery, but it reads the manifest for namespace order. The MCP server uses the same manifest for settings and vault registry lookup, so durable external agent registrations resolve mounted workspaces the same way the app resolves installation-local settings. + +## Alternatives considered + +- **Shared JSON policy manifest consumed by Rust and Node** (chosen): keeps the cross-runtime app config contract explicit and testable while preserving the existing XDG and legacy fallback behavior. +- **Keep separate Rust and Node constants**: avoids introducing a manifest, but invites drift between the app and MCP server whenever a namespace, file name, or fallback rule changes. +- **Move all MCP path resolution behind a Rust command**: centralizes logic in the app process, but external MCP clients must work when launched outside the running desktop app. +- **Expose only environment variables to external MCP servers**: preserves static launch configuration, but reintroduces stale vault/workspace state after mounted workspace changes. + +## Consequences + +The app and external MCP server now share one durable source for app config namespace and file naming policy. Mounted workspace resolution, settings resolution, and future app-owned config files are less likely to drift across runtimes. + +Changes to app config namespace or fallback order must update the manifest and keep both Rust and Node tests passing. The manifest remains a policy contract, not a replacement for platform root discovery: Rust still determines the preferred and platform config roots, and Node still resolves files from its runtime environment when launched externally. diff --git a/docs/adr/0150-github-copilot-cli-agent-adapter.md b/docs/adr/0150-github-copilot-cli-agent-adapter.md new file mode 100644 index 0000000..db2ad83 --- /dev/null +++ b/docs/adr/0150-github-copilot-cli-agent-adapter.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0150" +title: "GitHub Copilot CLI agent adapter" +status: active +date: 2026-07-02 +supersedes: +--- + +## Context + +Tolaria already exposes several app-managed local AI agent adapters through one normalized AI panel contract. GitHub Copilot CLI is a mainstream local agent with programmatic `copilot -p ...` execution, per-session tool permissions, current-directory path scoping, custom instructions support including `AGENTS.md`, and MCP server support. + +Users who prefer Copilot should not have to leave Tolaria or manually configure a generic/custom agent to get vault-aware assistance. + +## Decision + +Tolaria adds GitHub Copilot as a first-class local agent with product-facing id `copilot`. + +The desktop backend: + +- discovers `copilot` through PATH, login-shell lookup, and common local install locations including npm, pnpm, Bun, Mise, asdf, nvm-managed Node versions, Homebrew, and Windows npm/pnpm/Scoop shims +- runs `copilot -p -s --no-ask-user` from the active vault directory +- passes Tolaria MCP as a per-session `--additional-mcp-config` JSON payload instead of mutating `~/.copilot/mcp-config.json` +- uses Safe mode with `--available-tools=write,tolaria`, `--allow-tool=write,tolaria`, and `--deny-tool=shell` +- maps Power User mode to `--allow-all-tools` +- intentionally avoids `--allow-all`, `--yolo`, `--allow-all-paths`, and `--allow-all-urls` +- streams line-oriented stdout through the shared agent event contract and registers the child process under the request-scoped stream id for cancellation +- maps authentication, organization policy, subscription, and directory-trust stderr into Copilot-specific setup guidance + +The renderer adds Copilot to the shared AI-agent registry so the onboarding prompt, Settings target picker, command palette switch command, AI workspace target groups, and persisted `agent:copilot` target all use the same definition. + +## Consequences + +- Users can select GitHub Copilot anywhere Tolaria lists local/app-managed AI agents. +- App-managed Copilot sessions stay vault-scoped by current working directory and Copilot's default path verification unless the user chooses Tolaria Power User, which still does not bypass all paths or URLs. +- Tolaria does not persist Copilot credentials, model-provider secrets, or durable third-party MCP files. +- Native end-to-end QA requires a local Copilot CLI install and authentication; automated tests use fake executables for deterministic streaming and error coverage. +- Future Copilot CLI permission or MCP flag changes must update this adapter and ADR rather than falling back to broad `--allow-all` / `--yolo` behavior. diff --git a/docs/adr/0151-antigravity-add-dir-workspace-flag.md b/docs/adr/0151-antigravity-add-dir-workspace-flag.md new file mode 100644 index 0000000..3f2f2f9 --- /dev/null +++ b/docs/adr/0151-antigravity-add-dir-workspace-flag.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0151" +title: "Antigravity add-dir workspace flag" +status: active +date: 2026-07-02 +supersedes: "0147" +--- + +## Context + +ADR-0147 introduced the Antigravity CLI adapter with `agy -p --cwd `. +Newer Antigravity CLI builds reject that workspace flag with `flags provided but not defined: -cwd` and list `--add-dir` as the supported workspace-directory argument. + +Tolaria still needs app-managed Antigravity runs to start in the active vault, expose that vault as the only intended workspace, and preserve the Safe / Power User permission mapping from ADR-0103. + +## Decision + +Tolaria launches app-managed Antigravity sessions with: + +```text +agy -p --add-dir +``` + +The subprocess `current_dir` remains the active vault path, and Tolaria still writes the transient MCP config to `/.agents/mcp_config.json` before launch. + +Safe mode continues to pass `--sandbox=true --toolPermission=proceed-in-sandbox`. +Power User continues to pass `--sandbox=false --toolPermission=always-proceed`. +Tolaria still avoids `--dangerously-skip-permissions`. + +## Consequences + +- Antigravity CLI versions that reject `--cwd` can start from the Tolaria AI panel. +- The active vault remains both the process working directory and the explicit Antigravity workspace directory. +- Adapter tests must reject regressions that reintroduce `--cwd` or drop `--add-dir`. +- Future Antigravity CLI workspace flag changes should supersede this ADR with a new adapter decision and test update. diff --git a/docs/adr/0152-wsl2-git-provider.md b/docs/adr/0152-wsl2-git-provider.md new file mode 100644 index 0000000..69d14f4 --- /dev/null +++ b/docs/adr/0152-wsl2-git-provider.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0152" +title: "WSL2 Git provider" +status: active +date: 2026-07-03 +supersedes: +--- + +## Context + +Windows users may keep their vaults and Git credentials inside WSL2 even when running Tolaria as a native desktop app. Requiring native Windows Git blocks that setup, but silently switching to WSL based on detection would make Git operations difficult to reason about and could send the wrong filesystem paths to the selected executable. + +Tolaria already treats Git as a per-vault capability and app settings as installation-local. The executable provider is also machine-specific: one Windows installation may use native Git while another uses Git from an Ubuntu WSL distribution for the same vault content. + +## Decision + +Tolaria adds an explicit Git provider setting with native Git as the default and WSL2 Git as an opt-in provider on Windows. + +The Rust Git module resolves a `GitLaunchConfig` for each Git command from app settings. Native launches keep the existing `git`/configured path behavior. WSL launches use `wsl.exe`, optional `--distribution `, and `--exec git`, while repository and clone destination paths are translated to Linux-style paths before they cross into WSL. Provider probes and tests run on blocking worker threads with timeouts so unavailable WSL distributions cannot freeze the renderer. + +The Settings Git section shows the provider, WSL distribution, and a test action before save. Detection populates available WSL distributions, but selection remains explicit; Tolaria does not automatically switch from native Git to WSL2 Git. + +## Consequences + +- Windows users can run Tolaria with Git from WSL2 without installing or configuring native Windows Git. +- Native Git remains supported and remains the default provider. +- Git command paths must go through the Git provider abstraction rather than passing Windows repository paths directly to subprocess arguments. +- WSL-specific QA requires a Windows host with WSL2 and at least one distribution containing Git; non-Windows automated coverage verifies path translation, distro parsing, settings persistence, and renderer controls. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..b343ccf --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,211 @@ +# Architecture Decision Records + +This folder contains Architecture Decision Records (ADRs) for the Laputa app. + +## Format + +Each ADR is a markdown note with YAML frontmatter. Template: + +```markdown +--- +type: ADR +id: "0001" +title: "Short decision title" +status: proposed # proposed | active | superseded | retired +date: YYYY-MM-DD +superseded_by: "0007" # only if status: superseded +--- + +## Context +What situation led to this decision? What forces and constraints are at play? + +## Decision +**What was decided.** State it clearly in one or two sentences — bold so it stands out. + +## Options considered +- **Option A** (chosen): brief description — pros / cons +- **Option B**: brief description — pros / cons +- **Option C**: brief description — pros / cons + +## Consequences +What becomes easier or harder as a result? +What are the positive and negative ramifications? +What would trigger re-evaluation of this decision? + +## Advice +*(optional)* Input received before making this decision — who was consulted, what they said, when. +Omit if the decision was made unilaterally with no external input. +``` + +### Status lifecycle + +``` +proposed → active → superseded + ↘ retired (decision no longer relevant, not replaced) +``` + +## Rules + +- One decision per file +- Files named `NNNN-short-title.md` (monotonic numbering) +- Once `active`, never edit — supersede instead +- When superseded: update `status: superseded` and add `superseded_by: "NNNN"` +- ARCHITECTURE.md reflects the current state (active decisions only) + +## Index + +| ID | Title | Status | +|----|-------|--------| +| [0001](0001-tauri-react-stack.md) | Tauri v2 + React as application stack | active | +| [0002](0002-filesystem-source-of-truth.md) | Filesystem as the single source of truth | active | +| [0003](0003-single-note-model.md) | Single note open at a time (no tabs) | active | +| [0004](0004-vault-vs-app-settings-storage.md) | Vault vs app settings for state storage | active | +| [0005](0005-tauri-ios-for-ipad.md) | Tauri v2 iOS for iPad support (vs SwiftUI rewrite) | active | +| [0006](0006-flat-vault-structure.md) | Flat vault structure (no type-based folders) | active | +| [0007](0007-title-filename-sync.md) | Title equals filename (slug sync) | active | +| [0008](0008-underscore-system-properties.md) | Underscore convention for system properties | active | +| [0009](0009-keyword-only-search.md) | Keyword-only search (remove semantic indexing) | active | +| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active | +| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | +| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active | +| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) | +| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active | +| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | superseded → [0102](0102-low-end-safe-autosave-idle-window.md) | +| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active | +| [0017](canary-release-channel-and-local-feature-flags.md) | Canary release channel and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | +| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | superseded → [0064](0064-ratcheted-codescene-thresholds.md) | +| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) | +| [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active | +| [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active | +| [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active | +| [0023](0023-repair-vault-auto-bootstrap.md) | Repair Vault auto-bootstrap pattern | active | +| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active | +| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active | +| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | superseded → [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | +| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded | +| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active | +| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active | +| [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active | +| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active | +| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active | +| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active | +| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) | +| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active | +| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active | +| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active | +| [0038](0038-frontmatter-backed-favorites.md) | Frontmatter-backed favorites (_favorite, _favorite_index) | active | +| [0039](0039-git-history-for-note-dates.md) | Git history as source of truth for note creation/modification dates | active | +| [0040](0040-custom-views-yml-filter-engine.md) | Custom Views — .laputa/views/*.yml with YAML filter engine | active | +| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active | +| [0042](0042-posthog-release-channels-feature-flags.md) | PostHog-based release channels and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | +| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) | +| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active | +| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) | +| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active | +| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active | +| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active | +| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active | +| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active | +| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | +| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | +| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active | +| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active | +| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active | +| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | superseded → [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | +| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active | +| [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | superseded → [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) | +| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | superseded → [0062](0062-selectable-cli-ai-agents.md) | +| [0059](0059-local-only-git-commits-without-remote.md) | Local-only git commits for vaults without a remote | active | +| [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active | +| [0061](0061-ai-prompt-bridge-event-bus.md) | AI prompt bridge — module-level event bus for cross-component prompt routing | active | +| [0062](0062-selectable-cli-ai-agents.md) | Selectable CLI AI agents with a shared panel architecture | active | +| [0063](0063-blocknote-code-block-package-for-editor-highlighting.md) | BlockNote code-block package for editor syntax highlighting | active | +| [0064](0064-ratcheted-codescene-thresholds.md) | Ratcheted CodeScene thresholds as the quality gate baseline | active | +| [0065](0065-root-managed-ai-guidance-files.md) | Root-managed AI guidance files with Claude shim | active | +| [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) | Calendar-semver versioning for alpha and stable releases | active | +| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active | +| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active | +| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active | +| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active | +| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | superseded → [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | +| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active | +| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active | +| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active | +| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active | +| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active | +| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active | +| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active | +| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active | +| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | superseded → [0083](0083-dual-architecture-macos-release-artifacts.md) | +| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active | +| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active | +| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active | +| [0084](0084-app-localization-foundation.md) | App-owned localization foundation | superseded → [0087](0087-json-catalogs-and-lara-cli-localization.md) | +| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active | +| [0086](0086-in-app-image-file-preview.md) | In-app image previews for binary vault files | superseded → [0098](0098-in-app-image-and-pdf-file-previews.md) | +| [0087](0087-json-catalogs-and-lara-cli-localization.md) | JSON locale catalogs with Lara CLI synchronization | active | +| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active | +| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active | +| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active | +| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | superseded -> [0147](0147-antigravity-cli-agent-adapter.md) | +| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | superseded -> [0103](0103-adapter-specific-ai-permission-semantics.md) | +| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active | +| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active | +| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active | +| [0096](0096-root-created-type-documents.md) | Root-created type documents | active | +| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | superseded -> [0147](0147-antigravity-cli-agent-adapter.md) | +| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | superseded → [0110](0110-in-app-media-and-pdf-file-previews.md) | +| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active | +| [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active | +| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active | +| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active | +| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active | +| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active | +| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active | +| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active | +| [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active | +| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active | +| [0108](0108-direct-model-ai-targets.md) | Direct model AI targets alongside coding agents | active | +| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active | +| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active | +| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | +| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | superseded → [0135](0135-clean-active-note-refresh-after-external-edit.md) | +| [0112](0112-system-theme-mode.md) | System theme mode | active | +| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active | +| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active | +| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active | +| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active | +| [0117](0117-appimage-fcitx-gtk3-frontend-bundle.md) | Bundle the fcitx GTK3 frontend in Linux AppImages | active | +| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) | +| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active | +| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active | +| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active | +| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active | +| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | superseded -> [0124](0124-cached-secondary-note-window-startup.md) | +| [0124](0124-cached-secondary-note-window-startup.md) | Cached secondary note window startup | superseded -> [0146](0146-cached-main-window-startup-with-empty-reload-recovery.md) | +| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active | +| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) | +| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active | +| [0129](0129-tolaria-vault-item-deep-links.md) | Tolaria vault item deep links | active | +| [0130](0130-windows-authenticode-release-signing.md) | Windows Authenticode signing for release installers | amended -> [0139](0139-temporary-windows-authenticode-soft-gate.md) | +| [0131](0131-reusable-release-artifact-build-workflow.md) | Reusable release artifact build workflow | active | +| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | superseded -> [0139](0139-temporary-windows-authenticode-soft-gate.md) | +| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active | +| [0134](0134-sheet-nodes-with-plain-text-workbook-storage.md) | Sheet nodes with plain-text workbook storage | experimental | +| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active | +| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active | +| [0136](0136-macos-webview-pdf-export.md) | macOS Webview PDF export | active | +| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active | +| [0138](0138-authenticode-required-for-all-release-channels.md) | Require Authenticode signing for all Windows release channels | superseded -> [0139](0139-temporary-windows-authenticode-soft-gate.md) | +| [0139](0139-temporary-windows-authenticode-soft-gate.md) | Temporary Windows Authenticode soft gate | active | +| [0140](0140-extension-based-raw-text-syntax-highlighting.md) | Extension-based raw text syntax highlighting | active | +| [0141](0141-scoped-linux-webkit-rendering-safeguards.md) | Scoped Linux WebKit rendering safeguards | active | +| [0142](0142-rich-editor-prosemirror-decoration-dependency.md) | Rich editor ProseMirror decoration dependency | active | +| [0143](0143-shared-focus-ownership-guard.md) | Shared focus ownership guard | active | +| [0144](0144-collections-and-presentations.md) | Collections carry presentation configuration | active | +| [0145](0145-xdg-backed-app-config-path.md) | XDG-backed app config path | active | +| [0146](0146-cached-main-window-startup-with-empty-reload-recovery.md) | Cached main-window startup with empty reload recovery | active | +| [0147](0147-antigravity-cli-agent-adapter.md) | Antigravity CLI agent adapter | superseded -> [0151](0151-antigravity-add-dir-workspace-flag.md) | +| [0148](0148-cancellable-ai-agent-streams.md) | Cancellable AI agent streams | active | +| [0149](0149-shared-app-config-policy-manifest.md) | Shared app config policy manifest | active | +| [0151](0151-antigravity-add-dir-workspace-flag.md) | Antigravity add-dir workspace flag | active | diff --git a/docs/adr/canary-release-channel-and-local-feature-flags.md b/docs/adr/canary-release-channel-and-local-feature-flags.md new file mode 100644 index 0000000..a69c067 --- /dev/null +++ b/docs/adr/canary-release-channel-and-local-feature-flags.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0017" +title: "Canary release channel and local feature flags" +status: active +date: 2026-03-25 +--- +## Context + +Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release. + +[[Keyboard-first design principle]] + +## Decision + +**Add a canary release channel alongside stable, with builds from the **`canary`** branch. Feature flags are localStorage-based (**`ff_`**) with compile-time defaults, checked via **`useFeatureFlag(flag)`** hook. The update channel is configurable in Settings.** + +## Options considered + +* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages. +* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency. +* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds. + +## Consequences + +* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch. +* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease. +* `useUpdater(channel)` checks the appropriate update manifest. +* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union. +* `update_channel` stored in Settings as `"stable"` or `"canary"`. +* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts. diff --git a/e2e/ai-chat-screenshot.spec.ts b/e2e/ai-chat-screenshot.spec.ts new file mode 100644 index 0000000..e4f8a27 --- /dev/null +++ b/e2e/ai-chat-screenshot.spec.ts @@ -0,0 +1,57 @@ +import { type Page, test } from '@playwright/test' + +const SCREENSHOT_PATH = '/Users/luca/OpenClaw/ai-chat-final.jpg' + +async function clickNoteListItem(page: Page): Promise { + return page.evaluate(() => { + const items = document.querySelectorAll('[class*="cursor-pointer"]') + const noteListItem = Array.from(items).find((el) => { + const rect = el.getBoundingClientRect() + return rect.x > 249 && rect.x < 700 && rect.height > 40 && rect.width > 200 + }) + + if (!noteListItem) { + return 'Nothing found' + } + + const noteListElement = noteListItem as HTMLElement + noteListElement.click() + const rect = noteListItem.getBoundingClientRect() + return `Clicked: ${noteListItem.textContent?.trim().slice(0, 50)} at x=${rect.x}` + }) +} + +async function clickAiToolbarButton(page: Page): Promise { + return page.evaluate(() => { + const aiButton = Array.from(document.querySelectorAll('button')).find((btn) => + btn.title?.includes('AI'), + ) + + if (!aiButton) { + return 'AI button not found' + } + + aiButton.click() + return `Clicked: ${aiButton.title}` + }) +} + +test('screenshot AI chat panel', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/', { waitUntil: 'networkidle' }) + await page.waitForTimeout(3000) + + // Click a note item in the NoteList (x > 243, past the sidebar) + // Note items have: cursor-pointer border-b border-[var(--border)] + const clicked = await clickNoteListItem(page) + console.log('Note click result:', clicked) + await page.waitForTimeout(1200) + + // Now find and click the AI button in the editor toolbar + const aiBtn = await clickAiToolbarButton(page) + console.log('AI btn result:', aiBtn) + await page.waitForTimeout(800) + + await page.screenshot({ path: SCREENSHOT_PATH, type: 'jpeg', quality: 90 }) + console.log('Done') +}) diff --git a/e2e/ai-chat.spec.ts b/e2e/ai-chat.spec.ts new file mode 100644 index 0000000..76b3967 --- /dev/null +++ b/e2e/ai-chat.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test' + +test('AI chat panel: open, send message, close', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(2000) + + // Click the first note in the list — note items are cursor-pointer divs inside .app__note-list + const noteItem = page.locator('.app__note-list .cursor-pointer').first() + await noteItem.click() + await page.waitForTimeout(800) + + // Screenshot before opening AI chat + await page.screenshot({ path: 'test-results/ai-chat-before.png', fullPage: true }) + + // Find the Sparkle button in the breadcrumb/info bar and click it + const sparkleButton = page.locator('button[title="Open AI Chat"]') + await expect(sparkleButton).toBeVisible({ timeout: 5000 }) + await sparkleButton.click() + await page.waitForTimeout(300) + + // AI Chat panel should be visible + const aiChatHeader = page.locator('text=AI Chat') + await expect(aiChatHeader).toBeVisible() + + // Screenshot with AI chat panel open + await page.screenshot({ path: 'test-results/ai-chat-open.png', fullPage: true }) + + // Context pills should be visible + await expect(page.locator('text=Frontmatter')).toBeVisible() + await expect(page.locator('text=Links')).toBeVisible() + + // Type a message and send + const textarea = page.locator('textarea[placeholder="Ask about this document..."]') + await textarea.fill('Summarize this note') + await page.locator('button[title="Send message"]').click() + + // Should see user message + await expect(page.locator('text=Summarize this note')).toBeVisible() + + // Wait for typing indicator to appear + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/ai-chat-typing.png', fullPage: true }) + + // Wait for mock response + await page.waitForTimeout(1200) + await expect(page.locator('text=words and links to')).toBeVisible({ timeout: 5000 }) + + // Screenshot with response + await page.screenshot({ path: 'test-results/ai-chat-response.png', fullPage: true }) + + // Test quick action pill + const expandButton = page.locator('button', { hasText: 'Expand' }) + await expandButton.click() + await page.waitForTimeout(1800) + await expect(page.locator('text=Add more detail to the introduction')).toBeVisible({ timeout: 5000 }) + + // Screenshot with quick action response + await page.screenshot({ path: 'test-results/ai-chat-quick-action.png', fullPage: true }) + + // Close the panel using the X button in the AI Chat header + await page.locator('button[title="Close AI Chat"]').last().click() + await page.waitForTimeout(300) + + // Inspector should be back + await expect(page.locator('text=Properties')).toBeVisible() + + // Screenshot after closing + await page.screenshot({ path: 'test-results/ai-chat-closed.png', fullPage: true }) +}) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..f07efa2 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test' + +test('app loads with four-panel layout', async ({ page }) => { + await page.goto('/') + + // Verify the four panels are present + await expect(page.locator('.sidebar')).toBeVisible() + await expect(page.locator('.note-list')).toBeVisible() + await expect(page.locator('.editor')).toBeVisible() + await expect(page.locator('.inspector')).toBeVisible() +}) + +test('sidebar shows filters and section groups', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) // Wait for mock data + + await expect(page.getByRole('heading', { name: 'Laputa' })).toBeVisible() + // Filters + await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'All Notes' })).toBeVisible() + await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'People' })).toBeVisible() + await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'Events' })).toBeVisible() + // Section groups (use exact match to avoid collision with note list pills) + await expect(page.locator('.sidebar__section-label', { hasText: 'PROJECTS' })).toBeVisible() + await expect(page.locator('.sidebar__section-label', { hasText: 'EXPERIMENTS' })).toBeVisible() + await expect(page.locator('.sidebar__section-label', { hasText: 'RESPONSIBILITIES' })).toBeVisible() + await expect(page.locator('.sidebar__section-label', { hasText: 'PROCEDURES' })).toBeVisible() +}) diff --git a/e2e/auto-save.spec.ts b/e2e/auto-save.spec.ts new file mode 100644 index 0000000..9c4bb9e --- /dev/null +++ b/e2e/auto-save.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test' + +test.use({ baseURL: 'http://localhost:5239' }) + +test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) // Wait for vault data to load +}) + +test('editor loads and renders note content for editing', async ({ page }) => { + await page.screenshot({ path: 'test-results/save-01-initial.png', fullPage: true }) + + // 1. Click a note in the note list panel + const noteList = page.locator('.app__note-list') + await expect(noteList).toBeVisible({ timeout: 5000 }) + const firstNote = noteList.locator('div.cursor-pointer').first() + await expect(firstNote).toBeVisible({ timeout: 5000 }) + await firstNote.click() + await page.waitForTimeout(1000) + + // 2. Verify the BlockNote editor is visible with content + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 5000 }) + + // Verify the editor is contenteditable (ready for editing) + const isEditable = await editor.getAttribute('contenteditable') + expect(isEditable).toBe('true') + + await page.screenshot({ path: 'test-results/save-02-note-open.png', fullPage: true }) + + // 3. Verify the editor has content (not empty) + const editorText = await page.evaluate(() => { + const el = document.querySelector('.bn-editor') + return el?.textContent ?? '' + }) + expect(editorText.length).toBeGreaterThan(10) + + // 4. Verify tab bar shows the active note + const tabBar = page.locator('.editor') + await expect(tabBar).toBeVisible() + + await page.screenshot({ path: 'test-results/save-03-editor-ready.png', fullPage: true }) +}) + +test('Cmd+S keyboard shortcut triggers save toast', async ({ page }) => { + // Open a note + const noteList = page.locator('.app__note-list') + await expect(noteList).toBeVisible({ timeout: 5000 }) + const firstNote = noteList.locator('div.cursor-pointer').first() + await firstNote.click() + await page.waitForTimeout(1000) + + // Press Cmd+S — shows either "Saved" or "Nothing to save" depending on + // whether BlockNote's onChange fired from prior interactions + await page.keyboard.press('Meta+s') + await page.waitForTimeout(500) + + // Verify a save-related toast appears (the shortcut was handled) + const toast = page.locator('text=/Saved|Nothing to save/') + await expect(toast).toBeVisible({ timeout: 3000 }) + + await page.screenshot({ path: 'test-results/save-04-cmd-s.png', fullPage: true }) +}) diff --git a/e2e/core-flows.spec.ts b/e2e/core-flows.spec.ts new file mode 100644 index 0000000..51ba787 --- /dev/null +++ b/e2e/core-flows.spec.ts @@ -0,0 +1,260 @@ +import { test, expect } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) // Wait for mock data +}) + +// --- Flow 1: Open app, click note, verify editor shows content --- + +test('clicking a note opens it in the editor with content', async ({ page }) => { + // Click "Build Laputa App" in the note list + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Tab should appear and be active + await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/) + + // Editor should show note content (heading in live preview) + await expect(page.locator('.cm-editor')).toBeVisible() + + // Inspector should show properties + await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible() + + await page.screenshot({ path: 'test-results/core-open-note.png', fullPage: true }) +}) + +test('editor shows markdown content with live preview', async ({ page }) => { + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // CodeMirror should render the content + const editor = page.locator('.cm-editor') + await expect(editor).toBeVisible() + + // Should contain the heading text (rendered by live preview) + await expect(page.locator('.cm-content')).toContainText('Build Laputa App') +}) + +// --- Flow 2: Sidebar filter changes note list --- + +test('sidebar filter: People shows only Person entries', async ({ page }) => { + await page.locator('.sidebar__filter-item', { hasText: 'People' }).click() + await page.waitForTimeout(200) + + await expect(page.locator('.note-list__count')).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible() +}) + +test('sidebar filter: Events shows only Event entries', async ({ page }) => { + await page.locator('.sidebar__filter-item', { hasText: 'Events' }).click() + await page.waitForTimeout(200) + + await expect(page.locator('.note-list__count')).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible() +}) + +test('sidebar filter: clicking back to All Notes restores full list', async ({ page }) => { + await page.locator('.sidebar__filter-item', { hasText: 'People' }).click() + await page.waitForTimeout(200) + await expect(page.locator('.note-list__count')).toHaveText('1') + + await page.locator('.sidebar__filter-item', { hasText: 'All Notes' }).click() + await page.waitForTimeout(200) + await expect(page.locator('.note-list__count')).toHaveText('12') +}) + +// --- Flow 3: Search for a note --- + +test('search filters notes by title', async ({ page }) => { + await page.fill('.note-list__search-input', 'stock') + await page.waitForTimeout(200) + + await expect(page.locator('.note-list__count')).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Stock Screener' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible() +}) + +test('clearing search restores all results', async ({ page }) => { + await page.fill('.note-list__search-input', 'stock') + await page.waitForTimeout(200) + await expect(page.locator('.note-list__count')).toHaveText('1') + + await page.fill('.note-list__search-input', '') + await page.waitForTimeout(200) + await expect(page.locator('.note-list__count')).toHaveText('12') +}) + +// --- Flow 4: Open multiple tabs and switch between them --- + +test('opening multiple notes creates multiple tabs', async ({ page }) => { + // Open first note + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Open second note + await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click() + await page.waitForTimeout(300) + + // Both tabs should exist + const tabs = page.locator('.editor__tab') + await expect(tabs).toHaveCount(2) + + // Second tab should be active + await expect(page.locator('.editor__tab--active')).toHaveText(/Grow Newsletter/) + + await page.screenshot({ path: 'test-results/core-multi-tabs.png', fullPage: true }) +}) + +test('clicking a tab switches to it', async ({ page }) => { + // Open two notes + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click() + await page.waitForTimeout(300) + + // Switch back to first tab + await page.locator('.editor__tab', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(200) + + // First tab should now be active + await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/) + + // Editor should show first note's content + await expect(page.locator('.cm-content')).toContainText('Build Laputa App') +}) + +test('closing a tab removes it and switches to adjacent', async ({ page }) => { + // Open two notes + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click() + await page.waitForTimeout(300) + + // Close active tab (Grow Newsletter) + await page.locator('.editor__tab--active .editor__tab-close').click() + await page.waitForTimeout(200) + + // Should have 1 tab left + await expect(page.locator('.editor__tab')).toHaveCount(1) + await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/) +}) + +// --- Flow 5: Inspector shows correct properties --- + +test('inspector shows properties for selected note', async ({ page }) => { + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Type + await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible() + // Status + await expect(page.locator('.inspector__status-pill', { hasText: 'Active' })).toBeVisible() + // Owner + await expect(page.locator('.inspector__prop-value', { hasText: 'Luca Rossi' })).toBeVisible() + + await page.screenshot({ path: 'test-results/core-inspector.png', fullPage: true }) +}) + +test('inspector shows relationships', async ({ page }) => { + // Open a note with relationships + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Should show "Related to" relationships + await expect(page.locator('.inspector__section', { hasText: 'Relationships' })).toBeVisible() +}) + +test('inspector shows backlinks', async ({ page }) => { + // Open a note that has backlinks (Build Laputa App is referenced by Facebook Ads and Budget Allocation) + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Backlinks section should show + const backlinksSection = page.locator('.inspector__section', { hasText: 'Backlinks' }) + await expect(backlinksSection).toBeVisible() +}) + +test('inspector shows git history', async ({ page }) => { + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + + // Should show commits + await expect(page.locator('.inspector__commit-hash').first()).toBeVisible() + await expect(page.locator('.inspector__commit-msg').first()).toContainText('26q1-laputa-app') +}) + +test('inspector updates when switching tabs', async ({ page }) => { + // Open first note + await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(300) + await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible() + + // Open second note with different type — use title locator for precision + await page.locator('.note-list__item').filter({ has: page.locator('.note-list__title', { hasText: 'Matteo Cellini' }) }).click() + await page.waitForTimeout(300) + await expect(page.locator('.inspector__prop-value', { hasText: 'Person' })).toBeVisible() +}) + +// --- Flow 6: Note list preview snippets --- + +test('note list items show preview snippets', async ({ page }) => { + // Check that snippets are visible + const snippet = page.locator('.note-list__snippet').first() + await expect(snippet).toBeVisible() + // Snippet should have some text content + const text = await snippet.textContent() + expect(text!.length).toBeGreaterThan(10) +}) + +// --- Flow 7: Create note and verify it appears --- + +test('full create note flow', async ({ page }) => { + // Count before + const countBefore = await page.locator('.note-list__count').textContent() + + // Create new note + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + await page.fill('.create-dialog__input', 'E2E Test Note') + await page.click('.create-dialog__type-btn:text("Experiment")') + await page.click('.create-dialog__btn--create') + await page.waitForTimeout(500) + + // Count should increase + const countAfter = await page.locator('.note-list__count').textContent() + expect(parseInt(countAfter!, 10)).toBe(parseInt(countBefore!, 10) + 1) + + // Note should be opened in editor + await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/) + + await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true }) +}) + +// --- Flow 8: Wiki-link navigation --- + +test('clicking a wikilink opens the target note in a new tab', async ({ page }) => { + // Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink + await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click() + await page.waitForTimeout(300) + + // Verify we opened the right note + await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/) + + // Click the wikilink — use mouse.click to fire real mousedown + const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' }) + await expect(wikilink).toBeVisible() + const box = await wikilink.boundingBox() + expect(box).not.toBeNull() + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2) + await page.waitForTimeout(300) + + // New tab should open with the target note active + await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/) + + // Editor should show the target note's content + await expect(page.locator('.cm-content')).toContainText('Matteo Cellini') + + await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true }) +}) diff --git a/e2e/create-note.spec.ts b/e2e/create-note.spec.ts new file mode 100644 index 0000000..7d6aa61 --- /dev/null +++ b/e2e/create-note.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test' + +test('clicking + button opens create note dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click the + button + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + + // Dialog should be visible + await expect(page.locator('.create-dialog')).toBeVisible() + await expect(page.locator('.create-dialog__title')).toHaveText('Create New Note') + + await page.screenshot({ path: 'test-results/create-dialog.png', fullPage: true }) +}) + +test('create a new note via dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Open dialog + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + + // Type a title + await page.fill('.create-dialog__input', 'My Test Note') + + // Select "Project" type + await page.click('.create-dialog__type-btn:text("Project")') + + // Click Create + await page.click('.create-dialog__btn--create') + await page.waitForTimeout(300) + + // Dialog should close + await expect(page.locator('.create-dialog')).not.toBeVisible() + + // New note should appear in the list and be opened in editor + await expect(page.locator('.note-list__item:has-text("My Test Note")').first()).toBeVisible() + await expect(page.locator('.editor__tab--active:has-text("My Test Note")')).toBeVisible() + + await page.screenshot({ path: 'test-results/create-note-result.png', fullPage: true }) +}) + +test('escape closes create dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + await expect(page.locator('.create-dialog')).toBeVisible() + + await page.keyboard.press('Escape') + await page.waitForTimeout(100) + await expect(page.locator('.create-dialog')).not.toBeVisible() +}) diff --git a/e2e/create-type.spec.ts b/e2e/create-type.spec.ts new file mode 100644 index 0000000..3b62e52 --- /dev/null +++ b/e2e/create-type.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test' + +test.describe('Create New Type Feature', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:5203') + await page.waitForSelector('text=All Notes') + }) + + test('clicking + on Types section opens Create Type dialog', async ({ page }) => { + // Hover over the Types section to reveal the + button + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + + // Click the + button next to Types + const createTypeBtn = page.locator('[title="New Type"]') + await createTypeBtn.click() + + // Dialog should open with correct title and elements + await expect(page.locator('text=Create New Type')).toBeVisible() + await expect(page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]')).toBeVisible() + await expect(page.locator('text=Creates a type document')).toBeVisible() + + await page.screenshot({ path: 'test-results/create-type-dialog.png', fullPage: true }) + }) + + test('Create button is disabled when name is empty', async ({ page }) => { + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + await page.locator('[title="New Type"]').click() + + const createBtn = page.locator('button:has-text("Create")') + await expect(createBtn).toBeDisabled() + }) + + test('can create a new type and it appears in sidebar', async ({ page }) => { + // Open Create Type dialog + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + await page.locator('[title="New Type"]').click() + + // Type a name and submit + await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout') + await page.locator('button:has-text("Create")').click() + + // Dialog should close + await expect(page.locator('text=Create New Type')).not.toBeVisible() + + // New type should appear as a sidebar section (pluralized) + await expect(page.locator('text=Workouts')).toBeVisible({ timeout: 3000 }) + + // The type document should open in the editor + await expect(page.locator('text=Workout').first()).toBeVisible() + + await page.screenshot({ path: 'test-results/after-create-type.png', fullPage: true }) + }) + + test('newly created type appears in Create Note dialog type selector', async ({ page }) => { + // First, create a custom type + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + await page.locator('[title="New Type"]').click() + await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout') + await page.locator('button:has-text("Create")').click() + + // Now open Create Note dialog + await page.keyboard.press('Meta+n') + await page.waitForSelector('text=Create New Note') + + // Built-in types should be visible + await expect(page.locator('button:has-text("Note")')).toBeVisible() + await expect(page.locator('button:has-text("Project")')).toBeVisible() + + // Our custom type should also be visible + await expect(page.locator('button:has-text("Workout")')).toBeVisible() + + await page.screenshot({ path: 'test-results/create-note-with-custom-type.png', fullPage: true }) + }) + + test('can create an instance of a custom type', async ({ page }) => { + // First, create a custom type + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + await page.locator('[title="New Type"]').click() + await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout') + await page.locator('button:has-text("Create")').click() + await expect(page.locator('text=Workouts')).toBeVisible({ timeout: 3000 }) + + // Hover over the new Workouts section and click + + const workoutsSection = page.locator('text=Workouts').first() + await workoutsSection.hover() + await page.locator('[title="New Workout"]').click() + + // Create Note dialog should open + await expect(page.locator('text=Create New Note')).toBeVisible() + + // Type a title and create + await page.locator('input[placeholder="Enter note title..."]').fill('Morning Run') + await page.locator('button:has-text("Create")').last().click() + + // The note should open in editor + await expect(page.locator('text=Morning Run').first()).toBeVisible({ timeout: 3000 }) + + await page.screenshot({ path: 'test-results/custom-type-instance.png', fullPage: true }) + }) + + test('Cancel closes the dialog without creating', async ({ page }) => { + const typesSection = page.locator('text=Types').first() + await typesSection.hover() + await page.locator('[title="New Type"]').click() + + await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('ShouldNotExist') + await page.locator('button:has-text("Cancel")').click() + + // Dialog should close + await expect(page.locator('text=Create New Type')).not.toBeVisible() + + // Type should NOT appear in sidebar + await expect(page.locator('text=ShouldNotExists')).not.toBeVisible() + }) +}) diff --git a/e2e/debug-content-bug.spec.ts b/e2e/debug-content-bug.spec.ts new file mode 100644 index 0000000..0fa4649 --- /dev/null +++ b/e2e/debug-content-bug.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test' + +test.use({ baseURL: 'http://localhost:5201' }) + +/** + * Regression test: editor content must appear after clicking a note. + * + * Root cause: BlockNote's replaceBlocks/insertBlocks internally calls flushSync, + * which fails silently when invoked from inside React's useEffect lifecycle. + * Fix: defer the content swap via queueMicrotask. + */ +test('editor content appears on first note click', async ({ page }) => { + const errors: string[] = [] + page.on('console', msg => { + if (msg.type() === 'error') errors.push(msg.text()) + }) + + await page.goto('/') + // Wait for note list to load + await page.waitForSelector('.app__note-list .cursor-pointer', { timeout: 15000 }) + await page.waitForTimeout(200) + + // Click the first note in the note list + const noteList = page.locator('.app__note-list') + const firstNote = noteList.locator('.cursor-pointer').first() + await firstNote.click() + + // Wait for ProseMirror editor to have content + const pm = page.locator('.ProseMirror') + await expect(async () => { + const text = await pm.textContent() + expect(text?.trim().length, 'Editor content should not be empty').toBeGreaterThan(5) + }).toPass({ timeout: 5000 }) + + // Verify no flushSync errors appeared + const flushSyncErrors = errors.filter(e => e.includes('flushSync')) + expect(flushSyncErrors, 'No flushSync-inside-lifecycle errors should occur').toHaveLength(0) +}) diff --git a/e2e/editor-min-width.spec.ts b/e2e/editor-min-width.spec.ts new file mode 100644 index 0000000..51b1365 --- /dev/null +++ b/e2e/editor-min-width.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test' + +test('editor panel has proper width regardless of title length', async ({ page }) => { + await page.goto('http://localhost:5204') + await page.waitForTimeout(1000) + + // Click on a note with a short title from the note list + const noteItems = page.locator('.note-list__item') + const count = await noteItems.count() + + if (count > 0) { + // Click the first note + await noteItems.first().click() + await page.waitForTimeout(500) + + // Measure editor container width + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + + // The editor should have a reasonable width (at least 300px) + expect(box).toBeTruthy() + expect(box!.width).toBeGreaterThan(300) + + // Screenshot for visual verification + await page.screenshot({ path: 'test-results/editor-min-width.png', fullPage: true }) + + // Also check that .bn-container fills the editor width + const bnContainer = page.locator('.editor__blocknote-container .bn-container') + const bnBox = await bnContainer.boundingBox() + expect(bnBox).toBeTruthy() + expect(bnBox!.width).toBeGreaterThan(300) + } +}) diff --git a/e2e/filtering.spec.ts b/e2e/filtering.spec.ts new file mode 100644 index 0000000..d2f76d7 --- /dev/null +++ b/e2e/filtering.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) // Wait for mock data +}) + +test('All Notes shows all entries', async ({ page }) => { + const count = page.locator('.note-list__count') + await expect(count).toHaveText('12') +}) + +test('clicking People filter shows only people', async ({ page }) => { + await page.click('text=People') + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible() + await page.screenshot({ path: 'test-results/filter-people.png' }) +}) + +test('clicking Events filter shows only events', async ({ page }) => { + await page.click('text=Events') + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible() + await page.screenshot({ path: 'test-results/filter-events.png' }) +}) + +test('clicking PROJECTS header shows all projects', async ({ page }) => { + await page.click('text=PROJECTS') + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible() + await page.screenshot({ path: 'test-results/filter-projects.png' }) +}) + +test('clicking specific entity shows it pinned with children', async ({ page }) => { + await page.locator('.sidebar__item', { hasText: 'Build Laputa App' }).click() + await page.waitForTimeout(100) + // Pinned entity + children that belongTo this project + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Facebook Ads Strategy' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible() + await expect(page.locator('.note-list__item--pinned')).toBeVisible() + await page.screenshot({ path: 'test-results/filter-entity.png' }) +}) + +test('clicking topic shows entries related to that topic', async ({ page }) => { + await page.locator('.sidebar__topic-item', { hasText: 'Software Development' }).click() + await page.waitForTimeout(100) + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible() + await page.screenshot({ path: 'test-results/filter-topic.png' }) +}) + +test('search bar filters by title substring', async ({ page }) => { + await page.fill('.note-list__search-input', 'budget') + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible() + await page.screenshot({ path: 'test-results/search-budget.png' }) +}) + +test('type filter pills narrow results', async ({ page }) => { + // Click "Projects" pill + await page.locator('.note-list__pill', { hasText: 'Projects' }).click() + await page.waitForTimeout(100) + const count = page.locator('.note-list__count') + await expect(count).toHaveText('1') + await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible() + await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).not.toBeVisible() + await page.screenshot({ path: 'test-results/pill-projects.png' }) + + // Click "All" to reset + await page.locator('.note-list__pill', { hasText: 'All' }).click() + await page.waitForTimeout(100) + await expect(count).toHaveText('12') +}) diff --git a/e2e/find-selectors.spec.ts b/e2e/find-selectors.spec.ts new file mode 100644 index 0000000..4af5b42 --- /dev/null +++ b/e2e/find-selectors.spec.ts @@ -0,0 +1,19 @@ +import { test } from '@playwright/test' + +test('find note selectors', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/', { waitUntil: 'networkidle' }) + await page.waitForTimeout(3000) + + // Get all clickable elements with their text + const info = await page.evaluate(() => { + const items = document.querySelectorAll('[class*="cursor-pointer"]') + return Array.from(items).slice(0, 20).map(el => ({ + tag: el.tagName, + text: el.textContent?.trim().slice(0, 50), + cls: el.className.toString().slice(0, 80), + rect: el.getBoundingClientRect().toJSON() + })) + }) + console.log(JSON.stringify(info, null, 2)) +}) diff --git a/e2e/image-drag-drop.spec.ts b/e2e/image-drag-drop.spec.ts new file mode 100644 index 0000000..b33d22b --- /dev/null +++ b/e2e/image-drag-drop.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' + +// Minimal valid PNG: 1x1 red pixel +const TEST_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + +function createTestImage(filepath: string) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) +} + +test('drag & drop image into editor inserts image block', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await page.waitForTimeout(200) + + // Create a test image file + const testImagePath = path.join(process.cwd(), 'test-results', 'drag-test-image.png') + createTestImage(testImagePath) + + // Screenshot before + await page.screenshot({ path: 'test-results/drag-drop-before.png', fullPage: true }) + + // Simulate drag-and-drop of a file into the editor + // Playwright supports dispatching drag events with DataTransfer + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Use Playwright's page.dispatchEvent with a custom script to simulate file drop + await page.evaluate(async ({ base64, x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + // Convert base64 to Uint8Array + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + + // Create a File object + const file = new File([bytes], 'drag-test-image.png', { type: 'image/png' }) + + // Create DataTransfer with the file + const dt = new DataTransfer() + dt.items.add(file) + + // Dispatch dragover first (to set the drop effect) + const dragOverEvent = new DragEvent('dragover', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(dragOverEvent) + + // Then dispatch drop + const dropEvent = new DragEvent('drop', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(dropEvent) + }, { + base64: TEST_PNG_BASE64, + x: box!.x + box!.width / 2, + y: box!.y + box!.height / 2, + }) + + // Wait for the image to be uploaded and inserted + await page.waitForTimeout(2000) + + // Verify: image element exists in the editor + const images = page.locator('.bn-editor img') + await expect(images.first()).toBeVisible({ timeout: 5000 }) + + // Verify: image uses data URL (browser mode) + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) + + await page.screenshot({ path: 'test-results/drag-drop-after.png', fullPage: true }) + + // Clean up + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) +}) + +test('drop zone overlay appears during image drag', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Simulate dragover with an image file to trigger overlay + await page.evaluate(({ x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + const file = new File([new Uint8Array(1)], 'test.png', { type: 'image/png' }) + const dt = new DataTransfer() + dt.items.add(file) + + const event = new DragEvent('dragover', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(event) + }, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }) + + // The drop overlay should be visible + const overlay = page.locator('.editor__drop-overlay') + await expect(overlay).toBeVisible({ timeout: 2000 }) + await expect(overlay).toContainText('Drop image here') + + await page.screenshot({ path: 'test-results/drag-drop-overlay.png', fullPage: true }) +}) + +test('non-image file drop is ignored', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Count images before + const imagesBefore = await page.locator('.bn-editor img').count() + + // Simulate dropping a text file + await page.evaluate(({ x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + const file = new File(['not an image'], 'readme.txt', { type: 'text/plain' }) + const dt = new DataTransfer() + dt.items.add(file) + + container.dispatchEvent(new DragEvent('drop', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + })) + }, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }) + + await page.waitForTimeout(500) + + // No new images should have been added + const imagesAfter = await page.locator('.bn-editor img').count() + expect(imagesAfter).toBe(imagesBefore) +}) diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts new file mode 100644 index 0000000..82dfdb7 --- /dev/null +++ b/e2e/image-upload.spec.ts @@ -0,0 +1,134 @@ +import { test, expect, type Page } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../tests/helpers/fixtureVault' + +// Minimal valid PNG: 1x1 red pixel +const TEST_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + +function createTestPng(filepath: string) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) +} + +let tempVaultDir: string + +async function openImageTestNote(page: Page) { + await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click() + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + return editor +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('image upload via file picker displays image with data URL', async ({ page }) => { + const editor = await openImageTestNote(page) + await editor.click() + await page.waitForTimeout(200) + + // Insert image block via slash command + await page.keyboard.press('Enter') + await page.waitForTimeout(100) + await page.keyboard.type('/image', { delay: 80 }) + await page.waitForTimeout(500) + + // Select Image from slash menu (press Enter to pick first match) + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + + // Verify Upload tab is available (uploadFile is configured) + const fileInput = page.locator('input[type="file"]') + expect(await fileInput.count()).toBeGreaterThan(0) + + // Upload a test image + const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png') + createTestPng(testImagePath) + + await fileInput.first().setInputFiles(testImagePath) + await page.waitForTimeout(2000) + + // Verify: image element exists in the editor + const images = page.locator('.bn-editor img') + const imageCount = await images.count() + expect(imageCount).toBeGreaterThan(0) + + // Verify: image uses data URL (stable, survives reload in dev mode) + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) + + // Verify: no "Loading..." elements remain + const loadingEls = page.locator('.bn-file-loading-preview') + expect(await loadingEls.count()).toBe(0) + + await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true }) + + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) +}) + +test('image paste into editor inserts image block', async ({ page }) => { + const editor = await openImageTestNote(page) + await editor.click() + + await page.evaluate((base64) => { + const editorElement = document.querySelector('.bn-editor') + if (!editorElement) throw new Error('Editor not found') + + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + + const file = new File([bytes], 'pasted-image.png', { type: 'image/png' }) + const clipboardData = new DataTransfer() + clipboardData.items.add(file) + editorElement.dispatchEvent(new ClipboardEvent('paste', { + clipboardData, + bubbles: true, + cancelable: true, + })) + }, TEST_PNG_BASE64) + + const images = page.locator('.bn-editor img') + await expect(images.first()).toBeVisible({ timeout: 5000 }) + + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) +}) + +test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => { + const editor = await openImageTestNote(page) + + // Capture console errors + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // Insert an image block via slash command + await editor.click() + await page.keyboard.press('Enter') + await page.keyboard.type('/image', { delay: 30 }) + await page.waitForTimeout(500) + + // Press Enter to select Image + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + + await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true }) + + // No errors related to upload should have occurred + const uploadErrors = errors.filter(e => e.includes('upload')) + expect(uploadErrors).toHaveLength(0) +}) diff --git a/e2e/keyboard-shortcuts.spec.ts b/e2e/keyboard-shortcuts.spec.ts new file mode 100644 index 0000000..c0e92be --- /dev/null +++ b/e2e/keyboard-shortcuts.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test' + +test('Cmd+N opens create note dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+n') + await page.waitForTimeout(200) + + await expect(page.locator('.create-dialog')).toBeVisible() + await expect(page.locator('.create-dialog__title')).toHaveText('Create New Note') +}) + +test('Cmd+S shows save toast', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+s') + await page.waitForTimeout(200) + + await expect(page.locator('.toast')).toBeVisible() + await expect(page.locator('.toast')).toHaveText('Saved') + + await page.screenshot({ path: 'test-results/save-toast.png', fullPage: true }) +}) + +test('Cmd+W closes the active tab', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Open a note + await page.click('.note-list__item') + await page.waitForTimeout(300) + await expect(page.locator('.editor__tab--active')).toBeVisible() + + // Close it with Cmd+W + await page.keyboard.press('Meta+w') + await page.waitForTimeout(300) + + // Tab should be gone, placeholder should show + await expect(page.locator('.editor__tab')).not.toBeVisible() + await expect(page.locator('.editor__placeholder')).toBeVisible() +}) diff --git a/e2e/quick-open.spec.ts b/e2e/quick-open.spec.ts new file mode 100644 index 0000000..d038013 --- /dev/null +++ b/e2e/quick-open.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test' + +test('Cmd+P opens quick open palette', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Open palette with keyboard shortcut + await page.keyboard.press('Meta+p') + await page.waitForTimeout(200) + + await expect(page.locator('.palette')).toBeVisible() + await expect(page.locator('.palette__input')).toBeFocused() + + await page.screenshot({ path: 'test-results/quick-open.png', fullPage: true }) +}) + +test('quick open: search and select a note', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+p') + await page.waitForTimeout(200) + + // Type to search + await page.fill('.palette__input', 'laputa') + await page.waitForTimeout(100) + + // Should show matching result + await expect(page.locator('.palette__item-title:has-text("Build Laputa App")')).toBeVisible() + + // Press Enter to select + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + + // Palette should close and note should be opened + await expect(page.locator('.palette')).not.toBeVisible() + // The top result should have been opened (wait for async content load) + await expect(page.locator('.editor__tab--active')).toBeVisible({ timeout: 3000 }) + + await page.screenshot({ path: 'test-results/quick-open-selected.png', fullPage: true }) +}) + +test('quick open: arrow keys navigate results', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+p') + await page.waitForTimeout(200) + + // First item should be selected by default + await expect(page.locator('.palette__item--selected').first()).toBeVisible() + + // Arrow down to move selection + await page.keyboard.press('ArrowDown') + await page.waitForTimeout(50) + + // Second item should be selected + const items = page.locator('.palette__item') + const count = await items.count() + expect(count).toBeGreaterThan(1) +}) + +test('quick open: Escape closes palette', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+p') + await page.waitForTimeout(200) + await expect(page.locator('.palette')).toBeVisible() + + await page.keyboard.press('Escape') + await page.waitForTimeout(100) + await expect(page.locator('.palette')).not.toBeVisible() +}) + +test('quick open: clicking outside closes palette', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.keyboard.press('Meta+p') + await page.waitForTimeout(200) + await expect(page.locator('.palette')).toBeVisible() + + // Click the overlay area (outside the palette) using mouse click at top-left + await page.mouse.click(10, 10) + await page.waitForTimeout(200) + await expect(page.locator('.palette')).not.toBeVisible() +}) diff --git a/e2e/rename-tab.spec.ts b/e2e/rename-tab.spec.ts new file mode 100644 index 0000000..10bcd37 --- /dev/null +++ b/e2e/rename-tab.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test' + +test('rename tab by double-clicking', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) + + // Screenshot initial state + await page.screenshot({ path: 'test-results/rename-01-initial.png', fullPage: true }) + + // Click a note in the list using text content + await page.getByText('Deprecated Workflow').first().click() + await page.waitForTimeout(500) + + // Screenshot: note opened as tab + await page.screenshot({ path: 'test-results/rename-02-note-opened.png', fullPage: true }) + + // Find the tab title in the tab bar and double-click it + const tabTitle = page.locator('.group span.truncate').first() + await expect(tabTitle).toBeVisible({ timeout: 5000 }) + const originalTitle = await tabTitle.textContent() + console.log(`Original title: "${originalTitle}"`) + + await tabTitle.dblclick() + await page.waitForTimeout(300) + + // Screenshot: editing mode + await page.screenshot({ path: 'test-results/rename-03-editing.png', fullPage: true }) + + // Verify input appeared + const editInput = page.locator('.group input') + await expect(editInput).toBeVisible({ timeout: 3000 }) + + // Type new name + await editInput.fill('Renamed Test Note') + await page.screenshot({ path: 'test-results/rename-04-typing.png', fullPage: true }) + + // Press Enter to save + await editInput.press('Enter') + await page.waitForTimeout(1000) + + // Screenshot: after rename + await page.screenshot({ path: 'test-results/rename-05-saved.png', fullPage: true }) + + // Verify tab title changed + const newTabTitle = page.locator('.group span.truncate').first() + await expect(newTabTitle).toHaveText('Renamed Test Note') +}) + +test('cancel rename with Escape', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) + + // Open a note + await page.getByText('Deprecated Workflow').first().click() + await page.waitForTimeout(500) + + const tabTitle = page.locator('.group span.truncate').first() + const originalTitle = await tabTitle.textContent() + + // Double-click to edit + await tabTitle.dblclick() + await page.waitForTimeout(300) + + const editInput = page.locator('.group input') + await expect(editInput).toBeVisible({ timeout: 3000 }) + + // Type something different + await editInput.fill('Will Be Cancelled') + + // Press Escape to cancel + await editInput.press('Escape') + await page.waitForTimeout(300) + + // Screenshot: after cancel + await page.screenshot({ path: 'test-results/rename-06-cancelled.png', fullPage: true }) + + // Verify title unchanged + const afterTitle = page.locator('.group span.truncate').first() + await expect(afterTitle).toHaveText(originalTitle!) +}) diff --git a/e2e/screenshot.spec.ts b/e2e/screenshot.spec.ts new file mode 100644 index 0000000..0680b0f --- /dev/null +++ b/e2e/screenshot.spec.ts @@ -0,0 +1,94 @@ +import { test } from '@playwright/test' + +test('capture app screenshot for review', async ({ page }) => { + await page.goto('/') + // Wait for mock data to load + await page.waitForTimeout(500) + await page.screenshot({ path: 'test-results/app-screenshot.png', fullPage: true }) +}) + +test('capture editor with note selected', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click the first note in the list + await page.click('.note-list__item') + await page.waitForTimeout(300) + + await page.screenshot({ path: 'test-results/editor-screenshot.png', fullPage: true }) +}) + +test('live preview: headings styled, syntax hidden', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click a note to load it + await page.click('.note-list__item') + await page.waitForTimeout(500) + + // Screenshot showing live preview (headings styled, syntax hidden) + await page.screenshot({ path: 'test-results/live-preview.png', fullPage: true }) +}) + +test('tab bar: multiple tabs open and close', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click first note + await page.click('.note-list__item') + await page.waitForTimeout(500) + + // Debug: screenshot after first click + await page.screenshot({ path: 'test-results/tabs-debug-1.png', fullPage: true }) + + // Check if other items are visible + const items = await page.locator('.note-list__item').count() + console.log(`Note list items visible after first click: ${items}`) + + // Click second item if available + if (items >= 2) { + await page.locator('.note-list__item').nth(1).click({ timeout: 5000 }) + await page.waitForTimeout(500) + } + + if (items >= 3) { + await page.locator('.note-list__item').nth(2).click({ timeout: 5000 }) + await page.waitForTimeout(500) + } + + await page.screenshot({ path: 'test-results/tabs-screenshot.png', fullPage: true }) +}) + +test('frontmatter hidden from editor view', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click a note that has frontmatter + await page.click('.note-list__item') + await page.waitForTimeout(500) + + // Frontmatter should be hidden — editor starts with content, not --- + await page.screenshot({ path: 'test-results/frontmatter-hidden.png', fullPage: true }) +}) + +test('wikilinks: rendered as styled elements and clickable', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink + await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click() + await page.waitForTimeout(500) + + // Screenshot showing wikilink rendered as styled text + await page.screenshot({ path: 'test-results/wikilinks-styled.png', fullPage: true }) + + // Click the wikilink to navigate — use mouse.click to fire real mousedown + const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' }) + const box = await wikilink.boundingBox() + if (box) { + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + await page.waitForTimeout(500) + // Should now have a new tab for Matteo Cellini + await page.screenshot({ path: 'test-results/wikilinks-navigated.png', fullPage: true }) + } +}) diff --git a/e2e/settings-oauth.spec.ts b/e2e/settings-oauth.spec.ts new file mode 100644 index 0000000..1b81755 --- /dev/null +++ b/e2e/settings-oauth.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test' + +test('settings shows connected GitHub state with username', async ({ page }) => { + await page.goto('http://localhost:5243/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + // Open settings + await page.keyboard.press('Meta+Comma') + await page.waitForTimeout(500) + + // Verify settings panel opened + await expect(page.getByTestId('settings-panel')).toBeVisible() + + // Mock data starts with github connected — verify connected state + const connected = page.getByTestId('github-connected') + await expect(connected).toBeVisible({ timeout: 5000 }) + await expect(connected).toContainText('lucaong') + await expect(connected).toContainText('Connected') + + // Verify disconnect button + await expect(page.getByTestId('github-disconnect')).toBeVisible() + + // Verify NO token input field + await expect(page.getByTestId('settings-key-github-token')).not.toBeVisible() + + await page.screenshot({ path: 'test-results/settings-oauth-connected.png' }) +}) + +test('disconnect shows Login with GitHub button', async ({ page }) => { + await page.goto('http://localhost:5243/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + // Open settings + await page.keyboard.press('Meta+Comma') + await page.waitForTimeout(500) + + // Click disconnect + const disconnectBtn = page.getByTestId('github-disconnect') + await expect(disconnectBtn).toBeVisible({ timeout: 5000 }) + await disconnectBtn.click() + await page.waitForTimeout(300) + + // Login button should now be visible + const loginBtn = page.getByTestId('github-login') + await expect(loginBtn).toBeVisible({ timeout: 3000 }) + await expect(loginBtn).toContainText('Login with GitHub') + + await page.screenshot({ path: 'test-results/settings-oauth-login.png' }) +}) diff --git a/e2e/sidebar-collapse.spec.ts b/e2e/sidebar-collapse.spec.ts new file mode 100644 index 0000000..c54528b --- /dev/null +++ b/e2e/sidebar-collapse.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test' + +// On macOS, Alt+key produces special characters, so we dispatch events directly +function dispatchAltKey(page: import('@playwright/test').Page, key: string) { + return page.evaluate((k) => { + window.dispatchEvent(new KeyboardEvent('keydown', { + key: k, altKey: true, metaKey: false, ctrlKey: false, + bubbles: true, cancelable: true, + })) + }, key) +} + +async function loadApp(page: import('@playwright/test').Page) { + await page.goto('/') + // Clear stored view mode so we start fresh + await page.evaluate(() => localStorage.removeItem('laputa-view-mode')) + await page.reload() + await page.waitForTimeout(500) +} + +test.describe('Sidebar collapse', () => { + test('default: all three panels visible', async ({ page }) => { + await loadApp(page) + + await expect(page.locator('.app__sidebar')).toBeVisible() + await expect(page.locator('.app__note-list')).toBeVisible() + await expect(page.locator('.app__editor')).toBeVisible() + + await page.screenshot({ path: 'test-results/collapse-all-panels.png', fullPage: true }) + }) + + test('collapse button hides sidebar', async ({ page }) => { + await loadApp(page) + + const collapseBtn = page.locator('button[aria-label="Collapse sidebar"]') + await expect(collapseBtn).toBeVisible() + await collapseBtn.click() + await page.waitForTimeout(500) + + await expect(page.locator('.app__sidebar')).toHaveCount(0) + await expect(page.locator('.app__note-list')).toBeVisible() + await expect(page.locator('.app__editor')).toBeVisible() + + await page.screenshot({ path: 'test-results/collapse-sidebar-hidden.png', fullPage: true }) + }) + + test('Alt+1 shows editor only', async ({ page }) => { + await loadApp(page) + + await dispatchAltKey(page, '1') + await page.waitForTimeout(500) + + await expect(page.locator('.app__sidebar')).toHaveCount(0) + await expect(page.locator('.app__note-list')).toHaveCount(0) + await expect(page.locator('.app__editor')).toBeVisible() + + await page.screenshot({ path: 'test-results/collapse-editor-only.png', fullPage: true }) + }) + + test('Alt+2 shows editor + note list', async ({ page }) => { + await loadApp(page) + + await dispatchAltKey(page, '2') + await page.waitForTimeout(500) + + await expect(page.locator('.app__sidebar')).toHaveCount(0) + await expect(page.locator('.app__note-list')).toBeVisible() + await expect(page.locator('.app__editor')).toBeVisible() + + await page.screenshot({ path: 'test-results/collapse-editor-list.png', fullPage: true }) + }) + + test('Alt+3 restores all panels after collapse', async ({ page }) => { + await loadApp(page) + + // Collapse first + await dispatchAltKey(page, '1') + await page.waitForTimeout(500) + await expect(page.locator('.app__sidebar')).toHaveCount(0) + + // Restore + await dispatchAltKey(page, '3') + await page.waitForTimeout(500) + await expect(page.locator('.app__sidebar')).toBeVisible() + await expect(page.locator('.app__note-list')).toBeVisible() + await expect(page.locator('.app__editor')).toBeVisible() + + await page.screenshot({ path: 'test-results/collapse-restored.png', fullPage: true }) + }) +}) diff --git a/e2e/vault-picker-local.spec.ts b/e2e/vault-picker-local.spec.ts new file mode 100644 index 0000000..3ab0b54 --- /dev/null +++ b/e2e/vault-picker-local.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test' + +test.describe('Vault Picker Local Options', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:5240') + await page.waitForSelector('text=notes', { timeout: 10000 }) + }) + + test('vault menu shows local folder options', async ({ page }) => { + // Screenshot before opening menu + await page.screenshot({ path: 'test-results/vault-picker-before.png', fullPage: true }) + + // Click the vault button in the status bar to open the menu + const vaultButton = page.locator('[title="Switch vault"]') + await expect(vaultButton).toBeVisible() + await vaultButton.click() + + // Wait for menu to appear + await page.waitForTimeout(300) + + // Verify all three options are visible + await expect(page.locator('text=Open local folder')).toBeVisible() + await expect(page.locator('text=Create new vault')).toBeVisible() + await expect(page.locator('text=Connect GitHub repo')).toBeVisible() + + // Screenshot with menu open showing all options + await page.screenshot({ path: 'test-results/vault-picker-menu-open.png', fullPage: true }) + }) + + test('vault menu options have correct test IDs', async ({ page }) => { + const vaultButton = page.locator('[title="Switch vault"]') + await vaultButton.click() + await page.waitForTimeout(200) + + await expect(page.locator('[data-testid="vault-menu-open-local"]')).toBeVisible() + await expect(page.locator('[data-testid="vault-menu-create-new"]')).toBeVisible() + await expect(page.locator('[data-testid="vault-menu-connect-github"]')).toBeVisible() + }) +}) diff --git a/e2e/visual-verify.spec.ts b/e2e/visual-verify.spec.ts new file mode 100644 index 0000000..2eb6779 --- /dev/null +++ b/e2e/visual-verify.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' + +test('visual verify: editor theme + list indentation', async ({ page }) => { + const consoleErrors: string[] = [] + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + + await page.goto('http://localhost:5173') + await page.waitForTimeout(1000) + + // Open "Write Weekly Essays" which has bullets, nested items, checkboxes + const noteItem = page.locator('.note-list__item', { hasText: 'Write Weekly Essays' }) + await noteItem.click() + await page.waitForTimeout(1000) + + const cmEditor = page.locator('.cm-editor') + await expect(cmEditor).toBeVisible() + + // No console errors + expect(consoleErrors).toHaveLength(0) + + // --- BUG 2: Verify list indentation --- + + // Level-0 bullets should have 40px padding-left + const level0Lines = page.locator('.cm-line.cm-live-list-level-0') + const level0Count = await level0Lines.count() + console.log(`Level-0 bullet lines: ${level0Count}`) + expect(level0Count).toBeGreaterThan(0) + + const paddingL0 = await level0Lines.first().evaluate(el => + window.getComputedStyle(el).paddingLeft + ) + console.log(`Level-0 padding-left: ${paddingL0}`) + expect(parseInt(paddingL0, 10)).toBe(40) + + // Bullet widgets and checkboxes are rendered + const bulletCount = await page.locator('.cm-live-bullet').count() + console.log(`Bullet widgets: ${bulletCount}`) + expect(bulletCount).toBeGreaterThan(0) + + const checkboxCount = await page.locator('.cm-live-checkbox').count() + console.log(`Checkbox widgets: ${checkboxCount}`) + expect(checkboxCount).toBeGreaterThan(0) + + // Screenshot dark mode (top) + await page.screenshot({ path: 'test-results/01-dark-mode-editor.png', fullPage: true }) + + // Scroll down to see nested items section + const scroller = page.locator('.cm-scroller') + await scroller.evaluate(el => el.scrollTop = el.scrollHeight) + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/01b-dark-mode-nested.png', fullPage: true }) + + // --- BUG 1: Verify theme toggle --- + + // Dark mode: editor bg should be dark + const darkBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Dark mode bg: ${darkBg}`) + expect(darkBg).toBe('rgb(15, 15, 26)') + + // Toggle to light mode + const themeToggle = page.locator('.sidebar__theme-toggle') + await themeToggle.click() + await page.waitForTimeout(500) + + // Scroll back to top for light mode screenshot + await scroller.evaluate(el => el.scrollTop = 0) + await page.waitForTimeout(200) + await page.screenshot({ path: 'test-results/02-light-mode-editor.png', fullPage: true }) + + // Light mode: editor bg should be white + const lightBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Light mode bg: ${lightBg}`) + expect(lightBg).toBe('rgb(255, 255, 255)') + + // Heading color should be dark in light mode + const headingColor = await page.locator('.cm-live-heading').first().evaluate(el => + window.getComputedStyle(el).color + ) + console.log(`Light mode heading color: ${headingColor}`) + expect(headingColor).toBe('rgb(55, 53, 47)') + + // Scroll to nested items in light mode + await scroller.evaluate(el => el.scrollTop = el.scrollHeight) + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/02b-light-mode-nested.png', fullPage: true }) + + // Toggle back to dark mode + await themeToggle.click() + await page.waitForTimeout(500) + await page.screenshot({ path: 'test-results/03-dark-mode-restored.png', fullPage: true }) + + const restoredBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Restored dark mode bg: ${restoredBg}`) + expect(restoredBg).toBe('rgb(15, 15, 26)') +}) diff --git a/e2e/zero-shift-closeup.spec.ts b/e2e/zero-shift-closeup.spec.ts new file mode 100644 index 0000000..58ad875 --- /dev/null +++ b/e2e/zero-shift-closeup.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test' + +test('closeup: heading marker in gutter', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(800) + + const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' }) + await noteItem.click() + await page.waitForTimeout(800) + + // Find "Overview" heading and take a tight crop around it + const heading = page.locator('.cm-header-2', { hasText: 'Overview' }).first() + await expect(heading).toBeVisible() + const hBox = await heading.boundingBox() + if (!hBox) throw new Error('heading not found') + + // Crop: include gutter area to the left (extra 80px) and a small vertical band + const clip = { + x: Math.max(0, hBox.x - 80), + y: hBox.y - 10, + width: hBox.width + 120, + height: hBox.height + 20, + } + + // Before click — preview mode, no marker visible + await page.screenshot({ path: 'test-results/closeup-heading-inactive.png', clip }) + + // Click on heading to activate + await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2) + await page.waitForTimeout(500) + + // After click — ## marker should appear in gutter + await page.screenshot({ path: 'test-results/closeup-heading-active.png', clip }) + + // Verify the marker is visible (opacity > 0) + const marker = page.locator('.cm-heading-line .cm-formatting-block.cm-formatting-block-visible').first() + if (await marker.count() > 0) { + const opacity = await marker.evaluate(el => window.getComputedStyle(el).opacity) + console.log(`Heading marker opacity when active: ${opacity}`) + expect(parseFloat(opacity)).toBeGreaterThan(0) + + const mBox = await marker.boundingBox() + console.log(`Heading marker bounding box: ${JSON.stringify(mBox)}`) + + // The marker should be to the LEFT of the heading text + if (mBox && hBox) { + console.log(`Marker right edge: ${mBox.x + mBox.width}, Heading left edge: ${hBox.x}`) + expect(mBox.x + mBox.width).toBeLessThanOrEqual(hBox.x + 5) // marker is left of heading + } + } + + // Now check bullet closeup + const bulletLine = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first() + const bBox = await bulletLine.boundingBox() + if (!bBox) throw new Error('bullet not found') + + // First click elsewhere to deactivate + await page.mouse.click(hBox.x + 10, hBox.y - 40) + await page.waitForTimeout(300) + + const bulletClip = { + x: Math.max(0, bBox.x - 20), + y: bBox.y - 5, + width: Math.min(400, bBox.width + 40), + height: bBox.height + 60, // include next line too + } + + // Inactive bullets + await page.screenshot({ path: 'test-results/closeup-bullet-inactive.png', clip: bulletClip }) + + // Click on bullet line + await page.mouse.click(bBox.x + bBox.width / 2, bBox.y + bBox.height / 2) + await page.waitForTimeout(500) + + // Active bullet + await page.screenshot({ path: 'test-results/closeup-bullet-active.png', clip: bulletClip }) +}) diff --git a/e2e/zero-shift-detail.spec.ts b/e2e/zero-shift-detail.spec.ts new file mode 100644 index 0000000..cd8c94e --- /dev/null +++ b/e2e/zero-shift-detail.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test' + +test('zero-shift detail: focused editor screenshots', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(800) + + // Open "Build Laputa App" which has headings and bullets + const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' }) + await noteItem.click() + await page.waitForTimeout(800) + + const cmEditor = page.locator('.cm-editor') + await expect(cmEditor).toBeVisible() + + // Screenshot just the editor content area + const editorBox = await cmEditor.boundingBox() + if (!editorBox) throw new Error('Editor not visible') + + // Crop to top portion of editor where headings and bullets are + const clip = { + x: editorBox.x, + y: editorBox.y, + width: editorBox.width, + height: Math.min(editorBox.height, 500), + } + + // 1. Initial preview state (cursor not on headings/bullets) + await page.screenshot({ path: 'test-results/detail-01-preview.png', clip }) + + // 2. Click on "Overview" heading + const headingSpan = page.locator('.cm-header-2', { hasText: 'Overview' }).first() + const hBox = await headingSpan.boundingBox() + if (hBox) { + await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2) + await page.waitForTimeout(500) + } + await page.screenshot({ path: 'test-results/detail-02-heading-active.png', clip }) + + // 3. Click on a bullet line + const bulletLine = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first() + const bBox = await bulletLine.boundingBox() + if (bBox) { + await page.mouse.click(bBox.x + bBox.width / 2, bBox.y + bBox.height / 2) + await page.waitForTimeout(500) + } + await page.screenshot({ path: 'test-results/detail-03-bullet-active.png', clip }) + + // 4. Click on plain paragraph to go back to preview + const para = page.locator('.cm-line', { hasText: 'Custom desktop app' }).first() + const pBox = await para.boundingBox() + if (pBox) { + await page.mouse.click(pBox.x + 10, pBox.y + pBox.height / 2) + await page.waitForTimeout(500) + } + await page.screenshot({ path: 'test-results/detail-04-back-preview.png', clip }) + + // 5. Check that heading marker (.cm-formatting-block inside .cm-heading-line) is position:absolute + const headingMarkers = page.locator('.cm-heading-line .cm-formatting-block') + const markerCount = await headingMarkers.count() + console.log(`Heading markers found: ${markerCount}`) + + if (markerCount > 0) { + const position = await headingMarkers.first().evaluate(el => + window.getComputedStyle(el).position + ) + console.log(`Heading marker position: ${position}`) + expect(position).toBe('absolute') + } + + // 6. Check that non-heading markers always have font-size != 0.01em + const bulletMarkers = page.locator('.cm-line:not(.cm-heading-line) .cm-formatting-block') + const bmCount = await bulletMarkers.count() + console.log(`Non-heading block markers found: ${bmCount}`) + + if (bmCount > 0) { + const fontSize = await bulletMarkers.first().evaluate(el => + window.getComputedStyle(el).fontSize + ) + console.log(`Bullet marker font-size: ${fontSize}`) + // Should NOT be tiny (library default is 0.01em ≈ 0.15px) + expect(parseFloat(fontSize)).toBeGreaterThan(10) + } +}) diff --git a/e2e/zero-shift.spec.ts b/e2e/zero-shift.spec.ts new file mode 100644 index 0000000..2025271 --- /dev/null +++ b/e2e/zero-shift.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test' + +test('zero horizontal shift: headings and bullets', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(800) + + // Open "Build Laputa App" which has headings and bullets + const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' }) + await noteItem.click() + await page.waitForTimeout(800) + + const cmEditor = page.locator('.cm-editor') + await expect(cmEditor).toBeVisible() + + // Screenshot 1: initial state — cursor after frontmatter, headings/bullets in preview mode + await page.screenshot({ path: 'test-results/zero-shift-01-initial.png', fullPage: true }) + + // Find a heading line (## Overview) and measure its text position + const headingText = page.locator('.cm-header-2', { hasText: 'Overview' }).first() + await expect(headingText).toBeVisible() + + // Get bounding box BEFORE clicking on it (inactive state) + const beforeBox = await headingText.boundingBox() + console.log('Heading "Overview" BEFORE click:', JSON.stringify(beforeBox)) + + // Screenshot 2: before clicking heading + await page.screenshot({ path: 'test-results/zero-shift-02-before-heading-click.png', fullPage: true }) + + // Click on the heading to activate it + if (beforeBox) { + await page.mouse.click(beforeBox.x + beforeBox.width / 2, beforeBox.y + beforeBox.height / 2) + } + await page.waitForTimeout(500) + + // Screenshot 3: after clicking heading — ## should appear in gutter, text stays put + await page.screenshot({ path: 'test-results/zero-shift-03-after-heading-click.png', fullPage: true }) + + // Get bounding box AFTER clicking (active state) + const afterBox = await headingText.boundingBox() + console.log('Heading "Overview" AFTER click:', JSON.stringify(afterBox)) + + // CRITICAL: The heading text X position must not change + if (beforeBox && afterBox) { + const xShift = Math.abs(afterBox.x - beforeBox.x) + console.log(`Horizontal shift: ${xShift}px`) + expect(xShift).toBeLessThan(2) // Allow 1px tolerance for subpixel rendering + } + + // Now test bullet lines: click on a bullet item + const bulletText = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first() + await expect(bulletText).toBeVisible() + + const bulletBeforeBox = await bulletText.boundingBox() + console.log('Bullet line BEFORE click:', JSON.stringify(bulletBeforeBox)) + + // Screenshot 4: before clicking bullet + await page.screenshot({ path: 'test-results/zero-shift-04-before-bullet-click.png', fullPage: true }) + + // Click on the bullet line + if (bulletBeforeBox) { + await page.mouse.click(bulletBeforeBox.x + bulletBeforeBox.width / 2, bulletBeforeBox.y + bulletBeforeBox.height / 2) + } + await page.waitForTimeout(500) + + // Screenshot 5: after clicking bullet + await page.screenshot({ path: 'test-results/zero-shift-05-after-bullet-click.png', fullPage: true }) + + const bulletAfterBox = await bulletText.boundingBox() + console.log('Bullet line AFTER click:', JSON.stringify(bulletAfterBox)) + + // CRITICAL: Bullet line X position must not change + if (bulletBeforeBox && bulletAfterBox) { + const xShift = Math.abs(bulletAfterBox.x - bulletBeforeBox.x) + console.log(`Bullet horizontal shift: ${xShift}px`) + expect(xShift).toBeLessThan(2) + } + + // Screenshot 6: click somewhere else (a plain paragraph) to verify heading/bullets go back to preview + const paragraph = page.locator('.cm-line', { hasText: 'Custom desktop app' }).first() + if (await paragraph.isVisible()) { + const paraBox = await paragraph.boundingBox() + if (paraBox) { + await page.mouse.click(paraBox.x + 10, paraBox.y + paraBox.height / 2) + } + await page.waitForTimeout(500) + } + await page.screenshot({ path: 'test-results/zero-shift-06-back-to-preview.png', fullPage: true }) + + // Verify heading underline is removed (FIX 1) + const headingLine = page.locator('.cm-heading-line').first() + if (await headingLine.count() > 0) { + const borderBottom = await headingLine.evaluate(el => + window.getComputedStyle(el).borderBottom + ) + console.log(`Heading line border-bottom: ${borderBottom}`) + expect(borderBottom).toContain('none') + } +}) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2ecf567 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores([ + 'dist', + 'coverage', + 'site/.vitepress/cache/', + 'site/.vitepress/dist/', + 'src-tauri/resources/mcp-server/', + 'src-tauri/target/', + 'src-tauri/gen/', + 'tools/', + ]), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..34f6708 --- /dev/null +++ b/index.html @@ -0,0 +1,170 @@ + + + + + + + + + + + + Tolaria + + + + +
+ + + diff --git a/lara.lock b/lara.lock new file mode 100644 index 0000000..65aadbb --- /dev/null +++ b/lara.lock @@ -0,0 +1,1000 @@ +version: 1.1.0 +files: + e6ab9bf36c30b9d72bc97897e0d89a16: + command.noMatches: a2a97d96e196148e04c51c47d985e327 + command.palettePlaceholder: 6d6b61b4c08ed382ad7ba406a9ba395d + command.footerNavigate: e3808db59d29bcfc91532d6539c4f9ae + command.footerSelect: 6d73339906ed42e20f6be91bf7f5f4a9 + command.footerClose: dcf77460e548fe215ef28583f092a496 + command.footerSend: 6b19fc9694fd8b7b3f1aafaca83db864 + command.aiMode: 7197e3c5f9afd24022377176611f35cb + command.openSettings: 638c05f4f159a403e04aebe94b46a2a8 + command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7 + command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183 + command.openLanguageSettings.keywords: c92bcfddcacf33ad7bdb6ccb88f0d4f4 + command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca + command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf + command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 + command.contribute: 9887a4451812854f0f1b6f669a874307 + command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f + menu.application: bc16b1fbe2e90ace4991cafc5149955c + menu.file: 0b27918290ff5323bea1e3b78a9cf04e + menu.edit: 7dce122004969d56ae2e0245cb754d35 + menu.view: 4351cfebe4b61d8aa5efa1d020710005 + menu.go: 5f075ae3e1f9d0382bb8c4632991f96f + menu.note: 3b0649c72650c313a357338dcdfb64ec + menu.vault: 5b70a213f150f01f0776fa9481ef2ddf + menu.window: c89686a387d2b12b3c729ce35a0bcb5b + menu.file.quickOpen: d79cba7ca4badcccc081bdcba22abd32 + menu.file.quickOpenCmdO: 2f1c2a963833b85008562cd615515234 + menu.file.quickOpenCtrlO: 8aaba3ce15c61798ce80bda311fc1e2d + menu.file.save: c9cc8cce247e49bae79f15173ce97354 + menu.edit.pasteWithoutFormatting: 0219dac5b3b9c457aa65eb19fdd3d22c + menu.edit.findInVault: 7dad10e092a9fe1bfbb4a96c377bc417 + menu.edit.toggleNoteListSearch: f6e0cd9529f571609033958a55f18d78 + menu.view.allPanels: d33bf99e0756e1d5008cb78902e38ca9 + menu.view.zoomIn: f5ca4abce85e2dddb0342d0bae3a7270 + menu.view.zoomOut: 30850b501f98539b1aabaa29fabe41ef + menu.view.actualSize: a5dac79ddad1cbdb8b60c5b347ee047e + menu.view.commandPalette: c0a2436cd8b2dc5085c869e4a052572f + menu.go.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5 + menu.go.archived: 7d69b3cb4cada18ae61811304f8fbcb5 + menu.go.changes: c112bb3542e98308d12d5ecb10a67abc + menu.go.inbox: 3882d32c66e7e768145ecd8f104b0c08 + menu.note.toggleOrganized: 003f5648f585407c894e85543a5fda47 + menu.note.toggleTableOfContents: c464111f97490e65699fe18970fc00e1 + menu.vault.addRemote: 4d9d0f784920d6ef7f9f3b33266dd00e + feedback.title: 96ab5025e38aef43fdb73c9830795264 + feedback.description: e900af4efb9167033d4ecc8a02cfe463 + feedback.sponsor.title: 2f6a1db54812f51924c3324a178125ed + feedback.sponsor.description: 6517e0c5d4e4801814a6034dd54862b6 + feedback.sponsor.cta: a2c71ec076796ab6af0ba0ea882303fc + feedback.sponsor.linkLabel: 7fbe24a8f025219f4d165aeb75352125 + feedback.newsletter.title: ffb7e666a70151215b4c55c6268d7d72 + feedback.newsletter.description: 939ab3adc8c7ad0bca8b27a10462055d + feedback.newsletter.cta: a2c71ec076796ab6af0ba0ea882303fc + feedback.newsletter.linkLabel: 7fbe24a8f025219f4d165aeb75352125 + feedback.sponsors.title: e881053494ad211d02cdde59c3ab59d9 + feedback.sponsors.description: e748f640be55858af848a87e7ce59920 + feedback.sponsors.logoLinkLabel: e5744ab47880f7d488bbb06af085aa07 + feedback.featureRequests.title: 73ea5953d68b2c20a82bb94240f6fe17 + feedback.featureRequests.description: 9583575bdef16e12927b0a5acfa84307 + feedback.featureRequests.cta: c19ef0158349da249920b0d9f67d7d48 + feedback.featureRequests.linkLabel: d125f38d1dc8dbdc7dae3f1778df3a3a + feedback.discussions.title: 560f23c77d499c21038c0b4487f03cb2 + feedback.discussions.description: 2e4da4c5f54c7af3b8db9f3d93ed63c5 + feedback.discussions.cta: 61ccb6a90ec8037f2926c48c0a90caa3 + feedback.discussions.linkLabel: 4f85e3343c5e21d2b11812b2baa8ed83 + feedback.contributeCode.title: 8f93fd72b63620711cc118403b85133e + feedback.contributeCode.description: 53230626356951578842a8cd7233d1f1 + feedback.contributeCode.cta: c73dd5e7d432cd18c109b333e0b55299 + feedback.contributeCode.linkLabel: bfe9dd9816bb39f82fc003de794a075d + feedback.contributingGuide.cta: 6cc1fbcbbc69a70a70a2020c979d8e22 + feedback.contributingGuide.linkLabel: b1310938ecab4c502a8b3af2ca1ce188 + feedback.reportBug.title: 3406147c3ce729ce17ed76d45c2896f9 + feedback.reportBug.description: 6da172412f774e022b0f32ba17305491 + feedback.reportBug.cta: 1f5aa905b40956ff5ea8b4cc5ea29315 + feedback.reportBug.linkLabel: ffaa2eaa0f015cf037d2b5fe8af6b813 + feedback.linkFallback.title: c5396ec50cec23a09977180747737264 + feedback.linkFallback.description: 91d1e18130765119606f095ac473d9cd + feedback.copyDiagnostics: dedf5388697bba1fd53173b04823761a + feedback.diagnosticsCopied: 9c7f3f37df9c000fc67c1a6ee35f40e5 + feedback.diagnosticsCopiedSentence: 6f8ebe492dbb95827ccf7536965dc672 + feedback.clipboardUnavailable: 8e25a424420687ed8e0dc2b1be8c7406 + onboarding.welcome.title: 4e146a5fbd84f4282d608683de0a2d67 + onboarding.welcome.subtitle: 5e490bfb589bd74b043c330f90d806cc + onboarding.welcome.missingSubtitle: e0b59bce6bbcc8dd56722e25c2a60485 + onboarding.welcome.templateTitle: 30ddd36173006e5db734f878bf12b9ea + onboarding.welcome.templateDescription: a142a395648f742c6b39d8391e30ec9f + onboarding.welcome.templateOffline: cf717aaf6b6b9afeda233ce4bc86189f + onboarding.welcome.templateLoading: 2ab31cd86895006363298fe680e64e9c + onboarding.welcome.templateLoadingDescription: d246a8a2d8c53a65c34359ba503cbc7c + onboarding.welcome.templateStatus: 6a0789a3d9ba4dd326202663a74129fc + onboarding.welcome.createEmpty: f37c03f236fbf1516222360a936249e8 + onboarding.welcome.createEmptyDescription: 9fe50bc53b609c5f734abaa3a58b2a17 + onboarding.welcome.createEmptyLoading: 20e33d4be622b4eb0af517900937ee68 + onboarding.welcome.createEmptyLoadingDescription: 759757ef559c94a4c232e1093041a897 + onboarding.welcome.openExisting: 4cbce894cee19b239fd88ae2fafedaab + onboarding.welcome.openExistingDescription: 969a090bf259323830d733d0141b0b2a + onboarding.welcome.retryDownload: aa0d36f85911dde10fbe345ae1fa13de + onboarding.welcome.docsPrompt: 4a7dc2be7890babade71476e9910c58d + onboarding.welcome.docsLink: 1ae3906f2bb3da58cecf9db8375952e4 + onboarding.ai.checkingTitle: 62a4f35979899c0492a5be91f0172b98 + onboarding.ai.checkingDescription: 67e6fbe3aa24ec68e88d05def752b47a + onboarding.ai.missingTitle: 194d3777688e137188ad8949c097f441 + onboarding.ai.missingDescription: 217cf33df5cbcbcf4ce1d2808470903a + onboarding.ai.readyTitle: ced601bb8aae12a4a1a8dd9b6440c36a + onboarding.ai.readyDescription: 5d05f1ad0c8af7a9d78146b840cecb90 + onboarding.ai.checkingLocalTitle: d61e8c908156195767eb4729fe377471 + onboarding.ai.checkingLocalDescription: 1048df882350972acbad709dcdd8e6cd + onboarding.ai.detectedHeader: 52d48f0516070836e91a3e53a1c9b693 + onboarding.ai.installed: 2c8bb57a0b1dff255f3d6684a9fddda3 + onboarding.ai.installedVersion: 5e12a26fd64792c6798e5fa9580e2e3f + onboarding.ai.installedBadge: 98dd43dfae05b11befe1f140e0ec787a + onboarding.ai.otherOptionsTitle: cdbde4ccb0c9f57e955d3d3e2a02a2c4 + onboarding.ai.otherOptionsDescription: 57f4ccb3d3ab15693d7d8a047acab618 + onboarding.ai.supportedAgents: 88dcd8c5d8602ae7bd84b7d2463d1286 + onboarding.ai.continue: a0bfb8e59e6c13fc8d990781f77694fe + onboarding.ai.setUpLater: 31aa06160419d713441c155cd2ed1f90 + command.group.navigation: 846495f9ceed11accf8879f555936a7d + command.group.note: 3b0649c72650c313a357338dcdfb64ec + command.group.git: 0bcc70105ad279503e31fe7b3f47b665 + command.group.view: 4351cfebe4b61d8aa5efa1d020710005 + command.group.settings: f4f70727dc34561dfde1a3c529b6205c + command.navigation.searchNotes: d8002e888293774162e43b263658cbb5 + command.navigation.goAllNotes: 7030dcbbb85b6f01d3c8b2fdcb6a5cff + command.navigation.goArchived: b620b8cb8fef8a287b986ff83a992a2a + command.navigation.goChanges: c6dd094ce55f8a765b68cef40a1c6bd9 + command.navigation.goHistory: 39b86b661267053a240ffe89b9eca847 + command.navigation.goBack: 4f2f5e1d6e2a13469ad431474a68ab82 + command.navigation.goForward: d13c318438f67d749cfc0904e3fafcda + command.navigation.goInbox: 522af71c26bc7e3ce1dae6204c2fbe9f + command.navigation.renameFolder: a3d3b0f787c52a984af61085c577bff2 + command.navigation.deleteFolder: 991e322cd8225c369a963bb60b0a9688 + command.navigation.showOpenNotes: bd1905c340d0518187865f4a6694e14d + command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74 + command.navigation.listType: 4f70c27c6826a37543c8ada561397c22 + command.note.newNote: 23c4edda8b815f750782f01870d27271 + command.note.newSheet: 644f6b6a5bcbe0c3b723ec22ad198f3e + command.note.newNoteInCurrentFolder: 167b765903c70ab1b645908ff8a899ab + command.note.newType: adce5108f18945cc502a06c02445e32d + command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2 + command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047 + command.note.undo: 1cdc076b28f70afac5fcedadf99fa119 + command.note.undoAction: 83a1f43c314533161109d8d4daf032f2 + command.note.redo: 5afeaba074ef570dc720caaa855d49f6 + command.note.redoAction: 7556c9dd6845991933c56006bf7ffbac + command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c + command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc + command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1 + command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6 + command.note.archiveNote: 6043509fda6dd7f6f167e4108033f8e8 + command.note.unarchiveNote: 6937fb694d00b11d579c21cd4bf103a2 + command.note.addFavorite: 782d6b28dce4e5d0b2ac92142c6624ba + command.note.removeFavorite: 4f9b5fa08f2ef86fdc5d0ceefc271981 + command.note.markOrganized: 505ad63357f533fb39e4276a2bfb9496 + command.note.markUnorganized: 1c62f1bffcb793c63d729096d2794ced + command.note.restoreDeleted: d704f8283ca322a9b3713d2ccf9a6cd4 + command.note.setIcon: 99b81d2663266c53e45e4d9c62c87a29 + command.note.removeIcon: 4bccbad66025e506b37b4218498b299c + command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14 + command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905 + command.note.turnCurrentBlockInto: ffe5660e197b8cc6ea4220aef1cb8983 + command.note.copyDeepLink: 8de1795f59af124594dc4decf77909a1 + command.note.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0 + command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d + command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6 + command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46 + command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e + command.git.generateCommitMessage: 87cc84d36697c0763d61dd63df6ce40f + command.git.pull: 3988d61f4a4546381f61a4eaf61315b4 + command.git.pullRepository: 931ad25176586fd4332dd671574d3185 + command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee + command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d + git.author.label: 7a0ca1a813e4518e479a7927890568ce + git.author.warning.localOverridesGlobal: 5ea1eb9d1b9d2a80662368ee9e1fba63 + git.commitMessage.failed: 61e323218034e7e8d2de3f869b268701 + git.commitMessage.generate: 32b919d18cfaca89383f6000dcc9c031 + git.commitMessage.generatedAi: f919c1ee7bc85e658b2dcd3d2cc5ae92 + git.commitMessage.generatedFallback: a78024340c98d9720ad07ab55cb8f951 + git.commitMessage.generateFromDiff: 9defded1246bfd62d8bd089d0a600e84 + git.commitMessage.generating: f93642ae2d90147d24e974a5649c6622 + git.commitMessage.noChanges: 12d5dae6c75361dfbfeaddd092aea647 + git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2 + git.toast.autoGitFailed: 3e5d7e183a78e1f54c87ad0dba8a302b + git.toast.commitFailed: bb50986d29c30042bed1446f8adaa544 + git.toast.missingAuthor: 0d4e7a7d6b6a3f883146dd86c9cdca09 + command.view.editorOnly: 8355e056b32086b9190d639be290dda8 + command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0 + command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507 + command.view.toggleProperties: 54f12756a363401243d82cbd0466117e + command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86 + command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b + command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801 + command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de + command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263 + command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c + command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca + command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74 + command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e + command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b + command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7 + command.view.moveViewUp: 76f92d3250e028609349d825c672f13e + command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f + command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7 + command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711 + command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6 + command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9 + command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046 + command.settings.createEmptyVault: d7c9673bb6c62a2b8aef009546ce8b23 + command.settings.openVault: c40e74822da9417fdf52eb4a37bb23f2 + command.settings.removeVault: 390e2b2031cde0dd18381efee0923bea + command.settings.restoreGettingStarted: b3358dc1c7b873b2a02ac6f05473fe4d + command.settings.manageExternalAi: c36b9bd171f11a18a1e624365d2b57ac + command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a + command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54 + command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a + command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166 + command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f + command.settings.useSystemTheme: 6d8c69d916d3df8ceed8dfc154196d07 + command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 + command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23 + command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2 + command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2 + command.ai.switchDefault: 42724de254240d598fbcc40fdb5b18a3 + command.ai.switchDefaultWithAgent: d405f7914bb1cd86172f108f3c33d953 + settings.title: f4f70727dc34561dfde1a3c529b6205c + settings.close: 7812b3644f897c5d3efb0dd63440e751 + settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9 + settings.sync.description: 06a6957c40867be2c28783183dc1bf5f + settings.pullInterval: 4f95ca677398604bca241427261ed07b + settings.pullIntervalDescription: 4a75c15f44d59dc8cb100ec2d0693048 + settings.releaseChannel: 2da37055e15a217f18bd403922085c3f + settings.releaseChannelDescription: c5ae9b7f25376df383d36603366ac1fa + settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a + settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598 + settings.automaticUpdateChecks: 95e5c6aab9c42787930040ed1743a708 + settings.automaticUpdateChecksDescription: 1cecffe438411cac2f910946d0706ab1 + settings.workspaces.title: 10075fec14fcc45a489d02bedf972e97 + settings.workspaces.enable: f418b797fc4d13ca6b59333326ac3246 + settings.workspaces.enableDescription: 1487bd26586b773c77c01746399cc4cd + settings.workspaces.name: 49ee3087348e8d44e1feda1917443987 + settings.workspaces.label: b021df6aac4654c454f46c77646e745f + settings.workspaces.slug: 0c908588520b3ef787bce443fc2b507c + settings.workspaces.nameAria: a94a5bbfbc6bbcff81961501be487703 + settings.workspaces.labelAria: f6a0693020980910eaa8b168b30b33c3 + settings.workspaces.slugAria: 1d9e314005db037b4a83a220a45f3d77 + settings.workspaces.nameTooltip: 5c12e3507377eadae5c2340462dda553 + settings.workspaces.labelTooltip: b1b87a2affcd504e54bf97100e3d9c34 + settings.workspaces.slugTooltip: daa690f5855e35d1cfa9dbffe1870d40 + settings.workspaces.color: cb5feb1b7314637725a2e73bdc9f7295 + settings.workspaces.moveUpAria: 2ade43872ef9432d14af83056304c6d7 + settings.workspaces.moveDownAria: ed1ef4a310797ac84894a224bf4c77a6 + settings.workspaces.removeAria: a5d2ceeeb76bfcb99015fc780ced07c2 + settings.appearance.title: a1c58e94227389415de133efdf78ea6e + settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592 + settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30 + settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82 + settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2 + settings.theme.system: a45da96d0bf6575970f2d27af22be28a + settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174 + settings.language.description: e2bb9b523067fdc32a494b1d6fd97906 + settings.language.label: 244c7b77926f077b863c33ff1244e1ec + settings.language.system: 41e2252344aa441e925589ddcb762e6d + settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997 + settings.autogit.title: 0bcc70105ad279503e31fe7b3f47b665 + settings.git.enable: c40503e1b0f483eecf3aff2fd01665c4 + settings.git.enableDescription: a96a43dc4dbf1490d665eef437807b07 + settings.git.provider: 6a38305c8010acae3c04706773e0546f + settings.git.providerDescription: 7f23d6e2c5e462daf774c700ad6af2b6 + settings.git.providerNative: 2098510f1f33aa3fa4c1ea19ff543813 + settings.git.providerWsl: c28d793001f0044df477bfdfe280f88c + settings.git.wslDistro: cd2dfc009491c44acaa7d1c0d62689bc + settings.git.wslDistroDescription: 3acc5588d7c36c4a4dff199048d20eb3 + settings.git.wslDefaultDistro: 32266d0a0c5b47f0c7593ab569f21a2b + settings.git.providerTest: 96467c8e8ddf142f09616c1dcdb4193d + settings.git.providerTestDescription: cd60ba8d1135b9de4b5cdf1a19a3a31b + settings.git.providerTesting: 1e9731b0f033754e9df1973d1fd77b0f + settings.git.providerTestOk: 572542bead66201e0e49fa7c6ff18555 + settings.git.providerTestFailed: 3d0b8c3ec6a5a258bb3d6af98652c979 + settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018 + settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51 + settings.autogit.description.gitDisabled: 55a1dd2d7f797062cde1374930221649 + settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75 + settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554 + settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6 + settings.autogit.idleThresholdDescription: 521140491e8c9d5a1d7b7739bc333358 + settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474 + settings.autogit.inactiveThresholdDescription: f9e43c90bd10bae937c2cfa5c2767f1c + settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef + settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c + settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67 + settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e + settings.vaultContent.title: f15c1cae7882448b3fb0404682e17e61 + settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d + settings.dateDisplay.default: 915e8ca934abe6625e17695ee7ae266a + settings.dateDisplay.defaultDescription: 502ed7ff7b9c0fe5971e8cb0576e91f6 + settings.dateDisplay.us: abe1c573145c4419ba8bb158cf0068d6 + settings.dateDisplay.european: 0b4f509278b2919973c5d1ae906c162b + settings.dateDisplay.friendly: 81e56440280f6dfaef0eb503ca301578 + settings.dateDisplay.iso: 89b4ef461b67eb141eadd0fc6caa8d8d + settings.noteWidth.default: 66be0f882801fd19468181f31f58dc20 + settings.noteWidth.defaultDescription: 2b5fb6d7dfa2b1da1e47bcac30f58e8d + settings.noteWidth.normal: 960b44c579bc2f6818d2daaf9e4c16f0 + settings.noteWidth.wide: e7c770a61dbdf81ca922ae0260e327c1 + settings.sidebarTypePluralization.label: 1e5bd615b1abbf39129ef241f5cf2249 + settings.sidebarTypePluralization.description: 8d7bb0649fd7e637552429ea91298de8 + settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e + settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6 + settings.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3 + settings.allNotesVisibility.description: bc9caa48296281401aad694fed65a2d9 + settings.allNotesVisibility.pdfs: 27ce5e1e1761ff362dbf75c76c6b4941 + settings.allNotesVisibility.pdfsDescription: 8191ed4a095cdd60ef856ce0ff213a48 + settings.allNotesVisibility.images: ea15460e63b6e8e92f8c03a79eeb6e4e + settings.allNotesVisibility.imagesDescription: 1704e10d5e5cdc17d2d2a5fb7b8969bf + settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5 + settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a + settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47 + settings.aiAgents.description: 768c138c3432fb159ac58c5a4471e786 + settings.aiFeatures.enable: af47a6b2bd32a7b7309bac7bef34f026 + settings.aiFeatures.enableDescription: 7d3916f7f9e1bf8b8797f50f4533b9da + settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24 + settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5 + settings.aiAgents.agentGroup: 11eaa7f817e3f24d54f3cf3025be49b1 + settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5 + settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff + settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff + settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9 + settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37 + settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22 + settings.aiAgents.apiReady: e5b06f6ef4ef5f4bc2848d46195e3f48 + settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57 + settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf + settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053 + settings.aiAgents.installedTitle: 0de6536215f76c44351ba4d809804c7a + settings.aiAgents.installedDescription: 27c8ed48439ac4a613409f5303fbca84 + settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33 + settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1 + settings.aiProviders.description: d29dfa440713d15de311d29a64a27767 + settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce + settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39 + settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc + settings.aiProviders.apiDescription: 6ec773352e05a805af3abcebeff4e756 + settings.aiProviders.kind: 27703c8f150ac4bb0a3a83a7857353af + settings.aiProviders.kind.ollama: c23e8db458397f37c8e8d98e94e55a18 + settings.aiProviders.kind.lmStudio: 7b9b319662715e90583e823a6c3cfdbe + settings.aiProviders.kind.openAi: 0523b13262b12c215d8009938f5c14f1 + settings.aiProviders.kind.anthropic: f431db65cea024a5f19eab835afefee2 + settings.aiProviders.kind.gemini: 766cc4dd4d5005652e8514e3513683f8 + settings.aiProviders.kind.openRouter: a0607683815b6585fb80e7cac72ac59a + settings.aiProviders.kind.compatible: 1a4dc90a183c26d9026c6ac7727aa4cd + settings.aiProviders.name: 49ee3087348e8d44e1feda1917443987 + settings.aiProviders.baseUrl: ade86bc4899761ad46c52e381b6228bb + settings.aiProviders.model: 6bd36c36869288188863bd53a9bc5ed1 + settings.aiProviders.key: 656a6828d7ef1bb791e42087c4b5ee6e + settings.aiProviders.keyPlaceholder: 57dbf3720af3f156018bfff7f5a99b23 + settings.aiProviders.keyStorage: 3e5b8cfbc66d3a33dad3fda058bf5851 + settings.aiProviders.keyStorage.local: 7c6189f734f3c66d162a3f06529af1d9 + settings.aiProviders.keyStorage.env: a71b071971d7c54e9af381c9bea093d9 + settings.aiProviders.keyStorage.none: 94c841331ccd5179a283487f96285c90 + settings.aiProviders.keyEnv: cb72671bd5cf65512d4c94e8ad6182ef + settings.aiProviders.keySafety: 0d59d7e4def35e51c4bb785d74180afc + settings.aiProviders.keySafetyLocal: 8ef2ba0192ac725fa1a3e737e852387c + settings.aiProviders.localSafety: 60fb65e4e0d35cec3135584d8219e8fd + settings.aiProviders.add: a9f8e8b4ca3cbc4fc3bff5d59d6a5419 + settings.aiProviders.addLocal: 681985f2e412f22fcd55a0553f6e4bee + settings.aiProviders.addApi: 767b46f2a51aa9dd050cb226cd86e192 + settings.aiProviders.test: 602009d5aea1c1ef06fd777968f7e80e + settings.aiProviders.testing: 9d770c909c2c69b09eae2372c4cf405d + settings.aiProviders.testSuccess: 51677ed54231e41a4fe40850d90b64b2 + settings.aiProviders.empty: 9378027dec7f93fcc5011c5e528bdf82 + settings.aiProviders.defaultEndpoint: 666b355c305dd4c7b4649d3d7b4c5d11 + settings.aiProviders.keyLocalSaved: f4a102d6a67212e21f0f31f0758aea81 + settings.aiProviders.keyEnvSaved: bfed2d745dd76dbea9341d27e06c39f2 + settings.aiProviders.noKey: 1ab089f80ef4dc6c1ce3e173f5b405a0 + settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e + settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45 + settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361 + settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24 + settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243 + settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7 + settings.privacy.title: aa96a21412def0d916f43b639424f8e4 + settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27 + settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4 + settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42 + settings.privacy.analytics: 29d55bb222ea76e1a17396375dfab228 + settings.privacy.analyticsDescription: a0f8b61d555d6d3fd279de2f47aaca76 + settings.footerShortcut: 7dc840d6b0ef9f422a663ba41975871e + settings.cancel: ea4788705e6873b424c65e91c2846b19 + settings.save: c9cc8cce247e49bae79f15173ce97354 + common.cancel: ea4788705e6873b424c65e91c2846b19 + common.create: 686e697538050e4664636337cc3b834f + common.save: c9cc8cce247e49bae79f15173ce97354 + common.remove: 1063e38cb53d94d386f21227fcd84717 + customize.color: cb5feb1b7314637725a2e73bdc9f7295 + customize.icon: 817434295a673aed431435658b65d9a7 + customize.searchIcons: 0688a8098567785a05cb4cf937cbb880 + customize.noIconsFound: 89c25bea716a589b11599f2bbcf34054 + customize.template: 278c491bdd8a53618c149c4ac790da34 + customize.templatePlaceholder: 44482755791f96f37118f3a3bc66f668 + customize.done: f92965e2c8a7afb3c1b9a5c09a263636 + viewDialog.title.create: 01e53f288ebe0b7bdda19c8151aa7710 + viewDialog.title.edit: 6369f1c063cb2c22efa584a19e98d97f + viewDialog.description.create: a9600940be61edfcad2b47fb72965406 + viewDialog.description.edit: d4e6333950d5031c5f4ae766a1f1d886 + viewDialog.nameLabel: 49ee3087348e8d44e1feda1917443987 + viewDialog.namePlaceholder: dbb077d36957a33c4147eb24919192e1 + viewDialog.filtersLabel: f3f43e30c8c7d78c6ac0173515e57a00 + viewDialog.saveError: 80fe81f3ca09ea03af03fdb9ae809eda + viewDialog.selectedIcon: 5f98b507ccec0bbc51ca7e8a5b270250 + viewDialog.selectedColor: 192ec1a9973f328c3620cd3fa670c745 + ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122 + ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55 + ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd + ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057 + ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057 + ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03 + ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e + ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c + ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae + ai.panel.status.checking: 118740af1c4521b917e29791d661db82 + ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0 + ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b + ai.panel.mode.chat: 55dcdf017b51fc96f7b5f9d63013b95d + ai.panel.mode.chatDescription: d7744ae39e7920a8eac7b21b58fce348 + ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25 + ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9 + ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b + ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7 + ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b + ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb + ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b + ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26 + ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950 + ai.panel.empty.withContextTitle: cea2547385aeeb68fa494bb3b09b98f5 + ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9 + ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb + ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54 + ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3 + ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9 + ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114 + ai.panel.send: 747c6a3564774149c989884e0c581d53 + ai.panel.stop: 4c7fadd610e7adf3e0ab80fa4459ad96 + ai.panel.stoppedResponse: d223fbbd5ade7a06a14ea75105df4f9d + ai.error.claude.tooManyRedirects: 590099fedac46db892be91909814b0c7 + ai.error.pi.emptyOutput: f7e27de4f237828178113efbf60e8e52 + ai.error.pi.emptyOutputWithDiagnostic: 41a47dab0883245a1ac42013368e2da4 + ai.message.regenerate: e464a5330788a9f79d06c96989f840cb + ai.message.copy: 53fa249ce3d02c8a686b657b31d02ee7 + ai.message.fork: f5aace5e5b5e7b0954651a2da44b6755 + ai.message.reasoning: 87a222577e9c7b7a0739d92337f4fe46 + ai.message.toolUse: d6093cfe0c95f805875e5724eecd20aa + ai.workspace.title: 375099f0c804e924c1dc275370710d92 + ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9 + ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f + ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56 + ai.workspace.close: 184649ab030e9a609488608221c28d8c + ai.workspace.expandPanel: edbe17b531457367c1412e4b6349c194 + ai.workspace.restorePanel: 64eb6176c678c6b395cfc93bac02a16f + ai.workspace.restoreGuidance: 2bd339d85ee3b33e513359ce781b60cc + ai.workspace.closeChat: 6c5be35688a466297db40c3a65ac9805 + ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416 + ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4 + ai.workspace.settings: 2c178b1d7a60a3c2b37ca4221d322899 + ai.workspace.archive: a02bc089273a8c460ea3d2cd2c257b59 + ai.workspace.restore: 98ee8b364870e27dd48aca3240801abf + ai.workspace.showArchived: ae2bcb4cb837cc09a5767153efe4bf39 + ai.workspace.hideArchived: e7c72757d5bc7330d876eeb1b6ce9c53 + ai.workspace.noActiveChats: f0cdbf09f23eb9a2efbb5977bcc39c1a + ai.workspace.noArchivedChats: 68410efd12fc0308ce1ed2225000d4d5 + ai.workspace.chatTitle: 189013cfac88018c68ec02e8edc2379b + ai.workspace.renameChat: 0feba25e166f55afc51c4ebb33d8d757 + ai.workspace.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5 + ai.workspace.status.idle: e599161956d626eda4cb0a5ffb85271c + ai.workspace.status.running: 5bda814c4aedb126839228f1a3d92f09 + ai.workspace.status.done: f92965e2c8a7afb3c1b9a5c09a263636 + ai.workspace.status.error: 902b0d55fddef6f8d651fe1035b7d4bd + ai.workspace.permissionMode: 5a7983f759c9e15ce53d7326f0abad71 + ai.workspace.guidanceWarning: 00d95da9bc82394785728dec587b026e + ai.workspace.targetLabel: df5dd38c32585fe0c71c955b3a31f83c + ai.workspace.targetLocalAgents: 5a6a65e960093bfc37b4cc81c4a9f773 + ai.workspace.targetLocalModels: c7c20443d8aaf04271df9d713f5c02ce + ai.workspace.targetApiModels: 66537b9e5200ebbbfbd091144d8afbcc + ai.workspace.noTargets: 7c3ed1f10281b41070e5a5cbe77453d7 + ai.workspace.target.localAgents: 5a6a65e960093bfc37b4cc81c4a9f773 + ai.workspace.target.localModels: c7c20443d8aaf04271df9d713f5c02ce + ai.workspace.target.apiModels: 66537b9e5200ebbbfbd091144d8afbcc + ai.workspace.target.none: 7c3ed1f10281b41070e5a5cbe77453d7 + ai.workspace.target.unavailable: 453e6aa38d87b28ccae545967c53004f + ai.workspace.target.installed: 98dd43dfae05b11befe1f140e0ec787a + ai.workspace.target.installedVersion: 8677756d761e4cce240a637805482946 + ai.workspace.target.localModel: d34f4b997ce78da5aa57d657a5d6fcb5 + ai.workspace.target.apiModel: 589a257cbd265eaa7de93de8b4084dff + window.minimize: d27532d90ecd513e97ab811c0f34dbfd + window.maximize: 9369ba148ee259473fc1fb4939b6c2e8 + window.restore: 2bd339d85ee3b33e513359ce781b60cc + window.close: d3d2e617335f08df83599665eef8a418 + mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01 + mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c + mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa + mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094 + mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3 + mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652 + mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac + mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee + mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871 + mcp.setup.runtimeRequirement: 1d7a8c99b2bc875b3601fa57860432fb + mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6 + mcp.setup.clientPathsDescription: 541cd3d86aa9af81a6ab3a9d6f921a09 + mcp.setup.geminiGuidanceDescription: c6d8625d56151afce0bb455504490d84 + mcp.setup.manual.title: b240f1ff497fcaa83e6cd489739f11b6 + mcp.setup.manual.standardTitle: d2bc27dce31d27aec7612b638d443b95 + mcp.setup.manual.opencodeTitle: 5c38a2f5fe774f4cb886fcfa11a1acc4 + mcp.setup.manual.copyStandard: 89932d9339adf088de66947e8b7d4768 + mcp.setup.manual.copyOpenCode: dede4ad6a0b29559ea3bed6f796cd0fa + mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea + mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd + mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239 + mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54 + mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638 + mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf + mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a + mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf + mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed + mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e + mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee + save.toast.saved: 248336101b461380a4b2391a7625493d + save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f + save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68 + save.error.failed: b55b9fefe93fabae3c277e4e7956a534 + save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5 + save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995 + savedViews.reordered: 531081a566b826eed2e714ce70a0de08 + locale.en: 78463a384a5aa4fad5fa73e2f506ecfc + sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08 + sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5 + sidebar.nav.archive: e727b00944f81e1d0a95c12886ac4641 + sidebar.group.favorites: 953bf4d5e8a1807c15dec4e255384b1d + sidebar.group.views: 8f56a72923a6ca8ff53462533d89e567 + sidebar.group.types: 6b20155008db90cf7589c07baa7334e9 + sidebar.group.folders: d86e2756f698bea5aac3e2276639f042 + sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639 + sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9 + sidebar.action.renameView: 429b903fddf39bb21887282d188c4b62 + sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39 + sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e + sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e + sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e + sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703 + sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0 + sidebar.action.renameType: fda33d75aadfece7de1e1cc61186d99f + sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe + sidebar.action.deleteType: c52e15c44f6372c6ddb2758254a041ef + sidebar.typeDelete.blockedInUseOne: be7a2deea4ea29e8d5ec02f1fe2433c0 + sidebar.typeDelete.blockedInUseMany: 2b5c044e5c4cce91649de1f1942d0463 + sidebar.typeDelete.missingDefinition: fa8320bdc0d2b4e7e6a2dc4a8a97a311 + sidebar.action.createType: 6cfb8b8aa3fdce38e675819691b48f5c + sidebar.action.collapse: 00873bdddd457791609bff1243c40a6b + sidebar.action.expand: 8f98e9696f6eed958573012f52710faa + sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010 + sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019 + sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517 + sidebar.action.createNodeInFolderMenu: 857a479229a4ab0bc62bbfb621d3180a + sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4 + sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2 + sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2 + sidebar.action.deleteFolderMenu: c78c889ac7396dc148fc859c9221e545 + sidebar.view.name: 67b904f75d1872abba088a3442caff40 + sidebar.folder.name: 710417c4e57a6a01500fe4c0c448ce3d + sidebar.folder.newName: e0ad5b2db20359a85de80c1231323bea + sidebar.folder.expand: b7545cb143816942ff096d890090fb6f + sidebar.folder.collapse: e9f8c6da708219313d72d99cc3998388 + sidebar.section.name: c3642037e309db278bbbfe0590114c02 + sidebar.section.showInSidebar: e38d073926d6fb3dfc4d4347708e3cf5 + sidebar.section.toggle: 2992ffbc76e607a2dc0e99781f101637 + sidebar.disabled.comingSoon: 81151a1669ebd695e837f304a0d8ec79 + noteList.title.archive: e727b00944f81e1d0a95c12886ac4641 + noteList.title.changes: c112bb3542e98308d12d5ecb10a67abc + noteList.title.inbox: 3882d32c66e7e768145ecd8f104b0c08 + noteList.title.history: 16d2b386b2034b9488996466aaae0b57 + noteList.title.view: 4351cfebe4b61d8aa5efa1d020710005 + noteList.title.notes: f4c6f851b00d5518bf888815de279aba + noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e + noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd + noteList.clearSearch: 9983381c21069a3d6e4432e0aaea0079 + noteList.quickOpenCreate: a4e964a3bc5b25e7ae87163624731e05 + noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af + noteList.context.renameNote: c31c2b468229232ad6287e734fe67d96 + noteList.rename.title: c31c2b468229232ad6287e734fe67d96 + noteList.rename.description: dad38b42b70ad1518d65a3063bba6708 + noteList.rename.nameLabel: 1351017ac6423911223bc19a8cb7c653 + noteList.rename.cancel: ea4788705e6873b424c65e91c2846b19 + noteList.rename.confirm: 904a8304056d77e4547744781b7ceb50 + noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4 + noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f + noteList.empty.noArchived: 75fa9e2aaa902f65d433be856a2e8331 + noteList.empty.noMatching: 22838e3b8d3d174d33d7986ab6bdd693 + noteList.empty.allOrganized: ea8fbb54d21c03fae1469635e7043b9b + noteList.empty.noNotes: 910bd677fdd52f3e6edb4abc1d03ade8 + noteList.empty.noMatchingItems: 25cd76c4bbd00b682a45687705b41a14 + noteList.empty.noRelatedItems: 67eecd103b56ef0e56fd892d379b5a5e + noteList.sort.modified: 35e0c8c0b180c95d4e122e55ed62cc64 + noteList.sort.created: 0eceeb45861f9585dd7a97a3e36f85c6 + noteList.sort.title: b78a3223503896721cca1303f776159b + noteList.sort.status: ec53a8c4f07baed5d8825072c89799be + noteList.sort.by: 5cd12b67b005056a94f06368a7645b8e + noteList.sort.menu: 62bfadcf958eb6ad238e6c71e312a0ce + noteList.sort.ascending: cf3fb1ff52ea1eed3347ac5401ee7f0c + noteList.sort.descending: e3cf5ac19407b1a62c6fccaff675a53b + noteList.filter.open: c3bf447eabe632720a3aa1a7ce401274 + noteList.filter.archived: 7d69b3cb4cada18ae61811304f8fbcb5 + noteList.filter.week: d2ce009594dcc60befa6a4e6cbeb71fc + noteList.filter.month: 7cbb885aa1164b390a0bc050a64e1812 + noteList.filter.all: b1c94ca2fbc3e78fc30069c8d0f01680 + noteList.properties.customizeColumns: be39bb193d872e11bb21388add6eca23 + noteList.properties.customizeAllColumns: 0d382ece9a9d08bb9ddf10ed2c88d726 + noteList.properties.customizeInboxColumns: e7cf53b914ce1079801eeee603875660 + noteList.properties.customizeViewColumns: a5612e5d2aa2e86941d536a85b27c8b4 + noteList.properties.showInNoteList: f22a57c5fc14ff477000cc66d837c344 + noteList.properties.searchPlaceholder: 9c7569eb531ff041b2b7cf4de1e5a619 + noteList.properties.searchLabel: de1bc9695d5e79a75296abf74dc25056 + noteList.properties.noMatches: 1627ecc53b9e97c94b2b6b1b93a58721 + noteList.properties.reorder: c0955c7efaa1ce90d449a23cbc52b553 + noteList.changes.restoreNote: d6e99303c0e0b7b2abce06fe9214788b + noteList.changes.discardChanges: 98313f623bb6f464b9a154eca0b99bf3 + noteList.changes.restoreDescription: 45e13380b0538e6cbff330f99c260c0a + noteList.changes.discardDescription: cd953f51a81e10f8c90135e0106a45d6 + noteList.changes.thisFile: 976b976e66879a470635bf0f660e81fc + noteList.changes.restore: 2bd339d85ee3b33e513359ce781b60cc + noteList.changes.discard: d94b42030b9785fd754d5c1754961269 + noteList.changes.cancel: ea4788705e6873b424c65e91c2846b19 + editor.empty.selectNote: a71ec7c80aefe49c59f6aba033bf453e + editor.empty.shortcuts: 1133fdf3ecef6a09dd9e2a185e1bd4ec + editor.raw.label: 7a9754c15264b0604aa31dfea32321ea + editor.sheet.loading: 59df3a3f0bb3834a820e8fbf6a515114 + editor.sheet.unavailable: f0920deeec857fa2251bb073b956364e + editor.sheet.context.auto: 06b9281e396db002010bde1de57262eb + editor.sheet.context.number: b2ee912b91d69b435159c7c3f6df7f5f + editor.sheet.context.percentage: 37be07209f53a5d636d5c904ca9ae64c + editor.sheet.context.currencyUsd: 3cb6cef8f5a71608c2a6903d2dfb6656 + editor.sheet.context.currencyEur: 24c64850c930dcaa85bbf03615fe2e78 + editor.sheet.context.insertRowAbove: 9e4b77d73f7e68e13150a59175bcb6d9 + editor.sheet.context.insertRowBelow: 410af72a850d577d370f3b19db951f29 + editor.sheet.context.insertColumnLeft: 951489ec15082daab6174f1f79e99035 + editor.sheet.context.insertColumnRight: d76ad5e27c1b1268ddd839b7b3f95108 + editor.sheet.context.deleteRow: d501f89526d0fe57c1dfb94aeb3fc234 + editor.sheet.context.deleteColumn: 864c8b59fdcfbf1d3872bc484c510c3b + editor.sheet.context.freezeRows: 5fa71e4def6c2b644894ee2af7285686 + editor.sheet.context.freezeColumns: e23bf93c6a51438420d26004fd51d7bd + editor.sheet.context.unfreezeRows: 7069b8a8fb5d9428c3c8814a1d9bc5e9 + editor.sheet.context.unfreezeColumns: b8bc2e9647448b820cdc20fd31cbdc52 + editor.sheet.context.decreaseDecimals: 6b515416941b465cbbcddaa74f6559c1 + editor.sheet.context.increaseDecimals: 07b8bdec0845dd1acbaebdebb0c15671 + editor.sheet.context.bold: 114c3050111d8b8ddd830b99ccebd246 + editor.sheet.context.italic: 1d874710ccdcd46b95397049d2e7500c + editor.sheet.context.wrapText: bcd38c54cc299e7f9d5d37f9b999c027 + editor.sheet.context.unwrapText: bde49101f3872b8d2bf6b08cccc6c667 + editor.sheet.context.clearFormatting: 4494691b92e1f195feb25bd63ee5468b + editor.sheet.formula.category.logical: e7a731a6342f126055b89b80d800a180 + editor.sheet.formula.category.math: b6980ca593af7e22633cafaa2afbd14a + editor.sheet.formula.category.lookup: 222642b2096833929903c20111fbd29d + editor.sheet.formula.category.text: 9dffbf69ffba8bc38bc4e01abf4b1675 + editor.sheet.formula.category.information: a82be0f551b8708bc08eb33cd9ded0cf + editor.sheet.formula.category.statistical: ad2ee48af836d71513e02e3b82b34e48 + editor.sheet.formula.category.dateTime: 2b0b7c9cbd6d6e9fa6b99aa06e828673 + editor.sheet.formula.category.financial: 35f156073cb9314e5ddcabd2d16f443c + editor.sheet.formula.category.engineering: ab0268fb8036a892dc341945cb7ae3be + editor.sheet.formula.description.generic: 0c79cdd955d17cfe5e586a4e14907be6 + editor.sheet.formula.description.abs: e49ccb55e16059eda228ec3a10f03ac9 + editor.sheet.formula.description.and: 86314c9bc9282ef7234090eb3428271e + editor.sheet.formula.description.average: 725636fff01291d97c3ff6c221a2859b + editor.sheet.formula.description.concat: 0706d31060e4b6d013351d606d783cc7 + editor.sheet.formula.description.concatenate: 0706d31060e4b6d013351d606d783cc7 + editor.sheet.formula.description.count: 3a44e7f9d53ba4b9043767837b703a42 + editor.sheet.formula.description.counta: cff7cefde504a7bfda26f6b025250647 + editor.sheet.formula.description.countif: a7f8ec539622fb56ad1a69a8f0749b99 + editor.sheet.formula.description.date: b0a194b65fecd9326ea5682824d78372 + editor.sheet.formula.description.day: 939afa1fa3226d5de31605a68bf5b504 + editor.sheet.formula.description.if: 0ed6716d89deab3daf691b52eac43da2 + editor.sheet.formula.description.iferror: 78985d37abb3c595234f2f0eebd49647 + editor.sheet.formula.description.ifs: 3260bbc522a06128a1b12e61512490e6 + editor.sheet.formula.description.index: 8074638b36f662023374715a46212475 + editor.sheet.formula.description.left: ba11420ae9f98af2d32e478623432ba8 + editor.sheet.formula.description.len: b2e40842023453512efd37aac98d0509 + editor.sheet.formula.description.lookup: 6bbaf0ac03847d9b9370fea658fdf996 + editor.sheet.formula.description.lower: c2a995fa02ce7f13a234e1d941dd4dbc + editor.sheet.formula.description.match: fd0a986f7a5d74695c8926c060da6bbd + editor.sheet.formula.description.max: 8a6b16d3edf2adf0b14b48ea681f7fca + editor.sheet.formula.description.min: e9b1a7cc7aab7c814fc3199a50486fbd + editor.sheet.formula.description.month: 0e1de9df4811e0f39e6dd1df237ad97b + editor.sheet.formula.description.not: af45222aa223d8464769ab8e92aa068d + editor.sheet.formula.description.now: b079b385cd81adf67130fe02cd0b70c3 + editor.sheet.formula.description.or: fd1436942b6c9d5d03331e98072b98b0 + editor.sheet.formula.description.right: 86696dc97461745b60f015851e900a6e + editor.sheet.formula.description.round: c55ffe7fccf229e07738384dd1941391 + editor.sheet.formula.description.rounddown: b5f24d2cb58ec0829680e4567b4d8a7d + editor.sheet.formula.description.roundup: e9770aba9a743b397694e26b3fa1b8e7 + editor.sheet.formula.description.sum: f3e468f4b6279f941b37a40d1799a4af + editor.sheet.formula.description.sumif: bf24baec6c2916df7328f0f244758225 + editor.sheet.formula.description.sumifs: e590b9b3b695a9e70192f60baeae5a76 + editor.sheet.formula.description.text: b35a184f1978cdd14355a31c8bc17a2b + editor.sheet.formula.description.today: 34b315129b960fc5616e33ab582d403d + editor.sheet.formula.description.trim: ed6e316cd71dc0e96a437e47834691d4 + editor.sheet.formula.description.upper: 68dc8fd2dd906b5ca5462fe1c2bd1479 + editor.sheet.formula.description.value: b1168b1edcb926027d0d939c2f61e91b + editor.sheet.formula.description.vlookup: d7b75f9a4829a2310d654e8e2ad9753d + editor.sheet.formula.description.xlookup: 6bbaf0ac03847d9b9370fea658fdf996 + editor.sheet.formula.description.year: 1bafda4ca42f9bc0002c2c63129bbe90 + editor.find.findLabel: 4cfa6c981549e990fe2344e4c805405e + editor.find.findPlaceholder: 4cfa6c981549e990fe2344e4c805405e + editor.find.replaceLabel: 0ebe6df8a3ac338e0512acc741823fdb + editor.find.replacePlaceholder: 0ebe6df8a3ac338e0512acc741823fdb + editor.find.matchCount: 29d3cd243c6850f5fbac283e79d6d6a7 + editor.find.noMatches: 3b470c1f6a10f58c8a8cb6b904577786 + editor.find.invalidRegex: 9704b2ccc6158864810a313702019d68 + editor.find.regexMustMatchText: 267728c2e7f2889b39847ee9b9b38891 + editor.find.previousMatch: afb1b7e6bbdfaa97114e1b519a817f13 + editor.find.nextMatch: 7f4d218b4f566ea7ff69ab8d912ba7b9 + editor.find.showReplace: 82c8fdb73dfe8baa1759a36c6c0fb282 + editor.find.hideReplace: a85baf9735f9acf26aa15b70f2ee55fb + editor.find.regex: 7486f19f279f95357c84706dd09ce9da + editor.find.matchCase: ba945280b8802ee1a7a50140e2fe94e2 + editor.find.close: b040fbda901d2135cad53e54e0127071 + editor.find.replace: 0ebe6df8a3ac338e0512acc741823fdb + editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680 + editor.blockType.paragraph: feaf0a320c3d678ad30dd179b7d21584 + editor.blockType.heading1: fd7500ca2aba5ce6ba82e8dee1ffe5f4 + editor.blockType.heading2: ea59935a58010a876f94374fb2f41d51 + editor.blockType.heading3: 652864eb81a28bd7164f1605ab52a26a + editor.blockType.heading4: 553ddd7695ba37dc6f821067d9f5da30 + editor.blockType.heading5: 8c08de90796fbc4c4aae36014c40eec4 + editor.blockType.heading6: a24b3f7226370522c2a0e7ffd542a031 + editor.blockType.quote: c48e929b2b1eabba2ba036884433345e + editor.blockType.bulletList: 0fc5eb4762a5088027c181896d394181 + editor.blockType.numberedList: bf087c065d8d3f2e77bc07a82c07df52 + editor.blockType.checklist: a5d727df2ce2d168bae39c78df4b9054 + editor.blockType.codeBlock: febf6b24daaaeced55c4cc4db7dc931b + editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d + editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec + editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44 + editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec + editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7 + editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485 + editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb + editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e + editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198 + editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5 + editor.toolbar.openNeighborhood: 80f659c99954ab7681266b5a1e5da6df + editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94 + editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372 + editor.toolbar.gitDiff: 51b46b6eac27ba484479246b875f76f5 + editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6 + editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1 + editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498 + editor.toolbar.openTableOfContents: b733f053ea3fde4a9acd589ec7f58c10 + editor.toolbar.closeTableOfContents: d3292972a10edd1a06a627f3552f760a + editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84 + editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b + editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41 + editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4 + editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27 + editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce + editor.toolbar.copyNoteGitUrl: 04a732b98f7585e3c3b5474688e0baac + editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0 + editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b + editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e + editor.sideMenu.expandSection: 2d8eda764f87bd48192245a238fe8b65 + editor.sideMenu.collapseSection: 3d3b02981ab3390bf66247c92a2d8dd9 + editor.sideMenu.expandItem: 16291fbe2d88fc603b23f97ed2bc22a3 + editor.sideMenu.collapseItem: 91750c64ed8141fdb63ffb1e878a11ad + editor.sideMenu.turnInto: c2d2b328447c4974e839564b054c72f8 + editor.formatting.highlight: 0b90582f4589d84be89f5b847d4d1ed1 + editor.formatting.highlightTooltip: c27173fea9ec1718030c5fa8440b2c78 + editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72 + editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944 + editor.whiteboard.permissionDeniedTitle: f40def4c1928438aa4ac86421fed9fd9 + editor.whiteboard.permissionDeniedBody: eb1399413cae00b1614695d3c5c7ac9f + editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9 + editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a + editor.imageImport.unsupportedHeic: 75481136eb7ca33ef4f82806765e406f + editor.imageImport.unsupported: 9008202219a82edb8e70700bf351ce60 + filePreview.copyDeepLink: b75833669968adbef634495636e3fe50 + deepLinks.copied: 6ada57681192daf3b4afa07ce7a30575 + deepLinks.error.ambiguousVault: 4365c6d5cf8c2c1165259b32d24a6274 + deepLinks.error.copyFailed: 57b74ecce2d00b4c327d15830f79410d + deepLinks.error.invalidScheme: 58eaa7ac701a0508cbae90c5d3837ce4 + deepLinks.error.malformedUrl: 4f85323322ef0a1d627df621edb749c7 + deepLinks.error.missingFile: 79a0c73176508c58b4d785fabaf7d8cc + deepLinks.error.missingPath: a6bb1c92f1a38476ff603a2d3ff81223 + deepLinks.error.missingVault: e2936e1f599c7ed229efb5d4a122fc4b + deepLinks.error.openFailed: 97901feafe25688fcc3fc4dcfaf82619 + deepLinks.error.outsideVault: fda2fd5675b596df7ba0e0b1a11ec47d + deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8 + deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f + deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6 + noteGitUrls.copied: 6832bf85abaa03a6304cd1cce073d747 + noteGitUrls.error.copyFailed: 1952ee267f473efe8e28ca90659f75f6 + noteGitUrls.error.unavailable: ed08c134bba26350fd28f780c6f1b55b + editor.slash.math: a49950aa047c2292e989e368a97a3aae + tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b + tableOfContents.close: d3292972a10edd1a06a627f3552f760a + tableOfContents.empty: e4f02ad69cbbce1ec65d14215e3d5871 + tableOfContents.emptyNoNote: 046d95682b747a30ed8a7b0b1d581629 + tableOfContents.navLabel: 598b8ae10dfce818e73d3218daddbdae + tableOfContents.untitledHeading: 93c2dde207965b383b7b22af005ebe4f + tableOfContents.expandHeading: 6353ab44fe75f5dd935789bdab8551a0 + tableOfContents.collapseHeading: 040b4c102283028831ea78713235e478 + editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347 + editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c + editor.filename.rename: c31c2b468229232ad6287e734fe67d96 + editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f + editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9 + editor.banner.archived: 7d69b3cb4cada18ae61811304f8fbcb5 + editor.banner.unarchive: 9fd02d2eb5ce348a74372eea202a0aec + editor.banner.conflict: 384fc5a4b31c662b1e3426a7ef56441e + editor.banner.keepMine: 544a2f2e8e09f93919f10e1920d1b69e + editor.banner.keepMineTooltip: 9988cb86b051dd728e2d2f852f0283e4 + editor.banner.keepTheirs: 46131020af44cc6ec4bfa0a8ff39e044 + editor.banner.keepTheirsTooltip: 9e5b845c4459747d6ab0df6438ccfac4 + inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9 + inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407 + inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29 + inspector.title.collidingProperties: bf71358d0af34696e88bea278eac046a + inspector.title.collidingPropertiesAria: ce5c3569f304cd69b0f33a6fc185f49b + inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629 + inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822 + inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4 + inspector.empty.invalidProperties: 2ee2429c1ffbabcda9f57f91fb6c0b9d + inspector.empty.fixInEditor: b86d4934630d68bc5333c0feb25e5f3f + inspector.properties.addProperty: 9820ade036bf1bdf80c0b7da24a4ce0a + inspector.properties.deleteProperty: f1d0ab48c8596108e949dfc90e13050b + inspector.properties.none: 6adf97f83acf6453d4a6a4b1070f3754 + inspector.properties.displayAs: 6e0b81cc9a0919bc2182df7a5762fd26 + inspector.properties.formatText: 9dffbf69ffba8bc38bc4e01abf4b1675 + inspector.properties.formatSheet: 83a293c58d996a90ac357b10ba864c83 + inspector.properties.workspace: 5b70a213f150f01f0776fa9481ef2ddf + inspector.properties.missingType: 9179080e7bcc72408f3de7cb944ff400 + inspector.properties.missingTypeAria: 582c2c46117cff0534d13e7c8a45a3ba + inspector.properties.searchWorkspaces: ebd1ca0ae74f6d6f5b32f18db699be11 + inspector.properties.noMatchingWorkspaces: a4339dbce88712e787c0615a961636f6 + inspector.properties.searchTypes: 84c8d94846183a79444bf36667e8d7ea + inspector.properties.noMatchingTypes: 9b64c31214171ba3b2d591743990b840 + inspector.properties.yes: 93cba07454f06a4a960172bbd6e2a435 + inspector.properties.no: bafd7322c6e97d25b6299b5d6fe8920b + inspector.properties.pickDate: e8e91fbba934dc8adfd7e433f5193911 + inspector.properties.valuePlaceholder: 689202409e48743b914713f96d93947c + inspector.properties.propertyName: 7e9982311d69d66d38809e5d85377a08 + inspector.relationship.add: ec211f7c20af43e742bf2570c3cb84f9 + inspector.relationship.addRelationship: d2b0cb45dcdbb00ee70db397c36f73ad + inspector.relationship.name: 7ed3fccc071d6939eff211b1a6fd9696 + inspector.relationship.noteTitle: f72ea00f174785710f0369b23c53963c + inspector.relationship.createAndOpen: 55bb53fea743c584c4024e7439c55bfe + inspector.info.title: 4059b0251f66a18cb56f544728796875 + inspector.info.modified: 35e0c8c0b180c95d4e122e55ed62cc64 + inspector.info.created: 0eceeb45861f9585dd7a97a3e36f85c6 + inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84 + inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719 + update.available: 992477975168b876be8e77ce2975e390 + update.checking: a07813cc05571f23ce8dd7039583d7da + update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3 + update.updateNow: 99b1054c0f320be9f109c877a5efd0b2 + update.dismiss: c8a59e7135a20b362f9c768b09454fdb + update.downloading: 2c7fdcafb08f831420f661810f826549 + update.readyRestart: 4d7487b02d955a44d599189ec383e469 + update.restartNow: 2d9c2140c53daf05855033aaceeaa8fd + status.update.check: eb1b4bb3667dd670210c7822badc2f1d + status.zoom.reset: dbf36ab275e5fbd256590e65aa1ca9a3 + status.feedback.contribute: 96ab5025e38aef43fdb73c9830795264 + status.feedback.label: 9887a4451812854f0f1b6f669a874307 + status.docs.open: 2066997d2262d4a9b79de68f255dc4a9 + status.docs.label: a3907cd461d8739aa3266047bc4b8c0c + status.theme.light: 4abf27a209985375949aaa426c7c7f3d + status.theme.dark: 541be2a55a52b518b8e510c476c7cc95 + status.settings.open: bae08226d065231df6f01b08cd681c9b + status.build.unknown: d250349dd489bdfeb0cb0750b8b3ff89 + status.vault.switch: 8a2ad262643c556ff0cfa99497aaa529 + status.vault.default: 5b70a213f150f01f0776fa9481ef2ddf + status.vault.createEmpty: f37c03f236fbf1516222360a936249e8 + status.vault.openLocal: a367344e0429a12a5cc9a6cecbbfcde5 + status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe + status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df + status.vault.availableHeader: 2be36e986c7859a439c30bc5696c15a9 + status.vault.manageWorkspaces: ecce6ef1f37e975762b1413d8bc7d21d + status.vault.includeWorkspace: fe8ee397206a29f2d91eea7b95e9c112 + status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e + status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9 + status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8 + status.vault.remove: 7f3b4f76df23626170940dbc55c70728 + status.vault.removeConfirmTitle: 713713c6d8c4eb8b75cf12ebaca44f96 + status.vault.removeConfirmMessage: 854744291c28ea9e18bed1110b0737d9 + status.vault.removeConfirmAction: 8b064b07e2f4f1e63d1d5087aead2648 + workspace.manager.title: ecce6ef1f37e975762b1413d8bc7d21d + workspace.manager.description: d0de6e81a4268a52f756f6073f938fc3 + workspace.manager.default: 7a1920d61156abc05a60135aefe8bc67 + workspace.manager.makeDefault: 581e972562013d920e9b4d364265829e + workspace.manager.label: ce0aa8f9a9fad0faf99b6185fbc09a1f + workspace.manager.alias: e89ff14f985995ab3af3bd8af66d019c + workspace.manager.mounted: 38ca22b706f01504d11883bc1984d45b + status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1 + status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91 + status.remote.aheadTitle: 1516f4b92671b83c17441b4cddf25a7a + status.remote.behindTitle: 55ee20469bc46b7c61e7515fbfdc0b5d + status.remote.ahead: 681557ace3a84e15060ffca1623b75e6 + status.remote.behind: 1825bb12c857a71861280e490e29debd + status.remote.add: f292f8ef85219f0acde6b66eb9c9c0f4 + status.remote.none: ed3f2ebe0d847584fd62fc9e0db2df33 + status.remote.noneDescription: f47636010c21c88a6f81cc41a962b26c + status.remote.noUpstream: 41c05263e8d71d526a70f01481ded660 + status.git.currentBranch: db28ec1bca968fc1dc7a2d32006afc8e + status.git.branchLine: cfda131b88d16e9f47db17a9a8c40102 + status.git.unknownBranch: 658240910e4c727d045070efe206beb6 + status.sync.syncing: b21888f3f83c224b3451901da243d00a + status.sync.conflict: f1d4ac54357cc0932f385d56814ba7e4 + status.sync.failed: 85b08b086888f8ee2680ddd3398f3574 + status.sync.pullRequired: 1ea27a2d3048b078a18665b24e8dcd91 + status.sync.notSynced: d18d6927f7ffe96f72e18b4da12e306b + status.sync.justNow: 7b9275ee2786ef410555041b92b36bb3 + status.sync.minutesAgo: b3ac76d1e2595531940035a8e2e2d13c + status.sync.resolveConflicts: b9f44cc5ee01c964f004f16e3a2833ca + status.sync.inProgress: 278d2bf6768809fbf6e97e7bb319a7c0 + status.sync.pullAndPush: e07c084a9e734a54bd079d5327a924e7 + status.sync.retry: ca43e18d4f930e8a3e6d403f31d30a39 + status.sync.now: c03fafa3a653240e6104348e1293bb88 + status.sync.synced: 5befab0dde764b6dd8b24a34dc30afa7 + status.sync.conflicts: d35cef9595a935771f5e8158f387227b + status.sync.error: 902b0d55fddef6f8d651fe1035b7d4bd + status.sync.status: 76590d5f9708692e3735891bdca40903 + status.sync.pull: 718f59718640c6506b3721fbc8bf3a4d + status.conflict.count: 46293d20ed2071fb30762b71fdaa5894 + status.offline.title: 3bf45a7003646cc4f963810b0b7ac0f5 + status.offline.label: 8d9da4bc0e49a50e09ac9f7e56789d39 + status.changes.view: 6d60d24d8f475ddaef94bbbc58514b5a + status.changes.label: c112bb3542e98308d12d5ecb10a67abc + status.commit.local: 6474fe7e2ed525df3da3eb447943bfcd + status.commit.push: a4aaf10ef20cf17a982bb5b6dad198e0 + status.commit.label: 59d5b10c3a447f036d85cb5ce524c96c + status.commit.openOnGitHub: 445ad18680a573c2aab062c3273ae16a + status.git.disabledTooltip: 6981fe5b4993e4b3299c774b185ecd70 + status.git.disabled: 7f822b0ee91b8922b0c41723da867bd1 + status.history.onlyGit: 7da5397c33ada1b73a81c5a449cbab16 + status.history.open: 802ec1fd97592612c19ebef7bebbb5c2 + status.history.label: 16d2b386b2034b9488996466aaae0b57 + status.mcp.notConnected: 7e8850f4a564088ced4f592996a39309 + status.mcp.unknown: 051ff29ff900592f75974d0ef554c2eb + status.claude.missing: 89021856e6152467f54ed33faed55e2f + status.claude.label: 38c66b824d5769e42c3866005296525b + status.claude.install: 1f4688bc2a2acc268811c9edcec810a1 + status.ai.noAgents: 0876a176a6297e79366d0a2935851f81 + status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318 + status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97 + status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7 + status.ai.defaultTarget: 65ea5bdb609833c4baffd2cbd0e86542 + status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f + status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182 + status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98 + status.ai.unavailable: 0d3e6e3afef545710aa24ed2ea4990af + status.ai.install: 349838fb1d851d3e2014b9fe39203275 + status.ai.installAgent: 78ac3395d977f8b86ca9a02f781f4af8 + status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519 + status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2 + status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e + status.ai.openWorkspace: 178247429f30251ce21265570b612e9f + status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9 + status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326 + status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d + pulse.title: 16d2b386b2034b9488996466aaae0b57 + pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd + pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956 + pulse.commitCount: 17dc5f11868c946f55ff8164d318856d + pulse.commitSingular: fffca4d67ea0a788813031b8bbc3b329 + pulse.commitPlural: a90814b38bbdfe717205f6d24183f6a7 + pulse.openOnGitHub: ddab0146d46f585f2ab36072c92a0048 + pulse.expandFiles: 53c567ce9f999dd937fc954c39853e81 + pulse.collapseFiles: 0ae74096d72faf840cd7d35635aa0cf9 + pulse.noActivity: cfbd8245c849f5f00b495d61ea14dfdf + pulse.emptyDescription: b5ee4968b62908444166182fe8e593c1 + pulse.retry: 6327b4e59f58137083214a1fec358855 + pulse.loadingActivity: 26bf6a6bc2fd8148cd8753542a0ddf86 + pulse.loading: 5f02f05c744a0f875aebb8625ae1486f + pulse.loadError: 63c17996fe219e6bd33b27d2b7ce0c96 + command.switchLanguage: 60f1a6760c7d1da0b1f7f6d0529608ee + locale.itIT: 4be8e06d27bca7e1828f2fa9a49ca985 + locale.frFR: ad225f707802ba118c22987186dd38e8 + locale.deDE: 86bc3115eb4e9873ac96904a4a68e19e + locale.ruRU: deba6920e70615401385fe1fb5a379ec + locale.esES: cdbe021be1976335ab583a845edf7ed6 + locale.ptBR: 26d5352ead4a731f3d11eb2e85d0e297 + locale.ptPT: 71bfc0d79772120b52c1c0782450ff84 + locale.es419: edbf64ac382b82a54795fb409beb54be + locale.zhCN: 1e81fc32fea0e9f3a978661e4b5e484d + locale.zhTW: d0bb8c23f6e4f8c8037b6774ba912036 + locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e + locale.koKR: d0bdb3cde477d82e766da05ebda50ccb + locale.vi: 7b80fae85640c16cdb0261bef0c27636 + locale.plPL: c730389bc8d99e59c867766babdd48b5 + locale.beBY: 0a6de4460095f398e8a45ed512a7538f + locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad + locale.idID: cc1c9b57607b4439578b68074a5e2d80 + locale.ukUA: e78a6fc14ad64f7a78386b20568ce95b + locale.svSE: 41171a0fcd362ce98b5f0f11398713b6 + locale.skSK: 9accc467b159e7ce158e53209c0cde2a diff --git a/lara.yaml b/lara.yaml new file mode 100644 index 0000000..1bde63f --- /dev/null +++ b/lara.yaml @@ -0,0 +1,38 @@ +version: "1.0.0" + +project: + instruction: > + Tolaria is a desktop knowledge-management app. Keep product names, CLI names, + markdown wikilinks, frontmatter keys, file paths, and placeholders like + {agent}, {zoom}, {language}, {name}, {label}, {file}, and {count} unchanged. + +locales: + source: en + target: + - it-IT + - fr-FR + - de-DE + - ru-RU + - es-ES + - pt-BR + - pt-PT + - es-419 + - zh-CN + - zh-TW + - ja-JP + - ko-KR + - vi + - pl-PL + - be-BY + - id-ID + - uk-UA + - sv-SE + - sk-SK + +files: + json: + include: + - "src/lib/locales/[locale].json" + exclude: [] + lockedKeys: [] + ignoredKeys: [] diff --git a/mcp-server/agent-instructions.js b/mcp-server/agent-instructions.js new file mode 100644 index 0000000..512e474 --- /dev/null +++ b/mcp-server/agent-instructions.js @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { vaultContext } from './vault.js' + +export async function readAgentInstructions(vaultPath) { + const instructionsPath = path.join(vaultPath, 'AGENTS.md') + try { + return { + path: instructionsPath, + content: await readFile(instructionsPath, 'utf8'), + } + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } +} + +export async function vaultContextWithInstructions(vaultPath) { + return { + ...(await vaultContext(vaultPath)), + agentInstructions: await readAgentInstructions(vaultPath), + } +} diff --git a/mcp-server/app-config-policy.json b/mcp-server/app-config-policy.json new file mode 100644 index 0000000..cd074b5 --- /dev/null +++ b/mcp-server/app-config-policy.json @@ -0,0 +1,20 @@ +{ + "current_namespace": "com.tolaria.app", + "legacy_namespace": "com.laputa.app", + "namespace_read_order": ["current", "legacy"], + "files": { + "settings": "settings.json", + "vaults": "vaults.json", + "last_vault": "last-vault.txt", + "ai_workspace_sessions": "ai-workspace-sessions.json", + "window_state": "window-state.json", + "ai_provider_secrets": "ai-provider-secrets.json" + }, + "read_order": [ + "preferred config root/current namespace", + "preferred config root/legacy namespace", + "platform config root/current namespace when different", + "platform config root/legacy namespace when different" + ], + "write_target": "preferred config root/current namespace" +} diff --git a/mcp-server/index.js b/mcp-server/index.js new file mode 100644 index 0000000..a2e8b1a --- /dev/null +++ b/mcp-server/index.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node +/** + * Tolaria MCP Server — lightweight vault tools for AI agents. + * + * These MCP tools provide Tolaria-specific capabilities alongside each + * app-managed agent's own Safe / Power User permission profile: + * + * - search_notes: full-text search across vault notes + * - get_vault_context: vault structure overview (types, note count, folders) + * - get_note: parsed frontmatter + content (convenience over raw cat) + * - create_note: create a new markdown note without overwriting existing files + * - open_note: signal Tolaria UI to open a note as a tab + * - highlight_editor: visually highlight a UI element (editor, tab, etc.) + * - refresh_vault: trigger vault rescan so new/modified files appear + */ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import WebSocket from 'ws' +import { createMcpToolService } from './tool-service.js' + +const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) +const WS_UI_URL = `ws://localhost:${WS_UI_PORT}` +const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}) +const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, +}) + +// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js). +// The bridge relays messages to all other clients (the React frontend). +let uiSocket = null +let reconnectTimer = null +let shutdownStarted = false +const RECONNECT_INTERVAL_MS = 3000 + +function connectUiBridge() { + if (shutdownStarted) return + + try { + const ws = new WebSocket(WS_UI_URL) + uiSocket = ws + ws.on('open', () => { + if (shutdownStarted) { + closeUiSocket() + return + } + console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`) + }) + ws.on('close', () => { + if (uiSocket === ws) uiSocket = null + scheduleUiReconnect() + }) + ws.on('error', () => { + // Silent — bridge may not be running yet, will retry + }) + } catch { + scheduleUiReconnect() + } +} + +function scheduleUiReconnect() { + if (shutdownStarted) return + + clearUiReconnectTimer() + reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS) + reconnectTimer.unref?.() +} + +function clearUiReconnectTimer() { + if (!reconnectTimer) return + + clearTimeout(reconnectTimer) + reconnectTimer = null +} + +function closeUiSocket() { + const socket = uiSocket + uiSocket = null + if (!socket) return + + socket.removeAllListeners() + socket.on('error', () => {}) + if (socket.readyState === WebSocket.CONNECTING) { + socket.terminate?.() + return + } + + try { + socket.close() + } catch { + // Ignore close races during process teardown. + } + socket.terminate?.() +} + +function broadcastUiAction(action, payload) { + if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return + uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload })) +} + +const toolService = createMcpToolService({ emitUiAction: broadcastUiAction }) + +const TOOLS = [ + { + name: 'search_notes', + description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query string' }, + limit: { type: 'number', description: 'Maximum number of results (default: 10)' }, + }, + required: ['query'], + }, + }, + { + name: 'get_vault_context', + description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + vaultPath: { type: 'string', description: 'Optional target vault root. Omit to inspect all active vaults.' }, + }, + }, + }, + { + name: 'list_vaults', + description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'get_note', + description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' }, + vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' }, + }, + required: ['path'], + }, + }, + { + name: 'create_note', + description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.', + annotations: LOCAL_CREATE_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' }, + content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter when needed.' }, + title: { type: 'string', description: 'Optional title used only when content is omitted.' }, + type: { type: 'string', description: 'Optional note type used only when content is omitted.' }, + is_a: { type: 'string', description: 'Legacy alias for type, used only when content is omitted.' }, + vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' }, + }, + required: ['path'], + }, + }, + { + name: 'open_note', + description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + vaultPath: { type: 'string', description: 'Optional target vault root when opening a note outside the default vault.' }, + }, + required: ['path'], + }, + }, + { + name: 'highlight_editor', + description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' }, + path: { type: 'string', description: 'Optional note path to associate with the highlight' }, + }, + required: ['element'], + }, + }, + { + name: 'refresh_vault', + description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.', + annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Optional specific note path that changed' }, + vaultPath: { type: 'string', description: 'Optional target vault root when refreshing a note outside the default vault.' }, + }, + }, + }, +] + +async function handleSearchNotes(args) { + const results = await toolService.searchNotes(args) + const text = results.length === 0 + ? 'No matching notes found.' + : results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n') + return { content: [{ type: 'text', text }] } +} + +async function handleVaultContext(args = {}) { + const ctx = await toolService.vaultContext(args) + return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] } +} + +async function handleListVaults() { + return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] } +} + +async function handleGetNote(args) { + const note = await toolService.readNote(args) + return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] } +} + +async function handleCreateNote(args = {}) { + const note = await toolService.createNote(args) + return { + content: [{ + type: 'text', + text: JSON.stringify(note, null, 2), + }], + } +} + +function handleOpenNote(args) { + // Refresh vault first so the new/modified note appears in the note list, + // then signal the UI to open it in a tab. + const { targetPath } = toolService.openNoteAsTab(args) + return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] } +} + +function handleHighlightEditor(args) { + toolService.highlightEditor(args) + return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } +} + +function handleRefreshVault(args) { + toolService.refreshVault(args) + return { content: [{ type: 'text', text: 'Vault refresh triggered' }] } +} + +const TOOL_HANDLERS = new Map([ + ['search_notes', handleSearchNotes], + ['get_vault_context', handleVaultContext], + ['list_vaults', handleListVaults], + ['get_note', handleGetNote], + ['create_note', handleCreateNote], + ['open_note', handleOpenNote], + ['highlight_editor', handleHighlightEditor], + ['refresh_vault', handleRefreshVault], +]) + +function callToolHandler(name, args) { + const handler = TOOL_HANDLERS.get(name) + if (!handler) throw new Error(`Unknown tool: ${name}`) + return handler(args) +} + +// --- Server setup --- + +const server = new Server( + { name: 'tolaria-mcp-server', version: '0.3.0' }, + { capabilities: { tools: {} } }, +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS, +})) + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + try { + return await callToolHandler(name, args) + } catch (error) { + return { + content: [{ type: 'text', text: `Error: ${error.message}` }], + isError: true, + } + } +}) + +async function shutdown(exitCode = 0) { + if (shutdownStarted) return + + shutdownStarted = true + clearUiReconnectTimer() + closeUiSocket() + + try { + await server.close() + } catch (error) { + console.error(`[mcp] Error while closing server: ${error.message}`) + } + + process.exitCode = exitCode + setImmediate(() => process.exit(exitCode)) +} + +async function main() { + const transport = new StdioServerTransport() + server.onclose = () => { + void shutdown(0) + } + process.stdin.once('end', () => { + void shutdown(0) + }) + process.stdin.once('close', () => { + void shutdown(0) + }) + process.once('SIGINT', () => { + void shutdown(0) + }) + process.once('SIGTERM', () => { + void shutdown(0) + }) + + connectUiBridge() + await server.connect(transport) + console.error('Tolaria MCP server running (vaults resolved per call)') +} + +main().catch((error) => { + console.error(error) + void shutdown(1) +}) diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 0000000..d209f21 --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1269 @@ +{ + "name": "tolaria-mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tolaria-mcp-server", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "gray-matter": "^4.0.3", + "ws": "^8.20.1" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz", + "integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", + "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..06b1513 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "tolaria-mcp-server", + "version": "0.1.0", + "description": "MCP server for Tolaria vault operations", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "node --test test.js tool-service.test.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "gray-matter": "^4.0.3", + "ws": "^8.20.1" + }, + "overrides": { + "@hono/node-server": "1.19.13", + "express-rate-limit": "8.2.2", + "fast-uri": "3.1.2", + "hono": "4.12.25", + "qs": "6.15.2", + "ip-address": "10.1.1", + "path-to-regexp": "8.4.0" + } +} diff --git a/mcp-server/test.js b/mcp-server/test.js new file mode 100644 index 0000000..65e633c --- /dev/null +++ b/mcp-server/test.js @@ -0,0 +1,608 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { + access, mkdtemp, mkdir, open, readFile, rm, writeFile, +} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { clearTimeout, setTimeout } from 'node:timers' +import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { + createNote, findMarkdownFiles, getNote, searchNotes, vaultContext, +} from './vault.js' +import { + appConfigBaseDirs, + appConfigFilePath, + requireVaultPath, + requireVaultPaths, +} from './vault-path.js' +import { vaultContextWithInstructions } from './agent-instructions.js' +import { evaluateBridgeRequest } from './ws-bridge.js' + +let tmpDir +const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault' +const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url)) + +before(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-')) + + await mkdir(path.join(tmpDir, 'project'), { recursive: true }) + await mkdir(path.join(tmpDir, 'note'), { recursive: true }) + + await writeTextFile(path.join(tmpDir, 'project', 'test-project.md'), `--- +title: Test Project +is_a: Project +status: Active +--- + +# Test Project + +This is a test project for the MCP server. +`) + + await writeTextFile(path.join(tmpDir, 'note', 'daily-log.md'), `--- +title: Daily Log +is_a: Note +--- + +# Daily Log + +Today I worked on the MCP server implementation. +`) + + await writeTextFile(path.join(tmpDir, 'note', 'hashtag-tags.md'), `--- +title: Hashtag Tags +type: Note +tags: [#abc, def, ghi] +--- + +# Hashtag Tags + +This note has AI-generated hashtag-style YAML tags. +`) + + await writeTextFile(path.join(tmpDir, 'project', 'second-project.md'), `--- +title: Second Project +type: Project +status: Draft +belongs_to: + - "[[project/test-project]]" +--- + +# Second Project + +Another project for testing list and context. +`) +}) + +after(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('findMarkdownFiles', () => { + it('should find all .md files recursively', async () => { + const files = await findMarkdownFiles(tmpDir) + assert.equal(files.length, 4) + assert.ok(files.some(f => f.endsWith('test-project.md'))) + assert.ok(files.some(f => f.endsWith('daily-log.md'))) + assert.ok(files.some(f => f.endsWith('second-project.md'))) + assert.ok(files.some(f => f.endsWith('hashtag-tags.md'))) + }) +}) + +describe('getNote', () => { + it('should read a note with parsed frontmatter', async () => { + const note = await getNote(tmpDir, 'project/test-project.md') + assert.equal(note.path, 'project/test-project.md') + assert.equal(note.frontmatter.title, 'Test Project') + assert.equal(note.frontmatter.is_a, 'Project') + assert.ok(note.content.includes('test project for the MCP server')) + }) + + it('should tolerate hashtag-style tags in malformed YAML frontmatter', async () => { + const note = await getNote(tmpDir, 'note/hashtag-tags.md') + assert.equal(note.path, 'note/hashtag-tags.md') + assert.equal(note.frontmatter.title, 'Hashtag Tags') + assert.equal(note.frontmatter.type, 'Note') + assert.deepEqual(note.frontmatter.tags, ['#abc', 'def', 'ghi']) + assert.ok(note.content.includes('has AI-generated hashtag-style YAML tags')) + }) + + it('should throw for missing notes', async () => { + await assert.rejects( + () => getNote(tmpDir, 'nonexistent.md'), + { code: 'ENOENT' } + ) + }) + + it('should reject absolute paths outside the vault', async () => { + await assertRejectsOutsideVault('laputa-mcp-outside-', outsideNote => outsideNote) + }) + + it('should reject traversal paths outside the vault', async () => { + await assertRejectsOutsideVault( + 'laputa-mcp-traversal-', + outsideNote => path.relative(tmpDir, outsideNote), + ) + }) +}) + +describe('createNote', () => { + it('creates a new markdown note inside the vault', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-')) + const content = `--- +type: Note +--- + +# MCP Created +` + + try { + const note = await createNote(vaultDir, 'note/mcp-created.md', content) + assert.equal(note.path, 'note/mcp-created.md') + assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + } + }) + + it('does not overwrite an existing note', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-')) + const notePath = path.join(vaultDir, 'existing.md') + await writeFile(notePath, '# Existing\n', 'utf-8') + + try { + await assert.rejects( + () => createNote(vaultDir, 'existing.md', '# Replacement\n'), + { code: 'EEXIST' }, + ) + assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n') + } finally { + await rm(vaultDir, { recursive: true, force: true }) + } + }) + + it('rejects absolute paths outside the vault', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-')) + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-')) + + try { + await assert.rejects( + () => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'), + { message: ACTIVE_VAULT_ERROR }, + ) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects outside paths before creating missing parent folders', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-')) + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-')) + const outsideParent = path.join(outsideDir, 'missing-parent') + + try { + await assert.rejects( + () => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'), + { message: ACTIVE_VAULT_ERROR }, + ) + await assert.rejects(() => access(outsideParent), { code: 'ENOENT' }) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) +}) + +describe('searchNotes', () => { + it('should find notes matching title', async () => { + const results = await searchNotes(tmpDir, 'Test Project') + assert.ok(results.length >= 1) + assert.equal(results[0].title, 'Test Project') + }) + + it('should find notes matching content', async () => { + const results = await searchNotes(tmpDir, 'MCP server') + assert.ok(results.length >= 1) + }) + + it('should return empty for no matches', async () => { + const results = await searchNotes(tmpDir, 'xyzzy-nonexistent-12345') + assert.equal(results.length, 0) + }) + + it('should respect limit', async () => { + const results = await searchNotes(tmpDir, 'project', 1) + assert.ok(results.length <= 1) + }) +}) + +describe('vaultContext', () => { + it('should return types, recent notes, and vault path', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(Array.isArray(ctx.types)) + assert.ok(Array.isArray(ctx.recentNotes)) + assert.equal(ctx.vaultPath, tmpDir) + }) + + it('should include known entity types', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(ctx.types.includes('Project')) + assert.ok(ctx.types.includes('Note')) + }) + + it('should include notes with hashtag-style tags in malformed YAML frontmatter', async () => { + const ctx = await vaultContext(tmpDir) + const note = ctx.recentNotes.find(entry => entry.path === 'note/hashtag-tags.md') + assert.ok(note) + assert.equal(note.title, 'Hashtag Tags') + assert.equal(note.type, 'Note') + }) + + it('should cap recent notes at 20', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(ctx.recentNotes.length <= 20) + }) + + it('should include path and title in recent notes', async () => { + const ctx = await vaultContext(tmpDir) + for (const note of ctx.recentNotes) { + assert.ok(note.path) + assert.ok(note.title) + } + }) + + it('should include folders', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(ctx.folders.includes('project/')) + assert.ok(ctx.folders.includes('note/')) + }) + + it('should report correct note count', async () => { + const ctx = await vaultContext(tmpDir) + assert.equal(ctx.noteCount, 4) + }) + + it('includes root AGENTS.md instructions when present', async () => { + const agentsPath = path.join(tmpDir, 'AGENTS.md') + await writeFile(agentsPath, '# Vault Rules\n\nUse this vault carefully.\n', 'utf-8') + + try { + const ctx = await vaultContextWithInstructions(tmpDir) + assert.deepEqual(ctx.agentInstructions, { + path: agentsPath, + content: '# Vault Rules\n\nUse this vault carefully.\n', + }) + } finally { + await rm(agentsPath, { force: true }) + } + }) + + it('reports null agent instructions when AGENTS.md is absent', async () => { + const ctx = await vaultContextWithInstructions(tmpDir) + assert.equal(ctx.agentInstructions, null) + }) +}) + +describe('evaluateBridgeRequest', () => { + it('accepts loopback UI requests from trusted origins', () => { + assert.deepEqual( + evaluateBridgeRequest({ + bridgeType: 'ui', + origin: 'http://localhost:5202', + remoteAddress: '127.0.0.1', + }), + { ok: true, reason: null }, + ) + }) + + it('rejects browser origins on the tool bridge', () => { + assert.deepEqual( + evaluateBridgeRequest({ + bridgeType: 'tool', + origin: 'https://evil.example', + remoteAddress: '127.0.0.1', + }), + { ok: false, reason: 'browser origins are not allowed on the tool bridge' }, + ) + }) + + it('rejects non-loopback clients even without an origin', () => { + assert.deepEqual( + evaluateBridgeRequest({ + bridgeType: 'ui', + origin: undefined, + remoteAddress: '192.168.1.10', + }), + { ok: false, reason: 'non-local client' }, + ) + }) +}) + +describe('requireVaultPath', () => { + it('returns the explicit configured vault path', () => { + assert.equal( + requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }), + '/tmp/Selected Vault', + ) + }) + + it('rejects missing vault paths instead of falling back to ~/Laputa', async () => { + const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-empty-config-')) + assert.throws( + () => requireVaultPaths({}, { configDir }), + /VAULT_PATH is required/, + ) + await rm(configDir, { recursive: true, force: true }) + }) + + it('returns all configured active vault paths with the primary vault first', () => { + assert.deepEqual( + requireVaultPaths({ + VAULT_PATH: '/tmp/Default Vault', + VAULT_PATHS: JSON.stringify(['/tmp/Default Vault', '/tmp/Second Vault']), + }), + ['/tmp/Default Vault', '/tmp/Second Vault'], + ) + }) + + it('loads active mounted vault paths from Tolaria config when env is vault-neutral', async () => { + const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-config-')) + const primaryVault = path.join(configDir, 'Primary Vault') + const secondaryVault = path.join(configDir, 'Secondary Vault') + const hiddenVault = path.join(configDir, 'Hidden Vault') + const configPath = path.join(configDir, 'com.tolaria.app', 'vaults.json') + + await mkdir(path.dirname(configPath), { recursive: true }) + await writeFile(configPath, JSON.stringify({ + active_vault: primaryVault, + vaults: [ + { label: 'Secondary', path: secondaryVault, mounted: true }, + { label: 'Hidden', path: hiddenVault, mounted: false }, + { label: 'Primary', path: primaryVault, mounted: true }, + ], + }), 'utf-8') + + try { + assert.deepEqual( + requireVaultPaths({}, { configDir }), + [primaryVault, secondaryVault], + ) + } finally { + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('uses one app config resolver for settings and vault registry files', async () => { + const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-shared-config-')) + const files = ['settings.json', 'vaults.json'] + + for (const fileName of files) { + const legacyPath = path.join(configDir, 'com.laputa.app', fileName) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, '{}', 'utf-8') + } + + try { + for (const fileName of files) { + assert.equal( + appConfigFilePath(fileName, { configDir }), + path.join(configDir, 'com.laputa.app', fileName), + ) + } + } finally { + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('loads macOS vault registry from the XDG-backed Tolaria config before Application Support', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-macos-home-')) + const primaryVault = path.join(homeDir, 'Primary Vault') + const legacyPlatformVault = path.join(homeDir, 'Legacy Platform Vault') + const xdgConfigPath = path.join(homeDir, '.config', 'com.tolaria.app', 'vaults.json') + const platformConfigPath = path.join( + homeDir, + 'Library', + 'Application Support', + 'com.tolaria.app', + 'vaults.json', + ) + + await mkdir(path.dirname(xdgConfigPath), { recursive: true }) + await mkdir(path.dirname(platformConfigPath), { recursive: true }) + await writeFile(xdgConfigPath, JSON.stringify({ active_vault: primaryVault, vaults: [] }), 'utf-8') + await writeFile( + platformConfigPath, + JSON.stringify({ active_vault: legacyPlatformVault, vaults: [] }), + 'utf-8', + ) + + try { + assert.deepEqual( + requireVaultPaths({}, { + configDirs: appConfigBaseDirs({ env: {}, homeDir, platformName: 'darwin' }), + }), + [primaryVault], + ) + } finally { + await rm(homeDir, { recursive: true, force: true }) + } + }) +}) + +describe('stdio process lifecycle', () => { + it('advertises local vault tools as approval-safe for MCP clients', async () => { + const { client, stderr } = await connectMcpClient() + + try { + const { tools } = await client.listTools() + const toolsByName = new Map(tools.map(tool => [tool.name, tool])) + const safeReadTools = [ + 'search_notes', + 'get_vault_context', + 'list_vaults', + 'get_note', + 'open_note', + 'highlight_editor', + 'refresh_vault', + ] + + for (const name of safeReadTools) { + const tool = toolsByName.get(name) + assert.ok(tool, `Missing MCP tool: ${name}`) + assert.equal(tool.annotations?.readOnlyHint, true, `${name} should not require destructive approval`) + assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`) + assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`) + } + + const createTool = toolsByName.get('create_note') + assert.ok(createTool, 'Missing MCP tool: create_note') + assert.equal(createTool.annotations?.readOnlyHint, false) + assert.equal(createTool.annotations?.destructiveHint, false) + assert.equal(createTool.annotations?.openWorldHint, false) + } finally { + await closeMcpClient(client, stderr) + } + }) + + it('creates a note through the MCP create_note tool', async () => { + const { client, stderr } = await connectMcpClient() + const relativePath = 'note/mcp-tool-created.md' + const absolutePath = path.join(tmpDir, relativePath) + const content = `--- +type: Note +--- + +# MCP Tool Created +` + + try { + await rm(absolutePath, { force: true }) + const result = await client.callTool({ + name: 'create_note', + arguments: { path: relativePath, content }, + }) + + assert.equal(await readFile(absolutePath, 'utf-8'), content) + assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/) + } finally { + await rm(absolutePath, { force: true }) + await closeMcpClient(client, stderr) + } + }) + + it('exits when the MCP client closes stdin', async () => { + const child = spawn(process.execPath, ['index.js'], { + cwd: MCP_SERVER_DIR, + env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' }, + stdio: ['pipe', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', chunk => { + stderr += chunk + }) + + await sleep(200) + child.stdin.end() + + const exit = await waitForExit(child, 1_500) + if (!exit) { + child.kill() + await waitForExit(child, 1_000) + assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`) + } + + assert.equal(exit.signal, null) + assert.equal(exit.code, 0, stderr) + }) +}) + +async function connectMcpClient() { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['index.js'], + cwd: MCP_SERVER_DIR, + env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' }, + stderr: 'pipe', + }) + const stderr = collectTransportStderr(transport) + const client = new Client( + { name: 'tolaria-mcp-test-client', version: '0.0.0' }, + { capabilities: {} }, + ) + + await client.connect(transport) + return { client, stderr } +} + +function collectTransportStderr(transport) { + const chunks = [] + transport.stderr?.setEncoding('utf8') + transport.stderr?.on('data', chunk => { + chunks.push(chunk) + }) + return () => chunks.join('') +} + +async function closeMcpClient(client, stderr) { + try { + await client.close() + } catch (error) { + assert.fail(`Failed to close MCP test client: ${error.message}\n${stderr()}`) + } +} + +async function assertRejectsOutsideVault(prefix, resolveNotePath) { + const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix)) + const outsideNote = path.join(outsideDir, 'outside.md') + + try { + await writeTextFile(outsideNote, '# Outside\n') + await assert.rejects( + () => getNote(tmpDir, resolveNotePath(outsideNote)), + { message: ACTIVE_VAULT_ERROR }, + ) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } +} + +async function writeTextFile(filePath, content) { + const handle = await open(filePath, 'w') + try { + await handle.writeFile(content, 'utf-8') + } finally { + await handle.close() + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function waitForExit(child, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup() + resolve(null) + }, timeoutMs) + + child.once('exit', onExit) + + function onExit(code, signal) { + cleanup() + resolve({ code, signal }) + } + + function cleanup() { + clearTimeout(timer) + child.off('exit', onExit) + } + }) +} diff --git a/mcp-server/tool-service.js b/mcp-server/tool-service.js new file mode 100644 index 0000000..f533d8b --- /dev/null +++ b/mcp-server/tool-service.js @@ -0,0 +1,213 @@ +import path from 'node:path' +import { + createNote as createVaultNote, + getNote, + searchNotes as searchVaultNotes, +} from './vault.js' +import { requireVaultPaths } from './vault-path.js' +import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' + +export function createMcpToolService({ + resolveVaultPaths = () => requireVaultPaths(), + emitUiAction = () => {}, +} = {}) { + function activeVaultPaths() { + return resolveVaultPaths() + } + + function requestedVaultPath(args = {}) { + const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : '' + if (!requested) return null + if (!activeVaultPaths().includes(requested)) { + throw new Error(`Vault is not active in Tolaria: ${requested}`) + } + return requested + } + + function resolveUiPath(args = {}) { + const notePath = typeof args.path === 'string' ? args.path : '' + if (path.isAbsolute(notePath)) return notePath + const roots = activeVaultPaths() + const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '') + return vaultPath ? path.join(vaultPath, notePath) : notePath + } + + async function readNote(args = {}) { + return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args)) + } + + async function searchNotes(args = {}) { + const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10 + const results = [] + + for (const vaultPath of activeVaultPaths()) { + const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit) + results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath))) + if (results.length >= requestedLimit) break + } + + return results.slice(0, requestedLimit) + } + + async function vaultContext(args = {}) { + const targetVaultPath = requestedVaultPath(args) + const roots = activeVaultPaths() + if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath) + if (roots.length === 1) return vaultContextWithInstructions(roots[0]) + + return { + vaults: await Promise.all(roots.map(vaultContextWithInstructions)), + } + } + + async function listVaults() { + const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => { + const agentInstructions = await readAgentInstructions(vaultPath) + return { + path: vaultPath, + label: vaultLabel(vaultPath), + agentInstructionsPath: agentInstructions?.path ?? null, + hasAgentInstructions: agentInstructions !== null, + } + })) + + return { vaults } + } + + async function createNote(args = {}) { + const vaultPath = writableVaultPath(args) + const notePath = writableNotePath(args, vaultPath) + const note = await createVaultNote(vaultPath, notePath, createNoteContent(args)) + const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath }) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_tab', { path: targetPath }) + return { path: note.path, absolutePath: note.absolutePath, vaultPath } + } + + function openNoteAsTab(args = {}) { + const targetPath = resolveUiPath(args) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_tab', { path: targetPath }) + return { targetPath } + } + + function openNoteInEditor(args = {}) { + const targetPath = resolveUiPath(args) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_note', { path: targetPath }) + return { targetPath } + } + + function highlightEditor(args = {}) { + emitUiAction('highlight', { element: args.element, path: args.path }) + } + + function setFilter(args = {}) { + emitUiAction('set_filter', { filterType: args.type }) + } + + function refreshVault(args = {}) { + emitUiAction('vault_changed', { path: resolveUiPath(args) }) + } + + async function getNoteFromActiveVaults(notePath, vaultPath = null) { + const candidates = vaultPath ? [vaultPath] : activeVaultPaths() + const matches = [] + const errors = [] + + for (const candidate of candidates) { + try { + matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate)) + } catch (error) { + errors.push(error) + } + } + + if (matches.length === 1) return matches[0] + if (matches.length > 1) { + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) + } + throw errors[0] ?? new Error(`Note not found: ${notePath}`) + } + + function writableVaultPath(args = {}) { + const requested = requestedVaultPath(args) + if (requested) return requested + + const roots = activeVaultPaths() + const notePath = notePathArg(args) + if (path.isAbsolute(notePath)) { + const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) + if (root) return root + } + if (roots.length === 1) return roots[0] + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) + } + + return { + activeVaultPaths, + createNote, + highlightEditor, + listVaults, + openNoteAsTab, + openNoteInEditor, + readNote, + refreshVault, + requestedVaultPath, + resolveUiPath, + searchNotes, + setFilter, + vaultContext, + } +} + +function writableNotePath(args, vaultPath) { + const notePath = notePathArg(args) + if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath + return path.relative(vaultPath, notePath) +} + +function withVaultMetadata(note, vaultPath) { + return { + ...note, + vaultPath, + vaultLabel: vaultLabel(vaultPath), + } +} + +function vaultLabel(vaultPath) { + return path.basename(vaultPath) || vaultPath +} + +function isInsideVaultRoot(vaultPath, notePath) { + const relative = path.relative(vaultPath, notePath) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function notePathArg(args = {}) { + const notePath = typeof args.path === 'string' ? args.path.trim() : '' + if (!notePath) throw new Error('Note path is required') + return notePath +} + +function yamlScalar(value) { + return JSON.stringify(value) +} + +function fallbackCreateNoteContent(args = {}) { + const title = typeof args.title === 'string' && args.title.trim() + ? args.title.trim() + : path.basename(notePathArg(args), '.md') + const type = typeof args.type === 'string' && args.type.trim() + ? args.type.trim() + : typeof args.is_a === 'string' && args.is_a.trim() + ? args.is_a.trim() + : 'Note' + return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` +} + +function createNoteContent(args = {}) { + return typeof args.content === 'string' && args.content.trim() + ? args.content + : fallbackCreateNoteContent(args) +} diff --git a/mcp-server/tool-service.test.js b/mcp-server/tool-service.test.js new file mode 100644 index 0000000..ff1c709 --- /dev/null +++ b/mcp-server/tool-service.test.js @@ -0,0 +1,156 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createMcpToolService } from './tool-service.js' + +let tmpDir +let firstVault +let secondVault + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-')) + firstVault = path.join(tmpDir, 'First Vault') + secondVault = path.join(tmpDir, 'Second Vault') + + await seedVault(firstVault, { + 'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'), + 'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'), + }) + await seedVault(secondVault, { + 'AGENTS.md': '# Second Vault Rules\n', + 'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'), + 'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'), + }) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('createMcpToolService', () => { + it('requires vaultPath when reading an ambiguous note path', async () => { + const service = makeService() + + await assert.rejects( + () => service.readNote({ path: 'note/shared.md' }), + /Note path is ambiguous across active vaults/, + ) + + const note = await service.readNote({ + path: 'note/shared.md', + vaultPath: secondVault, + }) + + assert.equal(note.vaultPath, secondVault) + assert.equal(note.vaultLabel, 'Second Vault') + assert.match(note.content, /second vault/) + }) + + it('creates notes with fallback markdown and emits refresh and tab actions', async () => { + const emittedActions = [] + const service = makeService({ emittedActions }) + const absolutePath = path.join(secondVault, 'note/created.md') + + const note = await service.createNote({ + path: absolutePath, + title: 'Created From MCP', + type: 'Project', + }) + + assert.equal(note.path, 'note/created.md') + assert.equal(note.vaultPath, secondVault) + assert.equal(path.basename(note.absolutePath), 'created.md') + assert.equal( + await readFile(note.absolutePath, 'utf-8'), + '---\ntype: "Project"\n---\n\n# Created From MCP\n', + ) + assert.deepEqual(emittedActions, [ + { action: 'vault_changed', payload: { path: absolutePath } }, + { action: 'open_tab', payload: { path: absolutePath } }, + ]) + }) + + it('searches active vaults with consistent vault metadata', async () => { + const service = makeService() + + const results = await service.searchNotes({ query: 'Project', limit: 2 }) + + assert.equal(results.length, 2) + assert.deepEqual( + results.map(({ path: notePath, vaultPath, vaultLabel }) => ({ + notePath, + vaultPath, + vaultLabel, + })), + [ + { notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' }, + { notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' }, + ], + ) + }) + + it('lists active vaults with agent-instruction metadata', async () => { + const service = makeService() + + assert.deepEqual(await service.listVaults(), { + vaults: [ + { + path: firstVault, + label: 'First Vault', + agentInstructionsPath: null, + hasAgentInstructions: false, + }, + { + path: secondVault, + label: 'Second Vault', + agentInstructionsPath: path.join(secondVault, 'AGENTS.md'), + hasAgentInstructions: true, + }, + ], + }) + }) + + it('emits transport-neutral UI intents for note opening and filters', () => { + const emittedActions = [] + const service = makeService({ emittedActions }) + + service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault }) + service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault }) + service.highlightEditor({ element: 'editor', path: 'note/beta.md' }) + service.setFilter({ type: 'Project' }) + service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault }) + + assert.deepEqual(emittedActions, [ + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } }, + { action: 'set_filter', payload: { filterType: 'Project' } }, + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + ]) + }) +}) + +function makeService({ emittedActions = [] } = {}) { + return createMcpToolService({ + resolveVaultPaths: () => [firstVault, secondVault], + emitUiAction: (action, payload) => { + emittedActions.push({ action, payload }) + }, + }) +} + +async function seedVault(vaultPath, files) { + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(vaultPath, relativePath) + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, content, 'utf-8') + } +} + +function noteFixture(title, body) { + return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n` +} diff --git a/mcp-server/vault-path.js b/mcp-server/vault-path.js new file mode 100644 index 0000000..d3f2279 --- /dev/null +++ b/mcp-server/vault-path.js @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from 'node:fs' +import { homedir, platform } from 'node:os' +import { isAbsolute, join } from 'node:path' +import appConfigPolicy from './app-config-policy.json' with { type: 'json' } + +const APP_CONFIG_DIR = appConfigPolicy.current_namespace +const APP_CONFIG_FILES = Object.freeze(appConfigPolicy.files) + +function parseVaultPathList(rawValue) { + if (!rawValue?.trim()) return [] + + try { + const parsed = JSON.parse(rawValue) + if (Array.isArray(parsed)) return parsed.filter(value => typeof value === 'string') + } catch { + // Older clients only set VAULT_PATH; keep VAULT_PATHS strict JSON so paths + // with platform separators are never split incorrectly. + } + + return [] +} + +function uniqueVaultPaths(paths) { + const seen = new Set() + const unique = [] + for (const path of paths) { + const trimmed = path.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + unique.push(trimmed) + } + return unique +} + +function absolutePath(path) { + return typeof path === 'string' && isAbsolute(path) ? path : null +} + +function defaultXdgConfigHome(platformName, homeDir) { + if (platformName === 'win32') return null + return absolutePath(homeDir) ? join(homeDir, '.config') : null +} + +function platformConfigDir(env, platformName, homeDir) { + if (platformName === 'darwin') return join(homeDir, 'Library', 'Application Support') + if (platformName === 'win32') return absolutePath(env.APPDATA) || join(homeDir, 'AppData', 'Roaming') + return absolutePath(env.XDG_CONFIG_HOME) || defaultXdgConfigHome(platformName, homeDir) +} + +export function appConfigBaseDirs({ + env = process.env, + homeDir = homedir(), + platformName = platform(), + platformDir = platformConfigDir(env, platformName, homeDir), +} = {}) { + const primary = absolutePath(env.XDG_CONFIG_HOME) + || defaultXdgConfigHome(platformName, homeDir) + || platformDir + const dirs = primary ? [primary] : [] + if (platformDir && platformDir !== primary) dirs.push(platformDir) + return dirs +} + +function namespaceDir(namespace) { + if (namespace === 'current') return APP_CONFIG_DIR + if (namespace === 'legacy') return appConfigPolicy.legacy_namespace + throw new Error(`Unknown app config namespace: ${namespace}`) +} + +function preferredAppConfigPath(configDir, fileName) { + return join(configDir, APP_CONFIG_DIR, fileName) +} + +function existingOrPreferredAppConfigPath(configDirs, fileName) { + for (const configDir of configDirs) { + for (const namespace of appConfigPolicy.namespace_read_order) { + const candidate = join(configDir, namespaceDir(namespace), fileName) + if (existsSync(candidate)) return candidate + } + } + + return preferredAppConfigPath(configDirs[0], fileName) +} + +export function appConfigFilePath( + fileName, + { configDir, configDirs = configDir ? [configDir] : appConfigBaseDirs() } = {}, +) { + return existingOrPreferredAppConfigPath(configDirs, fileName) +} + +export function vaultsJsonPath({ + configDir, + configDirs = configDir ? [configDir] : appConfigBaseDirs(), +} = {}) { + return existingOrPreferredAppConfigPath(configDirs, APP_CONFIG_FILES.vaults) +} + +function pushUniquePath(paths, value) { + const path = typeof value === 'string' ? value.trim() : '' + if (!path || paths.includes(path)) return + paths.push(path) +} + +function activeVaultPathsFromList(list) { + const paths = [] + pushUniquePath(paths, list?.active_vault) + + for (const vault of list?.vaults ?? []) { + if (vault?.mounted === false) continue + pushUniquePath(paths, vault?.path) + } + + return paths +} + +export function configuredVaultPaths(options = {}) { + const filePath = vaultsJsonPath(options) + if (!existsSync(filePath)) return [] + + return activeVaultPathsFromList(JSON.parse(readFileSync(filePath, 'utf-8'))) +} + +export function requireVaultPaths(env = process.env, options = {}) { + const vaultPaths = uniqueVaultPaths([ + env.VAULT_PATH?.trim() ?? '', + ...parseVaultPathList(env.VAULT_PATHS), + ]) + if (vaultPaths.length === 0) { + const configuredPaths = configuredVaultPaths(options) + if (configuredPaths.length > 0) return configuredPaths + throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.') + } + return vaultPaths +} + +export function requireVaultPath(env = process.env, options = {}) { + return requireVaultPaths(env, options)[0] +} diff --git a/mcp-server/vault.js b/mcp-server/vault.js new file mode 100644 index 0000000..0c9ac3f --- /dev/null +++ b/mcp-server/vault.js @@ -0,0 +1,460 @@ +/** + * Vault operations — read-only helpers for Tolaria markdown vault. + * Most write operations are handled by the app-managed agent's active + * permission profile and native file-edit tools; createNote is intentionally + * narrow so read-only agents can create a new Markdown file without overwrite. + */ +import { mkdir, open, opendir, realpath } from 'node:fs/promises' +import path from 'node:path' +import matter from 'gray-matter' + +const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault' + +/** + * Recursively find all .md files under a directory. + * @param {string} dir + * @returns {Promise} + */ +export async function findMarkdownFiles(dir) { + const results = [] + const items = await opendir(dir) + for await (const item of items) { + await collectMarkdownFile(results, dir, item) + } + return results +} + +async function resolveVaultNotePath(vaultPath, notePath) { + const vaultRoot = await realpath(vaultPath) + const requestedPath = resolveRequestedNotePath(vaultRoot, notePath) + const noteRealPath = await realpath(requestedPath) + const relativePath = path.relative(vaultRoot, noteRealPath) + + if (!isVaultRelativePath(relativePath)) { + throw new Error(ACTIVE_VAULT_ERROR) + } + + return { + vaultRoot, + noteRealPath, + relativePath, + } +} + +/** + * Read a note with parsed frontmatter and content. + * @param {string} vaultPath + * @param {string} notePath + * @returns {Promise<{path: string, frontmatter: Record, content: string}>} + */ +export async function getNote(vaultPath, notePath) { + const { + noteRealPath, + relativePath, + } = await resolveVaultNotePath(vaultPath, notePath) + const raw = await readUtf8File(noteRealPath) + const parsed = parseMarkdownNote(raw) + return { + path: relativePath, + frontmatter: parsed.data, + content: parsed.content.trim(), + } +} + +/** + * Create a new markdown note inside the vault without overwriting an existing file. + * @param {string} vaultPath + * @param {string} notePath + * @param {string} content + * @returns {Promise<{path: string, absolutePath: string}>} + */ +export async function createNote(vaultPath, notePath, content) { + const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath) + await writeNewUtf8File(requestedPath, content) + return { + path: relativePath, + absolutePath: requestedPath, + } +} + +/** + * Search notes by title or content substring. + * @param {string} vaultPath + * @param {string} query + * @param {number} [limit=10] + * @returns {Promise>} + */ +export async function searchNotes(vaultPath, query, limit = 10) { + const files = await findMarkdownFiles(vaultPath) + const q = query.toLowerCase() + const results = [] + + for (const filePath of files) { + if (results.length >= limit) break + const content = await readUtf8File(filePath) + const filename = path.basename(filePath, '.md') + const titleMatch = extractTitle(content, filename) + if (!matchesSearchQuery(titleMatch, content, q)) continue + + const snippet = extractSnippet(content, q) + results.push({ + path: path.relative(vaultPath, filePath), + title: titleMatch, + snippet, + }) + } + + return results +} + +/** + * Get vault context: unique types, note count, top-level folders, and 20 most recent notes. + * @param {string} vaultPath + * @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>} + */ +export async function vaultContext(vaultPath) { + const files = await findMarkdownFiles(vaultPath) + const typesSet = new Set() + const foldersSet = new Set() + const notesWithMtime = [] + + for (const filePath of files) { + const { topFolder, note, type } = await readVaultContextNote(vaultPath, filePath) + if (type) typesSet.add(type) + if (topFolder) foldersSet.add(topFolder) + notesWithMtime.push(note) + } + + notesWithMtime.sort((a, b) => b.mtime - a.mtime) + const recentNotes = notesWithMtime.slice(0, 20).map(contextNoteWithoutMtime) + + return { + types: [...typesSet].sort(), + noteCount: files.length, + folders: [...foldersSet].sort(), + recentNotes, + configFiles: await readConfigFiles(vaultPath), + vaultPath, + } +} + +// --- Helpers --- + +async function collectMarkdownFile(results, dir, item) { + if (item.name.startsWith('.')) return + + const full = resolveInside(dir, item.name) + if (!full) return + if (item.isDirectory()) { + results.push(...await findMarkdownFiles(full)) + return + } + + if (item.name.endsWith('.md')) { + results.push(full) + } +} + +function resolveRequestedNotePath(vaultRoot, notePath) { + if (path.isAbsolute(notePath)) return notePath + const resolved = resolveInside(vaultRoot, notePath) + if (!resolved) throw new Error(ACTIVE_VAULT_ERROR) + return resolved +} + +async function resolveNewVaultNotePath(vaultPath, notePath) { + const requestedNotePath = validateNewNotePath(notePath) + const vaultRoot = await realpath(vaultPath) + const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath) + const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath) + await ensureWritableParentInsideVault(vaultRoot, requestedPath) + return { requestedPath, relativePath } +} + +function validateNewNotePath(notePath) { + const trimmedPath = typeof notePath === 'string' ? notePath.trim() : '' + if (!trimmedPath) { + throw new Error('Note path is required') + } + if (!trimmedPath.endsWith('.md')) { + throw new Error('New notes must be markdown files ending in .md') + } + return trimmedPath +} + +async function ensureWritableParentInsideVault(vaultRoot, requestedPath) { + const parentPath = path.dirname(requestedPath) + const existingAncestor = await nearestExistingAncestor(parentPath) + assertInsideVault(vaultRoot, existingAncestor) + await mkdir(parentPath, { recursive: true }) + assertInsideVault(vaultRoot, await realpath(parentPath)) +} + +async function nearestExistingAncestor(targetPath) { + let currentPath = targetPath + while (currentPath && currentPath !== path.dirname(currentPath)) { + try { + return await realpath(currentPath) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + currentPath = path.dirname(currentPath) + } + } + return realpath(currentPath) +} + +function assertInsideVault(vaultRoot, targetPath) { + if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) { + throw new Error(ACTIVE_VAULT_ERROR) + } +} + +function relativeNotePathInsideVault(vaultRoot, requestedPath) { + const relativePath = path.relative(vaultRoot, requestedPath) + if (!isVaultRelativePath(relativePath) || !relativePath) { + throw new Error(ACTIVE_VAULT_ERROR) + } + return relativePath +} + +function resolveInside(root, target) { + const resolved = path.resolve(root, target) + const relative = path.relative(root, resolved) + if (isVaultRelativePath(relative)) return resolved + return null +} + +function isVaultRelativePath(relativePath) { + return !relativePath.startsWith('..') && !path.isAbsolute(relativePath) +} + +function matchesSearchQuery(title, content, query) { + return title.toLowerCase().includes(query) || content.toLowerCase().includes(query) +} + +function contextNoteWithoutMtime(note) { + return { + path: note.path, + title: note.title, + type: note.type, + } +} + +async function readVaultContextNote(vaultPath, filePath) { + const raw = await readUtf8File(filePath) + const parsed = parseMarkdownNote(raw) + const rel = path.relative(vaultPath, filePath) + const topFolder = extractTopFolder(rel) + const stat = await statFile(filePath) + const type = parsed.data.type || parsed.data.is_a || null + + return { + topFolder, + type, + note: { + path: rel, + title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')), + type, + mtime: stat.mtimeMs, + }, + } +} + +function parseMarkdownNote(raw) { + try { + const parsed = matter(raw) + const fallback = parseFrontmatterFallback(raw) + return shouldUseFallbackFrontmatter(parsed, fallback) ? fallback : parsed + } catch { + return parseFrontmatterFallback(raw) + } +} + +function shouldUseFallbackFrontmatter(parsed, fallback) { + return Object.keys(parsed.data).length === 0 && Object.keys(fallback.data).length > 0 +} + +function parseFrontmatterFallback(raw) { + const split = splitFrontmatter(raw) + if (!split) return { data: {}, content: raw } + + return { + data: parseFrontmatterBlock(split.frontmatter), + content: split.content, + } +} + +function splitFrontmatter(raw) { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/) + if (!match) return null + return { frontmatter: match[1], content: match[2] } +} + +function parseFrontmatterBlock(frontmatter) { + const data = {} + let listKey = null + + for (const line of frontmatter.split(/\r?\n/)) { + const item = parseYamlListItem(line) + if (listKey && item !== null) { + data[listKey].push(parseYamlScalar(item)) + continue + } + + listKey = null + const field = parseTopLevelYamlField(line) + if (!field) continue + + data[field.key] = field.value ? parseYamlValue(field.value) : [] + listKey = field.value ? null : field.key + } + + return data +} + +function parseTopLevelYamlField(line) { + if (!line || line.trimStart() !== line || line.trimStart().startsWith('#')) return null + + const separatorIndex = line.indexOf(':') + if (separatorIndex <= 0) return null + + return { + key: stripMatchingQuotes(line.slice(0, separatorIndex).trim()), + value: line.slice(separatorIndex + 1).trim(), + } +} + +function parseYamlValue(value) { + if (value.startsWith('[') && value.endsWith(']')) { + return splitInlineYamlArray(value).map(parseYamlScalar) + } + return parseYamlScalar(value) +} + +function splitInlineYamlArray(value) { + const inner = value.slice(1, -1) + const items = [] + let current = '' + let quote = null + + for (const char of inner) { + if (quote) { + current += char + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") { + quote = char + current += char + continue + } + if (char === ',') { + items.push(current.trim()) + current = '' + continue + } + current += char + } + + if (current.trim()) items.push(current.trim()) + return items +} + +function parseYamlListItem(line) { + const match = line.match(/^\s+-\s*(.*)$/) + return match ? match[1].trim() : null +} + +function parseYamlScalar(value) { + const unquoted = stripMatchingQuotes(value.trim()) + if (unquoted !== value.trim()) return unquoted + + if (/^(true|yes)$/i.test(unquoted)) return true + if (/^(false|no)$/i.test(unquoted)) return false + if (/^(null|~)$/i.test(unquoted)) return null + if (/^-?\d+(\.\d+)?$/.test(unquoted)) return Number(unquoted) + + return unquoted +} + +function stripMatchingQuotes(value) { + const first = value[0] + const last = value[value.length - 1] + return (first === '"' || first === "'") && first === last ? value.slice(1, -1) : value +} + +function extractTopFolder(relativePath) { + const topFolder = relativePath.split(path.sep)[0] + return topFolder === relativePath ? null : `${topFolder}/` +} + +async function readConfigFiles(vaultPath) { + const configFiles = {} + + try { + const agentsPath = resolveInside(vaultPath, 'config/agents.md') + if (agentsPath) configFiles.agents = await readUtf8File(agentsPath) + } catch { + // config/agents.md may not exist yet + } + + return configFiles +} + +async function readUtf8File(filePath) { + const handle = await open(filePath, 'r') + try { + return await handle.readFile('utf-8') + } finally { + await handle.close() + } +} + +async function writeNewUtf8File(filePath, content) { + const handle = await open(filePath, 'wx') + try { + await handle.writeFile(content, 'utf-8') + } finally { + await handle.close() + } +} + +async function statFile(filePath) { + const handle = await open(filePath, 'r') + try { + return await handle.stat() + } finally { + await handle.close() + } +} + +/** + * Extract title from markdown content (first H1 or frontmatter title). + * @param {string} content + * @param {string} fallback + * @returns {string} + */ +function extractTitle(content, fallback) { + const h1Match = content.match(/^#\s+(.+)$/m) + if (h1Match) return h1Match[1].trim() + + const titleMatch = content.match(/^title:\s*(.+)$/m) + if (titleMatch) return titleMatch[1].trim() + + return fallback +} + +/** + * Extract a snippet around the query match. + * @param {string} content + * @param {string} query + * @returns {string} + */ +function extractSnippet(content, query) { + const body = content.replace(/^---[\s\S]*?---\n?/, '').trim() + const idx = body.toLowerCase().indexOf(query) + if (idx === -1) return body.slice(0, 120) + const start = Math.max(0, idx - 40) + const end = Math.min(body.length, idx + query.length + 80) + return (start > 0 ? '...' : '') + body.slice(start, end) + (end < body.length ? '...' : '') +} diff --git a/mcp-server/ws-bridge.js b/mcp-server/ws-bridge.js new file mode 100644 index 0000000..50b2a44 --- /dev/null +++ b/mcp-server/ws-bridge.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * WebSocket bridge for Tolaria MCP tools. + * + * Exposes vault operations over WebSocket so the Tolaria app frontend + * can invoke MCP tools in real-time without going through stdio. + * + * Port 9710: Tool bridge — Claude/AI clients call vault tools here. + * Port 9711: UI bridge — Frontend listens for UI action broadcasts. + * + * Usage: + * VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js + * + * Protocol (tool bridge): + * Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } } + * Server sends: { "id": "req-1", "result": { ... } } + * On error: { "id": "req-1", "error": "message" } + * + * Protocol (UI bridge): + * Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." } + */ +import { createServer } from 'node:http' +import { WebSocketServer } from 'ws' +import { createMcpToolService } from './tool-service.js' + +const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10) +const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) +const LOOPBACK_HOST = 'localhost' +const TRUSTED_UI_ORIGINS = new Set([ + 'tauri://localhost', + 'http://tauri.localhost', + 'https://tauri.localhost', +]) + +/** @type {WebSocketServer | null} */ +let uiBridge = null +const UNKNOWN_TOOL = Symbol('unknown tool') + +function broadcastUiAction(action, payload) { + if (!uiBridge) return + const msg = JSON.stringify({ type: 'ui_action', action, ...payload }) + for (const client of uiBridge.clients) { + if (client.readyState === 1) client.send(msg) + } +} + +const toolService = createMcpToolService({ emitUiAction: broadcastUiAction }) + +async function readNoteTool(args) { + const note = await toolService.readNote(args) + return { content: note.content, frontmatter: note.frontmatter } +} + +function uiOpenNoteTool(args) { + toolService.openNoteInEditor(args) + return { ok: true } +} + +function uiOpenTabTool(args) { + toolService.openNoteAsTab(args) + return { ok: true } +} + +async function createNoteTool(args = {}) { + return { ok: true, ...(await toolService.createNote(args)) } +} + +function highlightTool(args) { + toolService.highlightEditor(args) + return { ok: true } +} + +function uiSetFilterTool(args) { + toolService.setFilter(args) + return { ok: true } +} + +function refreshVaultTool(args) { + toolService.refreshVault(args) + return { ok: true } +} + +const TOOL_EXECUTORS = [ + ['open_note', readNoteTool], + ['read_note', readNoteTool], + ['create_note', createNoteTool], + ['search_notes', (args) => toolService.searchNotes(args)], + ['vault_context', (args) => toolService.vaultContext(args)], + ['list_vaults', () => toolService.listVaults()], + ['ui_open_note', uiOpenNoteTool], + ['ui_open_tab', uiOpenTabTool], + ['ui_highlight', highlightTool], + ['highlight_editor', highlightTool], + ['ui_set_filter', uiSetFilterTool], + ['refresh_vault', refreshVaultTool], +] + +function callToolHandler(tool, args) { + const executor = TOOL_EXECUTORS.find(([name]) => name === tool)?.[1] + return executor ? executor(args) : UNKNOWN_TOOL +} + +async function handleMessage(data) { + const msg = JSON.parse(data) + const { id, tool, args } = msg + + try { + const result = await callToolHandler(tool, args || {}) + if (result === UNKNOWN_TOOL) { + return { id, error: `Unknown tool: ${tool}` } + } + return { id, result } + } catch (err) { + return { id, error: err.message } + } +} + +export function isLoopbackAddress(remoteAddress) { + return remoteAddress === '127.0.0.1' + || remoteAddress === '::1' + || remoteAddress === '::ffff:127.0.0.1' +} + +export function isTrustedUiOrigin(origin) { + if (!origin) return true + if (TRUSTED_UI_ORIGINS.has(origin)) return true + return /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/u.test(origin) +} + +export function evaluateBridgeRequest({ bridgeType, origin, remoteAddress }) { + if (!isLoopbackAddress(remoteAddress)) { + return { ok: false, reason: 'non-local client' } + } + + if (bridgeType === 'tool' && origin) { + return { ok: false, reason: 'browser origins are not allowed on the tool bridge' } + } + + if (bridgeType === 'ui' && !isTrustedUiOrigin(origin)) { + return { ok: false, reason: 'untrusted UI origin' } + } + + return { ok: true, reason: null } +} + +function verifyBridgeRequest(bridgeType) { + return (info, done) => { + const verdict = evaluateBridgeRequest({ + bridgeType, + origin: info.origin, + remoteAddress: info.req.socket.remoteAddress, + }) + + if (!verdict.ok) { + console.error(`[ws-bridge] Rejected ${bridgeType} bridge client: ${verdict.reason}`) + done(false, 403, 'Forbidden') + return + } + + done(true) + } +} + +/** + * Attempt to start the UI bridge WebSocket server. + * Returns a Promise that resolves to the WebSocketServer or null if the port + * is unavailable (e.g. another Tolaria instance owns it). + */ +export function startUiBridge(port = WS_UI_PORT) { + return new Promise((resolve) => { + const httpServer = createServer() + + httpServer.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`) + } else { + console.error(`[ws-bridge] UI bridge error: ${err.message}`) + } + resolve(null) + }) + + httpServer.listen(port, LOOPBACK_HOST, () => { + const wss = new WebSocketServer({ + server: httpServer, + verifyClient: verifyBridgeRequest('ui'), + }) + wss.on('connection', (ws) => { + console.error(`[ws-bridge] UI client connected on port ${port}`) + // Relay: when a client sends a message, broadcast to all OTHER clients. + // This allows the MCP stdio server (connected as a client) to reach the frontend. + ws.on('message', (raw) => { + for (const client of wss.clients) { + if (client !== ws && client.readyState === 1) client.send(raw.toString()) + } + }) + }) + uiBridge = wss + console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`) + resolve(wss) + }) + }) +} + +export function startBridge(port = WS_PORT) { + const currentVaultPaths = toolService.activeVaultPaths() + const wss = new WebSocketServer({ + port, + host: LOOPBACK_HOST, + verifyClient: verifyBridgeRequest('tool'), + }) + + wss.on('connection', (ws) => { + console.error(`[ws-bridge] Client connected (vaults: ${currentVaultPaths.join(', ')})`) + + ws.on('message', async (raw) => { + try { + const response = await handleMessage(raw.toString()) + ws.send(JSON.stringify(response)) + } catch (err) { + ws.send(JSON.stringify({ error: `Parse error: ${err.message}` })) + } + }) + + ws.on('close', () => console.error('[ws-bridge] Client disconnected')) + }) + + console.error(`[ws-bridge] Listening on ws://${LOOPBACK_HOST}:${port}`) + return wss +} + +// Run directly if invoked as main module +const isMain = process.argv[1]?.endsWith('ws-bridge.js') +if (isMain) { + try { + toolService.activeVaultPaths() + startUiBridge().then(() => startBridge()) + } catch (err) { + console.error(`[ws-bridge] ${err.message}`) + process.exit(1) + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7475a23 --- /dev/null +++ b/package.json @@ -0,0 +1,156 @@ +{ + "name": "tolaria", + "private": true, + "license": "AGPL-3.0-or-later", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "agent-docs": "node scripts/build-agent-docs.mjs", + "bundle-mcp": "node scripts/bundle-mcp-server.mjs", + "docs:dev": "vitepress dev site --host 127.0.0.1", + "docs:build": "pnpm agent-docs && vitepress build site", + "docs:preview": "vitepress preview site --host 127.0.0.1", + "lint": "eslint . --max-warnings=0", + "l10n:translate": "lara-cli translate", + "l10n:translate:force": "lara-cli translate --force", + "l10n:validate": "node scripts/validate-locales.mjs", + "perf:editor": "node scripts/editor-performance-benchmark.mjs", + "perf:editor:update": "node scripts/editor-performance-benchmark.mjs --update", + "preview": "vite preview", + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/quick-open-create-note.spec.ts tests/smoke/save-before-note-switch.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/wikilink-path-fix.spec.ts", + "playwright:regression": "playwright test tests/smoke/", + "playwright:integration": "playwright test --config playwright.integration.config.ts", + "test:coverage": "node scripts/run-vitest-coverage.mjs", + "prepare": "husky" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@blocknote/code-block": "^0.46.2", + "@blocknote/core": "^0.46.2", + "@blocknote/mantine": "^0.46.2", + "@blocknote/react": "^0.46.2", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.2", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.16", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@ironcalc/wasm": "0.5.4", + "@ironcalc/workbook": "0.5.7", + "@lezer/highlight": "^1.2.3", + "@mantine/core": "^8.3.14", + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@sentry/react": "^10.47.0", + "@shikijs/langs": "3.23.0", + "@tailwindcss/vite": "^4.1.18", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.0", + "@tiptap/pm": "3.22.5", + "@tldraw/assets": "4.5.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dompurify": "3.4.2", + "katex": "^0.16.28", + "mermaid": "^11.14.0", + "posthog-js": "^1.363.5", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-day-picker": "^9.13.2", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.1", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "safe-regex2": "5.1.1", + "tailwind-merge": "^3.4.1", + "tailwindcss": "^4.1.18", + "tldraw": "^4.5.10", + "tw-animate-css": "^1.4.0", + "unicode-emoji-json": "^0.8.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", + "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@translated/lara-cli": "^1.3.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.18", + "esbuild": "^0.27.3", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "gray-matter": "^4.0.3", + "husky": "^9.1.7", + "jsdom": "^28.0.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.5", + "vitepress": "^1.6.4", + "vitest": "^4.0.18", + "ws": "^8.19.0" + }, + "pnpm": { + "overrides": { + "@hono/node-server": "1.19.13", + "express-rate-limit": "8.2.2", + "hono": "4.12.25", + "ip-address": "10.1.1", + "mermaid>uuid": "11.1.1", + "path-to-regexp": "8.4.0", + "fast-uri": "3.1.2", + "fast-xml-builder": "1.1.7", + "flatted": "3.4.2", + "minimatch@3.1.2": "3.1.5", + "minimatch@3.1.3": "3.1.5", + "minimatch@9.0.5": "9.0.9", + "minimatch@9.0.6": "9.0.9", + "picomatch": "4.0.4", + "postcss": "8.5.10", + "protobufjs": "7.6.3", + "linkify-it": "5.0.1", + "qs": "6.15.2", + "rollup": "4.59.0", + "undici": "7.25.0", + "@blocknote/core>uuid": "11.1.1" + }, + "patchedDependencies": { + "@blocknote/core@0.46.2": "patches/@blocknote__core@0.46.2.patch", + "@blocknote/react@0.46.2": "patches/@blocknote__react@0.46.2.patch", + "@tiptap/extension-link@3.19.0": "patches/@tiptap__extension-link@3.19.0.patch", + "prosemirror-tables@1.8.5": "patches/prosemirror-tables@1.8.5.patch", + "@blocknote/code-block@0.46.2": "patches/@blocknote__code-block@0.46.2.patch" + } + } +} diff --git a/patches/@blocknote__code-block@0.46.2.patch b/patches/@blocknote__code-block@0.46.2.patch new file mode 100644 index 0000000..e37b45c --- /dev/null +++ b/patches/@blocknote__code-block@0.46.2.patch @@ -0,0 +1,86 @@ +diff --git a/dist/blocknote-code-block.cjs b/dist/blocknote-code-block.cjs +index 1df95b16a98b56ff314d5069b20c864f831640e4..27b9e8fa03566327175e4d3dddb1aebee2950c71 100644 +--- a/dist/blocknote-code-block.cjs ++++ b/dist/blocknote-code-block.cjs +@@ -1,2 +1,2 @@ +-"use strict";var r=Object.create;var l=Object.defineProperty;var p=Object.getOwnPropertyDescriptor;var o=Object.getOwnPropertyNames;var n=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty;var h=(a,e,i,m)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of o(e))!c.call(a,t)&&t!==i&&l(a,t,{get:()=>e[t],enumerable:!(m=p(e,t))||m.enumerable});return a};var s=(a,e,i)=>(i=a!=null?r(n(a)):{},h(e||!a||!a.__esModule?l(i,"default",{value:a,enumerable:!0}):i,a));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=require("@shikijs/core"),u=require("@shikijs/engine-javascript"),j={c:()=>import("@shikijs/langs-precompiled/c"),cpp:()=>import("@shikijs/langs-precompiled/cpp"),"c++":()=>import("@shikijs/langs-precompiled/cpp"),css:()=>import("@shikijs/langs-precompiled/css"),glsl:()=>import("@shikijs/langs-precompiled/glsl"),graphql:()=>import("@shikijs/langs-precompiled/graphql"),gql:()=>import("@shikijs/langs-precompiled/graphql"),haml:()=>import("@shikijs/langs-precompiled/haml"),html:()=>import("@shikijs/langs-precompiled/html"),java:()=>import("@shikijs/langs-precompiled/java"),javascript:()=>import("@shikijs/langs-precompiled/javascript"),js:()=>import("@shikijs/langs-precompiled/javascript"),json:()=>import("@shikijs/langs-precompiled/json"),jsonc:()=>import("@shikijs/langs-precompiled/jsonc"),jsonl:()=>import("@shikijs/langs-precompiled/jsonl"),jsx:()=>import("@shikijs/langs-precompiled/jsx"),julia:()=>import("@shikijs/langs-precompiled/julia"),jl:()=>import("@shikijs/langs-precompiled/julia"),less:()=>import("@shikijs/langs-precompiled/less"),markdown:()=>import("@shikijs/langs-precompiled/markdown"),md:()=>import("@shikijs/langs-precompiled/markdown"),mdx:()=>import("@shikijs/langs-precompiled/mdx"),php:()=>import("@shikijs/langs-precompiled/php"),postcss:()=>import("@shikijs/langs-precompiled/postcss"),pug:()=>import("@shikijs/langs-precompiled/pug"),jade:()=>import("@shikijs/langs-precompiled/pug"),python:()=>import("@shikijs/langs-precompiled/python"),py:()=>import("@shikijs/langs-precompiled/python"),r:()=>import("@shikijs/langs-precompiled/r"),regexp:()=>import("@shikijs/langs-precompiled/regexp"),regex:()=>import("@shikijs/langs-precompiled/regexp"),sass:()=>import("@shikijs/langs-precompiled/sass"),scss:()=>import("@shikijs/langs-precompiled/scss"),shellscript:()=>import("@shikijs/langs-precompiled/shellscript"),bash:()=>import("@shikijs/langs-precompiled/shellscript"),sh:()=>import("@shikijs/langs-precompiled/shellscript"),shell:()=>import("@shikijs/langs-precompiled/shellscript"),zsh:()=>import("@shikijs/langs-precompiled/shellscript"),sql:()=>import("@shikijs/langs-precompiled/sql"),svelte:()=>import("@shikijs/langs-precompiled/svelte"),typescript:()=>import("@shikijs/langs-precompiled/typescript"),ts:()=>import("@shikijs/langs-precompiled/typescript"),vue:()=>import("@shikijs/langs-precompiled/vue"),"vue-html":()=>import("@shikijs/langs-precompiled/vue-html"),wasm:()=>import("@shikijs/langs-precompiled/wasm"),wgsl:()=>import("@shikijs/langs-precompiled/wgsl"),xml:()=>import("@shikijs/langs-precompiled/xml"),yaml:()=>import("@shikijs/langs-precompiled/yaml"),yml:()=>import("@shikijs/langs-precompiled/yaml"),tsx:()=>import("@shikijs/langs-precompiled/tsx"),typescriptreact:()=>import("@shikijs/langs-precompiled/tsx"),haskell:()=>import("@shikijs/langs-precompiled/haskell"),hs:()=>import("@shikijs/langs-precompiled/haskell"),"c#":()=>import("@shikijs/langs-precompiled/csharp"),csharp:()=>import("@shikijs/langs-precompiled/csharp"),cs:()=>import("@shikijs/langs-precompiled/csharp"),latex:()=>import("@shikijs/langs-precompiled/latex"),lua:()=>import("@shikijs/langs-precompiled/lua"),mermaid:()=>import("@shikijs/langs-precompiled/mermaid"),mmd:()=>import("@shikijs/langs-precompiled/mermaid"),ruby:()=>import("@shikijs/langs-precompiled/ruby"),rb:()=>import("@shikijs/langs-precompiled/ruby"),rust:()=>import("@shikijs/langs-precompiled/rust"),rs:()=>import("@shikijs/langs-precompiled/rust"),scala:()=>import("@shikijs/langs-precompiled/scala"),swift:()=>import("@shikijs/langs/swift"),kotlin:()=>import("@shikijs/langs-precompiled/kotlin"),kt:()=>import("@shikijs/langs-precompiled/kotlin"),kts:()=>import("@shikijs/langs-precompiled/kotlin"),"objective-c":()=>import("@shikijs/langs-precompiled/objective-c"),objc:()=>import("@shikijs/langs-precompiled/objective-c")},d={"github-dark":()=>import("@shikijs/themes/github-dark"),"github-light":()=>import("@shikijs/themes/github-light")},v=g.createdBundledHighlighter({langs:j,themes:d,engine:()=>u.createJavaScriptRegexEngine()}),x={defaultLanguage:"javascript",supportedLanguages:{text:{name:"Plain Text",aliases:["text","txt","plain"]},c:{name:"C",aliases:["c"]},cpp:{name:"C++",aliases:["cpp","c++"]},css:{name:"CSS",aliases:["css"]},glsl:{name:"GLSL",aliases:["glsl"]},graphql:{name:"GraphQL",aliases:["graphql","gql"]},haml:{name:"Ruby Haml",aliases:["haml"]},html:{name:"HTML",aliases:["html"]},java:{name:"Java",aliases:["java"]},javascript:{name:"JavaScript",aliases:["javascript","js"]},json:{name:"JSON",aliases:["json"]},jsonc:{name:"JSON with Comments",aliases:["jsonc"]},jsonl:{name:"JSON Lines",aliases:["jsonl"]},jsx:{name:"JSX",aliases:["jsx"]},julia:{name:"Julia",aliases:["julia","jl"]},less:{name:"Less",aliases:["less"]},markdown:{name:"Markdown",aliases:["markdown","md"]},mdx:{name:"MDX",aliases:["mdx"]},php:{name:"PHP",aliases:["php"]},postcss:{name:"PostCSS",aliases:["postcss"]},pug:{name:"Pug",aliases:["pug","jade"]},python:{name:"Python",aliases:["python","py"]},r:{name:"R",aliases:["r"]},regexp:{name:"RegExp",aliases:["regexp","regex"]},sass:{name:"Sass",aliases:["sass"]},scss:{name:"SCSS",aliases:["scss"]},shellscript:{name:"Shell",aliases:["shellscript","bash","sh","shell","zsh"]},sql:{name:"SQL",aliases:["sql"]},svelte:{name:"Svelte",aliases:["svelte"]},typescript:{name:"TypeScript",aliases:["typescript","ts"]},vue:{name:"Vue",aliases:["vue"]},"vue-html":{name:"Vue HTML",aliases:["vue-html"]},wasm:{name:"WebAssembly",aliases:["wasm"]},wgsl:{name:"WGSL",aliases:["wgsl"]},xml:{name:"XML",aliases:["xml"]},yaml:{name:"YAML",aliases:["yaml","yml"]},tsx:{name:"TSX",aliases:["tsx","typescriptreact"]},haskell:{name:"Haskell",aliases:["haskell","hs"]},csharp:{name:"C#",aliases:["c#","csharp","cs"]},latex:{name:"LaTeX",aliases:["latex"]},lua:{name:"Lua",aliases:["lua"]},mermaid:{name:"Mermaid",aliases:["mermaid","mmd"]},ruby:{name:"Ruby",aliases:["ruby","rb"]},rust:{name:"Rust",aliases:["rust","rs"]},scala:{name:"Scala",aliases:["scala"]},swift:{name:"Swift",aliases:["swift"]},kotlin:{name:"Kotlin",aliases:["kotlin","kt","kts"]},"objective-c":{name:"Objective C",aliases:["objective-c","objc"]}},createHighlighter:()=>v({themes:["github-dark","github-light"],langs:[]})};exports.codeBlockOptions=x; ++"use strict";var r=Object.create;var l=Object.defineProperty;var p=Object.getOwnPropertyDescriptor;var o=Object.getOwnPropertyNames;var n=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty;var h=(a,e,i,m)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of o(e))!c.call(a,t)&&t!==i&&l(a,t,{get:()=>e[t],enumerable:!(m=p(e,t))||m.enumerable});return a};var s=(a,e,i)=>(i=a!=null?r(n(a)):{},h(e||!a||!a.__esModule?l(i,"default",{value:a,enumerable:!0}):i,a));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=require("@shikijs/core"),u=require("@shikijs/engine-javascript"),j={c:()=>import("@shikijs/langs-precompiled/c"),cpp:()=>import("@shikijs/langs-precompiled/cpp"),"c++":()=>import("@shikijs/langs-precompiled/cpp"),css:()=>import("@shikijs/langs-precompiled/css"),glsl:()=>import("@shikijs/langs-precompiled/glsl"),graphql:()=>import("@shikijs/langs-precompiled/graphql"),gql:()=>import("@shikijs/langs-precompiled/graphql"),haml:()=>import("@shikijs/langs-precompiled/haml"),html:()=>import("@shikijs/langs-precompiled/html"),java:()=>import("@shikijs/langs-precompiled/java"),go:()=>import("@shikijs/langs-precompiled/go"),golang:()=>import("@shikijs/langs-precompiled/go"),javascript:()=>import("@shikijs/langs-precompiled/javascript"),js:()=>import("@shikijs/langs-precompiled/javascript"),json:()=>import("@shikijs/langs-precompiled/json"),jsonc:()=>import("@shikijs/langs-precompiled/jsonc"),jsonl:()=>import("@shikijs/langs-precompiled/jsonl"),jsx:()=>import("@shikijs/langs-precompiled/jsx"),julia:()=>import("@shikijs/langs-precompiled/julia"),jl:()=>import("@shikijs/langs-precompiled/julia"),less:()=>import("@shikijs/langs-precompiled/less"),markdown:()=>import("@shikijs/langs-precompiled/markdown"),md:()=>import("@shikijs/langs-precompiled/markdown"),mdx:()=>import("@shikijs/langs-precompiled/mdx"),php:()=>import("@shikijs/langs-precompiled/php"),postcss:()=>import("@shikijs/langs-precompiled/postcss"),pug:()=>import("@shikijs/langs-precompiled/pug"),jade:()=>import("@shikijs/langs-precompiled/pug"),python:()=>import("@shikijs/langs-precompiled/python"),py:()=>import("@shikijs/langs-precompiled/python"),r:()=>import("@shikijs/langs-precompiled/r"),regexp:()=>import("@shikijs/langs-precompiled/regexp"),regex:()=>import("@shikijs/langs-precompiled/regexp"),sass:()=>import("@shikijs/langs-precompiled/sass"),scss:()=>import("@shikijs/langs-precompiled/scss"),shellscript:()=>import("@shikijs/langs-precompiled/shellscript"),bash:()=>import("@shikijs/langs-precompiled/shellscript"),sh:()=>import("@shikijs/langs-precompiled/shellscript"),shell:()=>import("@shikijs/langs-precompiled/shellscript"),zsh:()=>import("@shikijs/langs-precompiled/shellscript"),sql:()=>import("@shikijs/langs-precompiled/sql"),svelte:()=>import("@shikijs/langs-precompiled/svelte"),typescript:()=>import("@shikijs/langs-precompiled/typescript"),ts:()=>import("@shikijs/langs-precompiled/typescript"),vue:()=>import("@shikijs/langs-precompiled/vue"),"vue-html":()=>import("@shikijs/langs-precompiled/vue-html"),wasm:()=>import("@shikijs/langs-precompiled/wasm"),wgsl:()=>import("@shikijs/langs-precompiled/wgsl"),xml:()=>import("@shikijs/langs-precompiled/xml"),yaml:()=>import("@shikijs/langs-precompiled/yaml"),yml:()=>import("@shikijs/langs-precompiled/yaml"),tsx:()=>import("@shikijs/langs-precompiled/tsx"),typescriptreact:()=>import("@shikijs/langs-precompiled/tsx"),haskell:()=>import("@shikijs/langs-precompiled/haskell"),hs:()=>import("@shikijs/langs-precompiled/haskell"),"c#":()=>import("@shikijs/langs-precompiled/csharp"),csharp:()=>import("@shikijs/langs-precompiled/csharp"),cs:()=>import("@shikijs/langs-precompiled/csharp"),latex:()=>import("@shikijs/langs-precompiled/latex"),lua:()=>import("@shikijs/langs-precompiled/lua"),mermaid:()=>import("@shikijs/langs-precompiled/mermaid"),mmd:()=>import("@shikijs/langs-precompiled/mermaid"),ruby:()=>import("@shikijs/langs-precompiled/ruby"),rb:()=>import("@shikijs/langs-precompiled/ruby"),rust:()=>import("@shikijs/langs-precompiled/rust"),rs:()=>import("@shikijs/langs-precompiled/rust"),scala:()=>import("@shikijs/langs-precompiled/scala"),swift:()=>import("@shikijs/langs/swift"),kotlin:()=>import("@shikijs/langs-precompiled/kotlin"),kt:()=>import("@shikijs/langs-precompiled/kotlin"),kts:()=>import("@shikijs/langs-precompiled/kotlin"),"objective-c":()=>import("@shikijs/langs-precompiled/objective-c"),objc:()=>import("@shikijs/langs-precompiled/objective-c")},d={"github-dark":()=>import("@shikijs/themes/github-dark"),"github-light":()=>import("@shikijs/themes/github-light")},v=g.createdBundledHighlighter({langs:j,themes:d,engine:()=>u.createJavaScriptRegexEngine()}),x={defaultLanguage:"javascript",supportedLanguages:{text:{name:"Plain Text",aliases:["text","txt","plain"]},c:{name:"C",aliases:["c"]},cpp:{name:"C++",aliases:["cpp","c++"]},css:{name:"CSS",aliases:["css"]},glsl:{name:"GLSL",aliases:["glsl"]},graphql:{name:"GraphQL",aliases:["graphql","gql"]},haml:{name:"Ruby Haml",aliases:["haml"]},html:{name:"HTML",aliases:["html"]},java:{name:"Java",aliases:["java"]},go:{name:"Go",aliases:["go","golang"]},javascript:{name:"JavaScript",aliases:["javascript","js"]},json:{name:"JSON",aliases:["json"]},jsonc:{name:"JSON with Comments",aliases:["jsonc"]},jsonl:{name:"JSON Lines",aliases:["jsonl"]},jsx:{name:"JSX",aliases:["jsx"]},julia:{name:"Julia",aliases:["julia","jl"]},less:{name:"Less",aliases:["less"]},markdown:{name:"Markdown",aliases:["markdown","md"]},mdx:{name:"MDX",aliases:["mdx"]},php:{name:"PHP",aliases:["php"]},postcss:{name:"PostCSS",aliases:["postcss"]},pug:{name:"Pug",aliases:["pug","jade"]},python:{name:"Python",aliases:["python","py"]},r:{name:"R",aliases:["r"]},regexp:{name:"RegExp",aliases:["regexp","regex"]},sass:{name:"Sass",aliases:["sass"]},scss:{name:"SCSS",aliases:["scss"]},shellscript:{name:"Shell",aliases:["shellscript","bash","sh","shell","zsh"]},sql:{name:"SQL",aliases:["sql"]},svelte:{name:"Svelte",aliases:["svelte"]},typescript:{name:"TypeScript",aliases:["typescript","ts"]},vue:{name:"Vue",aliases:["vue"]},"vue-html":{name:"Vue HTML",aliases:["vue-html"]},wasm:{name:"WebAssembly",aliases:["wasm"]},wgsl:{name:"WGSL",aliases:["wgsl"]},xml:{name:"XML",aliases:["xml"]},yaml:{name:"YAML",aliases:["yaml","yml"]},tsx:{name:"TSX",aliases:["tsx","typescriptreact"]},haskell:{name:"Haskell",aliases:["haskell","hs"]},csharp:{name:"C#",aliases:["c#","csharp","cs"]},latex:{name:"LaTeX",aliases:["latex"]},lua:{name:"Lua",aliases:["lua"]},mermaid:{name:"Mermaid",aliases:["mermaid","mmd"]},ruby:{name:"Ruby",aliases:["ruby","rb"]},rust:{name:"Rust",aliases:["rust","rs"]},scala:{name:"Scala",aliases:["scala"]},swift:{name:"Swift",aliases:["swift"]},kotlin:{name:"Kotlin",aliases:["kotlin","kt","kts"]},"objective-c":{name:"Objective C",aliases:["objective-c","objc"]}},createHighlighter:()=>v({themes:["github-dark","github-light"],langs:[]})};exports.codeBlockOptions=x; + //# sourceMappingURL=blocknote-code-block.cjs.map +diff --git a/dist/blocknote-code-block.js b/dist/blocknote-code-block.js +index 076d13a36736bb100e60c0113d231e716f1d9024..87b7919ef41fd6c8d0d39aa0df8f04bffa16284e 100644 +--- a/dist/blocknote-code-block.js ++++ b/dist/blocknote-code-block.js +@@ -11,6 +11,8 @@ const e = { + haml: () => import("@shikijs/langs-precompiled/haml"), + html: () => import("@shikijs/langs-precompiled/html"), + java: () => import("@shikijs/langs-precompiled/java"), ++ go: () => import("@shikijs/langs-precompiled/go"), ++ golang: () => import("@shikijs/langs-precompiled/go"), + javascript: () => import("@shikijs/langs-precompiled/javascript"), + js: () => import("@shikijs/langs-precompiled/javascript"), + json: () => import("@shikijs/langs-precompiled/json"), +@@ -119,6 +121,10 @@ const e = { + name: "Java", + aliases: ["java"] + }, ++ go: { ++ name: "Go", ++ aliases: ["go", "golang"] ++ }, + javascript: { + name: "JavaScript", + aliases: ["javascript", "js"] +diff --git a/src/index.ts b/src/index.ts +index 2cb588092df03b2b25ea7e89ccc9249c61d45de7..f7b834aaf35f406ea2318b16cf86ddd9808389bd 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -40,6 +40,10 @@ export const codeBlockOptions = { + name: "Java", + aliases: ["java"], + }, ++ go: { ++ name: "Go", ++ aliases: ["go", "golang"], ++ }, + javascript: { + name: "JavaScript", + aliases: ["javascript", "js"], +diff --git a/src/shiki.bundle.ts b/src/shiki.bundle.ts +index 75962c807f46bc44f327790ac96fb4b034821ec3..c1c2cda0563e0215b592d4f178524f7d20a9ee3f 100644 +--- a/src/shiki.bundle.ts ++++ b/src/shiki.bundle.ts +@@ -22,6 +22,8 @@ const bundledLanguages = { + haml: () => import("@shikijs/langs-precompiled/haml"), + html: () => import("@shikijs/langs-precompiled/html"), + java: () => import("@shikijs/langs-precompiled/java"), ++ go: () => import("@shikijs/langs-precompiled/go"), ++ golang: () => import("@shikijs/langs-precompiled/go"), + javascript: () => import("@shikijs/langs-precompiled/javascript"), + js: () => import("@shikijs/langs-precompiled/javascript"), + json: () => import("@shikijs/langs-precompiled/json"), +diff --git a/types/src/index.d.ts b/types/src/index.d.ts +index c1c64ef8ae6be44336af47fc2a1af3dfdac8ecc5..5d5797013aadec08337b1a6cdc30f8941da38d3a 100644 +--- a/types/src/index.d.ts ++++ b/types/src/index.d.ts +@@ -37,6 +37,10 @@ export declare const codeBlockOptions: { + name: string; + aliases: string[]; + }; ++ go: { ++ name: string; ++ aliases: string[]; ++ }; + javascript: { + name: string; + aliases: string[]; +diff --git a/types/src/shiki.bundle.d.ts b/types/src/shiki.bundle.d.ts +index 6a65953a2c51cebca6fbf0da74f968f69f27ab44..dd7ca33f1a9636137704432635a7abf91e483419 100644 +--- a/types/src/shiki.bundle.d.ts ++++ b/types/src/shiki.bundle.d.ts +@@ -1,5 +1,5 @@ + import type { HighlighterGeneric } from "@shikijs/types"; +-type BundledLanguage = "typescript" | "ts" | "javascript" | "js" | "vue"; ++type BundledLanguage = "typescript" | "ts" | "javascript" | "js" | "vue" | "go" | "golang"; + type BundledTheme = "github-light" | "github-dark"; + type Highlighter = HighlighterGeneric; + declare const createHighlighter: import("@shikijs/types").CreateHighlighterFactory; diff --git a/patches/@blocknote__core@0.46.2.patch b/patches/@blocknote__core@0.46.2.patch new file mode 100644 index 0000000..0276657 --- /dev/null +++ b/patches/@blocknote__core@0.46.2.patch @@ -0,0 +1,1209 @@ +diff --git a/dist/TrailingNode-CxM966vN.js b/dist/TrailingNode-CxM966vN.js +index 3ac6fe16aa9ae605a59916f8343b8440befda89e..e9aa5d6b0a2b4608271de17e6c6332746b46b7d8 100644 +--- a/dist/TrailingNode-CxM966vN.js ++++ b/dist/TrailingNode-CxM966vN.js +@@ -80,7 +80,7 @@ function be(n, e, t, o) { + let l = document.createTextNode( + c.textContent + ); +- for (const u of c.marks.toReversed()) ++ for (const u of Array.from(c.marks).reverse()) + if (u.type.name in n.schema.styleSpecs) { + const h = (n.schema.styleSpecs[u.type.name].implementation.toExternalHTML ?? n.schema.styleSpecs[u.type.name].implementation.render)(u.attrs.stringValue, n); + h.contentDOM.appendChild(l), l = h.dom; +@@ -1076,7 +1076,15 @@ class B extends N { + return { type: "multiple-node", anchor: this.anchor, head: this.head }; + } + } +-N.jsonID("multiple-node", B); ++try { ++ N.jsonID("multiple-node", B); ++} catch (n) { ++ if (!(n instanceof RangeError) || n.message !== "Duplicate use of selection JSON ID multiple-node") ++ throw n; ++ Object.defineProperty(B.prototype, "jsonID", { ++ value: "multiple-node" ++ }); ++} + let E; + function Lt(n, e) { + let t, o; +@@ -1183,6 +1191,10 @@ class Ft { + b(this, "updateState", (e) => { + this.state = e, this.emitUpdate(this.state); + }); ++ b(this, "hideMenu", () => { ++ var e; ++ (e = this.state) != null && e.show && (this.state.show = !1, this.updateState(this.state)); ++ }); + b(this, "updateStateFromMousePos", () => { + var o, r, s, i, a; + if (this.menuFrozen || !this.mousePos) +@@ -1192,33 +1204,39 @@ class Ft { + clientY: this.mousePos.y + }); + if ((e == null ? void 0 : e.element) !== this.pmView.dom || e.distance > se) { +- (o = this.state) != null && o.show && (this.state.show = !1, this.updateState(this.state)); ++ this.hideMenu(); + return; + } + const t = Ht(this.mousePos, this.pmView); + if (!t || !this.editor.isEditable) { +- (r = this.state) != null && r.show && (this.state.show = !1, this.updateState(this.state)); ++ this.hideMenu(); + return; + } +- if (!((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) && (this.hoveredBlock = t.node, this.editor.isEditable)) { +- const c = t.node.getBoundingClientRect(), l = t.node.closest("[data-node-type=column]"); ++ if ((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) ++ return; ++ this.hoveredBlock = t.node; ++ const c = this.hoveredBlock.getAttribute("data-id"), l = c ? this.editor.getBlock(c) : void 0; ++ if (!l) { ++ this.hideMenu(); ++ return; ++ } ++ if (this.editor.isEditable) { ++ const u = t.node.getBoundingClientRect(), h = t.node.closest("[data-node-type=column]"); + this.state = { + show: !0, + referencePos: new DOMRect( +- l ? ( ++ h ? ( + // We take the first child as column elements have some default + // padding. This is a little weird since this child element will + // be the first block, but since it's always non-nested and we + // only take the x coordinate, it's ok. +- l.firstElementChild.getBoundingClientRect().x ++ h.firstElementChild.getBoundingClientRect().x + ) : this.pmView.dom.firstChild.getBoundingClientRect().x, +- c.y, +- c.width, +- c.height ++ u.y, ++ u.width, ++ u.height + ), +- block: this.editor.getBlock( +- this.hoveredBlock.getAttribute("data-id") +- ) ++ block: l + }, this.updateState(this.state); + } + }); +@@ -1522,6 +1540,30 @@ function Ut(n) { + function M(n) { + return Array.prototype.indexOf.call(n.parentElement.childNodes, n); + } ++function isValidTablePosition(n) { ++ return Number.isInteger(n) && n >= 0; ++} ++function isValidCellIndex(n) { ++ return Number.isInteger(n) && n >= 0; ++} ++function isValidRelativeCell(n) { ++ return isValidCellIndex(n.row) && isValidCellIndex(n.col); ++} ++function resolveCellPosition(n, e, t) { ++ if (!isValidTablePosition(e) || !isValidRelativeCell(t)) ++ return; ++ try { ++ const o = n.doc.resolve(e + 1); ++ if (t.row >= o.node().childCount) ++ return; ++ const r = n.doc.resolve( ++ o.posAtIndex(t.row) + 1 ++ ); ++ return t.col >= r.node().childCount ? void 0 : n.doc.resolve(r.posAtIndex(t.col)); ++ } catch { ++ return; ++ } ++} + function _t(n) { + let e = n; + for (; e && e.nodeName !== "TD" && e.nodeName !== "TH" && !e.classList.contains("tableWrapper"); ) { +@@ -1557,6 +1599,12 @@ class Kt { + b(this, "menuFrozen", !1); + b(this, "mouseState", "up"); + b(this, "prevWasEditable", null); ++ b(this, "hideHandles", (e = {}) => { ++ const { resetCell: t = !1, resetDragging: o = !1 } = e; ++ if (!this.state || !this.state.show && !t && !o) ++ return; ++ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, o && (this.state.draggingState = void 0), t && (this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0), this.emitUpdate(); ++ }); + b(this, "viewMousedownHandler", () => { + this.mouseState = "down"; + }); +@@ -1569,11 +1617,11 @@ class Kt { + return; + const t = _t(e.target); + if ((t == null ? void 0 : t.type) === "cell" && this.mouseState === "down" && !((l = this.state) != null && l.draggingState)) { +- this.mouseState = "selecting", (u = this.state) != null && u.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate()); ++ this.mouseState = "selecting", this.hideHandles(); + return; + } + if (!t || !this.editor.isEditable) { +- (h = this.state) != null && h.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate()); ++ this.hideHandles(); + return; + } + if (!t.tbodyNode) +@@ -1582,12 +1630,19 @@ class Kt { + if (!r) + return; + this.tableElement = r.node; +- let s; +- const i = this.editor.transact( +- (w) => me(r.id, w.doc) +- ); +- if (!i) +- throw new Error(`Block with ID ${r.id} not found`); ++ let s, i; ++ try { ++ i = this.editor.transact( ++ (w) => me(r.id, w.doc) ++ ); ++ } catch { ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return; ++ } ++ if (!i) { ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return; ++ } + const a = L( + i.node, + this.editor.pmSchema, +@@ -1603,7 +1658,7 @@ class Kt { + const w = e.clientY >= o.bottom - 1 && // -1 to account for fractions of pixels in "bottom" + e.clientY < o.bottom + 20, y = e.clientX >= o.right - 1 && e.clientX < o.right + 20, C = ( + // always hide handles when the actively hovered table changed +- ((m = this.state) == null ? void 0 : m.block.id) !== s.id || // make sure we don't hide existing handles (keep col / row index) when ++ ((p = (m = this.state) == null ? void 0 : m.block) == null ? void 0 : p.id) !== s.id || // make sure we don't hide existing handles (keep col / row index) when + // we're hovering just above or to the right of a table + e.clientX > o.right || e.clientY > o.bottom + ); +@@ -1671,9 +1726,7 @@ class Kt { + if (this.mouseState = "up", this.state === void 0 || this.state.draggingState === void 0) + return !1; + if (this.state.rowIndex === void 0 || this.state.colIndex === void 0) +- throw new Error( +- "Attempted to drop table row or column, but no table block was hovered prior." +- ); ++ return e.preventDefault(), this.hideHandles({ resetCell: !0, resetDragging: !0 }), !1; + e.preventDefault(); + const { draggingState: t, colIndex: o, rowIndex: r } = this.state, s = this.state.block.content.columnWidths; + if (t.draggedCellOrientation === "row") { +@@ -1735,26 +1788,31 @@ class Kt { + var r; + if (!this.state || !this.state.show) + return; +- if (this.state.block = this.editor.getBlock(this.state.block.id), !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date ++ const e = this.state.block; ++ if (!e) { ++ this.hideHandles({ resetCell: !0, resetDragging: !0 }); ++ return; ++ } ++ if (this.state.block = this.editor.getBlock(e.id), !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date + // because yjs replaces the element when for example you change the color via the side menu + !((r = this.tableElement) != null && r.isConnected)) { +- this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate(); ++ this.hideHandles(); + return; + } +- const { height: e, width: t } = Ve( ++ const { height: t, width: o } = Ve( + this.state.block + ); +- this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1)); +- const o = this.tableElement.querySelector("tbody"); +- if (!o) +- throw new Error( +- "Table block does not contain a 'tbody' HTML element. This should never happen." +- ); ++ this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= t && (this.state.rowIndex = t - 1), this.state.colIndex >= o && (this.state.colIndex = o - 1)); ++ const i = this.tableElement.querySelector("tbody"); ++ if (!i) { ++ this.hideHandles({ resetCell: !0 }); ++ return; ++ } + if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) { +- const i = o.children[this.state.rowIndex].children[this.state.colIndex]; +- i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0); ++ const a = i.children[this.state.rowIndex].children[this.state.colIndex]; ++ a ? this.state.referencePosCell = a.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0); + } +- this.state.referencePosTable = o.getBoundingClientRect(), this.emitUpdate(); ++ this.state.referencePosTable = i.getBoundingClientRect(), this.emitUpdate(); + } + destroy() { + this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler), window.removeEventListener("mouseup", this.mouseUpHandler), this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler), this.pmView.root.removeEventListener( +@@ -1838,10 +1896,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + * is used as the column drag handle. + */ + colDragStart(o) { +- if (e === void 0 || e.state === void 0 || e.state.colIndex === void 0) +- throw new Error( +- "Attempted to drag table column, but no table block was hovered prior." +- ); ++ if (e === void 0 || e.state === void 0 || e.state.colIndex === void 0 || e.tablePos === void 0) { ++ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 }); ++ return; ++ } + e.state.draggingState = { + draggedCellOrientation: "col", + originalIndex: e.state.colIndex, +@@ -1860,10 +1918,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + * is used as the row drag handle. + */ + rowDragStart(o) { +- if (e.state === void 0 || e.state.rowIndex === void 0) +- throw new Error( +- "Attempted to drag table row, but no table block was hovered prior." +- ); ++ if (e === void 0 || e.state === void 0 || e.state.rowIndex === void 0 || e.tablePos === void 0) { ++ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 }); ++ return; ++ } + e.state.draggingState = { + draggedCellOrientation: "row", + originalIndex: e.state.rowIndex, +@@ -1882,10 +1940,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + * used as the row drag handle, and the one used as the column drag handle. + */ + dragEnd() { +- if (e.state === void 0) +- throw new Error( +- "Attempted to drag table row, but no table block was hovered prior." +- ); ++ if (e === void 0 || e.state === void 0) { ++ n.headless || Ut(n.prosemirrorView.root); ++ return; ++ } + e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root); + }, + /** +@@ -1918,30 +1976,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + setCellSelection(o, r, s = r) { + if (!e) + throw new Error("Table handles view not initialized"); +- const i = o.doc.resolve(e.tablePos + 1), a = o.doc.resolve( +- i.posAtIndex(r.row) + 1 +- ), c = o.doc.resolve( +- // No need for +1, since CellSelection expects the position before the cell +- a.posAtIndex(r.col) +- ), l = o.doc.resolve( +- i.posAtIndex(s.row) + 1 +- ), u = o.doc.resolve( +- // No need for +1, since CellSelection expects the position before the cell +- l.posAtIndex(s.col) +- ), h = o.tr; +- return h.setSelection( +- new yt(c, u) +- ), o.apply(h); ++ const i = resolveCellPosition( ++ o, ++ e.tablePos, ++ r ++ ), a = resolveCellPosition( ++ o, ++ e.tablePos, ++ s ++ ); ++ if (!i || !a) ++ return; ++ const c = o.tr; ++ return c.setSelection( ++ new yt(i, a) ++ ), o.apply(c); + }, + /** + * Adds a row or column to the table using prosemirror-table commands + */ + addRowOrColumn(o, r) { ++ if (!e || !isValidTablePosition(e.tablePos) || !isValidCellIndex(o)) { ++ e == null || e.hideHandles({ resetCell: !0 }); ++ return; ++ } + n.exec((s, i) => { + const a = this.setCellSelection( + s, + r.orientation === "row" ? { row: o, col: 0 } : { row: 0, col: o } + ); ++ if (!a) ++ return e == null || e.hideHandles({ resetCell: !0 }), !1; + return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i); + }); + }, +@@ -1949,17 +2014,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + * Removes a row or column from the table using prosemirror-table commands + */ + removeRowOrColumn(o, r) { ++ if (!isValidCellIndex(o)) ++ return !1; + return r === "row" ? n.exec((s, i) => { + const a = this.setCellSelection(s, { + row: o, + col: 0 + }); ++ if (!a) ++ return !1; + return ht(a, i); + }) : n.exec((s, i) => { + const a = this.setCellSelection(s, { + row: 0, + col: o + }); ++ if (!a) ++ return !1; + return mt(a, i); + }); + }, +@@ -1973,6 +2044,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + o.relativeStartCell, + o.relativeEndCell + ) : r; ++ if (!i) ++ return !1; + return ut(i, s); + }); + }, +@@ -1983,6 +2056,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => { + splitCell(o) { + return n.exec((r, s) => { + const i = o ? this.setCellSelection(r, o) : r; ++ if (!i) ++ return !1; + return dt(i, s); + }); + }, +diff --git a/dist/TrailingNode-D-CZ76FS.cjs b/dist/TrailingNode-D-CZ76FS.cjs +index 8406fda31f41172a823c26412698616a56800aae..b53947892a39f3fabac0fe4cb37186dd84f9a369 100644 +--- a/dist/TrailingNode-D-CZ76FS.cjs ++++ b/dist/TrailingNode-D-CZ76FS.cjs +@@ -1,2 +1,2 @@ +-"use strict";var we=Object.defineProperty;var be=(n,e,t)=>e in n?we(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var b=(n,e,t)=>be(n,typeof e!="symbol"?e+"":e,t);const C=require("prosemirror-state"),R=require("@tiptap/core"),ke=require("fast-deep-equal"),k=require("./blockToNode-CumVjgem.cjs"),O=require("./defaultBlocks-DosClM5E.cjs"),v=require("./BlockNoteExtension-BWw0r8Gy.cjs"),M=require("y-prosemirror"),Ce=require("yjs"),V=require("@tiptap/pm/state"),ve=require("prosemirror-dropcursor"),q=require("@tiptap/pm/history"),D=require("prosemirror-view"),Se=require("uuid"),Z=require("@tiptap/pm/model"),H=require("prosemirror-model"),xe=require("rehype-parse"),Ee=require("rehype-remark"),Ie=require("remark-gfm"),Pe=require("remark-stringify"),Te=require("unified"),Be=require("hast-util-from-dom"),De=require("unist-util-visit"),T=require("prosemirror-tables"),F=n=>n&&typeof n=="object"&&"default"in n?n:{default:n};function Oe(n){if(n&&typeof n=="object"&&"default"in n)return n;const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(n){for(const t in n)if(t!=="default"){const o=Object.getOwnPropertyDescriptor(n,t);Object.defineProperty(e,t,o.get?o:{enumerable:!0,get:()=>n[t]})}}return e.default=n,Object.freeze(e)}const Me=F(ke),B=Oe(Ce),Ae=F(xe),Ne=F(Ee),Le=F(Ie),Re=F(Pe);function ae(n){const e=Array.from(n.classList).filter(t=>!t.startsWith("bn-"))||[];e.length>0?n.className=e.join(" "):n.removeAttribute("class")}function le(n,e,t,o){var a;let r;if(e)if(typeof e=="string")r=k.inlineContentToNodes([e],n.pmSchema);else if(Array.isArray(e))r=k.inlineContentToNodes(e,n.pmSchema);else if(e.type==="tableContent")r=k.tableContentToNodes(e,n.pmSchema);else throw new k.UnreachableCaseError(e.type);else throw new Error("blockContent is required");const i=((o==null?void 0:o.document)??document).createDocumentFragment();for(const c of r)if(c.type.name!=="text"&&n.schema.inlineContentSchema[c.type.name]){const l=n.schema.inlineContentSpecs[c.type.name].implementation;if(l){const u=k.nodeToCustomInlineContent(c,n.schema.inlineContentSchema,n.schema.styleSchema),h=l.toExternalHTML?l.toExternalHTML(u,n):l.render.call({renderType:"dom",props:void 0},u,()=>{},n);if(h){if(i.appendChild(h.dom),h.contentDOM){const g=t.serializeFragment(c.content,o);h.contentDOM.dataset.editable="",h.contentDOM.appendChild(g)}continue}}}else if(c.type.name==="text"){let l=document.createTextNode(c.textContent);for(const u of c.marks.toReversed())if(u.type.name in n.schema.styleSpecs){const h=(n.schema.styleSpecs[u.type.name].implementation.toExternalHTML??n.schema.styleSpecs[u.type.name].implementation.render)(u.attrs.stringValue,n);h.contentDOM.appendChild(l),l=h.dom}else{const h=u.type.spec.toDOM(u,!0),g=H.DOMSerializer.renderSpec(document,h);g.contentDOM.appendChild(l),l=g.dom}i.appendChild(l)}else{const l=t.serializeFragment(H.Fragment.from([c]),o);i.appendChild(l)}return i.childNodes.length===1&&((a=i.firstChild)==null?void 0:a.nodeType)===1&&ae(i.firstChild),i}function Ve(n,e,t,o,r,s,i,a){var y,w,x,L,X,W,G,J,Q;const c=(a==null?void 0:a.document)??document,l=e.pmSchema.nodes.blockContainer,u=t.props||{};for(const[S,I]of Object.entries(e.schema.blockSchema[t.type].propSchema))!(S in u)&&I.default!==void 0&&(u[S]=I.default);const h=(w=(y=l.spec)==null?void 0:y.toDOM)==null?void 0:w.call(y,l.create({id:t.id,...u})),g=Array.from(h.dom.attributes),m=e.blockImplementations[t.type].implementation,p=((x=m.toExternalHTML)==null?void 0:x.call({},{...t,props:u},e,{nestingLevel:i}))||m.render.call({},{...t,props:u},e),d=c.createDocumentFragment();if(p.dom.classList.contains("bn-block-content")){const S=[...g,...Array.from(p.dom.attributes)].filter(I=>I.name.startsWith("data")&&I.name!=="data-content-type"&&I.name!=="data-file-block"&&I.name!=="data-node-view-wrapper"&&I.name!=="data-node-type"&&I.name!=="data-id"&&I.name!=="data-editable");for(const I of S)p.dom.firstChild.setAttribute(I.name,I.value);ae(p.dom.firstChild),i>0&&p.dom.firstChild.setAttribute("data-nesting-level",i.toString()),d.append(...Array.from(p.dom.childNodes))}else d.append(p.dom),i>0&&p.dom.setAttribute("data-nesting-level",i.toString());if(p.contentDOM&&t.content){const S=le(e,t.content,o,a);p.contentDOM.appendChild(S)}let f;if(r.has(t.type)?f="OL":s.has(t.type)&&(f="UL"),f){if(((L=n.lastChild)==null?void 0:L.nodeName)!==f){const S=c.createElement(f);f==="OL"&&"start"in u&&u.start&&(u==null?void 0:u.start)!==1&&S.setAttribute("start",u.start+""),n.append(S)}n.lastChild.appendChild(d)}else n.append(d);if(t.children&&t.children.length>0){const S=c.createDocumentFragment();if(ce(S,e,t.children,o,r,s,i+1,a),((X=n.lastChild)==null?void 0:X.nodeName)==="UL"||((W=n.lastChild)==null?void 0:W.nodeName)==="OL")for(;((G=S.firstChild)==null?void 0:G.nodeName)==="UL"||((J=S.firstChild)==null?void 0:J.nodeName)==="OL";)n.lastChild.lastChild.appendChild(S.firstChild);e.pmSchema.nodes[t.type].isInGroup("blockContent")?n.append(S):(Q=p.contentDOM)==null||Q.append(S)}}const ce=(n,e,t,o,r,s,i=0,a)=>{for(const c of t)Ve(n,e,c,o,r,s,i,a)},He=(n,e,t,o,r,s)=>{const a=((s==null?void 0:s.document)??document).createDocumentFragment();return ce(a,n,e,t,o,r,0,s),a},Y=(n,e)=>{const t=H.DOMSerializer.fromSchema(n);return{exportBlocks:(o,r)=>{const s=He(e,o,t,new Set(["numberedListItem"]),new Set(["bulletListItem","checkListItem","toggleListItem"]),r),i=document.createElement("div");return i.append(s),i.innerHTML},exportInlineContent:(o,r)=>{const s=le(e,o,t,r),i=document.createElement("div");return i.append(s.cloneNode(!0)),i.innerHTML}}};function Fe(n,e){if(e===0)return;const t=n.resolve(e);for(let o=t.depth;o>0;o--){const r=t.node(o);if(O.isNodeBlock(r))return r.attrs.id}}function _e(n){return n.getMeta("paste")?{type:"paste"}:n.getMeta("uiEvent")==="drop"?{type:"drop"}:n.getMeta("history$")?{type:n.getMeta("history$").redo?"redo":"undo"}:n.getMeta("y-sync$")?n.getMeta("y-sync$").isUndoRedoOperation?{type:"undo-redo"}:{type:"yjs-remote"}:{type:"local"}}function ee(n){const e="__root__",t={},o={},r=k.getPmSchema(n);return n.descendants((s,i)=>{if(!O.isNodeBlock(s))return!0;const a=Fe(n,i),c=a??e;o[c]||(o[c]=[]);const l=k.nodeToBlock(s,r);return t[s.attrs.id]={block:l,parentId:a},o[c].push(s.attrs.id),!0}),{byId:t,childrenByParent:o}}function Ue(n,e){const t=new Set;if(!n||!e)return t;const o=new Set(n),r=e.filter(d=>o.has(d)),s=n.filter(d=>r.includes(d));if(s.length<=1||r.length<=1)return t;const i={};for(let d=0;di[d]),c=a.length,l=[],u=[],h=new Array(c).fill(-1),g=(d,f)=>{let y=0,w=d.length;for(;y>>1;d[x]0&&(h[d]=u[y-1]),y===l.length?(l.push(f),u.push(d)):(l[y]=f,u[y]=d)}const m=new Set;let p=u[u.length-1]??-1;for(;p!==-1;)m.add(p),p=h[p];for(let d=0;d!(m in r.byId)).forEach(m=>{i.push({type:"insert",block:s.byId[m].block,source:t,prevBlock:void 0}),a.add(m)}),Object.keys(r.byId).filter(m=>!(m in s.byId)).forEach(m=>{i.push({type:"delete",block:r.byId[m].block,source:t,prevBlock:void 0}),a.add(m)}),Object.keys(s.byId).filter(m=>m in r.byId).forEach(m=>{var y,w;const p=r.byId[m],d=s.byId[m];p.parentId!==d.parentId?(i.push({type:"move",block:d.block,prevBlock:p.block,source:t,prevParent:p.parentId?(y=r.byId[p.parentId])==null?void 0:y.block:void 0,currentParent:d.parentId?(w=s.byId[d.parentId])==null?void 0:w.block:void 0}),a.add(m)):Me.default({...p.block,children:void 0},{...d.block,children:void 0})||(i.push({type:"update",block:d.block,prevBlock:p.block,source:t}),a.add(m))});const c=r.childrenByParent,l=s.childrenByParent,u="__root__",h=new Set([...Object.keys(c),...Object.keys(l)]),g=new Set;return h.forEach(m=>{const p=Ue(c[m],l[m]);p.size!==0&&p.forEach(d=>{var x,L;const f=r.byId[d],y=s.byId[d];!f||!y||f.parentId!==y.parentId||a.has(d)||(f.parentId??u)!==m||g.has(d)||(g.add(d),i.push({type:"move",block:y.block,prevBlock:f.block,source:t,prevParent:f.parentId?(x=r.byId[f.parentId])==null?void 0:x.block:void 0,currentParent:y.parentId?(L=s.byId[y.parentId])==null?void 0:L.block:void 0}),a.add(d))})}),i}const $e=v.createExtension(()=>{const n=[];return{key:"blockChange",prosemirrorPlugins:[new C.Plugin({key:new C.PluginKey("blockChange"),filterTransaction:e=>{let t;return n.reduce((o,r)=>o===!1?o:r({getChanges(){return t||(t=de(e),t)},tr:e})!==!1,!0)}})],subscribe(e){return n.push(e),()=>{n.splice(n.indexOf(e),1)}}}});function te(n){const e=n.charAt(0)==="#"?n.substring(1,7):n,t=parseInt(e.substring(0,2),16),o=parseInt(e.substring(2,4),16),r=parseInt(e.substring(4,6),16),i=[t/255,o/255,r/255].map(c=>c<=.03928?c/12.92:Math.pow((c+.055)/1.055,2.4));return .2126*i[0]+.7152*i[1]+.0722*i[2]<=.179}function qe(n){const e=document.createElement("span");e.classList.add("bn-collaboration-cursor__base");const t=document.createElement("span");t.setAttribute("contentedEditable","false"),t.classList.add("bn-collaboration-cursor__caret"),t.setAttribute("style",`background-color: ${n.color}; color: ${te(n.color)?"white":"black"}`);const o=document.createElement("span");return o.classList.add("bn-collaboration-cursor__label"),o.setAttribute("style",`background-color: ${n.color}; color: ${te(n.color)?"white":"black"}`),o.insertBefore(document.createTextNode(n.name),null),t.insertBefore(o,null),e.insertBefore(document.createTextNode("⁠"),null),e.insertBefore(t,null),e.insertBefore(document.createTextNode("⁠"),null),e}const z=v.createExtension(({options:n})=>{const e=new Map,t=n.provider&&"awareness"in n.provider&&typeof n.provider.awareness=="object"?n.provider.awareness:void 0;return t&&("setLocalStateField"in t&&typeof t.setLocalStateField=="function"&&t.setLocalStateField("user",n.user),"on"in t&&typeof t.on=="function"&&n.showCursorLabels!=="always"&&t.on("change",({updated:o})=>{for(const r of o){const s=e.get(r);s&&(s.element.setAttribute("data-active",""),s.hideTimeout&&clearTimeout(s.hideTimeout),e.set(r,{element:s.element,hideTimeout:setTimeout(()=>{s.element.removeAttribute("data-active")},2e3)}))}})),{key:"yCursor",prosemirrorPlugins:[t?M.yCursorPlugin(t,{selectionBuilder:M.defaultSelectionBuilder,cursorBuilder(o,r){let s=e.get(r);if(!s){const i=(n.renderCursor??qe)(o);n.showCursorLabels!=="always"&&(i.addEventListener("mouseenter",()=>{const a=e.get(r);a.element.setAttribute("data-active",""),a.hideTimeout&&(clearTimeout(a.hideTimeout),e.set(r,{element:a.element,hideTimeout:void 0}))}),i.addEventListener("mouseleave",()=>{const a=e.get(r);e.set(r,{element:a.element,hideTimeout:setTimeout(()=>{a.element.removeAttribute("data-active")},2e3)})})),s={element:i,hideTimeout:void 0},e.set(r,s)}return s.element}}):void 0].filter(Boolean),dependsOn:["ySync"],updateUser(o){t==null||t.setLocalStateField("user",o)}}}),U=v.createExtension(({options:n})=>({key:"ySync",prosemirrorPlugins:[M.ySyncPlugin(n.fragment)],runsBefore:["default"]})),$=v.createExtension(()=>({key:"yUndo",prosemirrorPlugins:[M.yUndoPlugin()],dependsOn:["yCursor","ySync"],undoCommand:M.undoCommand,redoCommand:M.redoCommand}));function ze(n,e){const t=n.doc;if(n._item===null){const o=Array.from(t.share.keys()).find(r=>t.share.get(r)===n);if(o==null)throw new Error("type does not exist in other ydoc");return e.get(o,n.constructor)}else{const o=n._item,r=e.store.clients.get(o.id.client)??[],s=B.findIndexSS(r,o.id.clock);return r[s].content.type}}const Ke=v.createExtension(({editor:n,options:e})=>{let t;const o=v.createStore({isForked:!1});return{key:"yForkDoc",store:o,fork(){if(t)return;const r=e.fragment;if(!r)throw new Error("No fragment to fork from");const s=new B.Doc;B.applyUpdate(s,B.encodeStateAsUpdate(r.doc));const i=ze(r,s);t={undoStack:M.yUndoPluginKey.getState(n.prosemirrorState).undoManager.undoStack,originalFragment:r,forkedFragment:i},n.unregisterExtension([$,z,U]);const a={...e,fragment:i};n.registerExtension([U(a),$()]),o.setState({isForked:!0})},merge({keepChanges:r}){if(!t)return;n.unregisterExtension(["ySync","yCursor","yUndo"]);const{originalFragment:s,forkedFragment:i,undoStack:a}=t;if(n.registerExtension([U(e),z(e),$()]),M.yUndoPluginKey.getState(n.prosemirrorState).undoManager.undoStack=a,r){const c=B.encodeStateAsUpdate(i.doc,B.encodeStateVector(s.doc));B.applyUpdate(s.doc,c,n)}t=void 0,o.setState({isForked:!1})}}}),ue=(n,e)=>{e(n),n.forEach(t=>{t instanceof B.XmlElement&&ue(t,e)})},Ye=(n,e)=>{const t=new Map;return n.forEach(o=>{o instanceof B.XmlElement&&ue(o,r=>{if(r.nodeName==="blockContainer"&&r.hasAttribute("id")){const s=r.getAttribute("textColor"),i=r.getAttribute("backgroundColor"),a={textColor:s===O.defaultProps.textColor.default?void 0:s,backgroundColor:i===O.defaultProps.backgroundColor.default?void 0:i};(a.textColor||a.backgroundColor)&&t.set(r.getAttribute("id"),a)}})}),t.size===0?!1:(e.doc.descendants((o,r)=>{if(o.type.name==="blockContainer"&&t.has(o.attrs.id)){const s=e.doc.nodeAt(r+1);if(!s)throw new Error("No element found");e.setNodeMarkup(r+1,void 0,{...s.attrs,...t.get(o.attrs.id)})}}),!0)},je=[Ye],Xe=v.createExtension(({options:n})=>{let e=!1;const t=new V.PluginKey("schemaMigration");return{key:"schemaMigration",prosemirrorPlugins:[new V.Plugin({key:t,appendTransaction:(o,r,s)=>{if(e||!o.some(a=>a.getMeta("y-sync$"))||o.every(a=>!a.docChanged)||!n.fragment.firstChild)return;const i=s.tr;for(const a of je)a(n.fragment,i);if(e=!0,!!i.docChanged)return i}})]}}),We=v.createExtension(({editor:n,options:e})=>({key:"dropCursor",prosemirrorPlugins:[(e.dropCursor??ve.dropCursor)({width:5,color:"#ddeeff",editor:n})]})),Ge=v.createExtension(({editor:n})=>{const e=v.createStore(!1),t=()=>n.transact(o=>{var s;if(o.selection.empty||o.selection instanceof C.NodeSelection&&(o.selection.node.type.spec.content==="inline*"||((s=o.selection.node.firstChild)==null?void 0:s.type.spec.content)==="inline*")||o.selection instanceof C.TextSelection&&o.doc.textBetween(o.selection.from,o.selection.to).length===0)return!1;let r=!1;return o.selection.content().content.descendants(i=>(i.type.spec.code&&(r=!0),!r)),!r});return{key:"formattingToolbar",store:e,mount({dom:o,signal:r}){let s=!1;const i=n.onChange(()=>{s||e.setState(t())}),a=n.onSelectionChange(()=>{s||e.setState(t())});o.addEventListener("pointerdown",()=>{s=!0,e.setState(!1)},{signal:r}),n.prosemirrorView.root.addEventListener("pointerup",()=>{s=!1,n.isFocused()&&e.setState(t())},{signal:r,capture:!0}),o.addEventListener("pointercancel",()=>{s=!1},{signal:r,capture:!0}),r.addEventListener("abort",()=>{i(),a()})}}}),Je=v.createExtension(()=>({key:"history",prosemirrorPlugins:[q.history()],undoCommand:q.undo,redoCommand:q.redo})),Qe=v.createExtension(({editor:n})=>{function e(r){let s=n.prosemirrorView.nodeDOM(r);for(;s&&s.parentElement;){if(s.nodeName==="A")return s;s=s.parentElement}return null}function t(r,s){return n.transact(i=>{const a=i.doc.resolve(r),c=a.marks().find(u=>u.type.name===s);if(!c)return;const l=R.getMarkRange(a,c.type);if(l)return{range:l,mark:c,get text(){return i.doc.textBetween(l.from,l.to)},get position(){return R.posToDOMRect(n.prosemirrorView,l.from,l.to).toJSON()}}})}function o(){return n.transact(r=>{const s=r.selection;if(s.empty)return t(s.anchor,"link")})}return{key:"linkToolbar",getLinkAtSelection:o,getLinkElementAtPos:e,getMarkAtPos:t,getLinkAtElement(r){return n.transact(()=>{const s=n.prosemirrorView.posAtDOM(r,0)+1;return t(s,"link")})},editLink(r,s,i=n.transact(a=>a.selection.anchor)){n.transact(a=>{const c=k.getPmSchema(a),{range:l}=t(i+1,"link")||{range:{from:a.selection.from,to:a.selection.to}};l&&(a.insertText(s,l.from,l.to),a.addMark(l.from,l.from+s.length,c.mark("link",{href:r})))}),n.prosemirrorView.focus()},deleteLink(r=n.transact(s=>s.selection.anchor)){n.transact(s=>{const i=k.getPmSchema(s),{range:a}=t(r+1,"link")||{range:{from:s.selection.from,to:s.selection.to}};a&&s.removeMark(a.from,a.to,i.marks.link).setMeta("preventAutolink",!0)}),n.prosemirrorView.focus()}}}),Ze=["http","https","ftp","ftps","mailto","tel","callto","sms","cid","xmpp"],et="https",tt=new C.PluginKey("node-selection-keyboard"),ot=v.createExtension(()=>({key:"nodeSelectionKeyboard",prosemirrorPlugins:[new C.Plugin({key:tt,props:{handleKeyDown:(n,e)=>{if("node"in n.state.selection){if(e.ctrlKey||e.metaKey)return!1;if(e.key.length===1)return e.preventDefault(),!0;if(e.key==="Enter"&&!e.isComposing&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey){const t=n.state.tr;return n.dispatch(t.insert(n.state.tr.selection.$to.after(),n.state.schema.nodes.paragraph.createChecked()).setSelection(new C.TextSelection(t.doc.resolve(n.state.tr.selection.$to.after()+1)))),!0}}return!1}}})]})),nt=new C.PluginKey("blocknote-placeholder"),rt=v.createExtension(({editor:n,options:e})=>{const t=e.placeholders;return{key:"placeholder",prosemirrorPlugins:[new C.Plugin({key:nt,view:o=>{const r=`placeholder-selector-${Se.v4()}`;o.dom.classList.add(r);const s=document.createElement("style"),i=n._tiptapEditor.options.injectNonce;i&&s.setAttribute("nonce",i),o.root instanceof window.ShadowRoot?o.root.append(s):o.root.head.appendChild(s);const a=s.sheet,c=(l="")=>`.${r} .bn-block-content${l} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`;try{const{default:l,emptyDocument:u,...h}=t||{};for(const[p,d]of Object.entries(h)){const f=`[data-content-type="${p}"]`;a.insertRule(`${c(f)} { content: ${JSON.stringify(d)}; }`)}const g="[data-is-only-empty-block]",m="[data-is-empty-and-focused]";a.insertRule(`${c(g)} { content: ${JSON.stringify(u)}; }`),a.insertRule(`${c(m)} { content: ${JSON.stringify(l)}; }`)}catch(l){console.warn("Failed to insert placeholder CSS rule - this is likely due to the browser not supporting certain CSS pseudo-element selectors (:has, :only-child:, or :before)",l)}return{destroy:()=>{o.root instanceof window.ShadowRoot?o.root.removeChild(s):o.root.head.removeChild(s)}}},props:{decorations:o=>{const{doc:r,selection:s}=o;if(!n.isEditable||!s.empty||s.$from.parent.type.spec.code)return;const i=[];o.doc.content.size===6&&i.push(D.Decoration.node(2,4,{"data-is-only-empty-block":"true"}));const a=s.$anchor,c=a.parent;if(c.content.size===0){const l=a.before();i.push(D.Decoration.node(l,l+c.nodeSize,{"data-is-empty-and-focused":"true"}))}return D.DecorationSet.create(r,i)}}})]}}),oe=new C.PluginKey("previous-blocks"),st={index:"index",level:"level",type:"type",depth:"depth","depth-change":"depth-change"},it=v.createExtension(()=>{let n;return{key:"previousBlockType",prosemirrorPlugins:[new C.Plugin({key:oe,view(e){return{update:async(t,o)=>{var r;((r=this.key)==null?void 0:r.getState(t.state).updatedBlocks.size)>0&&(n=setTimeout(()=>{t.dispatch(t.state.tr.setMeta(oe,{clearUpdate:!0}))},0))},destroy:()=>{n&&clearTimeout(n)}}},state:{init(){return{prevTransactionOldBlockAttrs:{},currentTransactionOldBlockAttrs:{},updatedBlocks:new Set}},apply(e,t,o,r){if(t.currentTransactionOldBlockAttrs={},t.updatedBlocks.clear(),!e.docChanged||o.doc.eq(r.doc))return t;const s={},i=R.findChildren(o.doc,l=>l.attrs.id),a=new Map(i.map(l=>[l.node.attrs.id,l])),c=R.findChildren(r.doc,l=>l.attrs.id);for(const l of c){const u=a.get(l.node.attrs.id),h=u==null?void 0:u.node.firstChild,g=l.node.firstChild;if(u&&h&&g){const m={index:g.attrs.index,level:g.attrs.level,type:g.type.name,depth:r.doc.resolve(l.pos).depth},p={index:h.attrs.index,level:h.attrs.level,type:h.type.name,depth:o.doc.resolve(u.pos).depth};s[l.node.attrs.id]=p,t.currentTransactionOldBlockAttrs[l.node.attrs.id]=p,JSON.stringify(p)!==JSON.stringify(m)&&(p["depth-change"]=p.depth-m.depth,t.updatedBlocks.add(l.node.attrs.id))}}return t.prevTransactionOldBlockAttrs=s,t}},props:{decorations(e){const t=this.getState(e);if(t.updatedBlocks.size===0)return;const o=[];return e.doc.descendants((r,s)=>{if(!r.attrs.id||!t.updatedBlocks.has(r.attrs.id))return;const i=t.currentTransactionOldBlockAttrs[r.attrs.id],a={};for(const[l,u]of Object.entries(i))a["data-prev-"+st[l]]=u||"none";const c=D.Decoration.node(s,s+r.nodeSize,{...a});o.push(c)}),D.DecorationSet.create(e.doc,o)}}})]}});function he(n,e){var t,o;for(;n&&n.parentElement&&n.parentElement!==e.dom&&((t=n.getAttribute)==null?void 0:t.call(n,"data-node-type"))!=="blockContainer";)n=n.parentElement;if(((o=n.getAttribute)==null?void 0:o.call(n,"data-node-type"))==="blockContainer")return{node:n,id:n.getAttribute("data-id")}}function at(){const n=e=>{let t=e.children.length;for(let o=0;o0){e.children.splice(o,1,...r.children);const s=r.children.length-1;t+=s,o+=s}else e.children.splice(o,1),t--,o--}};return n}function lt(){const n=e=>{var t;if(e.children&&"length"in e.children&&e.children.length)for(let o=e.children.length-1;o>=0;o--){const r=e.children[o],s=o+1{De.visit(n,"element",(e,t,o)=>{var r,s,i,a;if(o&&e.tagName==="video"){const c=((r=e.properties)==null?void 0:r.src)||((s=e.properties)==null?void 0:s["data-url"])||"",l=((i=e.properties)==null?void 0:i.title)||((a=e.properties)==null?void 0:a["data-name"])||"";o.children[t]={type:"text",value:`![${l}](${c})`}}})}}function j(n){return Te.unified().use(Ae.default,{fragment:!0}).use(ct).use(at).use(lt).use(Ne.default).use(Le.default).use(Re.default,{handlers:{text:t=>t.value}}).processSync(n).value}function dt(n,e,t,o){const s=Y(e,t).exportBlocks(n,o);return j(s)}function me(n){const e=[];return n.descendants(t=>{var r,s;const o=k.getPmSchema(t);return t.type.name==="blockContainer"&&((r=t.firstChild)==null?void 0:r.type.name)==="blockGroup"?!0:t.type.name==="columnList"&&t.childCount===1?((s=t.firstChild)==null||s.forEach(i=>{e.push(k.nodeToBlock(i,o))}),!1):t.type.isInGroup("bnBlock")?(e.push(k.nodeToBlock(t,o)),!1):!0}),e}class A extends C.Selection{constructor(t,o){super(t,o);b(this,"nodes");const r=t.node();this.nodes=[],t.doc.nodesBetween(t.pos,o.pos,(s,i,a)=>{if(a!==null&&a.eq(r))return this.nodes.push(s),!1})}static create(t,o,r=o){return new A(t.resolve(o),t.resolve(r))}content(){return new H.Slice(H.Fragment.from(this.nodes),0,0)}eq(t){if(!(t instanceof A)||this.nodes.length!==t.nodes.length||this.from!==t.from||this.to!==t.to)return!1;for(let o=0;oArray.prototype.indexOf.call(h.children,g),i=s(r,n.domAtPos(e+1).node.parentElement),a=s(r,n.domAtPos(t-1).node.parentElement);for(let h=r.childElementCount-1;h>=0;h--)(h>a||hh!=="ProseMirror"&&h!=="bn-root"&&h!=="bn-editor").join(" ");P.className=P.className+" bn-drag-preview "+u,n.root instanceof ShadowRoot?n.root.appendChild(P):n.root.body.appendChild(P)}function pe(n){P!==void 0&&(n instanceof ShadowRoot?n.removeChild(P):n.body.removeChild(P),P=void 0)}function ht(n,e,t){if(!n.dataTransfer||t.headless)return;const o=t.prosemirrorView,r=O.getNodeById(e.id,o.state.doc);if(!r)throw new Error(`Block with ID ${e.id} not found`);const s=r.posBeforeNode;if(s!=null){const i=o.state.selection,a=o.state.doc,{from:c,to:l}=ut(i,a),u=c<=s&&s{this.state=e,this.emitUpdate(this.state)});b(this,"updateStateFromMousePos",()=>{var o,r,s,i,a;if(this.menuFrozen||!this.mousePos)return;const e=this.findClosestEditorElement({clientX:this.mousePos.x,clientY:this.mousePos.y});if((e==null?void 0:e.element)!==this.pmView.dom||e.distance>re){(o=this.state)!=null&&o.show&&(this.state.show=!1,this.updateState(this.state));return}const t=mt(this.mousePos,this.pmView);if(!t||!this.editor.isEditable){(r=this.state)!=null&&r.show&&(this.state.show=!1,this.updateState(this.state));return}if(!((s=this.state)!=null&&s.show&&((i=this.hoveredBlock)!=null&&i.hasAttribute("data-id"))&&((a=this.hoveredBlock)==null?void 0:a.getAttribute("data-id"))===t.id)&&(this.hoveredBlock=t.node,this.editor.isEditable)){const c=t.node.getBoundingClientRect(),l=t.node.closest("[data-node-type=column]");this.state={show:!0,referencePos:new DOMRect(l?l.firstElementChild.getBoundingClientRect().x:this.pmView.dom.firstChild.getBoundingClientRect().x,c.y,c.width,c.height),block:this.editor.getBlock(this.hoveredBlock.getAttribute("data-id"))},this.updateState(this.state)}});b(this,"onDragStart",e=>{var i;const t=(i=e.dataTransfer)==null?void 0:i.getData("blocknote/html");if(!t||this.pmView.dragging)return;const o=document.createElement("div");o.innerHTML=t;const s=Z.DOMParser.fromSchema(this.pmView.state.schema).parse(o,{topNode:this.pmView.state.schema.nodes.blockGroup.create()});this.pmView.dragging={slice:new Z.Slice(s.content,0,0),move:!0}});b(this,"findClosestEditorElement",e=>{const t=Array.from(this.pmView.root.querySelectorAll(".bn-editor"));if(t.length===0)return null;let o=t[0],r=Number.MAX_VALUE;return t.forEach(s=>{const i=s.querySelector(".bn-block-group").getBoundingClientRect(),a=e.clientXi.right?e.clientX-i.right:0,c=e.clientYi.bottom?e.clientY-i.bottom:0,l=Math.sqrt(Math.pow(a,2)+Math.pow(c,2));l{if(e.synthetic)return;const t=this.getDragEventContext(e);if(!t||!t.isDropPoint){this.closeDropCursor();return}t.isDropPoint&&!t.isDropWithinEditorBounds&&this.dispatchSyntheticEvent(e)});b(this,"closeDropCursor",()=>{const e=new Event("dragleave",{bubbles:!1});e.synthetic=!0,this.pmView.dom.dispatchEvent(e)});b(this,"getDragEventContext",e=>{var c;const t=!((c=e.dataTransfer)!=null&&c.types.includes("blocknote/html"))&&!!this.pmView.dragging,o=!!this.isDragOrigin,r=t||o,s=this.findClosestEditorElement(e);if(!s||s.distance>re)return;const i=s.element===this.pmView.dom,a=i&&s.distance===0;if(!(!i&&!r))return{isDropPoint:i,isDropWithinEditorBounds:a,isDragOrigin:r}});b(this,"onDrop",e=>{if(e.synthetic)return;const t=this.getDragEventContext(e);if(!t){this.closeDropCursor();return}const{isDropPoint:o,isDropWithinEditorBounds:r,isDragOrigin:s}=t;if(!r&&o&&this.dispatchSyntheticEvent(e),o){if(this.pmView.dragging)return;this.pmView.dispatch(this.pmView.state.tr.setSelection(V.TextSelection.create(this.pmView.state.tr.doc,this.pmView.state.tr.selection.anchor)));return}else if(s){setTimeout(()=>this.pmView.dispatch(this.pmView.state.tr.deleteSelection()),0);return}});b(this,"onDragEnd",e=>{e.synthetic||(this.pmView.dragging=null)});b(this,"onKeyDown",e=>{var t;(t=this.state)!=null&&t.show&&this.editor.isFocused()&&(this.state.show=!1,this.emitUpdate(this.state))});b(this,"onMouseMove",e=>{var s;if(this.menuFrozen)return;this.mousePos={x:e.clientX,y:e.clientY};const t=this.pmView.dom.getBoundingClientRect(),o=this.mousePos.x>t.left&&this.mousePos.xt.top&&this.mousePos.y{if(!this.state)throw new Error("Attempting to update uninitialized side menu");o(this.state)},this.pmView.root.addEventListener("dragstart",this.onDragStart),this.pmView.root.addEventListener("dragover",this.onDragOver),this.pmView.root.addEventListener("drop",this.onDrop,!0),this.pmView.root.addEventListener("dragend",this.onDragEnd,!0),this.pmView.root.addEventListener("mousemove",this.onMouseMove,!0),this.pmView.root.addEventListener("keydown",this.onKeyDown,!0)}dispatchSyntheticEvent(e){const t=new Event(e.type,e),o=this.pmView.dom.firstChild.getBoundingClientRect();t.clientX=e.clientX,t.clientY=e.clientY,t.clientX=Math.min(Math.max(e.clientX,o.left),o.left+o.width),t.clientY=Math.min(Math.max(e.clientY,o.top),o.top+o.height),t.dataTransfer=e.dataTransfer,t.preventDefault=()=>e.preventDefault(),t.synthetic=!0,this.pmView.dom.dispatchEvent(t)}update(e,t){var r;!t.doc.eq(this.pmView.state.doc)&&((r=this.state)!=null&&r.show)&&this.updateStateFromMousePos()}destroy(){var e;(e=this.state)!=null&&e.show&&(this.state.show=!1,this.emitUpdate(this.state)),this.pmView.root.removeEventListener("mousemove",this.onMouseMove,!0),this.pmView.root.removeEventListener("dragstart",this.onDragStart),this.pmView.root.removeEventListener("dragover",this.onDragOver),this.pmView.root.removeEventListener("drop",this.onDrop,!0),this.pmView.root.removeEventListener("dragend",this.onDragEnd,!0),this.pmView.root.removeEventListener("keydown",this.onKeyDown,!0)}}const ge=new V.PluginKey("SideMenuPlugin"),pt=v.createExtension(({editor:n})=>{let e;const t=v.createStore(void 0);return{key:"sideMenu",store:t,prosemirrorPlugins:[new V.Plugin({key:ge,view:o=>(e=new fe(n,o,r=>{t.setState({...r})}),e)})],blockDragStart(o,r){e&&(e.isDragOrigin=!0),ht(o,r,n)},blockDragEnd(){pe(n.prosemirrorView.root),e&&(e.isDragOrigin=!1),n.blur()},freezeMenu(){e.menuFrozen=!0,e.state.show=!0,e.emitUpdate(e.state)},unfreezeMenu(){e.menuFrozen=!1,e.state.show=!1,e.emitUpdate(e.state)}}});let E;function se(n){E||(E=document.createElement("div"),E.innerHTML="_",E.style.opacity="0",E.style.height="1px",E.style.width="1px",n instanceof Document?n.body.appendChild(E):n.appendChild(E))}function ft(n){E&&(n instanceof Document?n.body.removeChild(E):n.removeChild(E),E=void 0)}function _(n){return Array.prototype.indexOf.call(n.parentElement.childNodes,n)}function gt(n){let e=n;for(;e&&e.nodeName!=="TD"&&e.nodeName!=="TH"&&!e.classList.contains("tableWrapper");){if(e.classList.contains("ProseMirror"))return;const t=e.parentNode;if(!t||!(t instanceof Element))return;e=t}return e.nodeName==="TD"||e.nodeName==="TH"?{type:"cell",domNode:e,tbodyNode:e.closest("tbody")}:{type:"wrapper",domNode:e,tbodyNode:e.querySelector("tbody")}}function yt(n,e){const t=e.querySelectorAll(n);for(let o=0;o{this.mouseState="down"});b(this,"mouseUpHandler",e=>{this.mouseState="up",this.mouseMoveHandler(e)});b(this,"mouseMoveHandler",e=>{var l,u,h,g,m,p,d,f;if(this.menuFrozen||this.mouseState==="selecting"||!(e.target instanceof Element)||!this.pmView.dom.contains(e.target))return;const t=gt(e.target);if((t==null?void 0:t.type)==="cell"&&this.mouseState==="down"&&!((l=this.state)!=null&&l.draggingState)){this.mouseState="selecting",(u=this.state)!=null&&u.show&&(this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,this.emitUpdate());return}if(!t||!this.editor.isEditable){(h=this.state)!=null&&h.show&&(this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,this.emitUpdate());return}if(!t.tbodyNode)return;const o=t.tbodyNode.getBoundingClientRect(),r=he(t.domNode,this.pmView);if(!r)return;this.tableElement=r.node;let s;const i=this.editor.transact(y=>O.getNodeById(r.id,y.doc));if(!i)throw new Error(`Block with ID ${r.id} not found`);const a=k.nodeToBlock(i.node,this.editor.pmSchema,this.editor.schema.blockSchema,this.editor.schema.inlineContentSchema,this.editor.schema.styleSchema);if(O.editorHasBlockWithType(this.editor,"table")&&(this.tablePos=i.posBeforeNode+1,s=a),!s)return;this.tableId=r.id;const c=(g=t.domNode.closest(".tableWrapper"))==null?void 0:g.querySelector(".table-widgets-container");if((t==null?void 0:t.type)==="wrapper"){const y=e.clientY>=o.bottom-1&&e.clientY=o.right-1&&e.clientXo.right||e.clientY>o.bottom;this.state={...this.state,show:!0,showAddOrRemoveRowsButton:y,showAddOrRemoveColumnsButton:w,referencePosTable:o,block:s,widgetContainer:c,colIndex:x||(p=this.state)==null?void 0:p.colIndex,rowIndex:x||(d=this.state)==null?void 0:d.rowIndex,referencePosCell:x||(f=this.state)==null?void 0:f.referencePosCell}}else{const y=_(t.domNode),w=_(t.domNode.parentElement),x=t.domNode.getBoundingClientRect();if(this.state!==void 0&&this.state.show&&this.tableId===r.id&&this.state.rowIndex===w&&this.state.colIndex===y)return;this.state={show:!0,showAddOrRemoveColumnsButton:y===s.content.rows[0].cells.length-1,showAddOrRemoveRowsButton:w===s.content.rows.length-1,referencePosTable:o,block:s,draggingState:void 0,referencePosCell:x,colIndex:y,rowIndex:w,widgetContainer:c}}return this.emitUpdate(),!1});b(this,"dragOverHandler",e=>{var g;if(((g=this.state)==null?void 0:g.draggingState)===void 0)return;e.preventDefault(),e.dataTransfer.dropEffect="move",yt(".prosemirror-dropcursor-block, .prosemirror-dropcursor-inline",this.pmView.root);const t={left:Math.min(Math.max(e.clientX,this.state.referencePosTable.left+1),this.state.referencePosTable.right-1),top:Math.min(Math.max(e.clientY,this.state.referencePosTable.top+1),this.state.referencePosTable.bottom-1)},o=this.pmView.root.elementsFromPoint(t.left,t.top).filter(m=>m.tagName==="TD"||m.tagName==="TH");if(o.length===0)return;const r=o[0];let s=!1;const i=_(r.parentElement),a=_(r),c=this.state.draggingState.draggedCellOrientation==="row"?this.state.rowIndex:this.state.colIndex,u=(this.state.draggingState.draggedCellOrientation==="row"?i:a)!==c;(this.state.rowIndex!==i||this.state.colIndex!==a)&&(this.state.rowIndex=i,this.state.colIndex=a,this.state.referencePosCell=r.getBoundingClientRect(),s=!0);const h=this.state.draggingState.draggedCellOrientation==="row"?t.top:t.left;this.state.draggingState.mousePos!==h&&(this.state.draggingState.mousePos=h,s=!0),s&&this.emitUpdate(),u&&this.editor.transact(m=>m.setMeta(N,!0))});b(this,"dropHandler",e=>{if(this.mouseState="up",this.state===void 0||this.state.draggingState===void 0)return!1;if(this.state.rowIndex===void 0||this.state.colIndex===void 0)throw new Error("Attempted to drop table row or column, but no table block was hovered prior.");e.preventDefault();const{draggingState:t,colIndex:o,rowIndex:r}=this.state,s=this.state.block.content.columnWidths;if(t.draggedCellOrientation==="row"){if(!k.canRowBeDraggedInto(this.state.block,t.originalIndex,r))return!1;const i=k.moveRow(this.state.block,t.originalIndex,r);this.editor.updateBlock(this.state.block,{type:"table",content:{...this.state.block.content,rows:i}})}else{if(!k.canColumnBeDraggedInto(this.state.block,t.originalIndex,o))return!1;const i=k.moveColumn(this.state.block,t.originalIndex,o),[a]=s.splice(t.originalIndex,1);s.splice(o,0,a),this.editor.updateBlock(this.state.block,{type:"table",content:{...this.state.block.content,columnWidths:s,rows:i}})}return this.editor.setTextCursorPosition(this.state.block.id),!0});this.editor=e,this.pmView=t,this.emitUpdate=()=>{if(!this.state)throw new Error("Attempting to update uninitialized image toolbar");o(this.state)},t.dom.addEventListener("mousemove",this.mouseMoveHandler),t.dom.addEventListener("mousedown",this.viewMousedownHandler),window.addEventListener("mouseup",this.mouseUpHandler),t.root.addEventListener("dragover",this.dragOverHandler),t.root.addEventListener("drop",this.dropHandler)}update(){var r;if(!this.state||!this.state.show)return;if(this.state.block=this.editor.getBlock(this.state.block.id),!this.state.block||this.state.block.type!=="table"||!((r=this.tableElement)!=null&&r.isConnected)){this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,this.emitUpdate();return}const{height:e,width:t}=k.getDimensionsOfTable(this.state.block);this.state.rowIndex!==void 0&&this.state.colIndex!==void 0&&(this.state.rowIndex>=e&&(this.state.rowIndex=e-1),this.state.colIndex>=t&&(this.state.colIndex=t-1));const o=this.tableElement.querySelector("tbody");if(!o)throw new Error("Table block does not contain a 'tbody' HTML element. This should never happen.");if(this.state.rowIndex!==void 0&&this.state.colIndex!==void 0){const i=o.children[this.state.rowIndex].children[this.state.colIndex];i?this.state.referencePosCell=i.getBoundingClientRect():(this.state.rowIndex=void 0,this.state.colIndex=void 0)}this.state.referencePosTable=o.getBoundingClientRect(),this.emitUpdate()}destroy(){this.pmView.dom.removeEventListener("mousemove",this.mouseMoveHandler),window.removeEventListener("mouseup",this.mouseUpHandler),this.pmView.dom.removeEventListener("mousedown",this.viewMousedownHandler),this.pmView.root.removeEventListener("dragover",this.dragOverHandler),this.pmView.root.removeEventListener("drop",this.dropHandler)}}const N=new C.PluginKey("TableHandlesPlugin"),wt=v.createExtension(({editor:n})=>{let e;const t=v.createStore(void 0);return{key:"tableHandles",store:t,prosemirrorPlugins:[new C.Plugin({key:N,view:o=>(e=new ye(n,o,r=>{t.setState(r.block?{...r,draggingState:r.draggingState?{...r.draggingState}:void 0}:void 0)}),e),props:{decorations:o=>{if(e===void 0||e.state===void 0||e.state.draggingState===void 0||e.tablePos===void 0)return;const r=e.state.draggingState.draggedCellOrientation==="row"?e.state.rowIndex:e.state.colIndex;if(r===void 0)return;const s=[],{block:i,draggingState:a}=e.state,{originalIndex:c,draggedCellOrientation:l}=a;if(r===c||!i||l==="row"&&!k.canRowBeDraggedInto(i,c,r)||l==="col"&&!k.canColumnBeDraggedInto(i,c,r))return D.DecorationSet.create(o.doc,s);const u=o.doc.resolve(e.tablePos+1);return e.state.draggingState.draggedCellOrientation==="row"?k.getCellsAtRowHandle(e.state.block,r).forEach(({row:g,col:m})=>{const p=o.doc.resolve(u.posAtIndex(g)+1),d=o.doc.resolve(p.posAtIndex(m)+1),f=d.node(),y=d.pos+(r>c?f.nodeSize-2:0);s.push(D.Decoration.widget(y,()=>{const w=document.createElement("div");return w.className="bn-table-drop-cursor",w.style.left="0",w.style.right="0",r>c?w.style.bottom="-2px":w.style.top="-3px",w.style.height="4px",w}))}):k.getCellsAtColumnHandle(e.state.block,r).forEach(({row:g,col:m})=>{const p=o.doc.resolve(u.posAtIndex(g)+1),d=o.doc.resolve(p.posAtIndex(m)+1),f=d.node(),y=d.pos+(r>c?f.nodeSize-2:0);s.push(D.Decoration.widget(y,()=>{const w=document.createElement("div");return w.className="bn-table-drop-cursor",w.style.top="0",w.style.bottom="0",r>c?w.style.right="-2px":w.style.left="-3px",w.style.width="4px",w}))}),D.DecorationSet.create(o.doc,s)}}})],colDragStart(o){if(e===void 0||e.state===void 0||e.state.colIndex===void 0)throw new Error("Attempted to drag table column, but no table block was hovered prior.");e.state.draggingState={draggedCellOrientation:"col",originalIndex:e.state.colIndex,mousePos:o.clientX},e.emitUpdate(),n.transact(r=>r.setMeta(N,{draggedCellOrientation:e.state.draggingState.draggedCellOrientation,originalIndex:e.state.colIndex,newIndex:e.state.colIndex,tablePos:e.tablePos})),!n.headless&&(se(n.prosemirrorView.root),o.dataTransfer.setDragImage(E,0,0),o.dataTransfer.effectAllowed="move")},rowDragStart(o){if(e.state===void 0||e.state.rowIndex===void 0)throw new Error("Attempted to drag table row, but no table block was hovered prior.");e.state.draggingState={draggedCellOrientation:"row",originalIndex:e.state.rowIndex,mousePos:o.clientY},e.emitUpdate(),n.transact(r=>r.setMeta(N,{draggedCellOrientation:e.state.draggingState.draggedCellOrientation,originalIndex:e.state.rowIndex,newIndex:e.state.rowIndex,tablePos:e.tablePos})),!n.headless&&(se(n.prosemirrorView.root),o.dataTransfer.setDragImage(E,0,0),o.dataTransfer.effectAllowed="copyMove")},dragEnd(){if(e.state===void 0)throw new Error("Attempted to drag table row, but no table block was hovered prior.");e.state.draggingState=void 0,e.emitUpdate(),n.transact(o=>o.setMeta(N,null)),!n.headless&&ft(n.prosemirrorView.root)},freezeHandles(){e.menuFrozen=!0},unfreezeHandles(){e.menuFrozen=!1},getCellsAtRowHandle(o,r){return k.getCellsAtRowHandle(o,r)},getCellsAtColumnHandle(o,r){return k.getCellsAtColumnHandle(o,r)},setCellSelection(o,r,s=r){if(!e)throw new Error("Table handles view not initialized");const i=o.doc.resolve(e.tablePos+1),a=o.doc.resolve(i.posAtIndex(r.row)+1),c=o.doc.resolve(a.posAtIndex(r.col)),l=o.doc.resolve(i.posAtIndex(s.row)+1),u=o.doc.resolve(l.posAtIndex(s.col)),h=o.tr;return h.setSelection(new T.CellSelection(c,u)),o.apply(h)},addRowOrColumn(o,r){n.exec((s,i)=>{const a=this.setCellSelection(s,r.orientation==="row"?{row:o,col:0}:{row:0,col:o});return r.orientation==="row"?r.side==="above"?T.addRowBefore(a,i):T.addRowAfter(a,i):r.side==="left"?T.addColumnBefore(a,i):T.addColumnAfter(a,i)})},removeRowOrColumn(o,r){return r==="row"?n.exec((s,i)=>{const a=this.setCellSelection(s,{row:o,col:0});return T.deleteRow(a,i)}):n.exec((s,i)=>{const a=this.setCellSelection(s,{row:0,col:o});return T.deleteColumn(a,i)})},mergeCells(o){return n.exec((r,s)=>{const i=o?this.setCellSelection(r,o.relativeStartCell,o.relativeEndCell):r;return T.mergeCells(i,s)})},splitCell(o){return n.exec((r,s)=>{const i=o?this.setCellSelection(r,o):r;return T.splitCell(i,s)})},getCellSelection(){return n.transact(o=>{const r=o.selection;let s=r.$from,i=r.$to;if(O.isTableCellSelection(r)){const{ranges:d}=r;d.forEach(f=>{s=f.$from.min(s??f.$from),i=f.$to.max(i??f.$to)})}else if(s=o.doc.resolve(r.$from.pos-r.$from.parentOffset-1),i=o.doc.resolve(r.$to.pos-r.$to.parentOffset-1),s.pos===0||i.pos===0)return;const a=o.doc.resolve(s.pos-s.parentOffset-1),c=o.doc.resolve(i.pos-i.parentOffset-1),l=o.doc.resolve(a.pos-a.parentOffset-1),u=s.index(a.depth),h=a.index(l.depth),g=i.index(c.depth),m=c.index(l.depth),p=[];for(let d=h;d<=m;d++)for(let f=u;f<=g;f++)p.push({row:d,col:f});return{from:{row:h,col:u},to:{row:m,col:g},cells:p}})},getMergeDirection(o){return n.transact(r=>{const s=O.isTableCellSelection(r.selection)?r.selection:void 0;if(!s||!o||s.ranges.length<=1)return;const i=this.getCellSelection();if(i)return k.areInSameColumn(i.from,i.to,o)?"vertical":"horizontal"})},cropEmptyRowsOrColumns(o,r){return k.cropEmptyRowsOrColumns(o,r)},addRowsOrColumns(o,r,s){return k.addRowsOrColumns(o,r,s)}}}),ie=new C.PluginKey("trailingNode"),bt=v.createExtension(()=>({key:"trailingNode",prosemirrorPlugins:[new C.Plugin({key:ie,appendTransaction:(n,e,t)=>{const{doc:o,tr:r,schema:s}=t,i=ie.getState(t),a=o.content.size-2,c=s.nodes.blockContainer,l=s.nodes.paragraph;if(i)return r.insert(a,c.create(void 0,l.create()))},state:{init:(n,e)=>{},apply:(n,e)=>{if(!n.docChanged)return e;let t=n.doc.lastChild;if(!t||t.type.name!=="blockGroup")throw new Error("Expected blockGroup");if(t=t.lastChild,!t||t.type.name!=="blockContainer")return!0;const o=t.firstChild;if(!o)throw new Error("Expected blockContent");return t.nodeSize>4||o.type.spec.content!=="inline*"}}})]}));exports.BlockChangeExtension=$e;exports.DEFAULT_LINK_PROTOCOL=et;exports.DropCursorExtension=We;exports.ForkYDocExtension=Ke;exports.FormattingToolbarExtension=Ge;exports.HistoryExtension=Je;exports.LinkToolbarExtension=Qe;exports.NodeSelectionKeyboardExtension=ot;exports.PlaceholderExtension=rt;exports.PreviousBlockTypeExtension=it;exports.SchemaMigration=Xe;exports.SideMenuExtension=pt;exports.SideMenuView=fe;exports.TableHandlesExtension=wt;exports.TableHandlesView=ye;exports.TrailingNodeExtension=bt;exports.VALID_LINK_PROTOCOLS=Ze;exports.YCursorExtension=z;exports.YSyncExtension=U;exports.YUndoExtension=$;exports.blocksToMarkdown=dt;exports.cleanHTMLToMarkdown=j;exports.createExternalHTMLExporter=Y;exports.fragmentToBlocks=me;exports.getBlocksChangedByTransaction=de;exports.sideMenuPluginKey=ge;exports.tableHandlesPluginKey=N; ++"use strict";var we=Object.defineProperty;var be=(n,e,t)=>e in n?we(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var b=(n,e,t)=>be(n,typeof e!="symbol"?e+"":e,t);const C=require("prosemirror-state"),R=require("@tiptap/core"),ke=require("fast-deep-equal"),k=require("./blockToNode-CumVjgem.cjs"),O=require("./defaultBlocks-DosClM5E.cjs"),v=require("./BlockNoteExtension-BWw0r8Gy.cjs"),M=require("y-prosemirror"),Ce=require("yjs"),V=require("@tiptap/pm/state"),ve=require("prosemirror-dropcursor"),q=require("@tiptap/pm/history"),D=require("prosemirror-view"),Se=require("uuid"),Z=require("@tiptap/pm/model"),H=require("prosemirror-model"),xe=require("rehype-parse"),Ee=require("rehype-remark"),Ie=require("remark-gfm"),Pe=require("remark-stringify"),Te=require("unified"),Be=require("hast-util-from-dom"),De=require("unist-util-visit"),T=require("prosemirror-tables"),F=n=>n&&typeof n=="object"&&"default"in n?n:{default:n};function Oe(n){if(n&&typeof n=="object"&&"default"in n)return n;const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(n){for(const t in n)if(t!=="default"){const o=Object.getOwnPropertyDescriptor(n,t);Object.defineProperty(e,t,o.get?o:{enumerable:!0,get:()=>n[t]})}}return e.default=n,Object.freeze(e)}const Me=F(ke),B=Oe(Ce),Ae=F(xe),Ne=F(Ee),Le=F(Ie),Re=F(Pe);function ae(n){const e=Array.from(n.classList).filter(t=>!t.startsWith("bn-"))||[];e.length>0?n.className=e.join(" "):n.removeAttribute("class")}function le(n,e,t,o){var a;let r;if(e)if(typeof e=="string")r=k.inlineContentToNodes([e],n.pmSchema);else if(Array.isArray(e))r=k.inlineContentToNodes(e,n.pmSchema);else if(e.type==="tableContent")r=k.tableContentToNodes(e,n.pmSchema);else throw new k.UnreachableCaseError(e.type);else throw new Error("blockContent is required");const i=((o==null?void 0:o.document)??document).createDocumentFragment();for(const c of r)if(c.type.name!=="text"&&n.schema.inlineContentSchema[c.type.name]){const l=n.schema.inlineContentSpecs[c.type.name].implementation;if(l){const u=k.nodeToCustomInlineContent(c,n.schema.inlineContentSchema,n.schema.styleSchema),h=l.toExternalHTML?l.toExternalHTML(u,n):l.render.call({renderType:"dom",props:void 0},u,()=>{},n);if(h){if(i.appendChild(h.dom),h.contentDOM){const g=t.serializeFragment(c.content,o);h.contentDOM.dataset.editable="",h.contentDOM.appendChild(g)}continue}}}else if(c.type.name==="text"){let l=document.createTextNode(c.textContent);for(const u of Array.from(c.marks).reverse())if(u.type.name in n.schema.styleSpecs){const h=(n.schema.styleSpecs[u.type.name].implementation.toExternalHTML??n.schema.styleSpecs[u.type.name].implementation.render)(u.attrs.stringValue,n);h.contentDOM.appendChild(l),l=h.dom}else{const h=u.type.spec.toDOM(u,!0),g=H.DOMSerializer.renderSpec(document,h);g.contentDOM.appendChild(l),l=g.dom}i.appendChild(l)}else{const l=t.serializeFragment(H.Fragment.from([c]),o);i.appendChild(l)}return i.childNodes.length===1&&((a=i.firstChild)==null?void 0:a.nodeType)===1&&ae(i.firstChild),i}function Ve(n,e,t,o,r,s,i,a){var y,w,x,L,X,W,G,J,Q;const c=(a==null?void 0:a.document)??document,l=e.pmSchema.nodes.blockContainer,u=t.props||{};for(const[S,I]of Object.entries(e.schema.blockSchema[t.type].propSchema))!(S in u)&&I.default!==void 0&&(u[S]=I.default);const h=(w=(y=l.spec)==null?void 0:y.toDOM)==null?void 0:w.call(y,l.create({id:t.id,...u})),g=Array.from(h.dom.attributes),m=e.blockImplementations[t.type].implementation,p=((x=m.toExternalHTML)==null?void 0:x.call({},{...t,props:u},e,{nestingLevel:i}))||m.render.call({},{...t,props:u},e),d=c.createDocumentFragment();if(p.dom.classList.contains("bn-block-content")){const S=[...g,...Array.from(p.dom.attributes)].filter(I=>I.name.startsWith("data")&&I.name!=="data-content-type"&&I.name!=="data-file-block"&&I.name!=="data-node-view-wrapper"&&I.name!=="data-node-type"&&I.name!=="data-id"&&I.name!=="data-editable");for(const I of S)p.dom.firstChild.setAttribute(I.name,I.value);ae(p.dom.firstChild),i>0&&p.dom.firstChild.setAttribute("data-nesting-level",i.toString()),d.append(...Array.from(p.dom.childNodes))}else d.append(p.dom),i>0&&p.dom.setAttribute("data-nesting-level",i.toString());if(p.contentDOM&&t.content){const S=le(e,t.content,o,a);p.contentDOM.appendChild(S)}let f;if(r.has(t.type)?f="OL":s.has(t.type)&&(f="UL"),f){if(((L=n.lastChild)==null?void 0:L.nodeName)!==f){const S=c.createElement(f);f==="OL"&&"start"in u&&u.start&&(u==null?void 0:u.start)!==1&&S.setAttribute("start",u.start+""),n.append(S)}n.lastChild.appendChild(d)}else n.append(d);if(t.children&&t.children.length>0){const S=c.createDocumentFragment();if(ce(S,e,t.children,o,r,s,i+1,a),((X=n.lastChild)==null?void 0:X.nodeName)==="UL"||((W=n.lastChild)==null?void 0:W.nodeName)==="OL")for(;((G=S.firstChild)==null?void 0:G.nodeName)==="UL"||((J=S.firstChild)==null?void 0:J.nodeName)==="OL";)n.lastChild.lastChild.appendChild(S.firstChild);e.pmSchema.nodes[t.type].isInGroup("blockContent")?n.append(S):(Q=p.contentDOM)==null||Q.append(S)}}const ce=(n,e,t,o,r,s,i=0,a)=>{for(const c of t)Ve(n,e,c,o,r,s,i,a)},He=(n,e,t,o,r,s)=>{const a=((s==null?void 0:s.document)??document).createDocumentFragment();return ce(a,n,e,t,o,r,0,s),a},Y=(n,e)=>{const t=H.DOMSerializer.fromSchema(n);return{exportBlocks:(o,r)=>{const s=He(e,o,t,new Set(["numberedListItem"]),new Set(["bulletListItem","checkListItem","toggleListItem"]),r),i=document.createElement("div");return i.append(s),i.innerHTML},exportInlineContent:(o,r)=>{const s=le(e,o,t,r),i=document.createElement("div");return i.append(s.cloneNode(!0)),i.innerHTML}}};function Fe(n,e){if(e===0)return;const t=n.resolve(e);for(let o=t.depth;o>0;o--){const r=t.node(o);if(O.isNodeBlock(r))return r.attrs.id}}function _e(n){return n.getMeta("paste")?{type:"paste"}:n.getMeta("uiEvent")==="drop"?{type:"drop"}:n.getMeta("history$")?{type:n.getMeta("history$").redo?"redo":"undo"}:n.getMeta("y-sync$")?n.getMeta("y-sync$").isUndoRedoOperation?{type:"undo-redo"}:{type:"yjs-remote"}:{type:"local"}}function ee(n){const e="__root__",t={},o={},r=k.getPmSchema(n);return n.descendants((s,i)=>{if(!O.isNodeBlock(s))return!0;const a=Fe(n,i),c=a??e;o[c]||(o[c]=[]);const l=k.nodeToBlock(s,r);return t[s.attrs.id]={block:l,parentId:a},o[c].push(s.attrs.id),!0}),{byId:t,childrenByParent:o}}function Ue(n,e){const t=new Set;if(!n||!e)return t;const o=new Set(n),r=e.filter(d=>o.has(d)),s=n.filter(d=>r.includes(d));if(s.length<=1||r.length<=1)return t;const i={};for(let d=0;di[d]),c=a.length,l=[],u=[],h=new Array(c).fill(-1),g=(d,f)=>{let y=0,w=d.length;for(;y>>1;d[x]0&&(h[d]=u[y-1]),y===l.length?(l.push(f),u.push(d)):(l[y]=f,u[y]=d)}const m=new Set;let p=u[u.length-1]??-1;for(;p!==-1;)m.add(p),p=h[p];for(let d=0;d!(m in r.byId)).forEach(m=>{i.push({type:"insert",block:s.byId[m].block,source:t,prevBlock:void 0}),a.add(m)}),Object.keys(r.byId).filter(m=>!(m in s.byId)).forEach(m=>{i.push({type:"delete",block:r.byId[m].block,source:t,prevBlock:void 0}),a.add(m)}),Object.keys(s.byId).filter(m=>m in r.byId).forEach(m=>{var y,w;const p=r.byId[m],d=s.byId[m];p.parentId!==d.parentId?(i.push({type:"move",block:d.block,prevBlock:p.block,source:t,prevParent:p.parentId?(y=r.byId[p.parentId])==null?void 0:y.block:void 0,currentParent:d.parentId?(w=s.byId[d.parentId])==null?void 0:w.block:void 0}),a.add(m)):Me.default({...p.block,children:void 0},{...d.block,children:void 0})||(i.push({type:"update",block:d.block,prevBlock:p.block,source:t}),a.add(m))});const c=r.childrenByParent,l=s.childrenByParent,u="__root__",h=new Set([...Object.keys(c),...Object.keys(l)]),g=new Set;return h.forEach(m=>{const p=Ue(c[m],l[m]);p.size!==0&&p.forEach(d=>{var x,L;const f=r.byId[d],y=s.byId[d];!f||!y||f.parentId!==y.parentId||a.has(d)||(f.parentId??u)!==m||g.has(d)||(g.add(d),i.push({type:"move",block:y.block,prevBlock:f.block,source:t,prevParent:f.parentId?(x=r.byId[f.parentId])==null?void 0:x.block:void 0,currentParent:y.parentId?(L=s.byId[y.parentId])==null?void 0:L.block:void 0}),a.add(d))})}),i}const $e=v.createExtension(()=>{const n=[];return{key:"blockChange",prosemirrorPlugins:[new C.Plugin({key:new C.PluginKey("blockChange"),filterTransaction:e=>{let t;return n.reduce((o,r)=>o===!1?o:r({getChanges(){return t||(t=de(e),t)},tr:e})!==!1,!0)}})],subscribe(e){return n.push(e),()=>{n.splice(n.indexOf(e),1)}}}});function te(n){const e=n.charAt(0)==="#"?n.substring(1,7):n,t=parseInt(e.substring(0,2),16),o=parseInt(e.substring(2,4),16),r=parseInt(e.substring(4,6),16),i=[t/255,o/255,r/255].map(c=>c<=.03928?c/12.92:Math.pow((c+.055)/1.055,2.4));return .2126*i[0]+.7152*i[1]+.0722*i[2]<=.179}function qe(n){const e=document.createElement("span");e.classList.add("bn-collaboration-cursor__base");const t=document.createElement("span");t.setAttribute("contentedEditable","false"),t.classList.add("bn-collaboration-cursor__caret"),t.setAttribute("style",`background-color: ${n.color}; color: ${te(n.color)?"white":"black"}`);const o=document.createElement("span");return o.classList.add("bn-collaboration-cursor__label"),o.setAttribute("style",`background-color: ${n.color}; color: ${te(n.color)?"white":"black"}`),o.insertBefore(document.createTextNode(n.name),null),t.insertBefore(o,null),e.insertBefore(document.createTextNode("⁠"),null),e.insertBefore(t,null),e.insertBefore(document.createTextNode("⁠"),null),e}const z=v.createExtension(({options:n})=>{const e=new Map,t=n.provider&&"awareness"in n.provider&&typeof n.provider.awareness=="object"?n.provider.awareness:void 0;return t&&("setLocalStateField"in t&&typeof t.setLocalStateField=="function"&&t.setLocalStateField("user",n.user),"on"in t&&typeof t.on=="function"&&n.showCursorLabels!=="always"&&t.on("change",({updated:o})=>{for(const r of o){const s=e.get(r);s&&(s.element.setAttribute("data-active",""),s.hideTimeout&&clearTimeout(s.hideTimeout),e.set(r,{element:s.element,hideTimeout:setTimeout(()=>{s.element.removeAttribute("data-active")},2e3)}))}})),{key:"yCursor",prosemirrorPlugins:[t?M.yCursorPlugin(t,{selectionBuilder:M.defaultSelectionBuilder,cursorBuilder(o,r){let s=e.get(r);if(!s){const i=(n.renderCursor??qe)(o);n.showCursorLabels!=="always"&&(i.addEventListener("mouseenter",()=>{const a=e.get(r);a.element.setAttribute("data-active",""),a.hideTimeout&&(clearTimeout(a.hideTimeout),e.set(r,{element:a.element,hideTimeout:void 0}))}),i.addEventListener("mouseleave",()=>{const a=e.get(r);e.set(r,{element:a.element,hideTimeout:setTimeout(()=>{a.element.removeAttribute("data-active")},2e3)})})),s={element:i,hideTimeout:void 0},e.set(r,s)}return s.element}}):void 0].filter(Boolean),dependsOn:["ySync"],updateUser(o){t==null||t.setLocalStateField("user",o)}}}),U=v.createExtension(({options:n})=>({key:"ySync",prosemirrorPlugins:[M.ySyncPlugin(n.fragment)],runsBefore:["default"]})),$=v.createExtension(()=>({key:"yUndo",prosemirrorPlugins:[M.yUndoPlugin()],dependsOn:["yCursor","ySync"],undoCommand:M.undoCommand,redoCommand:M.redoCommand}));function ze(n,e){const t=n.doc;if(n._item===null){const o=Array.from(t.share.keys()).find(r=>t.share.get(r)===n);if(o==null)throw new Error("type does not exist in other ydoc");return e.get(o,n.constructor)}else{const o=n._item,r=e.store.clients.get(o.id.client)??[],s=B.findIndexSS(r,o.id.clock);return r[s].content.type}}const Ke=v.createExtension(({editor:n,options:e})=>{let t;const o=v.createStore({isForked:!1});return{key:"yForkDoc",store:o,fork(){if(t)return;const r=e.fragment;if(!r)throw new Error("No fragment to fork from");const s=new B.Doc;B.applyUpdate(s,B.encodeStateAsUpdate(r.doc));const i=ze(r,s);t={undoStack:M.yUndoPluginKey.getState(n.prosemirrorState).undoManager.undoStack,originalFragment:r,forkedFragment:i},n.unregisterExtension([$,z,U]);const a={...e,fragment:i};n.registerExtension([U(a),$()]),o.setState({isForked:!0})},merge({keepChanges:r}){if(!t)return;n.unregisterExtension(["ySync","yCursor","yUndo"]);const{originalFragment:s,forkedFragment:i,undoStack:a}=t;if(n.registerExtension([U(e),z(e),$()]),M.yUndoPluginKey.getState(n.prosemirrorState).undoManager.undoStack=a,r){const c=B.encodeStateAsUpdate(i.doc,B.encodeStateVector(s.doc));B.applyUpdate(s.doc,c,n)}t=void 0,o.setState({isForked:!1})}}}),ue=(n,e)=>{e(n),n.forEach(t=>{t instanceof B.XmlElement&&ue(t,e)})},Ye=(n,e)=>{const t=new Map;return n.forEach(o=>{o instanceof B.XmlElement&&ue(o,r=>{if(r.nodeName==="blockContainer"&&r.hasAttribute("id")){const s=r.getAttribute("textColor"),i=r.getAttribute("backgroundColor"),a={textColor:s===O.defaultProps.textColor.default?void 0:s,backgroundColor:i===O.defaultProps.backgroundColor.default?void 0:i};(a.textColor||a.backgroundColor)&&t.set(r.getAttribute("id"),a)}})}),t.size===0?!1:(e.doc.descendants((o,r)=>{if(o.type.name==="blockContainer"&&t.has(o.attrs.id)){const s=e.doc.nodeAt(r+1);if(!s)throw new Error("No element found");e.setNodeMarkup(r+1,void 0,{...s.attrs,...t.get(o.attrs.id)})}}),!0)},je=[Ye],Xe=v.createExtension(({options:n})=>{let e=!1;const t=new V.PluginKey("schemaMigration");return{key:"schemaMigration",prosemirrorPlugins:[new V.Plugin({key:t,appendTransaction:(o,r,s)=>{if(e||!o.some(a=>a.getMeta("y-sync$"))||o.every(a=>!a.docChanged)||!n.fragment.firstChild)return;const i=s.tr;for(const a of je)a(n.fragment,i);if(e=!0,!!i.docChanged)return i}})]}}),We=v.createExtension(({editor:n,options:e})=>({key:"dropCursor",prosemirrorPlugins:[(e.dropCursor??ve.dropCursor)({width:5,color:"#ddeeff",editor:n})]})),Ge=v.createExtension(({editor:n})=>{const e=v.createStore(!1),t=()=>n.transact(o=>{var s;if(o.selection.empty||o.selection instanceof C.NodeSelection&&(o.selection.node.type.spec.content==="inline*"||((s=o.selection.node.firstChild)==null?void 0:s.type.spec.content)==="inline*")||o.selection instanceof C.TextSelection&&o.doc.textBetween(o.selection.from,o.selection.to).length===0)return!1;let r=!1;return o.selection.content().content.descendants(i=>(i.type.spec.code&&(r=!0),!r)),!r});return{key:"formattingToolbar",store:e,mount({dom:o,signal:r}){let s=!1;const i=n.onChange(()=>{s||e.setState(t())}),a=n.onSelectionChange(()=>{s||e.setState(t())});o.addEventListener("pointerdown",()=>{s=!0,e.setState(!1)},{signal:r}),n.prosemirrorView.root.addEventListener("pointerup",()=>{s=!1,n.isFocused()&&e.setState(t())},{signal:r,capture:!0}),o.addEventListener("pointercancel",()=>{s=!1},{signal:r,capture:!0}),r.addEventListener("abort",()=>{i(),a()})}}}),Je=v.createExtension(()=>({key:"history",prosemirrorPlugins:[q.history()],undoCommand:q.undo,redoCommand:q.redo})),Qe=v.createExtension(({editor:n})=>{function e(r){let s=n.prosemirrorView.nodeDOM(r);for(;s&&s.parentElement;){if(s.nodeName==="A")return s;s=s.parentElement}return null}function t(r,s){return n.transact(i=>{const a=i.doc.resolve(r),c=a.marks().find(u=>u.type.name===s);if(!c)return;const l=R.getMarkRange(a,c.type);if(l)return{range:l,mark:c,get text(){return i.doc.textBetween(l.from,l.to)},get position(){return R.posToDOMRect(n.prosemirrorView,l.from,l.to).toJSON()}}})}function o(){return n.transact(r=>{const s=r.selection;if(s.empty)return t(s.anchor,"link")})}return{key:"linkToolbar",getLinkAtSelection:o,getLinkElementAtPos:e,getMarkAtPos:t,getLinkAtElement(r){return n.transact(()=>{const s=n.prosemirrorView.posAtDOM(r,0)+1;return t(s,"link")})},editLink(r,s,i=n.transact(a=>a.selection.anchor)){n.transact(a=>{const c=k.getPmSchema(a),{range:l}=t(i+1,"link")||{range:{from:a.selection.from,to:a.selection.to}};l&&(a.insertText(s,l.from,l.to),a.addMark(l.from,l.from+s.length,c.mark("link",{href:r})))}),n.prosemirrorView.focus()},deleteLink(r=n.transact(s=>s.selection.anchor)){n.transact(s=>{const i=k.getPmSchema(s),{range:a}=t(r+1,"link")||{range:{from:s.selection.from,to:s.selection.to}};a&&s.removeMark(a.from,a.to,i.marks.link).setMeta("preventAutolink",!0)}),n.prosemirrorView.focus()}}}),Ze=["http","https","ftp","ftps","mailto","tel","callto","sms","cid","xmpp"],et="https",tt=new C.PluginKey("node-selection-keyboard"),ot=v.createExtension(()=>({key:"nodeSelectionKeyboard",prosemirrorPlugins:[new C.Plugin({key:tt,props:{handleKeyDown:(n,e)=>{if("node"in n.state.selection){if(e.ctrlKey||e.metaKey)return!1;if(e.key.length===1)return e.preventDefault(),!0;if(e.key==="Enter"&&!e.isComposing&&!e.shiftKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey){const t=n.state.tr;return n.dispatch(t.insert(n.state.tr.selection.$to.after(),n.state.schema.nodes.paragraph.createChecked()).setSelection(new C.TextSelection(t.doc.resolve(n.state.tr.selection.$to.after()+1)))),!0}}return!1}}})]})),nt=new C.PluginKey("blocknote-placeholder"),rt=v.createExtension(({editor:n,options:e})=>{const t=e.placeholders;return{key:"placeholder",prosemirrorPlugins:[new C.Plugin({key:nt,view:o=>{const r=`placeholder-selector-${Se.v4()}`;o.dom.classList.add(r);const s=document.createElement("style"),i=n._tiptapEditor.options.injectNonce;i&&s.setAttribute("nonce",i),o.root instanceof window.ShadowRoot?o.root.append(s):o.root.head.appendChild(s);const a=s.sheet,c=(l="")=>`.${r} .bn-block-content${l} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`;try{const{default:l,emptyDocument:u,...h}=t||{};for(const[p,d]of Object.entries(h)){const f=`[data-content-type="${p}"]`;a.insertRule(`${c(f)} { content: ${JSON.stringify(d)}; }`)}const g="[data-is-only-empty-block]",m="[data-is-empty-and-focused]";a.insertRule(`${c(g)} { content: ${JSON.stringify(u)}; }`),a.insertRule(`${c(m)} { content: ${JSON.stringify(l)}; }`)}catch(l){console.warn("Failed to insert placeholder CSS rule - this is likely due to the browser not supporting certain CSS pseudo-element selectors (:has, :only-child:, or :before)",l)}return{destroy:()=>{o.root instanceof window.ShadowRoot?o.root.removeChild(s):o.root.head.removeChild(s)}}},props:{decorations:o=>{const{doc:r,selection:s}=o;if(!n.isEditable||!s.empty||s.$from.parent.type.spec.code)return;const i=[];o.doc.content.size===6&&i.push(D.Decoration.node(2,4,{"data-is-only-empty-block":"true"}));const a=s.$anchor,c=a.parent;if(c.content.size===0){const l=a.before();i.push(D.Decoration.node(l,l+c.nodeSize,{"data-is-empty-and-focused":"true"}))}return D.DecorationSet.create(r,i)}}})]}}),oe=new C.PluginKey("previous-blocks"),st={index:"index",level:"level",type:"type",depth:"depth","depth-change":"depth-change"},it=v.createExtension(()=>{let n;return{key:"previousBlockType",prosemirrorPlugins:[new C.Plugin({key:oe,view(e){return{update:async(t,o)=>{var r;((r=this.key)==null?void 0:r.getState(t.state).updatedBlocks.size)>0&&(n=setTimeout(()=>{t.dispatch(t.state.tr.setMeta(oe,{clearUpdate:!0}))},0))},destroy:()=>{n&&clearTimeout(n)}}},state:{init(){return{prevTransactionOldBlockAttrs:{},currentTransactionOldBlockAttrs:{},updatedBlocks:new Set}},apply(e,t,o,r){if(t.currentTransactionOldBlockAttrs={},t.updatedBlocks.clear(),!e.docChanged||o.doc.eq(r.doc))return t;const s={},i=R.findChildren(o.doc,l=>l.attrs.id),a=new Map(i.map(l=>[l.node.attrs.id,l])),c=R.findChildren(r.doc,l=>l.attrs.id);for(const l of c){const u=a.get(l.node.attrs.id),h=u==null?void 0:u.node.firstChild,g=l.node.firstChild;if(u&&h&&g){const m={index:g.attrs.index,level:g.attrs.level,type:g.type.name,depth:r.doc.resolve(l.pos).depth},p={index:h.attrs.index,level:h.attrs.level,type:h.type.name,depth:o.doc.resolve(u.pos).depth};s[l.node.attrs.id]=p,t.currentTransactionOldBlockAttrs[l.node.attrs.id]=p,JSON.stringify(p)!==JSON.stringify(m)&&(p["depth-change"]=p.depth-m.depth,t.updatedBlocks.add(l.node.attrs.id))}}return t.prevTransactionOldBlockAttrs=s,t}},props:{decorations(e){const t=this.getState(e);if(t.updatedBlocks.size===0)return;const o=[];return e.doc.descendants((r,s)=>{if(!r.attrs.id||!t.updatedBlocks.has(r.attrs.id))return;const i=t.currentTransactionOldBlockAttrs[r.attrs.id],a={};for(const[l,u]of Object.entries(i))a["data-prev-"+st[l]]=u||"none";const c=D.Decoration.node(s,s+r.nodeSize,{...a});o.push(c)}),D.DecorationSet.create(e.doc,o)}}})]}});function he(n,e){var t,o;for(;n&&n.parentElement&&n.parentElement!==e.dom&&((t=n.getAttribute)==null?void 0:t.call(n,"data-node-type"))!=="blockContainer";)n=n.parentElement;if(((o=n.getAttribute)==null?void 0:o.call(n,"data-node-type"))==="blockContainer")return{node:n,id:n.getAttribute("data-id")}}function at(){const n=e=>{let t=e.children.length;for(let o=0;o0){e.children.splice(o,1,...r.children);const s=r.children.length-1;t+=s,o+=s}else e.children.splice(o,1),t--,o--}};return n}function lt(){const n=e=>{var t;if(e.children&&"length"in e.children&&e.children.length)for(let o=e.children.length-1;o>=0;o--){const r=e.children[o],s=o+1{De.visit(n,"element",(e,t,o)=>{var r,s,i,a;if(o&&e.tagName==="video"){const c=((r=e.properties)==null?void 0:r.src)||((s=e.properties)==null?void 0:s["data-url"])||"",l=((i=e.properties)==null?void 0:i.title)||((a=e.properties)==null?void 0:a["data-name"])||"";o.children[t]={type:"text",value:`![${l}](${c})`}}})}}function j(n){return Te.unified().use(Ae.default,{fragment:!0}).use(ct).use(at).use(lt).use(Ne.default).use(Le.default).use(Re.default,{handlers:{text:t=>t.value}}).processSync(n).value}function dt(n,e,t,o){const s=Y(e,t).exportBlocks(n,o);return j(s)}function me(n){const e=[];return n.descendants(t=>{var r,s;const o=k.getPmSchema(t);return t.type.name==="blockContainer"&&((r=t.firstChild)==null?void 0:r.type.name)==="blockGroup"?!0:t.type.name==="columnList"&&t.childCount===1?((s=t.firstChild)==null||s.forEach(i=>{e.push(k.nodeToBlock(i,o))}),!1):t.type.isInGroup("bnBlock")?(e.push(k.nodeToBlock(t,o)),!1):!0}),e}class A extends C.Selection{constructor(t,o){super(t,o);b(this,"nodes");const r=t.node();this.nodes=[],t.doc.nodesBetween(t.pos,o.pos,(s,i,a)=>{if(a!==null&&a.eq(r))return this.nodes.push(s),!1})}static create(t,o,r=o){return new A(t.resolve(o),t.resolve(r))}content(){return new H.Slice(H.Fragment.from(this.nodes),0,0)}eq(t){if(!(t instanceof A)||this.nodes.length!==t.nodes.length||this.from!==t.from||this.to!==t.to)return!1;for(let o=0;oArray.prototype.indexOf.call(h.children,g),i=s(r,n.domAtPos(e+1).node.parentElement),a=s(r,n.domAtPos(t-1).node.parentElement);for(let h=r.childElementCount-1;h>=0;h--)(h>a||hh!=="ProseMirror"&&h!=="bn-root"&&h!=="bn-editor").join(" ");P.className=P.className+" bn-drag-preview "+u,n.root instanceof ShadowRoot?n.root.appendChild(P):n.root.body.appendChild(P)}function pe(n){P!==void 0&&(n instanceof ShadowRoot?n.removeChild(P):n.body.removeChild(P),P=void 0)}function ht(n,e,t){if(!n.dataTransfer||t.headless)return;const o=t.prosemirrorView,r=O.getNodeById(e.id,o.state.doc);if(!r)throw new Error(`Block with ID ${e.id} not found`);const s=r.posBeforeNode;if(s!=null){const i=o.state.selection,a=o.state.doc,{from:c,to:l}=ut(i,a),u=c<=s&&s{this.state=e,this.emitUpdate(this.state)});b(this,"updateStateFromMousePos",()=>{var o,r,s,i,a;if(this.menuFrozen||!this.mousePos)return;const e=this.findClosestEditorElement({clientX:this.mousePos.x,clientY:this.mousePos.y});if((e==null?void 0:e.element)!==this.pmView.dom||e.distance>re){(o=this.state)!=null&&o.show&&(this.state.show=!1,this.updateState(this.state));return}const t=mt(this.mousePos,this.pmView);if(!t||!this.editor.isEditable){(r=this.state)!=null&&r.show&&(this.state.show=!1,this.updateState(this.state));return}if(!((s=this.state)!=null&&s.show&&((i=this.hoveredBlock)!=null&&i.hasAttribute("data-id"))&&((a=this.hoveredBlock)==null?void 0:a.getAttribute("data-id"))===t.id)&&(this.hoveredBlock=t.node,this.editor.isEditable)){const c=t.node.getBoundingClientRect(),l=t.node.closest("[data-node-type=column]");this.state={show:!0,referencePos:new DOMRect(l?l.firstElementChild.getBoundingClientRect().x:this.pmView.dom.firstChild.getBoundingClientRect().x,c.y,c.width,c.height),block:this.editor.getBlock(this.hoveredBlock.getAttribute("data-id"))},this.updateState(this.state)}});b(this,"onDragStart",e=>{var i;const t=(i=e.dataTransfer)==null?void 0:i.getData("blocknote/html");if(!t||this.pmView.dragging)return;const o=document.createElement("div");o.innerHTML=t;const s=Z.DOMParser.fromSchema(this.pmView.state.schema).parse(o,{topNode:this.pmView.state.schema.nodes.blockGroup.create()});this.pmView.dragging={slice:new Z.Slice(s.content,0,0),move:!0}});b(this,"findClosestEditorElement",e=>{const t=Array.from(this.pmView.root.querySelectorAll(".bn-editor"));if(t.length===0)return null;let o=t[0],r=Number.MAX_VALUE;return t.forEach(s=>{const i=s.querySelector(".bn-block-group").getBoundingClientRect(),a=e.clientXi.right?e.clientX-i.right:0,c=e.clientYi.bottom?e.clientY-i.bottom:0,l=Math.sqrt(Math.pow(a,2)+Math.pow(c,2));l{if(e.synthetic)return;const t=this.getDragEventContext(e);if(!t||!t.isDropPoint){this.closeDropCursor();return}t.isDropPoint&&!t.isDropWithinEditorBounds&&this.dispatchSyntheticEvent(e)});b(this,"closeDropCursor",()=>{const e=new Event("dragleave",{bubbles:!1});e.synthetic=!0,this.pmView.dom.dispatchEvent(e)});b(this,"getDragEventContext",e=>{var c;const t=!((c=e.dataTransfer)!=null&&c.types.includes("blocknote/html"))&&!!this.pmView.dragging,o=!!this.isDragOrigin,r=t||o,s=this.findClosestEditorElement(e);if(!s||s.distance>re)return;const i=s.element===this.pmView.dom,a=i&&s.distance===0;if(!(!i&&!r))return{isDropPoint:i,isDropWithinEditorBounds:a,isDragOrigin:r}});b(this,"onDrop",e=>{if(e.synthetic)return;const t=this.getDragEventContext(e);if(!t){this.closeDropCursor();return}const{isDropPoint:o,isDropWithinEditorBounds:r,isDragOrigin:s}=t;if(!r&&o&&this.dispatchSyntheticEvent(e),o){if(this.pmView.dragging)return;this.pmView.dispatch(this.pmView.state.tr.setSelection(V.TextSelection.create(this.pmView.state.tr.doc,this.pmView.state.tr.selection.anchor)));return}else if(s){setTimeout(()=>this.pmView.dispatch(this.pmView.state.tr.deleteSelection()),0);return}});b(this,"onDragEnd",e=>{e.synthetic||(this.pmView.dragging=null)});b(this,"onKeyDown",e=>{var t;(t=this.state)!=null&&t.show&&this.editor.isFocused()&&(this.state.show=!1,this.emitUpdate(this.state))});b(this,"onMouseMove",e=>{var s;if(this.menuFrozen)return;this.mousePos={x:e.clientX,y:e.clientY};const t=this.pmView.dom.getBoundingClientRect(),o=this.mousePos.x>t.left&&this.mousePos.xt.top&&this.mousePos.y{if(!this.state)throw new Error("Attempting to update uninitialized side menu");o(this.state)},this.pmView.root.addEventListener("dragstart",this.onDragStart),this.pmView.root.addEventListener("dragover",this.onDragOver),this.pmView.root.addEventListener("drop",this.onDrop,!0),this.pmView.root.addEventListener("dragend",this.onDragEnd,!0),this.pmView.root.addEventListener("mousemove",this.onMouseMove,!0),this.pmView.root.addEventListener("keydown",this.onKeyDown,!0)}dispatchSyntheticEvent(e){const t=new Event(e.type,e),o=this.pmView.dom.firstChild.getBoundingClientRect();t.clientX=e.clientX,t.clientY=e.clientY,t.clientX=Math.min(Math.max(e.clientX,o.left),o.left+o.width),t.clientY=Math.min(Math.max(e.clientY,o.top),o.top+o.height),t.dataTransfer=e.dataTransfer,t.preventDefault=()=>e.preventDefault(),t.synthetic=!0,this.pmView.dom.dispatchEvent(t)}update(e,t){var r;!t.doc.eq(this.pmView.state.doc)&&((r=this.state)!=null&&r.show)&&this.updateStateFromMousePos()}destroy(){var e;(e=this.state)!=null&&e.show&&(this.state.show=!1,this.emitUpdate(this.state)),this.pmView.root.removeEventListener("mousemove",this.onMouseMove,!0),this.pmView.root.removeEventListener("dragstart",this.onDragStart),this.pmView.root.removeEventListener("dragover",this.onDragOver),this.pmView.root.removeEventListener("drop",this.onDrop,!0),this.pmView.root.removeEventListener("dragend",this.onDragEnd,!0),this.pmView.root.removeEventListener("keydown",this.onKeyDown,!0)}}const ge=new V.PluginKey("SideMenuPlugin"),pt=v.createExtension(({editor:n})=>{let e;const t=v.createStore(void 0);return{key:"sideMenu",store:t,prosemirrorPlugins:[new V.Plugin({key:ge,view:o=>(e=new fe(n,o,r=>{t.setState({...r})}),e)})],blockDragStart(o,r){e&&(e.isDragOrigin=!0),ht(o,r,n)},blockDragEnd(){pe(n.prosemirrorView.root),e&&(e.isDragOrigin=!1),n.blur()},freezeMenu(){e.menuFrozen=!0,e.state.show=!0,e.emitUpdate(e.state)},unfreezeMenu(){e.menuFrozen=!1,e.state.show=!1,e.emitUpdate(e.state)}}});let E;function se(n){E||(E=document.createElement("div"),E.innerHTML="_",E.style.opacity="0",E.style.height="1px",E.style.width="1px",n instanceof Document?n.body.appendChild(E):n.appendChild(E))}function ft(n){E&&(n instanceof Document?n.body.removeChild(E):n.removeChild(E),E=void 0)}function _(n){return Array.prototype.indexOf.call(n.parentElement.childNodes,n)}function gt(n){let e=n;for(;e&&e.nodeName!=="TD"&&e.nodeName!=="TH"&&!e.classList.contains("tableWrapper");){if(e.classList.contains("ProseMirror"))return;const t=e.parentNode;if(!t||!(t instanceof Element))return;e=t}return e.nodeName==="TD"||e.nodeName==="TH"?{type:"cell",domNode:e,tbodyNode:e.closest("tbody")}:{type:"wrapper",domNode:e,tbodyNode:e.querySelector("tbody")}}function yt(n,e){const t=e.querySelectorAll(n);for(let o=0;o=0}function bnIsValidCellIndex(n){return Number.isInteger(n)&&n>=0}function bnIsValidRelativeCell(n){return bnIsValidCellIndex(n.row)&&bnIsValidCellIndex(n.col)}function bnResolveCellPosition(n,e,t){if(!bnIsValidTablePosition(e)||!bnIsValidRelativeCell(t))return;try{const o=n.doc.resolve(e+1);if(t.row>=o.node().childCount)return;const r=n.doc.resolve(o.posAtIndex(t.row)+1);return t.col>=r.node().childCount?void 0:n.doc.resolve(r.posAtIndex(t.col))}catch{return}}class ye{constructor(e,t,o){b(this,"state");b(this,"emitUpdate");b(this,"tableId");b(this,"tablePos");b(this,"tableElement");b(this,"menuFrozen",!1);b(this,"mouseState","up");b(this,"prevWasEditable",null);b(this,"hideHandles",(e={})=>{const{resetCell:t=!1,resetDragging:o=!1}=e;if(!this.state||!this.state.show&&!t&&!o)return;this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,o&&(this.state.draggingState=void 0),t&&(this.state.rowIndex=void 0,this.state.colIndex=void 0,this.state.referencePosCell=void 0),this.emitUpdate()});b(this,"viewMousedownHandler",()=>{this.mouseState="down"});b(this,"mouseUpHandler",e=>{this.mouseState="up",this.mouseMoveHandler(e)});b(this,"mouseMoveHandler",e=>{var l,u,h,g,m,p,d,f;if(this.menuFrozen||this.mouseState==="selecting"||!(e.target instanceof Element)||!this.pmView.dom.contains(e.target))return;const t=gt(e.target);if((t==null?void 0:t.type)==="cell"&&this.mouseState==="down"&&!((l=this.state)!=null&&l.draggingState)){this.mouseState="selecting",(u=this.state)!=null&&u.show&&(this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,this.emitUpdate());return}if(!t||!this.editor.isEditable){(h=this.state)!=null&&h.show&&(this.state.show=!1,this.state.showAddOrRemoveRowsButton=!1,this.state.showAddOrRemoveColumnsButton=!1,this.emitUpdate());return}if(!t.tbodyNode)return;const o=t.tbodyNode.getBoundingClientRect(),r=he(t.domNode,this.pmView);if(!r)return;this.tableElement=r.node;let s,i;try{i=this.editor.transact(y=>O.getNodeById(r.id,y.doc))}catch{this.hideHandles({resetCell:!0,resetDragging:!0});return}if(!i){this.hideHandles({resetCell:!0,resetDragging:!0});return}const a=k.nodeToBlock(i.node,this.editor.pmSchema,this.editor.schema.blockSchema,this.editor.schema.inlineContentSchema,this.editor.schema.styleSchema);if(O.editorHasBlockWithType(this.editor,"table")&&(this.tablePos=i.posBeforeNode+1,s=a),!s)return;this.tableId=r.id;const c=(g=t.domNode.closest(".tableWrapper"))==null?void 0:g.querySelector(".table-widgets-container");if((t==null?void 0:t.type)==="wrapper"){const y=e.clientY>=o.bottom-1&&e.clientY=o.right-1&&e.clientXo.right||e.clientY>o.bottom;this.state={...this.state,show:!0,showAddOrRemoveRowsButton:y,showAddOrRemoveColumnsButton:w,referencePosTable:o,block:s,widgetContainer:c,colIndex:x||(p=this.state)==null?void 0:p.colIndex,rowIndex:x||(d=this.state)==null?void 0:d.rowIndex,referencePosCell:x||(f=this.state)==null?void 0:f.referencePosCell}}else{const y=_(t.domNode),w=_(t.domNode.parentElement),x=t.domNode.getBoundingClientRect();if(this.state!==void 0&&this.state.show&&this.tableId===r.id&&this.state.rowIndex===w&&this.state.colIndex===y)return;this.state={show:!0,showAddOrRemoveColumnsButton:y===s.content.rows[0].cells.length-1,showAddOrRemoveRowsButton:w===s.content.rows.length-1,referencePosTable:o,block:s,draggingState:void 0,referencePosCell:x,colIndex:y,rowIndex:w,widgetContainer:c}}return this.emitUpdate(),!1});b(this,"dragOverHandler",e=>{var g;if(((g=this.state)==null?void 0:g.draggingState)===void 0)return;e.preventDefault(),e.dataTransfer.dropEffect="move",yt(".prosemirror-dropcursor-block, .prosemirror-dropcursor-inline",this.pmView.root);const t={left:Math.min(Math.max(e.clientX,this.state.referencePosTable.left+1),this.state.referencePosTable.right-1),top:Math.min(Math.max(e.clientY,this.state.referencePosTable.top+1),this.state.referencePosTable.bottom-1)},o=this.pmView.root.elementsFromPoint(t.left,t.top).filter(m=>m.tagName==="TD"||m.tagName==="TH");if(o.length===0)return;const r=o[0];let s=!1;const i=_(r.parentElement),a=_(r),c=this.state.draggingState.draggedCellOrientation==="row"?this.state.rowIndex:this.state.colIndex,u=(this.state.draggingState.draggedCellOrientation==="row"?i:a)!==c;(this.state.rowIndex!==i||this.state.colIndex!==a)&&(this.state.rowIndex=i,this.state.colIndex=a,this.state.referencePosCell=r.getBoundingClientRect(),s=!0);const h=this.state.draggingState.draggedCellOrientation==="row"?t.top:t.left;this.state.draggingState.mousePos!==h&&(this.state.draggingState.mousePos=h,s=!0),s&&this.emitUpdate(),u&&this.editor.transact(m=>m.setMeta(N,!0))});b(this,"dropHandler",e=>{if(this.mouseState="up",this.state===void 0||this.state.draggingState===void 0)return!1;if(this.state.rowIndex===void 0||this.state.colIndex===void 0)return e.preventDefault(),this.hideHandles({resetCell:!0,resetDragging:!0}),!1;e.preventDefault();const{draggingState:t,colIndex:o,rowIndex:r}=this.state,s=this.state.block.content.columnWidths;if(t.draggedCellOrientation==="row"){if(!k.canRowBeDraggedInto(this.state.block,t.originalIndex,r))return!1;const i=k.moveRow(this.state.block,t.originalIndex,r);this.editor.updateBlock(this.state.block,{type:"table",content:{...this.state.block.content,rows:i}})}else{if(!k.canColumnBeDraggedInto(this.state.block,t.originalIndex,o))return!1;const i=k.moveColumn(this.state.block,t.originalIndex,o),[a]=s.splice(t.originalIndex,1);s.splice(o,0,a),this.editor.updateBlock(this.state.block,{type:"table",content:{...this.state.block.content,columnWidths:s,rows:i}})}return this.editor.setTextCursorPosition(this.state.block.id),!0});this.editor=e,this.pmView=t,this.emitUpdate=()=>{if(!this.state)throw new Error("Attempting to update uninitialized image toolbar");o(this.state)},t.dom.addEventListener("mousemove",this.mouseMoveHandler),t.dom.addEventListener("mousedown",this.viewMousedownHandler),window.addEventListener("mouseup",this.mouseUpHandler),t.root.addEventListener("dragover",this.dragOverHandler),t.root.addEventListener("drop",this.dropHandler)}update(){var r;if(!this.state||!this.state.show)return;const e=this.state.block;if(!e){this.hideHandles({resetCell:!0,resetDragging:!0});return}if(this.state.block=this.editor.getBlock(e.id),!this.state.block||this.state.block.type!=="table"||!((r=this.tableElement)!=null&&r.isConnected)){this.hideHandles();return}const{height:t,width:o}=k.getDimensionsOfTable(this.state.block);this.state.rowIndex!==void 0&&this.state.colIndex!==void 0&&(this.state.rowIndex>=t&&(this.state.rowIndex=t-1),this.state.colIndex>=o&&(this.state.colIndex=o-1));const i=this.tableElement.querySelector("tbody");if(!i){this.hideHandles({resetCell:!0});return}if(this.state.rowIndex!==void 0&&this.state.colIndex!==void 0){const a=i.children[this.state.rowIndex].children[this.state.colIndex];a?this.state.referencePosCell=a.getBoundingClientRect():(this.state.rowIndex=void 0,this.state.colIndex=void 0)}this.state.referencePosTable=i.getBoundingClientRect(),this.emitUpdate()}destroy(){this.pmView.dom.removeEventListener("mousemove",this.mouseMoveHandler),window.removeEventListener("mouseup",this.mouseUpHandler),this.pmView.dom.removeEventListener("mousedown",this.viewMousedownHandler),this.pmView.root.removeEventListener("dragover",this.dragOverHandler),this.pmView.root.removeEventListener("drop",this.dropHandler)}}const N=new C.PluginKey("TableHandlesPlugin"),wt=v.createExtension(({editor:n})=>{let e;const t=v.createStore(void 0);return{key:"tableHandles",store:t,prosemirrorPlugins:[new C.Plugin({key:N,view:o=>(e=new ye(n,o,r=>{t.setState(r.block?{...r,draggingState:r.draggingState?{...r.draggingState}:void 0}:void 0)}),e),props:{decorations:o=>{if(e===void 0||e.state===void 0||e.state.draggingState===void 0||e.tablePos===void 0)return;const r=e.state.draggingState.draggedCellOrientation==="row"?e.state.rowIndex:e.state.colIndex;if(r===void 0)return;const s=[],{block:i,draggingState:a}=e.state,{originalIndex:c,draggedCellOrientation:l}=a;if(r===c||!i||l==="row"&&!k.canRowBeDraggedInto(i,c,r)||l==="col"&&!k.canColumnBeDraggedInto(i,c,r))return D.DecorationSet.create(o.doc,s);const u=o.doc.resolve(e.tablePos+1);return e.state.draggingState.draggedCellOrientation==="row"?k.getCellsAtRowHandle(e.state.block,r).forEach(({row:g,col:m})=>{const p=o.doc.resolve(u.posAtIndex(g)+1),d=o.doc.resolve(p.posAtIndex(m)+1),f=d.node(),y=d.pos+(r>c?f.nodeSize-2:0);s.push(D.Decoration.widget(y,()=>{const w=document.createElement("div");return w.className="bn-table-drop-cursor",w.style.left="0",w.style.right="0",r>c?w.style.bottom="-2px":w.style.top="-3px",w.style.height="4px",w}))}):k.getCellsAtColumnHandle(e.state.block,r).forEach(({row:g,col:m})=>{const p=o.doc.resolve(u.posAtIndex(g)+1),d=o.doc.resolve(p.posAtIndex(m)+1),f=d.node(),y=d.pos+(r>c?f.nodeSize-2:0);s.push(D.Decoration.widget(y,()=>{const w=document.createElement("div");return w.className="bn-table-drop-cursor",w.style.top="0",w.style.bottom="0",r>c?w.style.right="-2px":w.style.left="-3px",w.style.width="4px",w}))}),D.DecorationSet.create(o.doc,s)}}})],colDragStart(o){if(e===void 0||e.state===void 0||e.state.colIndex===void 0||e.tablePos===void 0){e==null||e.hideHandles({resetCell:!0,resetDragging:!0});return}e.state.draggingState={draggedCellOrientation:"col",originalIndex:e.state.colIndex,mousePos:o.clientX},e.emitUpdate(),n.transact(r=>r.setMeta(N,{draggedCellOrientation:e.state.draggingState.draggedCellOrientation,originalIndex:e.state.colIndex,newIndex:e.state.colIndex,tablePos:e.tablePos})),!n.headless&&(se(n.prosemirrorView.root),o.dataTransfer.setDragImage(E,0,0),o.dataTransfer.effectAllowed="move")},rowDragStart(o){if(e===void 0||e.state===void 0||e.state.rowIndex===void 0||e.tablePos===void 0){e==null||e.hideHandles({resetCell:!0,resetDragging:!0});return}e.state.draggingState={draggedCellOrientation:"row",originalIndex:e.state.rowIndex,mousePos:o.clientY},e.emitUpdate(),n.transact(r=>r.setMeta(N,{draggedCellOrientation:e.state.draggingState.draggedCellOrientation,originalIndex:e.state.rowIndex,newIndex:e.state.rowIndex,tablePos:e.tablePos})),!n.headless&&(se(n.prosemirrorView.root),o.dataTransfer.setDragImage(E,0,0),o.dataTransfer.effectAllowed="copyMove")},dragEnd(){if(e===void 0||e.state===void 0){n.headless||ft(n.prosemirrorView.root);return}e.state.draggingState=void 0,e.emitUpdate(),n.transact(o=>o.setMeta(N,null)),!n.headless&&ft(n.prosemirrorView.root)},freezeHandles(){e.menuFrozen=!0},unfreezeHandles(){e.menuFrozen=!1},getCellsAtRowHandle(o,r){return k.getCellsAtRowHandle(o,r)},getCellsAtColumnHandle(o,r){return k.getCellsAtColumnHandle(o,r)},setCellSelection(o,r,s=r){if(!e)throw new Error("Table handles view not initialized");const i=bnResolveCellPosition(o,e.tablePos,r),a=bnResolveCellPosition(o,e.tablePos,s);if(!i||!a)return;const c=o.tr;return c.setSelection(new T.CellSelection(i,a)),o.apply(c)},addRowOrColumn(o,r){if(!e||!bnIsValidTablePosition(e.tablePos)||!bnIsValidCellIndex(o)){e==null||e.hideHandles({resetCell:!0});return}n.exec((s,i)=>{const a=this.setCellSelection(s,r.orientation==="row"?{row:o,col:0}:{row:0,col:o});if(!a)return e==null||e.hideHandles({resetCell:!0}),!1;return r.orientation==="row"?r.side==="above"?T.addRowBefore(a,i):T.addRowAfter(a,i):r.side==="left"?T.addColumnBefore(a,i):T.addColumnAfter(a,i)})},removeRowOrColumn(o,r){return bnIsValidCellIndex(o)?r==="row"?n.exec((s,i)=>{const a=this.setCellSelection(s,{row:o,col:0});return a?T.deleteRow(a,i):!1}):n.exec((s,i)=>{const a=this.setCellSelection(s,{row:0,col:o});return a?T.deleteColumn(a,i):!1}):!1},mergeCells(o){return n.exec((r,s)=>{const i=o?this.setCellSelection(r,o.relativeStartCell,o.relativeEndCell):r;return i?T.mergeCells(i,s):!1})},splitCell(o){return n.exec((r,s)=>{const i=o?this.setCellSelection(r,o):r;return i?T.splitCell(i,s):!1})},getCellSelection(){return n.transact(o=>{const r=o.selection;let s=r.$from,i=r.$to;if(O.isTableCellSelection(r)){const{ranges:d}=r;d.forEach(f=>{s=f.$from.min(s??f.$from),i=f.$to.max(i??f.$to)})}else if(s=o.doc.resolve(r.$from.pos-r.$from.parentOffset-1),i=o.doc.resolve(r.$to.pos-r.$to.parentOffset-1),s.pos===0||i.pos===0)return;const a=o.doc.resolve(s.pos-s.parentOffset-1),c=o.doc.resolve(i.pos-i.parentOffset-1),l=o.doc.resolve(a.pos-a.parentOffset-1),u=s.index(a.depth),h=a.index(l.depth),g=i.index(c.depth),m=c.index(l.depth),p=[];for(let d=h;d<=m;d++)for(let f=u;f<=g;f++)p.push({row:d,col:f});return{from:{row:h,col:u},to:{row:m,col:g},cells:p}})},getMergeDirection(o){return n.transact(r=>{const s=O.isTableCellSelection(r.selection)?r.selection:void 0;if(!s||!o||s.ranges.length<=1)return;const i=this.getCellSelection();if(i)return k.areInSameColumn(i.from,i.to,o)?"vertical":"horizontal"})},cropEmptyRowsOrColumns(o,r){return k.cropEmptyRowsOrColumns(o,r)},addRowsOrColumns(o,r,s){return k.addRowsOrColumns(o,r,s)}}}),ie=new C.PluginKey("trailingNode"),bt=v.createExtension(()=>({key:"trailingNode",prosemirrorPlugins:[new C.Plugin({key:ie,appendTransaction:(n,e,t)=>{const{doc:o,tr:r,schema:s}=t,i=ie.getState(t),a=o.content.size-2,c=s.nodes.blockContainer,l=s.nodes.paragraph;if(i)return r.insert(a,c.create(void 0,l.create()))},state:{init:(n,e)=>{},apply:(n,e)=>{if(!n.docChanged)return e;let t=n.doc.lastChild;if(!t||t.type.name!=="blockGroup")throw new Error("Expected blockGroup");if(t=t.lastChild,!t||t.type.name!=="blockContainer")return!0;const o=t.firstChild;if(!o)throw new Error("Expected blockContent");return t.nodeSize>4||o.type.spec.content!=="inline*"}}})]}));exports.BlockChangeExtension=$e;exports.DEFAULT_LINK_PROTOCOL=et;exports.DropCursorExtension=We;exports.ForkYDocExtension=Ke;exports.FormattingToolbarExtension=Ge;exports.HistoryExtension=Je;exports.LinkToolbarExtension=Qe;exports.NodeSelectionKeyboardExtension=ot;exports.PlaceholderExtension=rt;exports.PreviousBlockTypeExtension=it;exports.SchemaMigration=Xe;exports.SideMenuExtension=pt;exports.SideMenuView=fe;exports.TableHandlesExtension=wt;exports.TableHandlesView=ye;exports.TrailingNodeExtension=bt;exports.VALID_LINK_PROTOCOLS=Ze;exports.YCursorExtension=z;exports.YSyncExtension=U;exports.YUndoExtension=$;exports.blocksToMarkdown=dt;exports.cleanHTMLToMarkdown=j;exports.createExternalHTMLExporter=Y;exports.fragmentToBlocks=me;exports.getBlocksChangedByTransaction=de;exports.sideMenuPluginKey=ge;exports.tableHandlesPluginKey=N; + //# sourceMappingURL=TrailingNode-D-CZ76FS.cjs.map +diff --git a/dist/blocknote.cjs b/dist/blocknote.cjs +index 778ed7a4062755747312db827f920e47a5aff127..f9e34b3ceb54a976b734fd70ac7fc183e0070c2b 100644 +--- a/dist/blocknote.cjs ++++ b/dist/blocknote.cjs +@@ -1,5 +1,5 @@ + "use strict";var Ee=Object.defineProperty;var Te=(o,e,t)=>e in o?Ee(o,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[e]=t;var k=(o,e,t)=>Te(o,typeof e!="symbol"?e+"":e,t);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const x=require("prosemirror-model"),L=require("prosemirror-transform"),u=require("./blockToNode-CumVjgem.cjs"),c=require("./defaultBlocks-DosClM5E.cjs"),b=require("./TrailingNode-D-CZ76FS.cjs"),M=require("./BlockNoteSchema-DmFDeA0n.cjs"),B=require("@tiptap/core"),R=require("./EventEmitter-CLwfmbqG.cjs"),A=require("@tiptap/pm/model"),xe=require("./en-Cl87Uuyf.cjs"),K=require("@handlewithcare/prosemirror-inputrules"),Me=require("@tiptap/pm/keymap"),F=require("./BlockNoteExtension-BWw0r8Gy.cjs"),Ie=require("@tiptap/extensions/gap-cursor"),Pe=require("@tiptap/extension-link"),we=require("@tiptap/extension-text"),C=require("prosemirror-state"),N=require("prosemirror-tables"),ve=require("./ShowSelection-BxnbRvy4.cjs"),Ae=require("remark-gfm"),Ne=require("remark-parse"),H=require("remark-rehype"),Le=require("rehype-stringify"),_e=require("unified"),De=require("@tiptap/pm/state"),O=o=>o&&typeof o=="object"&&"default"in o?o:{default:o},Fe=O(Ae),Oe=O(Ne),He=O(H),$e=O(Le);function oe(o,e){const t=[{tag:`[data-inline-content-type="${o.type}"]`,contentElement:n=>{const r=n;return r.matches("[data-editable]")?r:r.querySelector("[data-editable]")||r}}];return e&&t.push({tag:"*",getAttrs(n){if(typeof n=="string")return!1;const r=e==null?void 0:e(n);return r===void 0?!1:r}}),t}function Ue(o,e){var n;const t=B.Node.create({name:o.type,inline:!0,group:"inline",draggable:(n=e.meta)==null?void 0:n.draggable,selectable:o.content==="styled",atom:o.content==="none",content:o.content==="styled"?"inline*":"",addAttributes(){return c.propsToAttributes(o.propSchema)},addKeyboardShortcuts(){return c.addInlineContentKeyboardShortcuts(o)},parseHTML(){return oe(o,e.parse)},renderHTML({node:r}){const s=this.options.editor,i=e.render.call({renderType:"dom",props:void 0},u.nodeToCustomInlineContent(r,s.schema.inlineContentSchema,s.schema.styleSchema),()=>{},s);return c.addInlineContentAttributes(i,o.type,r.attrs,o.propSchema)},addNodeView(){return r=>{const{node:s,getPos:i}=r,l=this.options.editor,a=e.render.call({renderType:"nodeView",props:r},u.nodeToCustomInlineContent(s,l.schema.inlineContentSchema,l.schema.styleSchema),d=>{const p=u.inlineContentToNodes([d],l.pmSchema),f=i();f&&l.transact(h=>h.replaceWith(f,f+s.nodeSize,p))},l);return c.addInlineContentAttributes(a,o.type,s.attrs,o.propSchema)}}});return c.createInlineContentSpecFromTipTapNode(t,o.propSchema,{...e,toExternalHTML:e.toExternalHTML,render(r,s,i){const l=e.render(r,s,i);return c.addInlineContentAttributes(l,o.type,r.props,o.propSchema)}})}function ne(o,e,t,n="before"){const r=typeof t=="string"?t:t.id,s=u.getPmSchema(o),i=e.map(p=>u.blockToNode(p,s)),l=c.getNodeById(r,o.doc);if(!l)throw new Error(`Block with ID ${r} not found`);let a=l.posBeforeNode;return n==="after"&&(a+=l.node.nodeSize),o.step(new L.ReplaceStep(a,a,new x.Slice(x.Fragment.from(i),0,0))),i.map(p=>u.nodeToBlock(p,s))}function _(o){if(!o||o.type.name!=="column")throw new Error("Invalid columnPos: does not point to column node.");const e=o.firstChild;if(!e)throw new Error("Invalid column: does not have child node.");const t=e.firstChild;if(!t)throw new Error("Invalid blockContainer: does not have child node.");return o.childCount===1&&e.childCount===1&&t.type.name==="paragraph"&&t.content.content.length===0}function re(o,e){const t=o.doc.resolve(e),n=t.nodeAfter;if(!n||n.type.name!=="columnList")throw new Error("Invalid columnListPos: does not point to columnList node.");for(let r=n.childCount-1;r>=0;r--){const s=o.doc.resolve(t.pos+1).posAtIndex(r),l=o.doc.resolve(s).nodeAfter;if(!l||l.type.name!=="column")throw new Error("Invalid columnPos: does not point to column node.");_(l)&&o.delete(s,s+l.nodeSize)}}function D(o,e){re(o,e);const n=o.doc.resolve(e).nodeAfter;if(!n||n.type.name!=="columnList")throw new Error("Invalid columnListPos: does not point to columnList node.");if(n.childCount>2)return;if(n.childCount<2)throw new Error("Invalid columnList: contains fewer than two children.");const r=e+1,i=o.doc.resolve(r).nodeAfter,l=e+n.nodeSize-1,d=o.doc.resolve(l).nodeBefore;if(!i||!d)throw new Error("Invalid columnList: does not contain children.");const p=_(i),f=_(d);if(p&&f){o.delete(e,e+n.nodeSize);return}if(p){o.step(new L.ReplaceAroundStep(e,e+n.nodeSize,l-d.nodeSize+1,l-1,x.Slice.empty,0,!1));return}if(f){o.step(new L.ReplaceAroundStep(e,e+n.nodeSize,r+1,r+i.nodeSize-1,x.Slice.empty,0,!1));return}}function $(o,e,t){const n=u.getPmSchema(o),r=t.map(f=>u.blockToNode(f,n)),s=new Set(e.map(f=>typeof f=="string"?f:f.id)),i=[],l=new Set,a=typeof e[0]=="string"?e[0]:e[0].id;let d=0;if(o.doc.descendants((f,h)=>{if(s.size===0)return!1;if(!f.type.isInGroup("bnBlock")||!s.has(f.attrs.id))return!0;if(i.push(u.nodeToBlock(f,n)),s.delete(f.attrs.id),t.length>0&&f.attrs.id===a){const y=o.doc.nodeSize;o.insert(h,r);const E=o.doc.nodeSize;d+=y-E}const g=o.doc.nodeSize,m=o.doc.resolve(h-d);m.node().type.name==="column"?l.add(m.before(-1)):m.node().type.name==="columnList"&&l.add(m.before()),m.node().type.name==="blockGroup"&&m.node(m.depth-1).type.name!=="doc"&&m.node().childCount===1?o.delete(m.before(),m.after()):o.delete(h-d,h-d+f.nodeSize);const S=o.doc.nodeSize;return d+=g-S,!1}),s.size>0){const f=[...s].join(` +-`);throw Error("Blocks with the following IDs could not be found in the editor: "+f)}return l.forEach(f=>D(o,f)),{insertedBlocks:r.map(f=>u.nodeToBlock(f,n)),removedBlocks:i}}function Ve(o,e,t,n,r){let s;if(e)if(typeof e=="string")s=u.inlineContentToNodes([e],o.pmSchema,n);else if(Array.isArray(e))s=u.inlineContentToNodes(e,o.pmSchema,n);else if(e.type==="tableContent")s=u.tableContentToNodes(e,o.pmSchema);else throw new u.UnreachableCaseError(e.type);else throw new Error("blockContent is required");const l=((r==null?void 0:r.document)??document).createDocumentFragment();for(const a of s)if(a.type.name!=="text"&&o.schema.inlineContentSchema[a.type.name]){const d=o.schema.inlineContentSpecs[a.type.name].implementation;if(d){const p=u.nodeToCustomInlineContent(a,o.schema.inlineContentSchema,o.schema.styleSchema),f=d.render.call({renderType:"dom",props:void 0},p,()=>{},o);if(f){if(l.appendChild(f.dom),f.contentDOM){const h=t.serializeFragment(a.content,r);f.contentDOM.dataset.editable="",f.contentDOM.appendChild(h)}continue}}}else if(a.type.name==="text"){let d=document.createTextNode(a.textContent);for(const p of a.marks.toReversed())if(p.type.name in o.schema.styleSpecs){const f=o.schema.styleSpecs[p.type.name].implementation.render(p.attrs.stringValue,o);f.contentDOM.appendChild(d),d=f.dom}else{const f=p.type.spec.toDOM(p,!0),h=x.DOMSerializer.renderSpec(document,f);h.contentDOM.appendChild(d),d=h.dom}l.appendChild(d)}else{const d=t.serializeFragment(x.Fragment.from([a]),r);l.appendChild(d)}return l}function Re(o,e,t,n){var f,h,g,m,S;const r=o.pmSchema.nodes.blockContainer,s=e.props||{};for(const[y,E]of Object.entries(o.schema.blockSchema[e.type].propSchema))!(y in s)&&E.default!==void 0&&(s[y]=E.default);const i=e.children||[],a=o.blockImplementations[e.type].implementation.render.call({renderType:"dom",props:void 0},{...e,props:s,children:i},o);if(a.contentDOM&&e.content){const y=Ve(o,e.content,t,e.type,n);a.contentDOM.appendChild(y)}if(o.pmSchema.nodes[e.type].isInGroup("bnBlock")){if(e.children&&e.children.length>0){const y=se(o,e.children,t,n);(f=a.contentDOM)==null||f.append(y)}return a.dom}const p=(g=(h=r.spec)==null?void 0:h.toDOM)==null?void 0:g.call(h,r.create({id:e.id,...s}));return(m=p.contentDOM)==null||m.appendChild(a.dom),e.children&&e.children.length>0&&((S=p.contentDOM)==null||S.appendChild(ie(o,e.children,t,n))),p.dom}function se(o,e,t,n){const s=((n==null?void 0:n.document)??document).createDocumentFragment();for(const i of e){const l=Re(o,i,t,n);s.appendChild(l)}return s}const ie=(o,e,t,n)=>{var l;const r=o.pmSchema.nodes.blockGroup,s=r.spec.toDOM(r.create({})),i=se(o,e,t,n);return(l=s.contentDOM)==null||l.appendChild(i),s.dom},ze=o=>(o.querySelectorAll('[data-content-type="numberedListItem"]').forEach(t=>{var r,s;const n=(s=(r=t.closest(".bn-block-outer"))==null?void 0:r.previousElementSibling)==null?void 0:s.querySelector('[data-content-type="numberedListItem"]');if(!n)t.setAttribute("data-index",t.getAttribute("data-start")||"1");else{const i=n.getAttribute("data-index");t.setAttribute("data-index",(parseInt(i||"0")+1).toString())}}),o),Ge=o=>(o.querySelectorAll('[data-content-type="checkListItem"] input').forEach(t=>{t.disabled=!0}),o),qe=o=>(o.querySelectorAll('.bn-toggle-wrapper[data-show-children="false"]').forEach(t=>{t.setAttribute("data-show-children","true")}),o),We=o=>(o.querySelectorAll('[data-content-type="table"] table').forEach(t=>{t.setAttribute("style",`--default-cell-min-width: ${c.EMPTY_CELL_WIDTH}px;`),t.setAttribute("data-show-children","true")}),o),je=o=>(o.querySelectorAll('[data-content-type="table"] table').forEach(t=>{var s;const n=document.createElement("div");n.className="tableWrapper";const r=document.createElement("div");r.className="tableWrapper-inner",n.appendChild(r),(s=t.parentElement)==null||s.appendChild(n),n.appendChild(t)}),o),Ke=o=>(o.querySelectorAll(".bn-inline-content:empty").forEach(t=>{const n=document.createElement("span");n.className="ProseMirror-trailingBreak",n.setAttribute("style","display: inline-block;"),t.appendChild(n)}),o),ce=(o,e)=>{const t=x.DOMSerializer.fromSchema(o),n=[ze,Ge,qe,We,je,Ke];return{serializeBlocks:(r,s)=>{let i=ie(e,r,t,s);for(const l of n)i=l(i);return i.outerHTML}}};function Ye(o){return o.transact(e=>{const t=u.getNearestBlockPos(e.doc,e.selection.anchor);if(e.selection instanceof N.CellSelection)return{type:"cell",anchorBlockId:t.node.attrs.id,anchorCellOffset:e.selection.$anchorCell.pos-t.posBeforeNode,headCellOffset:e.selection.$headCell.pos-t.posBeforeNode};if(e.selection instanceof C.NodeSelection)return{type:"node",anchorBlockId:t.node.attrs.id};{const n=u.getNearestBlockPos(e.doc,e.selection.head);return{type:"text",anchorBlockId:t.node.attrs.id,headBlockId:n.node.attrs.id,anchorOffset:e.selection.anchor-t.posBeforeNode,headOffset:e.selection.head-n.posBeforeNode}}})}function Je(o,e){var r,s;const t=(r=c.getNodeById(e.anchorBlockId,o.doc))==null?void 0:r.posBeforeNode;if(t===void 0)throw new Error(`Could not find block with ID ${e.anchorBlockId} to update selection`);let n;if(e.type==="cell")n=N.CellSelection.create(o.doc,t+e.anchorCellOffset,t+e.headCellOffset);else if(e.type==="node")n=C.NodeSelection.create(o.doc,t+1);else{const i=(s=c.getNodeById(e.headBlockId,o.doc))==null?void 0:s.posBeforeNode;if(i===void 0)throw new Error(`Could not find block with ID ${e.headBlockId} to update selection`);n=C.TextSelection.create(o.doc,t+e.anchorOffset,i+e.headOffset)}o.setSelection(n)}function U(o){return o.map(e=>e.type==="columnList"?e.children.map(t=>U(t.children)).flat():{...e,children:U(e.children)}).flat()}function ae(o,e,t){o.transact(n=>{var i;const r=((i=o.getSelection())==null?void 0:i.blocks)||[o.getTextCursorPosition().block],s=Ye(o);o.removeBlocks(r),o.insertBlocks(U(r),e,t),Je(n,s)})}function le(o){return!o||o.type!=="columnList"}function de(o,e,t){let n,r;if(e?e.children.length>0?(n=e.children[e.children.length-1],r="after"):(n=e,r="before"):t&&(n=t,r="before"),!n||!r)return;const s=o.getParentBlock(n);return le(s)?{referenceBlock:n,placement:r}:de(o,r==="after"?n:o.getPrevBlock(n),s)}function ue(o,e,t){let n,r;if(e?e.children.length>0?(n=e.children[0],r="before"):(n=e,r="after"):t&&(n=t,r="after"),!n||!r)return;const s=o.getParentBlock(n);return le(s)?{referenceBlock:n,placement:r}:ue(o,r==="before"?n:o.getNextBlock(n),s)}function Qe(o){o.transact(()=>{const e=o.getSelection(),t=(e==null?void 0:e.blocks[0])||o.getTextCursorPosition().block,n=de(o,o.getPrevBlock(t),o.getParentBlock(t));n&&ae(o,n.referenceBlock,n.placement)})}function Xe(o){o.transact(()=>{const e=o.getSelection(),t=(e==null?void 0:e.blocks[(e==null?void 0:e.blocks.length)-1])||o.getTextCursorPosition().block,n=ue(o,o.getNextBlock(t),o.getParentBlock(t));n&&ae(o,n.referenceBlock,n.placement)})}function Ze(o,e,t){const{$from:n,$to:r}=o.selection,s=n.blockRange(r,m=>m.childCount>0&&(m.type.name==="blockGroup"||m.type.name==="column"));if(!s)return!1;const i=s.startIndex;if(i===0)return!1;const a=s.parent.child(i-1);if(a.type!==e)return!1;const d=a.lastChild&&a.lastChild.type===t,p=x.Fragment.from(d?e.create():null),f=new x.Slice(x.Fragment.from(e.create(null,x.Fragment.from(t.create(null,p)))),d?3:1,0),h=s.start,g=s.end;return o.step(new L.ReplaceAroundStep(h-(d?3:1),g,h,g,f,1,!0)).scrollIntoView(),!0}function pe(o){return o.transact(e=>Ze(e,o.pmSchema.nodes.blockContainer,o.pmSchema.nodes.blockGroup))}function et(o){o._tiptapEditor.commands.liftListItem("blockContainer")}function tt(o){return o.transact(e=>{const{bnBlock:t}=u.getBlockInfoFromTransaction(e);return e.doc.resolve(t.beforePos).nodeBefore!==null})}function ot(o){return o.transact(e=>{const{bnBlock:t}=u.getBlockInfoFromTransaction(e);return e.doc.resolve(t.beforePos).depth>1})}function fe(o,e){const t=typeof e=="string"?e:e.id,n=u.getPmSchema(o),r=c.getNodeById(t,o);if(r)return u.nodeToBlock(r.node,n)}function he(o,e){const t=typeof e=="string"?e:e.id,n=c.getNodeById(t,o),r=u.getPmSchema(o);if(!n)return;const i=o.resolve(n.posBeforeNode).nodeBefore;if(i)return u.nodeToBlock(i,r)}function me(o,e){const t=typeof e=="string"?e:e.id,n=c.getNodeById(t,o),r=u.getPmSchema(o);if(!n)return;const i=o.resolve(n.posBeforeNode+n.node.nodeSize).nodeAfter;if(i)return u.nodeToBlock(i,r)}function ke(o,e){const t=typeof e=="string"?e:e.id,n=u.getPmSchema(o),r=c.getNodeById(t,o);if(!r)return;const s=o.resolve(r.posBeforeNode),i=s.node(),l=s.node(-1),a=l.type.name!=="doc"?i.type.name==="blockGroup"?l:i:void 0;if(a)return u.nodeToBlock(a,n)}class nt{constructor(e){this.editor=e}get document(){return this.editor.transact(e=>u.docToBlocks(e.doc,this.editor.pmSchema))}getBlock(e){return this.editor.transact(t=>fe(t.doc,e))}getPrevBlock(e){return this.editor.transact(t=>he(t.doc,e))}getNextBlock(e){return this.editor.transact(t=>me(t.doc,e))}getParentBlock(e){return this.editor.transact(t=>ke(t.doc,e))}forEachBlock(e,t=!1){const n=this.document.slice();t&&n.reverse();function r(s){for(const i of s){if(e(i)===!1)return!1;const l=t?i.children.slice().reverse():i.children;if(!r(l))return!1}return!0}r(n)}insertBlocks(e,t,n="before"){return this.editor.transact(r=>ne(r,e,t,n))}updateBlock(e,t){return this.editor.transact(n=>c.updateBlock(n,e,t))}removeBlocks(e){return this.editor.transact(t=>$(t,e,[]).removedBlocks)}replaceBlocks(e,t){return this.editor.transact(n=>$(n,e,t))}canNestBlock(){return tt(this.editor)}nestBlock(){pe(this.editor)}canUnnestBlock(){return ot(this.editor)}unnestBlock(){et(this.editor)}moveBlocksUp(){return Qe(this.editor)}moveBlocksDown(){return Xe(this.editor)}}class rt extends R.EventEmitter{constructor(e){super(),this.editor=e,e.on("create",()=>{e._tiptapEditor.on("update",({transaction:t,appendedTransactions:n})=>{this.emit("onChange",{editor:e,transaction:t,appendedTransactions:n})}),e._tiptapEditor.on("selectionUpdate",({transaction:t})=>{this.emit("onSelectionChange",{editor:e,transaction:t})}),e._tiptapEditor.on("mount",()=>{this.emit("onMount",{editor:e})}),e._tiptapEditor.on("unmount",()=>{this.emit("onUnmount",{editor:e})})})}onChange(e,t=!0){const n=({transaction:r,appendedTransactions:s})=>{!t&&Y(r)||e(this.editor,{getChanges(){return b.getBlocksChangedByTransaction(r,s)}})};return this.on("onChange",n),()=>{this.off("onChange",n)}}onSelectionChange(e,t=!1){const n=r=>{!t&&Y(r.transaction)||e(this.editor)};return this.on("onSelectionChange",n),()=>{this.off("onSelectionChange",n)}}onMount(e){return this.on("onMount",e),()=>{this.off("onMount",e)}}onUnmount(e){return this.on("onUnmount",e),()=>{this.off("onUnmount",e)}}}function Y(o){return!!o.getMeta("y-sync$")}function st(o){return Array.prototype.indexOf.call(o.parentElement.childNodes,o)}function it(o){return o.nodeType===3&&!/\S/.test(o.nodeValue||"")}function ct(o){o.querySelectorAll("li > ul, li > ol").forEach(e=>{const t=st(e),n=e.parentElement,r=Array.from(n.childNodes).slice(t+1);e.remove(),r.forEach(s=>{s.remove()}),n.insertAdjacentElement("afterend",e),r.reverse().forEach(s=>{if(it(s))return;const i=document.createElement("li");i.append(s),e.insertAdjacentElement("afterend",i)}),n.childNodes.length===0&&n.remove()})}function at(o){o.querySelectorAll("li + ul, li + ol").forEach(e=>{var s,i;const t=e.previousElementSibling,n=document.createElement("div");t.insertAdjacentElement("afterend",n),n.append(t);const r=document.createElement("div");for(r.setAttribute("data-node-type","blockGroup"),n.append(r);((s=n.nextElementSibling)==null?void 0:s.nodeName)==="UL"||((i=n.nextElementSibling)==null?void 0:i.nodeName)==="OL";)r.append(n.nextElementSibling)})}let J=null;function lt(){return J||(J=document.implementation.createHTMLDocument("title"))}function dt(o){if(typeof o=="string"){const e=lt().createElement("div");e.innerHTML=o,o=e}return ct(o),at(o),o}function z(o,e){const t=dt(o),r=x.DOMParser.fromSchema(e).parse(t,{topNode:e.nodes.blockGroup.create()}),s=[];for(let i=0;i{const r=String((n==null?void 0:n.url)||"");return c.isVideoUrl(r)?pt(t,n):H.defaultHandlers.image(t,n)},code:ut,blockquote:(t,n)=>{const r={type:"element",tagName:"blockquote",properties:{},children:t.wrap(t.all(n),!1)};return t.patch(n,r),t.applyData(n,r)}}}).use($e.default).processSync(o).value}function ge(o,e){const t=G(o);return z(t,e)}class ft{constructor(e){this.editor=e}blocksToHTMLLossy(e=this.editor.document){return b.createExternalHTMLExporter(this.editor.pmSchema,this.editor).exportBlocks(e,{})}blocksToFullHTML(e=this.editor.document){return ce(this.editor.pmSchema,this.editor).serializeBlocks(e,{})}tryParseHTMLToBlocks(e){return z(e,this.editor.pmSchema)}blocksToMarkdownLossy(e=this.editor.document){return b.blocksToMarkdown(e,this.editor.pmSchema,this.editor,{})}tryParseMarkdownToBlocks(e){return ge(e,this.editor.pmSchema)}pasteHTML(e,t=!1){var r;let n=e;if(!t){const s=this.tryParseHTMLToBlocks(e);n=this.blocksToFullHTML(s)}n&&((r=this.editor.prosemirrorView)==null||r.pasteHTML(n))}pasteText(e){var t;return(t=this.editor.prosemirrorView)==null?void 0:t.pasteText(e)}pasteMarkdown(e){const t=G(e);return this.pasteHTML(t)}}const q=["vscode-editor-data","blocknote/html","text/markdown","text/html","text/plain","Files"];function ht(o,e){if(!o.startsWith(".")||!e.startsWith("."))throw new Error("The strings provided are not valid file extensions.");return o===e}function mt(o,e){const t=o.split("/"),n=e.split("/");if(t.length!==2)throw new Error(`The string ${o} is not a valid MIME type.`);if(n.length!==2)throw new Error(`The string ${e} is not a valid MIME type.`);return t[1]==="*"||n[1]==="*"?t[0]===n[0]:(t[0]==="*"||n[0]==="*"||t[0]===n[0])&&t[1]===n[1]}function Q(o,e,t,n="after"){let r;return Array.isArray(e.content)&&e.content.length===0?r=o.updateBlock(e,t).id:r=o.insertBlocks([t],e,n)[0].id,r}async function be(o,e){var s;if(!e.uploadFile){console.warn("Attempted ot insert file, but uploadFile is not set in the BlockNote editor options");return}const t="dataTransfer"in o?o.dataTransfer:o.clipboardData;if(t===null)return;let n=null;for(const i of q)if(t.types.includes(i)){n=i;break}if(n!=="Files")return;const r=t.items;if(r){o.preventDefault();for(let i=0;i{var T;const y=u.getNearestBlockPos(S.doc,m.pos),E=(T=e.domElement)==null?void 0:T.querySelector(`[data-id="${y.node.attrs.id}"]`),P=E==null?void 0:E.getBoundingClientRect();return Q(e,e.getBlock(y.node.attrs.id),d,P&&(P.top+P.bottom)/2>g.top?"before":"after")})}else return;const f=await e.uploadFile(a,p),h=typeof f=="string"?{props:{url:f}}:{...f};e.updateBlock(p,h)}}}}const kt=o=>B.Extension.create({name:"dropFile",addProseMirrorPlugins(){return[new C.Plugin({props:{handleDOMEvents:{drop(e,t){if(!o.isEditable)return;let n=null;for(const r of q)if(t.dataTransfer.types.includes(r)){n=r;break}return n===null?!0:n==="Files"?(be(t,o),!0):!1}}}})]}}),gt=/(^|\n) {0,3}#{1,6} {1,8}[^\n]{1,64}\r?\n\r?\n\s{0,32}\S/,bt=/(_|__|\*|\*\*|~~|==|\+\+)(?!\s)(?:[^\s](?:.{0,62}[^\s])?|\S)(?=\1)/,St=/\[[^\]]{1,128}\]\(https?:\/\/\S{1,999}\)/,Bt=/(?:\s|^)`(?!\s)(?:[^\s`](?:[^`]{0,46}[^\s`])?|[^\s`])`([^\w]|$)/,yt=/(?:^|\n)\s{0,5}-\s{1}[^\n]+\n\s{0,15}-\s/,Ct=/(?:^|\n)\s{0,5}\d+\.\s{1}[^\n]+\n\s{0,15}\d+\.\s/,Et=/\n{2} {0,3}-{2,48}\n{2}/,Tt=/(?:\n|^)(```|~~~|\$\$)(?!`|~)[^\s]{0,64} {0,64}[^\n]{0,64}\n[\s\S]{0,9999}?\s*\1 {0,64}(?:\n+|$)/,xt=/(?:\n|^)(?!\s)\w[^\n]{0,64}\r?\n(-|=)\1{0,64}\n\n\s{0,64}(\w|$)/,Mt=/(?:^|(\r?\n\r?\n))( {0,3}>[^\n]{1,333}\n){1,999}($|(\r?\n))/,It=/^\s*\|(.+\|)+\s*$/m,Pt=/^\s*\|(\s*[-:]+[-:]\s*\|)+\s*$/m,wt=/^\s*\|(.+\|)+\s*$/m,vt=o=>gt.test(o)||bt.test(o)||St.test(o)||Bt.test(o)||yt.test(o)||Ct.test(o)||Et.test(o)||Tt.test(o)||xt.test(o)||Mt.test(o)||It.test(o)||Pt.test(o)||wt.test(o);async function At(o,e){const{schema:t}=e.state;if(!o.clipboardData)return!1;const n=o.clipboardData.getData("text/plain");if(!n)return!1;if(!t.nodes.codeBlock)return e.pasteText(n),!0;const r=o.clipboardData.getData("vscode-editor-data"),s=r?JSON.parse(r):void 0,i=s==null?void 0:s.mode;return i?(e.pasteHTML(`
${n.replace(/\r\n?/g,`
++`);throw Error("Blocks with the following IDs could not be found in the editor: "+f)}return l.forEach(f=>D(o,f)),{insertedBlocks:r.map(f=>u.nodeToBlock(f,n)),removedBlocks:i}}function Ve(o,e,t,n,r){let s;if(e)if(typeof e=="string")s=u.inlineContentToNodes([e],o.pmSchema,n);else if(Array.isArray(e))s=u.inlineContentToNodes(e,o.pmSchema,n);else if(e.type==="tableContent")s=u.tableContentToNodes(e,o.pmSchema);else throw new u.UnreachableCaseError(e.type);else throw new Error("blockContent is required");const l=((r==null?void 0:r.document)??document).createDocumentFragment();for(const a of s)if(a.type.name!=="text"&&o.schema.inlineContentSchema[a.type.name]){const d=o.schema.inlineContentSpecs[a.type.name].implementation;if(d){const p=u.nodeToCustomInlineContent(a,o.schema.inlineContentSchema,o.schema.styleSchema),f=d.render.call({renderType:"dom",props:void 0},p,()=>{},o);if(f){if(l.appendChild(f.dom),f.contentDOM){const h=t.serializeFragment(a.content,r);f.contentDOM.dataset.editable="",f.contentDOM.appendChild(h)}continue}}}else if(a.type.name==="text"){let d=document.createTextNode(a.textContent);for(const p of Array.from(a.marks).reverse())if(p.type.name in o.schema.styleSpecs){const f=o.schema.styleSpecs[p.type.name].implementation.render(p.attrs.stringValue,o);f.contentDOM.appendChild(d),d=f.dom}else{const f=p.type.spec.toDOM(p,!0),h=x.DOMSerializer.renderSpec(document,f);h.contentDOM.appendChild(d),d=h.dom}l.appendChild(d)}else{const d=t.serializeFragment(x.Fragment.from([a]),r);l.appendChild(d)}return l}function Re(o,e,t,n){var f,h,g,m,S;const r=o.pmSchema.nodes.blockContainer,s=e.props||{};for(const[y,E]of Object.entries(o.schema.blockSchema[e.type].propSchema))!(y in s)&&E.default!==void 0&&(s[y]=E.default);const i=e.children||[],a=o.blockImplementations[e.type].implementation.render.call({renderType:"dom",props:void 0},{...e,props:s,children:i},o);if(a.contentDOM&&e.content){const y=Ve(o,e.content,t,e.type,n);a.contentDOM.appendChild(y)}if(o.pmSchema.nodes[e.type].isInGroup("bnBlock")){if(e.children&&e.children.length>0){const y=se(o,e.children,t,n);(f=a.contentDOM)==null||f.append(y)}return a.dom}const p=(g=(h=r.spec)==null?void 0:h.toDOM)==null?void 0:g.call(h,r.create({id:e.id,...s}));return(m=p.contentDOM)==null||m.appendChild(a.dom),e.children&&e.children.length>0&&((S=p.contentDOM)==null||S.appendChild(ie(o,e.children,t,n))),p.dom}function se(o,e,t,n){const s=((n==null?void 0:n.document)??document).createDocumentFragment();for(const i of e){const l=Re(o,i,t,n);s.appendChild(l)}return s}const ie=(o,e,t,n)=>{var l;const r=o.pmSchema.nodes.blockGroup,s=r.spec.toDOM(r.create({})),i=se(o,e,t,n);return(l=s.contentDOM)==null||l.appendChild(i),s.dom},ze=o=>(o.querySelectorAll('[data-content-type="numberedListItem"]').forEach(t=>{var r,s;const n=(s=(r=t.closest(".bn-block-outer"))==null?void 0:r.previousElementSibling)==null?void 0:s.querySelector('[data-content-type="numberedListItem"]');if(!n)t.setAttribute("data-index",t.getAttribute("data-start")||"1");else{const i=n.getAttribute("data-index");t.setAttribute("data-index",(parseInt(i||"0")+1).toString())}}),o),Ge=o=>(o.querySelectorAll('[data-content-type="checkListItem"] input').forEach(t=>{t.disabled=!0}),o),qe=o=>(o.querySelectorAll('.bn-toggle-wrapper[data-show-children="false"]').forEach(t=>{t.setAttribute("data-show-children","true")}),o),We=o=>(o.querySelectorAll('[data-content-type="table"] table').forEach(t=>{t.setAttribute("style",`--default-cell-min-width: ${c.EMPTY_CELL_WIDTH}px;`),t.setAttribute("data-show-children","true")}),o),je=o=>(o.querySelectorAll('[data-content-type="table"] table').forEach(t=>{var s;const n=document.createElement("div");n.className="tableWrapper";const r=document.createElement("div");r.className="tableWrapper-inner",n.appendChild(r),(s=t.parentElement)==null||s.appendChild(n),n.appendChild(t)}),o),Ke=o=>(o.querySelectorAll(".bn-inline-content:empty").forEach(t=>{const n=document.createElement("span");n.className="ProseMirror-trailingBreak",n.setAttribute("style","display: inline-block;"),t.appendChild(n)}),o),ce=(o,e)=>{const t=x.DOMSerializer.fromSchema(o),n=[ze,Ge,qe,We,je,Ke];return{serializeBlocks:(r,s)=>{let i=ie(e,r,t,s);for(const l of n)i=l(i);return i.outerHTML}}};function Ye(o){return o.transact(e=>{const t=u.getNearestBlockPos(e.doc,e.selection.anchor);if(e.selection instanceof N.CellSelection)return{type:"cell",anchorBlockId:t.node.attrs.id,anchorCellOffset:e.selection.$anchorCell.pos-t.posBeforeNode,headCellOffset:e.selection.$headCell.pos-t.posBeforeNode};if(e.selection instanceof C.NodeSelection)return{type:"node",anchorBlockId:t.node.attrs.id};{const n=u.getNearestBlockPos(e.doc,e.selection.head);return{type:"text",anchorBlockId:t.node.attrs.id,headBlockId:n.node.attrs.id,anchorOffset:e.selection.anchor-t.posBeforeNode,headOffset:e.selection.head-n.posBeforeNode}}})}function Je(o,e){var r,s;const t=(r=c.getNodeById(e.anchorBlockId,o.doc))==null?void 0:r.posBeforeNode;if(t===void 0)throw new Error(`Could not find block with ID ${e.anchorBlockId} to update selection`);let n;if(e.type==="cell")n=N.CellSelection.create(o.doc,t+e.anchorCellOffset,t+e.headCellOffset);else if(e.type==="node")n=C.NodeSelection.create(o.doc,t+1);else{const i=(s=c.getNodeById(e.headBlockId,o.doc))==null?void 0:s.posBeforeNode;if(i===void 0)throw new Error(`Could not find block with ID ${e.headBlockId} to update selection`);n=C.TextSelection.create(o.doc,t+e.anchorOffset,i+e.headOffset)}o.setSelection(n)}function U(o){return o.map(e=>e.type==="columnList"?e.children.map(t=>U(t.children)).flat():{...e,children:U(e.children)}).flat()}function ae(o,e,t){o.transact(n=>{var i;const r=((i=o.getSelection())==null?void 0:i.blocks)||[o.getTextCursorPosition().block],s=Ye(o);o.removeBlocks(r),o.insertBlocks(U(r),e,t),Je(n,s)})}function le(o){return!o||o.type!=="columnList"}function de(o,e,t){let n,r;if(e?e.children.length>0?(n=e.children[e.children.length-1],r="after"):(n=e,r="before"):t&&(n=t,r="before"),!n||!r)return;const s=o.getParentBlock(n);return le(s)?{referenceBlock:n,placement:r}:de(o,r==="after"?n:o.getPrevBlock(n),s)}function ue(o,e,t){let n,r;if(e?e.children.length>0?(n=e.children[0],r="before"):(n=e,r="after"):t&&(n=t,r="after"),!n||!r)return;const s=o.getParentBlock(n);return le(s)?{referenceBlock:n,placement:r}:ue(o,r==="before"?n:o.getNextBlock(n),s)}function Qe(o){o.transact(()=>{const e=o.getSelection(),t=(e==null?void 0:e.blocks[0])||o.getTextCursorPosition().block,n=de(o,o.getPrevBlock(t),o.getParentBlock(t));n&&ae(o,n.referenceBlock,n.placement)})}function Xe(o){o.transact(()=>{const e=o.getSelection(),t=(e==null?void 0:e.blocks[(e==null?void 0:e.blocks.length)-1])||o.getTextCursorPosition().block,n=ue(o,o.getNextBlock(t),o.getParentBlock(t));n&&ae(o,n.referenceBlock,n.placement)})}function Ze(o,e,t){const{$from:n,$to:r}=o.selection,s=n.blockRange(r,m=>m.childCount>0&&(m.type.name==="blockGroup"||m.type.name==="column"));if(!s)return!1;const i=s.startIndex;if(i===0)return!1;const a=s.parent.child(i-1);if(a.type!==e)return!1;const d=a.lastChild&&a.lastChild.type===t,p=x.Fragment.from(d?e.create():null),f=new x.Slice(x.Fragment.from(e.create(null,x.Fragment.from(t.create(null,p)))),d?3:1,0),h=s.start,g=s.end;return o.step(new L.ReplaceAroundStep(h-(d?3:1),g,h,g,f,1,!0)).scrollIntoView(),!0}function pe(o){return o.transact(e=>Ze(e,o.pmSchema.nodes.blockContainer,o.pmSchema.nodes.blockGroup))}function et(o){o._tiptapEditor.commands.liftListItem("blockContainer")}function tt(o){return o.transact(e=>{const{bnBlock:t}=u.getBlockInfoFromTransaction(e);return e.doc.resolve(t.beforePos).nodeBefore!==null})}function ot(o){return o.transact(e=>{const{bnBlock:t}=u.getBlockInfoFromTransaction(e);return e.doc.resolve(t.beforePos).depth>1})}function fe(o,e){const t=typeof e=="string"?e:e.id,n=u.getPmSchema(o),r=c.getNodeById(t,o);if(r)return u.nodeToBlock(r.node,n)}function he(o,e){const t=typeof e=="string"?e:e.id,n=c.getNodeById(t,o),r=u.getPmSchema(o);if(!n)return;const i=o.resolve(n.posBeforeNode).nodeBefore;if(i)return u.nodeToBlock(i,r)}function me(o,e){const t=typeof e=="string"?e:e.id,n=c.getNodeById(t,o),r=u.getPmSchema(o);if(!n)return;const i=o.resolve(n.posBeforeNode+n.node.nodeSize).nodeAfter;if(i)return u.nodeToBlock(i,r)}function ke(o,e){const t=typeof e=="string"?e:e.id,n=u.getPmSchema(o),r=c.getNodeById(t,o);if(!r)return;const s=o.resolve(r.posBeforeNode),i=s.node(),l=s.node(-1),a=l.type.name!=="doc"?i.type.name==="blockGroup"?l:i:void 0;if(a)return u.nodeToBlock(a,n)}class nt{constructor(e){this.editor=e}get document(){return this.editor.transact(e=>u.docToBlocks(e.doc,this.editor.pmSchema))}getBlock(e){return this.editor.transact(t=>fe(t.doc,e))}getPrevBlock(e){return this.editor.transact(t=>he(t.doc,e))}getNextBlock(e){return this.editor.transact(t=>me(t.doc,e))}getParentBlock(e){return this.editor.transact(t=>ke(t.doc,e))}forEachBlock(e,t=!1){const n=this.document.slice();t&&n.reverse();function r(s){for(const i of s){if(e(i)===!1)return!1;const l=t?i.children.slice().reverse():i.children;if(!r(l))return!1}return!0}r(n)}insertBlocks(e,t,n="before"){return this.editor.transact(r=>ne(r,e,t,n))}updateBlock(e,t){return this.editor.transact(n=>c.updateBlock(n,e,t))}removeBlocks(e){return this.editor.transact(t=>$(t,e,[]).removedBlocks)}replaceBlocks(e,t){return this.editor.transact(n=>$(n,e,t))}canNestBlock(){return tt(this.editor)}nestBlock(){pe(this.editor)}canUnnestBlock(){return ot(this.editor)}unnestBlock(){et(this.editor)}moveBlocksUp(){return Qe(this.editor)}moveBlocksDown(){return Xe(this.editor)}}class rt extends R.EventEmitter{constructor(e){super(),this.editor=e,e.on("create",()=>{e._tiptapEditor.on("update",({transaction:t,appendedTransactions:n})=>{this.emit("onChange",{editor:e,transaction:t,appendedTransactions:n})}),e._tiptapEditor.on("selectionUpdate",({transaction:t})=>{this.emit("onSelectionChange",{editor:e,transaction:t})}),e._tiptapEditor.on("mount",()=>{this.emit("onMount",{editor:e})}),e._tiptapEditor.on("unmount",()=>{this.emit("onUnmount",{editor:e})})})}onChange(e,t=!0){const n=({transaction:r,appendedTransactions:s})=>{!t&&Y(r)||e(this.editor,{getChanges(){return b.getBlocksChangedByTransaction(r,s)}})};return this.on("onChange",n),()=>{this.off("onChange",n)}}onSelectionChange(e,t=!1){const n=r=>{!t&&Y(r.transaction)||e(this.editor)};return this.on("onSelectionChange",n),()=>{this.off("onSelectionChange",n)}}onMount(e){return this.on("onMount",e),()=>{this.off("onMount",e)}}onUnmount(e){return this.on("onUnmount",e),()=>{this.off("onUnmount",e)}}}function Y(o){return!!o.getMeta("y-sync$")}function st(o){return Array.prototype.indexOf.call(o.parentElement.childNodes,o)}function it(o){return o.nodeType===3&&!/\S/.test(o.nodeValue||"")}function ct(o){o.querySelectorAll("li > ul, li > ol").forEach(e=>{const t=st(e),n=e.parentElement,r=Array.from(n.childNodes).slice(t+1);e.remove(),r.forEach(s=>{s.remove()}),n.insertAdjacentElement("afterend",e),r.reverse().forEach(s=>{if(it(s))return;const i=document.createElement("li");i.append(s),e.insertAdjacentElement("afterend",i)}),n.childNodes.length===0&&n.remove()})}function at(o){o.querySelectorAll("li + ul, li + ol").forEach(e=>{var s,i;const t=e.previousElementSibling,n=document.createElement("div");t.insertAdjacentElement("afterend",n),n.append(t);const r=document.createElement("div");for(r.setAttribute("data-node-type","blockGroup"),n.append(r);((s=n.nextElementSibling)==null?void 0:s.nodeName)==="UL"||((i=n.nextElementSibling)==null?void 0:i.nodeName)==="OL";)r.append(n.nextElementSibling)})}let J=null;function lt(){return J||(J=document.implementation.createHTMLDocument("title"))}function dt(o){if(typeof o=="string"){const e=lt().createElement("div");e.innerHTML=o,o=e}return ct(o),at(o),o}function z(o,e){const t=dt(o),r=x.DOMParser.fromSchema(e).parse(t,{topNode:e.nodes.blockGroup.create()}),s=[];for(let i=0;i{const r=String((n==null?void 0:n.url)||"");return c.isVideoUrl(r)?pt(t,n):H.defaultHandlers.image(t,n)},code:ut,blockquote:(t,n)=>{const r={type:"element",tagName:"blockquote",properties:{},children:t.wrap(t.all(n),!1)};return t.patch(n,r),t.applyData(n,r)}}}).use($e.default).processSync(o).value}function ge(o,e){const t=G(o);return z(t,e)}class ft{constructor(e){this.editor=e}blocksToHTMLLossy(e=this.editor.document){return b.createExternalHTMLExporter(this.editor.pmSchema,this.editor).exportBlocks(e,{})}blocksToFullHTML(e=this.editor.document){return ce(this.editor.pmSchema,this.editor).serializeBlocks(e,{})}tryParseHTMLToBlocks(e){return z(e,this.editor.pmSchema)}blocksToMarkdownLossy(e=this.editor.document){return b.blocksToMarkdown(e,this.editor.pmSchema,this.editor,{})}tryParseMarkdownToBlocks(e){return ge(e,this.editor.pmSchema)}pasteHTML(e,t=!1){var r;let n=e;if(!t){const s=this.tryParseHTMLToBlocks(e);n=this.blocksToFullHTML(s)}n&&((r=this.editor.prosemirrorView)==null||r.pasteHTML(n))}pasteText(e){var t;return(t=this.editor.prosemirrorView)==null?void 0:t.pasteText(e)}pasteMarkdown(e){const t=G(e);return this.pasteHTML(t)}}const q=["vscode-editor-data","blocknote/html","text/markdown","text/html","text/plain","Files"];function ht(o,e){if(!o.startsWith(".")||!e.startsWith("."))throw new Error("The strings provided are not valid file extensions.");return o===e}function mt(o,e){const t=o.split("/"),n=e.split("/");if(t.length!==2)throw new Error(`The string ${o} is not a valid MIME type.`);if(n.length!==2)throw new Error(`The string ${e} is not a valid MIME type.`);return t[1]==="*"||n[1]==="*"?t[0]===n[0]:(t[0]==="*"||n[0]==="*"||t[0]===n[0])&&t[1]===n[1]}function Q(o,e,t,n="after"){let r;return Array.isArray(e.content)&&e.content.length===0?r=o.updateBlock(e,t).id:r=o.insertBlocks([t],e,n)[0].id,r}async function be(o,e){var s;if(!e.uploadFile){console.warn("Attempted ot insert file, but uploadFile is not set in the BlockNote editor options");return}const t="dataTransfer"in o?o.dataTransfer:o.clipboardData;if(t===null)return;let n=null;for(const i of q)if(t.types.includes(i)){n=i;break}if(n!=="Files")return;const r=t.items;if(r){o.preventDefault();for(let i=0;i{var T;const y=u.getNearestBlockPos(S.doc,m.pos),E=(T=e.domElement)==null?void 0:T.querySelector(`[data-id="${y.node.attrs.id}"]`),P=E==null?void 0:E.getBoundingClientRect();return Q(e,e.getBlock(y.node.attrs.id),d,P&&(P.top+P.bottom)/2>g.top?"before":"after")})}else return;const f=await e.uploadFile(a,p),h=typeof f=="string"?{props:{url:f}}:{...f};e.updateBlock(p,h)}}}}const kt=o=>B.Extension.create({name:"dropFile",addProseMirrorPlugins(){return[new C.Plugin({props:{handleDOMEvents:{drop(e,t){if(!o.isEditable)return;let n=null;for(const r of q)if(t.dataTransfer.types.includes(r)){n=r;break}return n===null?!0:n==="Files"?(be(t,o),!0):!1}}}})]}}),gt=/(^|\n) {0,3}#{1,6} {1,8}[^\n]{1,64}\r?\n\r?\n\s{0,32}\S/,bt=/(_|__|\*|\*\*|~~|==|\+\+)(?!\s)(?:[^\s](?:.{0,62}[^\s])?|\S)(?=\1)/,St=/\[[^\]]{1,128}\]\(https?:\/\/\S{1,999}\)/,Bt=/(?:\s|^)`(?!\s)(?:[^\s`](?:[^`]{0,46}[^\s`])?|[^\s`])`([^\w]|$)/,yt=/(?:^|\n)\s{0,5}-\s{1}[^\n]+\n\s{0,15}-\s/,Ct=/(?:^|\n)\s{0,5}\d+\.\s{1}[^\n]+\n\s{0,15}\d+\.\s/,Et=/\n{2} {0,3}-{2,48}\n{2}/,Tt=/(?:\n|^)(```|~~~|\$\$)(?!`|~)[^\s]{0,64} {0,64}[^\n]{0,64}\n[\s\S]{0,9999}?\s*\1 {0,64}(?:\n+|$)/,xt=/(?:\n|^)(?!\s)\w[^\n]{0,64}\r?\n(-|=)\1{0,64}\n\n\s{0,64}(\w|$)/,Mt=/(?:^|(\r?\n\r?\n))( {0,3}>[^\n]{1,333}\n){1,999}($|(\r?\n))/,It=/^\s*\|(.+\|)+\s*$/m,Pt=/^\s*\|(\s*[-:]+[-:]\s*\|)+\s*$/m,wt=/^\s*\|(.+\|)+\s*$/m,vt=o=>gt.test(o)||bt.test(o)||St.test(o)||Bt.test(o)||yt.test(o)||Ct.test(o)||Et.test(o)||Tt.test(o)||xt.test(o)||Mt.test(o)||It.test(o)||Pt.test(o)||wt.test(o);async function At(o,e){const{schema:t}=e.state;if(!o.clipboardData)return!1;const n=o.clipboardData.getData("text/plain");if(!n)return!1;if(!t.nodes.codeBlock)return e.pasteText(n),!0;const r=o.clipboardData.getData("vscode-editor-data"),s=r?JSON.parse(r):void 0,i=s==null?void 0:s.mode;return i?(e.pasteHTML(`
${n.replace(/\r\n?/g,`
+ `)}
`),!0):!1}function Nt({event:o,editor:e,prioritizeMarkdownOverHTML:t,plainTextAsMarkdown:n}){var l;if(e.transact(a=>a.selection.$from.parent.type.spec.code&&a.selection.$to.parent.type.spec.code)){const a=(l=o.clipboardData)==null?void 0:l.getData("text/plain");if(a)return e.pasteText(a),!0}let s;for(const a of q)if(o.clipboardData.types.includes(a)){s=a;break}if(!s)return!0;if(s==="vscode-editor-data")return At(o,e.prosemirrorView),!0;if(s==="Files")return be(o,e),!0;const i=o.clipboardData.getData(s);if(s==="blocknote/html")return e.pasteHTML(i,!0),!0;if(s==="text/markdown")return e.pasteMarkdown(i),!0;if(t){const a=o.clipboardData.getData("text/plain");if(vt(a))return e.pasteMarkdown(a),!0}return s==="text/html"?(e.pasteHTML(i),!0):n?(e.pasteMarkdown(i),!0):(e.pasteText(i),!0)}const Lt=(o,e)=>B.Extension.create({name:"pasteFromClipboard",addProseMirrorPlugins(){return[new C.Plugin({props:{handleDOMEvents:{paste(t,n){if(n.preventDefault(),!!o.isEditable)return e({event:n,editor:o,defaultPasteHandler:({prioritizeMarkdownOverHTML:r=!0,plainTextAsMarkdown:s=!0}={})=>Nt({event:n,editor:o,prioritizeMarkdownOverHTML:r,plainTextAsMarkdown:s})})}}}})]}});function _t(o,e,t){var l;let n=!1;const r=o.state.selection instanceof N.CellSelection;if(!r){const a=o.state.doc.slice(o.state.selection.from,o.state.selection.to,!1).content,d=[];for(let p=0;pp.type.isInGroup("bnBlock")||p.type.name==="blockGroup"||p.type.spec.group==="blockContent")===void 0,n&&(e=a)}let s;const i=b.createExternalHTMLExporter(o.state.schema,t);if(r){((l=e.firstChild)==null?void 0:l.type.name)==="table"&&(e=e.firstChild.content);const a=u.contentNodeToTableContent(e,t.schema.inlineContentSchema,t.schema.styleSchema);s=`${i.exportInlineContent(a,{})}
`}else if(n){const a=u.contentNodeToInlineContent(e,t.schema.inlineContentSchema,t.schema.styleSchema);s=i.exportInlineContent(a,{})}else{const a=b.fragmentToBlocks(e);s=i.exportBlocks(a,{})}return s}function W(o,e){"node"in o.state.selection&&o.state.selection.node.type.spec.group==="blockContent"&&e.transact(i=>i.setSelection(new C.NodeSelection(i.doc.resolve(o.state.selection.from-1))));const t=o.serializeForClipboard(o.state.selection.content()).dom.innerHTML,n=o.state.selection.content().content,r=_t(o,n,e),s=b.cleanHTMLToMarkdown(r);return{clipboardHTML:t,externalHTML:r,markdown:s}}const X=()=>{const o=window.getSelection();if(!o||o.isCollapsed)return!0;let e=o.focusNode;for(;e;){if(e instanceof HTMLElement&&e.getAttribute("contenteditable")==="false")return!0;e=e.parentElement}return!1},Z=(o,e,t)=>{t.preventDefault(),t.clipboardData.clearData();const{clipboardHTML:n,externalHTML:r,markdown:s}=W(e,o);t.clipboardData.setData("blocknote/html",n),t.clipboardData.setData("text/html",r),t.clipboardData.setData("text/plain",s)},Dt=o=>B.Extension.create({name:"copyToClipboard",addProseMirrorPlugins(){return[new C.Plugin({props:{handleDOMEvents:{copy(e,t){return X()||Z(o,e,t),!0},cut(e,t){return X()||(Z(o,e,t),e.editable&&e.dispatch(e.state.tr.deleteSelection())),!0},dragstart(e,t){if(!("node"in e.state.selection)||e.state.selection.node.type.spec.group!=="blockContent")return;o.transact(i=>i.setSelection(new C.NodeSelection(i.doc.resolve(e.state.selection.from-1)))),t.preventDefault(),t.dataTransfer.clearData();const{clipboardHTML:n,externalHTML:r,markdown:s}=W(e,o);return t.dataTransfer.setData("blocknote/html",n),t.dataTransfer.setData("text/html",r),t.dataTransfer.setData("text/plain",s),!0}}}})]}}),Ft=B.Extension.create({name:"blockBackgroundColor",addGlobalAttributes(){return[{types:["tableCell","tableHeader"],attributes:{backgroundColor:c.getBackgroundColorAttribute()}}]}}),Ot=B.Node.create({name:"hardBreak",inline:!0,group:"inline",selectable:!1,linebreakReplacement:!0,priority:10,parseHTML(){return[{tag:"br"}]},renderHTML({HTMLAttributes:o}){return["br",B.mergeAttributes(this.options.HTMLAttributes,o)]},renderText(){return` + `}}),V=(o,e)=>{const t=o.resolve(e),n=t.index();if(n===0)return;const r=t.posAtIndex(n-1);return u.getBlockInfoFromResolvedPos(o.resolve(r))},Se=(o,e)=>{for(;e.childContainer;){const t=e.childContainer.node,n=o.resolve(e.childContainer.beforePos+1).posAtIndex(t.childCount-1);e=u.getBlockInfoFromResolvedPos(o.resolve(n))}return e},Ht=(o,e)=>o.isBlockContainer&&o.blockContent.node.type.spec.content==="inline*"&&o.blockContent.node.childCount>0&&e.isBlockContainer&&e.blockContent.node.type.spec.content==="inline*",$t=(o,e,t,n)=>{if(!n.isBlockContainer)throw new Error(`Attempted to merge block at position ${n.bnBlock.beforePos} into previous block at position ${t.bnBlock.beforePos}, but next block is not a block container`);if(n.childContainer){const r=o.doc.resolve(n.childContainer.beforePos+1),s=o.doc.resolve(n.childContainer.afterPos-1),i=r.blockRange(s);if(e){const l=o.doc.resolve(n.bnBlock.beforePos);o.tr.lift(i,l.depth)}}if(e){if(!t.isBlockContainer)throw new Error(`Attempted to merge block at position ${n.bnBlock.beforePos} into previous block at position ${t.bnBlock.beforePos}, but previous block is not a block container`);e(o.tr.delete(t.blockContent.afterPos-1,n.blockContent.beforePos+1))}return!0},ee=o=>({state:e,dispatch:t})=>{const n=e.doc.resolve(o),r=u.getBlockInfoFromResolvedPos(n),s=V(e.doc,r.bnBlock.beforePos);if(!s)return!1;const i=Se(e.doc,s);return Ht(i,r)?$t(e,t,i,r):!1},Ut=B.Extension.create({priority:50,addKeyboardShortcuts(){const o=()=>this.editor.commands.first(({chain:n,commands:r})=>[()=>r.deleteSelection(),()=>r.undoInputRule(),()=>r.command(({state:s})=>{const i=u.getBlockInfoFromSelection(s);if(!i.isBlockContainer)return!1;const l=s.selection.from===i.blockContent.beforePos+1,a=i.blockContent.node.type.name==="paragraph";return l&&!a?r.command(c.updateBlockCommand(i.bnBlock.beforePos,{type:"paragraph",props:{}})):!1}),()=>r.command(({state:s})=>{const i=u.getBlockInfoFromSelection(s);if(!i.isBlockContainer)return!1;const{blockContent:l}=i;return s.selection.from===l.beforePos+1?r.liftListItem("blockContainer"):!1}),()=>r.command(({state:s})=>{const i=u.getBlockInfoFromSelection(s);if(!i.isBlockContainer)return!1;const{bnBlock:l,blockContent:a}=i,d=s.selection.from===a.beforePos+1,p=s.selection.empty,f=l.beforePos;return d&&p?n().command(ee(f)).scrollIntoView().run():!1}),()=>r.command(({state:s,tr:i,dispatch:l})=>{const a=u.getBlockInfoFromSelection(s);if(!a.isBlockContainer||!(i.selection.from===a.blockContent.beforePos+1))return!1;const p=i.doc.resolve(a.bnBlock.beforePos);if(p.nodeBefore||p.node().type.name!=="column")return!1;const g=i.doc.resolve(a.bnBlock.beforePos),m=i.doc.resolve(g.before()),S=m.before();if(l){const y=i.doc.slice(a.bnBlock.beforePos,a.bnBlock.afterPos).content;i.delete(a.bnBlock.beforePos,a.bnBlock.afterPos),m.index()===0?(D(i,S),i.insert(S,y),i.setSelection(C.TextSelection.near(i.doc.resolve(S)))):(i.insert(m.pos-1,y),i.setSelection(C.TextSelection.near(i.doc.resolve(m.pos-1))),D(i,S))}return!0}),()=>r.command(({state:s})=>{const i=u.getBlockInfoFromSelection(s);if(!i.isBlockContainer)return!1;if(i.blockContent.node.childCount===0&&i.blockContent.node.type.spec.content==="inline*"){const a=V(s.doc,i.bnBlock.beforePos);if(!a||!a.isBlockContainer)return!1;let d=n();if(a.blockContent.node.type.spec.content==="tableRow+"){const m=i.bnBlock.beforePos-1-1-1-1-1;d=d.setTextSelection(m)}else if(a.blockContent.node.type.spec.content===""){const p=a.blockContent.afterPos-a.blockContent.node.nodeSize;d=d.setNodeSelection(p)}else{const p=a.blockContent.afterPos-a.blockContent.node.nodeSize;d=d.setTextSelection(p)}return d.deleteRange({from:i.bnBlock.beforePos,to:i.bnBlock.afterPos}).scrollIntoView().run()}return!1}),()=>r.command(({state:s})=>{const i=u.getBlockInfoFromSelection(s);if(!i.isBlockContainer)throw new Error("todo");const l=s.selection.from===i.blockContent.beforePos+1,a=s.selection.empty,d=V(s.doc,i.bnBlock.beforePos);if(d&&l&&a){const p=Se(s.doc,d);if(!p.isBlockContainer)throw new Error("todo");if(p.blockContent.node.type.spec.content===""||p.blockContent.node.type.spec.content==="inline*"&&p.blockContent.node.childCount===0)return n().cut({from:i.bnBlock.beforePos,to:i.bnBlock.afterPos},p.bnBlock.afterPos).deleteRange({from:p.bnBlock.beforePos,to:p.bnBlock.afterPos}).run()}return!1})]),e=()=>this.editor.commands.first(({commands:n})=>[()=>n.deleteSelection(),()=>n.command(({state:r})=>{const s=u.getBlockInfoFromSelection(r);if(!s.isBlockContainer)return!1;const{bnBlock:i,blockContent:l,childContainer:a}=s,{depth:d}=r.doc.resolve(i.beforePos),p=i.afterPos===r.doc.nodeSize-3,f=r.selection.from===l.afterPos-1,h=r.selection.empty;if(!p&&f&&h&&!(a!==void 0)){let m=d,S=i.afterPos+1,y=r.doc.resolve(S).depth;for(;ythis.editor.commands.first(({commands:r,tr:s})=>[()=>r.command(({state:i})=>{const l=u.getBlockInfoFromSelection(i);if(!l.isBlockContainer)return!1;const{bnBlock:a,blockContent:d}=l,{depth:p}=i.doc.resolve(a.beforePos),f=i.selection.$anchor.parentOffset===0,h=i.selection.anchor===i.selection.head,g=d.node.childCount===0,m=p>1;return f&&h&&g&&m?r.liftListItem("blockContainer"):!1}),()=>r.command(({state:i})=>{var d;const l=u.getBlockInfoFromSelection(i),a=((d=this.options.editor.schema.blockSchema[l.blockNoteType].meta)==null?void 0:d.hardBreakShortcut)??"shift+enter";if(a==="none")return!1;if(a==="shift+enter"&&n||a==="enter"){const p=s.storedMarks||s.selection.$head.marks().filter(f=>this.editor.extensionManager.splittableMarks.includes(f.type.name));return s.insert(s.selection.head,s.doc.type.schema.nodes.hardBreak.create()).ensureMarks(p),!0}return!1}),()=>r.command(({state:i,dispatch:l})=>{const a=u.getBlockInfoFromSelection(i);if(!a.isBlockContainer)return!1;const{bnBlock:d,blockContent:p}=a,f=i.selection.$anchor.parentOffset===0,h=i.selection.anchor===i.selection.head,g=p.node.childCount===0;if(f&&h&&g){const m=d.afterPos,S=m+2;if(l){const y=i.schema.nodes.blockContainer.createAndFill();i.tr.insert(m,y).scrollIntoView(),i.tr.setSelection(new C.TextSelection(i.doc.resolve(S)))}return!0}return!1}),()=>r.command(({state:i,chain:l})=>{const a=u.getBlockInfoFromSelection(i);if(!a.isBlockContainer)return!1;const{blockContent:d}=a,p=i.selection.$anchor.parentOffset===0;return d.node.childCount===0?!1:(l().deleteSelection().command(c.splitBlockCommand(i.selection.from,p,p)).run(),!0)})]);return{Backspace:o,Delete:e,Enter:()=>t(),"Shift-Enter":()=>t(!0),Tab:()=>{var n,r;return this.options.tabBehavior!=="prefer-indent"&&((n=this.options.editor.getExtension(b.FormattingToolbarExtension))!=null&&n.store.state||((r=this.options.editor.getExtension(c.FilePanelExtension))==null?void 0:r.store.state)!==void 0)?!1:pe(this.options.editor)},"Shift-Tab":()=>{var n,r;return this.options.tabBehavior!=="prefer-indent"&&((n=this.options.editor.getExtension(b.FormattingToolbarExtension))!=null&&n.store.state||((r=this.options.editor.getExtension(c.FilePanelExtension))==null?void 0:r.store.state)!==void 0)?!1:this.editor.commands.liftListItem("blockContainer")},"Shift-Mod-ArrowUp":()=>(this.options.editor.moveBlocksUp(),!0),"Shift-Mod-ArrowDown":()=>(this.options.editor.moveBlocksDown(),!0),"Mod-z":()=>this.options.editor.undo(),"Mod-y":()=>this.options.editor.redo(),"Shift-Mod-z":()=>this.options.editor.redo()}}}),Vt=B.Mark.create({name:"insertion",inclusive:!1,excludes:"deletion modification insertion",addAttributes(){return{id:{default:null,validate:"number"}}},extendMarkSchema(o){return o.name!=="insertion"?{}:{blocknoteIgnore:!0,inclusive:!1,toDOM(e,t){return["ins",{"data-id":String(e.attrs.id),"data-inline":String(t),...!t&&{style:"display: contents"}},0]},parseDOM:[{tag:"ins",getAttrs(e){return e.dataset.id?{id:parseInt(e.dataset.id,10)}:!1}}]}}}),Rt=B.Mark.create({name:"deletion",inclusive:!1,excludes:"insertion modification deletion",addAttributes(){return{id:{default:null,validate:"number"}}},extendMarkSchema(o){return o.name!=="deletion"?{}:{blocknoteIgnore:!0,inclusive:!1,toDOM(e,t){return["del",{"data-id":String(e.attrs.id),"data-inline":String(t),...!t&&{style:"display: contents"}},0]},parseDOM:[{tag:"del",getAttrs(e){return e.dataset.id?{id:parseInt(e.dataset.id,10)}:!1}}]}}}),zt=B.Mark.create({name:"modification",inclusive:!1,excludes:"deletion insertion",addAttributes(){return{id:{default:null,validate:"number"},type:{validate:"string"},attrName:{default:null,validate:"string|null"},previousValue:{default:null},newValue:{default:null}}},extendMarkSchema(o){return o.name!=="modification"?{}:{blocknoteIgnore:!0,inclusive:!1,toDOM(e,t){return[t?"span":"div",{"data-type":"modification","data-id":String(e.attrs.id),"data-mod-type":e.attrs.type,"data-mod-prev-val":JSON.stringify(e.attrs.previousValue),"data-mod-new-val":JSON.stringify(e.attrs.newValue)},0]},parseDOM:[{tag:"span[data-type='modification']",getAttrs(e){return e.dataset.id?{id:parseInt(e.dataset.id,10),type:e.dataset.modType,previousValue:e.dataset.modPrevVal,newValue:e.dataset.modNewVal}:!1}},{tag:"div[data-type='modification']",getAttrs(e){return e.dataset.id?{id:parseInt(e.dataset.id,10),type:e.dataset.modType,previousValue:e.dataset.modPrevVal}:!1}}]}}}),Gt=B.Extension.create({name:"textAlignment",addGlobalAttributes(){return[{types:["tableCell","tableHeader"],attributes:{textAlignment:{default:"left",parseHTML:o=>o.getAttribute("data-text-alignment"),renderHTML:o=>o.textAlignment==="left"?{}:{"data-text-alignment":o.textAlignment}}}}]}}),qt=B.Extension.create({name:"blockTextColor",addGlobalAttributes(){return[{types:["table","tableCell","tableHeader"],attributes:{textColor:c.getTextColorAttribute()}}]}}),Wt={blockColor:"data-block-color",blockStyle:"data-block-style",id:"data-id",depth:"data-depth",depthChange:"data-depth-change"},jt=B.Node.create({name:"blockContainer",group:"blockGroupChild bnBlock",content:"blockContent blockGroup?",priority:50,defining:!0,marks:"insertion modification deletion",parseHTML(){return[{tag:"div[data-node-type="+this.name+"]",getAttrs:o=>{if(typeof o=="string")return!1;const e={};for(const[t,n]of Object.entries(Wt))o.getAttribute(n)&&(e[t]=o.getAttribute(n));return e}},{tag:'div[data-node-type="blockOuter"]',skip:!0}]},renderHTML({HTMLAttributes:o}){var r;const e=document.createElement("div");e.className="bn-block-outer",e.setAttribute("data-node-type","blockOuter");for(const[s,i]of Object.entries(o))s!=="class"&&e.setAttribute(s,i);const t={...((r=this.options.domAttributes)==null?void 0:r.block)||{},...o},n=document.createElement("div");n.className=c.mergeCSSClasses("bn-block",t.class),n.setAttribute("data-node-type",this.name);for(const[s,i]of Object.entries(t))s!=="class"&&n.setAttribute(s,i);return e.appendChild(n),{dom:e,contentDOM:n}}}),Kt=B.Node.create({name:"blockGroup",group:"childContainer",content:"blockGroupChild+",marks:"deletion insertion modification",parseHTML(){return[{tag:"div",getAttrs:o=>typeof o=="string"?!1:o.getAttribute("data-node-type")==="blockGroup"?null:!1}]},renderHTML({HTMLAttributes:o}){var n;const e={...((n=this.options.domAttributes)==null?void 0:n.blockGroup)||{},...o},t=document.createElement("div");t.className=c.mergeCSSClasses("bn-block-group",e.class),t.setAttribute("data-node-type","blockGroup");for(const[r,s]of Object.entries(e))r!=="class"&&t.setAttribute(r,s);return{dom:t,contentDOM:t}}}),Yt=B.Node.create({name:"doc",topNode:!0,content:"blockGroup",marks:"insertion modification deletion"}),Jt=F.createExtension(({options:o})=>({key:"collaboration",blockNoteExtensions:[b.ForkYDocExtension(o),b.YCursorExtension(o),b.YSyncExtension(o),b.YUndoExtension(),b.SchemaMigration(o)]}));let te=!1;function Qt(o,e){const t=[B.extensions.ClipboardTextSerializer,B.extensions.Commands,B.extensions.Editable,B.extensions.FocusEvents,B.extensions.Tabindex,Ie.Gapcursor,u.UniqueID.configure({types:["blockContainer","columnList","column"],setIdAttribute:e.setIdAttribute}),Ot,we.Text,Vt,Rt,zt,Pe.Link.extend({inclusive:!1}).configure({defaultProtocol:b.DEFAULT_LINK_PROTOCOL,protocols:te?[]:b.VALID_LINK_PROTOCOLS}),...Object.values(o.schema.styleSpecs).map(n=>n.implementation.mark.configure({editor:o})),qt,Ft,Gt,B.Extension.create({name:"OverrideEscape",addKeyboardShortcuts:()=>({Escape:()=>{var n;return(n=o.getExtension(c.SuggestionMenu))!=null&&n.shown()?!1:(o.blur(),!0)}})}),Yt,jt.configure({editor:o,domAttributes:e.domAttributes}),Ut.configure({editor:o,tabBehavior:e.tabBehavior}),Kt.configure({domAttributes:e.domAttributes}),...Object.values(o.schema.inlineContentSpecs).filter(n=>n.config!=="link"&&n.config!=="text").map(n=>n.implementation.node.configure({editor:o})),...Object.values(o.schema.blockSpecs).flatMap(n=>[..."node"in n.implementation?[n.implementation.node.configure({editor:o,domAttributes:e.domAttributes})]:[]]),Dt(o),Lt(o,e.pasteHandler||(n=>n.defaultPasteHandler())),kt(o)];return te=!0,t}function Xt(o,e){const t=[b.BlockChangeExtension(),b.DropCursorExtension(e),c.FilePanelExtension(e),b.FormattingToolbarExtension(e),b.LinkToolbarExtension(e),b.NodeSelectionKeyboardExtension(),b.PlaceholderExtension(e),ve.ShowSelectionExtension(e),b.SideMenuExtension(e),c.SuggestionMenu(e),...e.trailingBlock!==!1?[b.TrailingNodeExtension()]:[]];return e.collaboration?t.push(Jt(e.collaboration)):t.push(b.HistoryExtension()),"table"in o.schema.blockSpecs&&t.push(b.TableHandlesExtension(e)),e.animations!==!1&&t.push(b.PreviousBlockTypeExtension()),t}class Zt{constructor(e,t){k(this,"disabledExtensions",new Set);k(this,"extensions",[]);k(this,"abortMap",new Map);k(this,"extensionFactories",new Map);k(this,"extensionPlugins",new Map);this.editor=e,this.options=t,e.onMount(()=>{for(const n of this.extensions)if(n.mount){const r=new window.AbortController,s=n.mount({dom:e.prosemirrorView.dom,root:e.prosemirrorView.root,signal:r.signal});s&&r.signal.addEventListener("abort",()=>{s()}),this.abortMap.set(n,r)}}),e.onUnmount(()=>{for(const[n,r]of this.abortMap.entries())this.abortMap.delete(n),r.abort()}),this.disabledExtensions=new Set(t.disableExtensions||[]);for(const n of Xt(this.editor,this.options))this.addExtension(n);for(const n of this.options.extensions??[])this.addExtension(n);for(const n of Object.values(this.editor.schema.blockSpecs))for(const r of n.extensions??[])this.addExtension(r)}registerExtension(e){var s;const t=[].concat(e).filter(Boolean);if(!t.length){console.warn("No extensions found to register",e);return}const n=t.map(i=>this.addExtension(i)).filter(Boolean),r=new Set;for(const i of n)i!=null&&i.tiptapExtensions&&console.warn(`Extension ${i.key} has tiptap extensions, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`,i),(s=i==null?void 0:i.inputRules)!=null&&s.length&&console.warn(`Extension ${i.key} has input rules, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`,i),this.getProsemirrorPluginsFromExtension(i).plugins.forEach(l=>{r.add(l)});this.updatePlugins(i=>[...i,...r])}addExtension(e){let t;if(typeof e=="function"?t=e({editor:this.editor}):t=e,!(!t||this.disabledExtensions.has(t.key))){if(typeof e=="function"){const n=t[F.originalFactorySymbol];typeof n=="function"&&this.extensionFactories.set(n,t)}if(this.extensions.push(t),t.blockNoteExtensions)for(const n of t.blockNoteExtensions)this.addExtension(n);return t}}resolveExtensions(e){const t=[];if(typeof e=="function"){const n=this.extensionFactories.get(e);n&&t.push(n)}else if(Array.isArray(e))for(const n of e)t.push(...this.resolveExtensions(n));else if(typeof e=="object"&&"key"in e)t.push(e);else if(typeof e=="string"){const n=this.extensions.find(r=>r.key===e);n&&t.push(n)}return t}unregisterExtension(e){var s;const t=this.resolveExtensions(e);if(!t.length){console.warn("No extensions found to unregister",e);return}let n=!1;const r=new Set;for(const i of t){this.extensions=this.extensions.filter(a=>a!==i),this.extensionFactories.forEach((a,d)=>{a===i&&this.extensionFactories.delete(d)}),(s=this.abortMap.get(i))==null||s.abort(),this.abortMap.delete(i);const l=this.extensionPlugins.get(i);l==null||l.forEach(a=>{r.add(a)}),this.extensionPlugins.delete(i),i.tiptapExtensions&&!n&&(n=!0,console.warn(`Extension ${i.key} has tiptap extensions, but they will not be removed. Please separate the extension into multiple extensions if you want to remove them, or re-initialize the editor.`,e))}this.updatePlugins(i=>i.filter(l=>!r.has(l)))}updatePlugins(e){const t=this.editor.prosemirrorState,n=t.reconfigure({plugins:e(t.plugins.slice())});this.editor.prosemirrorView.updateState(n)}getTiptapExtensions(){var r;const e=Qt(this.editor,this.options).filter(s=>!this.disabledExtensions.has(s.name)),t=M.sortByDependencies(this.extensions),n=new Map;for(const s of this.extensions){s.tiptapExtensions&&e.push(...s.tiptapExtensions);const i=t(s.key),{plugins:l,inputRules:a}=this.getProsemirrorPluginsFromExtension(s);l.length&&e.push(B.Extension.create({name:s.key,priority:i,addProseMirrorPlugins:()=>l})),a.length&&(n.has(i)||n.set(i,[]),n.get(i).push(...a))}e.push(B.Extension.create({name:"blocknote-input-rules",addProseMirrorPlugins(){const s=[];return Array.from(n.keys()).sort().reverse().forEach(i=>{s.push(...n.get(i))}),[K.inputRules({rules:s})]}}));for(const s of((r=this.options._tiptapOptions)==null?void 0:r.extensions)??[])e.push(s);return e}getProsemirrorPluginsFromExtension(e){var r,s,i;const t=[...e.prosemirrorPlugins??[]],n=[];return!((r=e.prosemirrorPlugins)!=null&&r.length)&&!Object.keys(e.keyboardShortcuts||{}).length&&!((s=e.inputRules)!=null&&s.length)?{plugins:t,inputRules:n}:(this.extensionPlugins.set(e,t),(i=e.inputRules)!=null&&i.length&&n.push(...e.inputRules.map(l=>new K.InputRule(l.find,(a,d,p,f)=>{const h=l.replace({match:d,range:{from:p,to:f},editor:this.editor});if(h){const g=this.editor.getTextCursorPosition();if(this.editor.schema.blockSchema[g.block.type].content!=="inline")return null;const m=u.getBlockInfoFromTransaction(a.tr),S=a.tr.deleteRange(p,f);return c.updateBlockTr(S,m.bnBlock.beforePos,h),S}return null}))),Object.keys(e.keyboardShortcuts||{}).length&&t.push(Me.keymap(Object.fromEntries(Object.entries(e.keyboardShortcuts).map(([l,a])=>[l,()=>a({editor:this.editor})])))),{plugins:t,inputRules:n})}getExtensions(){return new Map(this.extensions.map(e=>[e.key,e]))}getExtension(e){if(typeof e=="string"){const t=this.extensions.find(n=>n.key===e);return t||void 0}else if(typeof e=="function"){const t=this.extensionFactories.get(e);return t||void 0}throw new Error(`Invalid extension type: ${typeof e}`)}hasExtension(e){return typeof e=="string"?this.extensions.some(t=>t.key===e):typeof e=="object"&&"key"in e?this.extensions.some(t=>t.key===e.key):typeof e=="function"?this.extensionFactories.has(e):!1}}function Be(o,e){let{$from:t,$to:n}=e;if(t.pos>t.start()&&t.pos0){const r=o.textBetween(n.pos-1,n.pos);if(/^[\w\p{P}]$/u.test(r)){const i=o.textBetween(n.pos,n.end()).match(/^[\w\p{P}]+/u);i&&(n=o.resolve(n.pos+i[0].length))}}return{$from:t,$to:n,from:t.pos,to:n.pos}}function eo(o){const e=u.getPmSchema(o);if(o.selection.empty||"node"in o.selection)return;const t=o.doc.resolve(u.getNearestBlockPos(o.doc,o.selection.from).posBeforeNode),n=o.doc.resolve(u.getNearestBlockPos(o.doc,o.selection.to).posBeforeNode),r=(d,p)=>{const f=t.posAtIndex(d,p),h=o.doc.resolve(f).nodeAfter;if(!h)throw new Error(`Error getting selection - node not found at position ${f}`);return u.nodeToBlock(h,e)},s=[],i=t.sharedDepth(n.pos),l=t.index(i),a=n.index(i);if(t.depth>i){s.push(u.nodeToBlock(t.nodeAfter,e));for(let d=t.depth;d>i;d--)if(t.node(d).type.isInGroup("childContainer")){const f=t.index(d)+1,h=t.node(d).childCount;for(let g=f;g=s.parent.nodeSize-2&&s.depth>0;)s=o.doc.resolve(s.pos+1);for(;s.parentOffset===0&&s.depth>0;)s=o.doc.resolve(s.pos-1);for(;r.parentOffset===0&&r.depth>0;)r=o.doc.resolve(r.pos-1);for(;r.parentOffset>=r.parent.nodeSize-2&&r.depth>0;)r=o.doc.resolve(r.pos+1);const i=u.prosemirrorSliceToSlicedBlocks(o.doc.slice(r.pos,s.pos,!0),t);return{_meta:{startPos:r.pos,endPos:s.pos},...i}}function no(o){const{bnBlock:e}=u.getBlockInfoFromTransaction(o),t=u.getPmSchema(o.doc),n=o.doc.resolve(e.beforePos),r=n.nodeBefore,s=o.doc.resolve(e.afterPos).nodeAfter;let i;return n.depth>1&&(i=n.node(),i.type.isInGroup("bnBlock")||(i=n.node(n.depth-1))),{block:u.nodeToBlock(e.node,t),prevBlock:r===null?void 0:u.nodeToBlock(r,t),nextBlock:s===null?void 0:u.nodeToBlock(s,t),parentBlock:i===void 0?void 0:u.nodeToBlock(i,t)}}function ye(o,e,t="start"){const n=typeof e=="string"?e:e.id,r=u.getPmSchema(o.doc),s=u.getBlockNoteSchema(r),i=c.getNodeById(n,o.doc);if(!i)throw new Error(`Block with ID ${n} not found`);const l=u.getBlockInfo(i),a=s.blockSchema[l.blockNoteType].content;if(l.isBlockContainer){const d=l.blockContent;if(a==="none"){o.setSelection(C.NodeSelection.create(o.doc,d.beforePos));return}if(a==="inline")t==="start"?o.setSelection(C.TextSelection.create(o.doc,d.beforePos+1)):o.setSelection(C.TextSelection.create(o.doc,d.afterPos-1));else if(a==="table")t==="start"?o.setSelection(C.TextSelection.create(o.doc,d.beforePos+4)):o.setSelection(C.TextSelection.create(o.doc,d.afterPos-4));else throw new u.UnreachableCaseError(a)}else{const d=t==="start"?l.childContainer.node.firstChild:l.childContainer.node.lastChild;ye(o,d.attrs.id,t)}}class ro{constructor(e){this.editor=e}getSelection(){return this.editor.transact(e=>eo(e))}getSelectionCutBlocks(e=!1){return this.editor.transact(t=>oo(t,e))}setSelection(e,t){return this.editor.transact(n=>to(n,e,t))}getTextCursorPosition(){return this.editor.transact(e=>no(e))}setTextCursorPosition(e,t="start"){return this.editor.transact(n=>ye(n,e,t))}getSelectionBoundingBox(){if(!this.editor.prosemirrorView)return;const{selection:e}=this.editor.prosemirrorState,{ranges:t}=e,n=Math.min(...t.map(s=>s.$from.pos)),r=Math.max(...t.map(s=>s.$to.pos));if(B.isNodeSelection(e)){const s=this.editor.prosemirrorView.nodeDOM(n);if(s)return s.getBoundingClientRect()}return B.posToDOMRect(this.editor.prosemirrorView,n,r).toJSON()}}class so{constructor(e){k(this,"activeTransaction",null);k(this,"isInCan",!1);this.editor=e}can(e){try{return this.isInCan=!0,e()}finally{this.isInCan=!1}}exec(e){if(this.activeTransaction)throw new Error("`exec` should not be called within a `transact` call, move the `exec` call outside of the `transact` call");if(this.isInCan)return this.canExec(e);const t=this.prosemirrorState,n=this.prosemirrorView;return e(t,s=>this.prosemirrorView.dispatch(s),n)}canExec(e){if(this.activeTransaction)throw new Error("`canExec` should not be called within a `transact` call, move the `canExec` call outside of the `transact` call");const t=this.prosemirrorState,n=this.prosemirrorView;return e(t,void 0,n)}transact(e){if(this.activeTransaction)return e(this.activeTransaction);try{this.activeTransaction=this.editor._tiptapEditor.state.tr;const t=e(this.activeTransaction),n=this.activeTransaction;return this.activeTransaction=null,n&&(n.docChanged||n.selectionSet||n.scrolledIntoView||n.storedMarksSet||!n.isGeneric)&&this.prosemirrorView.dispatch(n),t}finally{this.activeTransaction=null}}get prosemirrorState(){if(this.activeTransaction)throw new Error("`prosemirrorState` should not be called within a `transact` call, move the `prosemirrorState` call outside of the `transact` call or use `editor.transact` to read the current editor state");return this.editor._tiptapEditor.state}get prosemirrorView(){return this.editor._tiptapEditor.view}isFocused(){var e;return((e=this.prosemirrorView)==null?void 0:e.hasFocus())||!1}focus(){var e;(e=this.prosemirrorView)==null||e.focus()}get isEditable(){if(!this.editor._tiptapEditor){if(!this.editor.headless)throw new Error("no editor, but also not headless?");return!1}return this.editor._tiptapEditor.isEditable===void 0?!0:this.editor._tiptapEditor.isEditable}set isEditable(e){if(!this.editor._tiptapEditor){if(!this.editor.headless)throw new Error("no editor, but also not headless?");return}this.editor._tiptapEditor.options.editable!==e&&this.editor._tiptapEditor.setEditable(e)}undo(){const e=this.editor.getExtension("yUndo");if(e)return this.exec(e.undoCommand);const t=this.editor.getExtension("history");if(t)return this.exec(t.undoCommand);throw new Error("No undo plugin found")}redo(){const e=this.editor.getExtension("yUndo");if(e)return this.exec(e.redoCommand);const t=this.editor.getExtension("history");if(t)return this.exec(t.redoCommand);throw new Error("No redo plugin found")}}function io(o,e,t,n={updateSelection:!0}){let{from:r,to:s}=typeof e=="number"?{from:e,to:e}:{from:e.from,to:e.to},i=!0,l=!0,a="";if(t.forEach(d=>{d.check(),i&&d.isText&&d.marks.length===0?a+=d.text:i=!1,l=l?d.isBlock:!1}),r===s&&l){const{parent:d}=o.doc.resolve(r);d.isTextblock&&!d.type.spec.code&&!d.childCount&&(r-=1,s+=1)}return i?o.insertText(a,r,s):o.replaceWith(r,s,t),n.updateSelection&&B.selectionToInsertionEnd(o,o.steps.length-1,-1),!0}class co{constructor(e){this.editor=e}insertInlineContent(e,{updateSelection:t=!1}={}){const n=u.inlineContentToNodes(e,this.editor.pmSchema);this.editor.transact(r=>{io(r,{from:r.selection.from,to:r.selection.to},n,{updateSelection:t})})}getActiveStyles(){return this.editor.transact(e=>{const t={},n=e.selection.$to.marks();for(const r of n){const s=this.editor.schema.styleSchema[r.type.name];if(!s){r.type.name!=="link"&&!r.type.spec.blocknoteIgnore&&console.warn("mark not found in styleschema",r.type.name);continue}s.propSchema==="boolean"?t[s.type]=!0:t[s.type]=r.attrs.stringValue}return t})}addStyles(e){for(const[t,n]of Object.entries(e)){const r=this.editor.schema.styleSchema[t];if(!r)throw new Error(`style ${t} not found in styleSchema`);if(r.propSchema==="boolean")this.editor._tiptapEditor.commands.setMark(t);else if(r.propSchema==="string")this.editor._tiptapEditor.commands.setMark(t,{stringValue:n});else throw new u.UnreachableCaseError(r.propSchema)}}removeStyles(e){for(const t of Object.keys(e))this.editor._tiptapEditor.commands.unsetMark(t)}toggleStyles(e){for(const[t,n]of Object.entries(e)){const r=this.editor.schema.styleSchema[t];if(!r)throw new Error(`style ${t} not found in styleSchema`);if(r.propSchema==="boolean")this.editor._tiptapEditor.commands.toggleMark(t);else if(r.propSchema==="string")this.editor._tiptapEditor.commands.toggleMark(t,{stringValue:n});else throw new u.UnreachableCaseError(r.propSchema)}}getSelectedText(){return this.editor.transact(e=>e.doc.textBetween(e.selection.from,e.selection.to))}getSelectedLinkUrl(){return this.editor._tiptapEditor.getAttributes("link").href}createLink(e,t){if(e==="")return;const n=this.editor.pmSchema.mark("link",{href:e});this.editor.transact(r=>{const{from:s,to:i}=r.selection;t?r.insertText(t,s,i).addMark(s,s+t.length,n):r.setSelection(De.TextSelection.create(r.doc,i)).addMark(s,i,n)})}}function ao(o,e){const t=[];return o.forEach((n,r,s)=>{s!==e&&t.push(n)}),A.Fragment.from(t)}function lo(o,e){const t=[];for(let n=0;n0&&t[t.length-1].type.name==="table"){const r=t[t.length-1],s=r.copy(r.content.addToEnd(o.child(n)));t[t.length-1]=s}else{const r=e.nodes.table.createChecked(void 0,o.child(n));t.push(r)}else t.push(o.child(n));return o=A.Fragment.from(t),o}function uo(o,e){let t=A.Fragment.from(o.content);if(t=lo(t,e.state.schema),!po(t,e))return new A.Slice(t,o.openStart,o.openEnd);for(let n=0;nthis._extensionManager.unregisterExtension(...t));k(this,"registerExtension",(...t)=>this._extensionManager.registerExtension(...t));k(this,"getExtension",(...t)=>this._extensionManager.getExtension(...t));k(this,"mount",t=>{this._tiptapEditor.mount({mount:t})});k(this,"unmount",()=>{this._tiptapEditor.unmount()});this.options=t,this.dictionary=t.dictionary||xe.en,this.settings={tables:{splitCells:((d=t==null?void 0:t.tables)==null?void 0:d.splitCells)??!1,cellBackgroundColor:((p=t==null?void 0:t.tables)==null?void 0:p.cellBackgroundColor)??!1,cellTextColor:((f=t==null?void 0:t.tables)==null?void 0:f.cellTextColor)??!1,headers:((h=t==null?void 0:t.tables)==null?void 0:h.headers)??!1}};const n={defaultStyles:!0,schema:t.schema||M.BlockNoteSchema.create(),...t,placeholders:{...this.dictionary.placeholders,...t.placeholders}};if(this.schema=n.schema,this.blockImplementations=n.schema.blockSpecs,this.inlineContentImplementations=n.schema.inlineContentSpecs,this.styleImplementations=n.schema.styleSpecs,n.uploadFile){const T=n.uploadFile;this.uploadFile=async(w,I)=>{this.onUploadStartCallbacks.forEach(v=>v.apply(this,[I]));try{return await T(w,I)}finally{this.onUploadEndCallbacks.forEach(v=>v.apply(this,[I]))}}}this.resolveFileUrl=n.resolveFileUrl,this._eventManager=new rt(this),this._extensionManager=new Zt(this,n);const r=this._extensionManager.getTiptapExtensions(),s=this._extensionManager.hasExtension("ySync")||this._extensionManager.hasExtension("liveblocksExtension");s&&n.initialContent&&console.warn("When using Collaboration, initialContent might cause conflicts, because changes should come from the collaboration provider");const i={...fo,...n._tiptapOptions,element:null,autofocus:n.autofocus??!1,extensions:r,editorProps:{...(g=n._tiptapOptions)==null?void 0:g.editorProps,attributes:{tabIndex:"0",...(S=(m=n._tiptapOptions)==null?void 0:m.editorProps)==null?void 0:S.attributes,...(y=n.domAttributes)==null?void 0:y.editor,class:c.mergeCSSClasses("bn-editor",n.defaultStyles?"bn-default-styles":"",((P=(E=n.domAttributes)==null?void 0:E.editor)==null?void 0:P.class)||"")},transformPasted:uo}};try{const T=n.initialContent||(s?[{type:"paragraph",id:"initialBlockId"}]:[{type:"paragraph",id:u.UniqueID.options.generateID()}]);if(!Array.isArray(T)||T.length===0)throw new Error("initialContent must be a non-empty array of blocks, received: "+T);const w=B.getSchema(i.extensions),I=T.map(Ce=>u.blockToNode(Ce,w,this.schema.styleSchema).toJSON()),v=B.createDocument({type:"doc",content:[{type:"blockGroup",content:I}]},w,i.parseOptions);this._tiptapEditor=new B.Editor({...i,content:v.toJSON()}),this.pmSchema=this._tiptapEditor.schema}catch(T){throw new Error("Error creating document from blocks passed as `initialContent`",{cause:T})}let l;const a=this.pmSchema.nodes.doc.createAndFill;this.pmSchema.nodes.doc.createAndFill=(...T)=>{if(l)return l;const w=a.apply(this.pmSchema.nodes.doc,T),I=JSON.parse(JSON.stringify(w.toJSON()));return I.content[0].content[0].attrs.id="initialBlockId",l=x.Node.fromJSON(this.pmSchema,I),l},this.pmSchema.cached.blockNoteEditor=this,this._blockManager=new nt(this),this._exportManager=new ft(this),this._selectionManager=new ro(this),this._stateManager=new so(this),this._styleManager=new co(this),this.emit("create")}static create(t){return new j(t??{})}get extensions(){return this._extensionManager.getExtensions()}exec(t){return this._stateManager.exec(t)}canExec(t){return this._stateManager.canExec(t)}transact(t){return this._stateManager.transact(t)}get prosemirrorState(){return this._stateManager.prosemirrorState}get prosemirrorView(){return this._stateManager.prosemirrorView}get domElement(){var t;if(!this.headless)return(t=this.prosemirrorView)==null?void 0:t.dom}isFocused(){var t;return this.headless?!1:((t=this.prosemirrorView)==null?void 0:t.hasFocus())||!1}get headless(){return!this._tiptapEditor.isInitialized}focus(){this.headless||this.prosemirrorView.focus()}blur(){var t;this.headless||(t=this.domElement)==null||t.blur()}onUploadStart(t){return this.onUploadStartCallbacks.push(t),()=>{const n=this.onUploadStartCallbacks.indexOf(t);n>-1&&this.onUploadStartCallbacks.splice(n,1)}}onUploadEnd(t){return this.onUploadEndCallbacks.push(t),()=>{const n=this.onUploadEndCallbacks.indexOf(t);n>-1&&this.onUploadEndCallbacks.splice(n,1)}}get topLevelBlocks(){return this.document}get document(){return this._blockManager.document}getBlock(t){return this._blockManager.getBlock(t)}getPrevBlock(t){return this._blockManager.getPrevBlock(t)}getNextBlock(t){return this._blockManager.getNextBlock(t)}getParentBlock(t){return this._blockManager.getParentBlock(t)}forEachBlock(t,n=!1){this._blockManager.forEachBlock(t,n)}onEditorContentChange(t){this._tiptapEditor.on("update",t)}onEditorSelectionChange(t){this._tiptapEditor.on("selectionUpdate",t)}onBeforeChange(t){return this._extensionManager.getExtension(b.BlockChangeExtension).subscribe(t)}getTextCursorPosition(){return this._selectionManager.getTextCursorPosition()}setTextCursorPosition(t,n="start"){return this._selectionManager.setTextCursorPosition(t,n)}getSelection(){return this._selectionManager.getSelection()}getSelectionCutBlocks(t=!1){return this._selectionManager.getSelectionCutBlocks(t)}setSelection(t,n){return this._selectionManager.setSelection(t,n)}get isEditable(){return this._stateManager.isEditable}set isEditable(t){this._stateManager.isEditable=t}insertBlocks(t,n,r="before"){return this._blockManager.insertBlocks(t,n,r)}updateBlock(t,n){return this._blockManager.updateBlock(t,n)}removeBlocks(t){return this._blockManager.removeBlocks(t)}replaceBlocks(t,n){return this._blockManager.replaceBlocks(t,n)}undo(){return this._stateManager.undo()}redo(){return this._stateManager.redo()}insertInlineContent(t,{updateSelection:n=!1}={}){this._styleManager.insertInlineContent(t,{updateSelection:n})}getActiveStyles(){return this._styleManager.getActiveStyles()}addStyles(t){this._styleManager.addStyles(t)}removeStyles(t){this._styleManager.removeStyles(t)}toggleStyles(t){this._styleManager.toggleStyles(t)}getSelectedText(){return this._styleManager.getSelectedText()}getSelectedLinkUrl(){return this._styleManager.getSelectedLinkUrl()}createLink(t,n){this._styleManager.createLink(t,n)}canNestBlock(){return this._blockManager.canNestBlock()}nestBlock(){this._blockManager.nestBlock()}canUnnestBlock(){return this._blockManager.canUnnestBlock()}unnestBlock(){this._blockManager.unnestBlock()}moveBlocksUp(){return this._blockManager.moveBlocksUp()}moveBlocksDown(){return this._blockManager.moveBlocksDown()}blocksToHTMLLossy(t=this.document){return this._exportManager.blocksToHTMLLossy(t)}blocksToFullHTML(t=this.document){return this._exportManager.blocksToFullHTML(t)}tryParseHTMLToBlocks(t){return this._exportManager.tryParseHTMLToBlocks(t)}blocksToMarkdownLossy(t=this.document){return this._exportManager.blocksToMarkdownLossy(t)}tryParseMarkdownToBlocks(t){return this._exportManager.tryParseMarkdownToBlocks(t)}onChange(t,n){return this._eventManager.onChange(t,n)}onSelectionChange(t,n){return this._eventManager.onSelectionChange(t,n)}onMount(t){this._eventManager.onMount(t)}onUnmount(t){this._eventManager.onUnmount(t)}getSelectionBoundingBox(){return this._selectionManager.getSelectionBoundingBox()}get isEmpty(){const t=this.document;return t.length===0||t.length===1&&t[0].type==="paragraph"&&t[0].content.length===0}pasteHTML(t,n=!1){this._exportManager.pasteHTML(t,n)}pasteText(t){return this._exportManager.pasteText(t)}pasteMarkdown(t){return this._exportManager.pasteMarkdown(t)}}class ho{constructor(e,t,n){this.mappings=t,this.options=n}async resolveFile(e){var n;if(!((n=this.options)!=null&&n.resolveFileUrl))return(await fetch(e)).blob();const t=await this.options.resolveFileUrl(e);return t instanceof Blob?t:(await fetch(t)).blob()}mapStyles(e){return Object.entries(e).map(([n,r])=>this.mappings.styleMapping[n](r,this))}mapInlineContent(e){return this.mappings.inlineContentMapping[e.type](e,this)}transformInlineContent(e){return e.map(t=>this.mapInlineContent(t))}async mapBlock(e,t,n,r){return this.mappings.blockMapping[e.type](e,this,t,n,r)}}function mo(o){return{createBlockMapping:e=>e,createInlineContentMapping:e=>e,createStyleMapping:e=>e}}function ko(o,...e){const t=[...o];for(const n of e)for(const r of n){const s=t.findLastIndex(i=>i.group===r.group);s===-1?t.push(r):t.splice(s+1,0,r)}return t}exports.UniqueID=u.UniqueID;exports.UnreachableCaseError=u.UnreachableCaseError;exports.assertEmpty=u.assertEmpty;exports.blockToNode=u.blockToNode;exports.contentNodeToInlineContent=u.contentNodeToInlineContent;exports.contentNodeToTableContent=u.contentNodeToTableContent;exports.docToBlocks=u.docToBlocks;exports.getBlockCache=u.getBlockCache;exports.getBlockInfo=u.getBlockInfo;exports.getBlockInfoFromResolvedPos=u.getBlockInfoFromResolvedPos;exports.getBlockInfoFromSelection=u.getBlockInfoFromSelection;exports.getBlockInfoFromTransaction=u.getBlockInfoFromTransaction;exports.getBlockInfoWithManualOffset=u.getBlockInfoWithManualOffset;exports.getBlockNoteSchema=u.getBlockNoteSchema;exports.getBlockSchema=u.getBlockSchema;exports.getColspan=u.getColspan;exports.getInlineContentSchema=u.getInlineContentSchema;exports.getNearestBlockPos=u.getNearestBlockPos;exports.getPmSchema=u.getPmSchema;exports.getRowspan=u.getRowspan;exports.getStyleSchema=u.getStyleSchema;exports.inlineContentToNodes=u.inlineContentToNodes;exports.isLinkInlineContent=u.isLinkInlineContent;exports.isPartialLinkInlineContent=u.isPartialLinkInlineContent;exports.isPartialTableCell=u.isPartialTableCell;exports.isStyledTextInlineContent=u.isStyledTextInlineContent;exports.isTableCell=u.isTableCell;exports.mapTableCell=u.mapTableCell;exports.nodeToBlock=u.nodeToBlock;exports.nodeToCustomInlineContent=u.nodeToCustomInlineContent;exports.prosemirrorSliceToSlicedBlocks=u.prosemirrorSliceToSlicedBlocks;exports.tableContentToNodes=u.tableContentToNodes;exports.COLORS_DARK_MODE_DEFAULT=c.COLORS_DARK_MODE_DEFAULT;exports.COLORS_DEFAULT=c.COLORS_DEFAULT;exports.EMPTY_CELL_HEIGHT=c.EMPTY_CELL_HEIGHT;exports.EMPTY_CELL_WIDTH=c.EMPTY_CELL_WIDTH;exports.FILE_AUDIO_ICON_SVG=c.FILE_AUDIO_ICON_SVG;exports.FILE_IMAGE_ICON_SVG=c.FILE_IMAGE_ICON_SVG;exports.FILE_VIDEO_ICON_SVG=c.FILE_VIDEO_ICON_SVG;exports.addDefaultPropsExternalHTML=c.addDefaultPropsExternalHTML;exports.addInlineContentAttributes=c.addInlineContentAttributes;exports.addInlineContentKeyboardShortcuts=c.addInlineContentKeyboardShortcuts;exports.addNodeAndExtensionsToSpec=c.addNodeAndExtensionsToSpec;exports.addStyleAttributes=c.addStyleAttributes;exports.applyNonSelectableBlockFix=c.applyNonSelectableBlockFix;exports.audioParse=c.audioParse;exports.audioRender=c.audioRender;exports.audioToExternalHTML=c.audioToExternalHTML;exports.blockHasType=c.blockHasType;exports.camelToDataKebab=c.camelToDataKebab;exports.captureCellAnchor=c.captureCellAnchor;exports.createAudioBlockConfig=c.createAudioBlockConfig;exports.createAudioBlockSpec=c.createAudioBlockSpec;exports.createBlockConfig=c.createBlockConfig;exports.createBlockSpec=c.createBlockSpec;exports.createBlockSpecFromTiptapNode=c.createBlockSpecFromTiptapNode;exports.createBulletListItemBlockConfig=c.createBulletListItemBlockConfig;exports.createBulletListItemBlockSpec=c.createBulletListItemBlockSpec;exports.createCheckListItemBlockSpec=c.createCheckListItemBlockSpec;exports.createCheckListItemConfig=c.createCheckListItemConfig;exports.createCodeBlockConfig=c.createCodeBlockConfig;exports.createCodeBlockSpec=c.createCodeBlockSpec;exports.createDefaultBlockDOMOutputSpec=c.createDefaultBlockDOMOutputSpec;exports.createDividerBlockConfig=c.createDividerBlockConfig;exports.createDividerBlockSpec=c.createDividerBlockSpec;exports.createFileBlockConfig=c.createFileBlockConfig;exports.createFileBlockSpec=c.createFileBlockSpec;exports.createHeadingBlockConfig=c.createHeadingBlockConfig;exports.createHeadingBlockSpec=c.createHeadingBlockSpec;exports.createImageBlockConfig=c.createImageBlockConfig;exports.createImageBlockSpec=c.createImageBlockSpec;exports.createInlineContentSpecFromTipTapNode=c.createInlineContentSpecFromTipTapNode;exports.createInternalInlineContentSpec=c.createInternalInlineContentSpec;exports.createInternalStyleSpec=c.createInternalStyleSpec;exports.createNumberedListItemBlockConfig=c.createNumberedListItemBlockConfig;exports.createNumberedListItemBlockSpec=c.createNumberedListItemBlockSpec;exports.createParagraphBlockConfig=c.createParagraphBlockConfig;exports.createParagraphBlockSpec=c.createParagraphBlockSpec;exports.createQuoteBlockConfig=c.createQuoteBlockConfig;exports.createQuoteBlockSpec=c.createQuoteBlockSpec;exports.createStyleSpec=c.createStyleSpec;exports.createStyleSpecFromTipTapMark=c.createStyleSpecFromTipTapMark;exports.createTableBlockSpec=c.createTableBlockSpec;exports.createToggleListItemBlockConfig=c.createToggleListItemBlockConfig;exports.createToggleListItemBlockSpec=c.createToggleListItemBlockSpec;exports.createToggleWrapper=c.createToggleWrapper;exports.createVideoBlockConfig=c.createVideoBlockConfig;exports.createVideoBlockSpec=c.createVideoBlockSpec;exports.defaultBlockSpecs=c.defaultBlockSpecs;exports.defaultBlockToHTML=c.defaultBlockToHTML;exports.defaultInlineContentSchema=c.defaultInlineContentSchema;exports.defaultInlineContentSpecs=c.defaultInlineContentSpecs;exports.defaultProps=c.defaultProps;exports.defaultStyleSchema=c.defaultStyleSchema;exports.defaultStyleSpecs=c.defaultStyleSpecs;exports.defaultToggledState=c.defaultToggledState;exports.editorHasBlockWithType=c.editorHasBlockWithType;exports.fileParse=c.fileParse;exports.filenameFromURL=c.filenameFromURL;exports.formatKeyboardShortcut=c.formatKeyboardShortcut;exports.getBackgroundColorAttribute=c.getBackgroundColorAttribute;exports.getBlockFromPos=c.getBlockFromPos;exports.getInlineContentSchemaFromSpecs=c.getInlineContentSchemaFromSpecs;exports.getLanguageId=c.getLanguageId;exports.getNodeById=c.getNodeById;exports.getParseRules=c.getParseRules;exports.getStyleParseRules=c.getStyleParseRules;exports.getStyleSchemaFromSpecs=c.getStyleSchemaFromSpecs;exports.getTextAlignmentAttribute=c.getTextAlignmentAttribute;exports.getTextColorAttribute=c.getTextColorAttribute;exports.imageParse=c.imageParse;exports.imageRender=c.imageRender;exports.imageToExternalHTML=c.imageToExternalHTML;exports.isAppleOS=c.isAppleOS;exports.isNodeBlock=c.isNodeBlock;exports.isSafari=c.isSafari;exports.isTableCellSelection=c.isTableCellSelection;exports.isVideoUrl=c.isVideoUrl;exports.mergeCSSClasses=c.mergeCSSClasses;exports.mergeParagraphs=c.mergeParagraphs;exports.parseAudioElement=c.parseAudioElement;exports.parseDefaultProps=c.parseDefaultProps;exports.propsToAttributes=c.propsToAttributes;exports.stylePropsToAttributes=c.stylePropsToAttributes;exports.tablePropSchema=c.tablePropSchema;exports.trackPosition=c.trackPosition;exports.updateBlock=c.updateBlock;exports.updateBlockCommand=c.updateBlockCommand;exports.updateBlockTr=c.updateBlockTr;exports.videoParse=c.videoParse;exports.wrapInBlockStructure=c.wrapInBlockStructure;exports.blocksToMarkdown=b.blocksToMarkdown;exports.cleanHTMLToMarkdown=b.cleanHTMLToMarkdown;exports.createExternalHTMLExporter=b.createExternalHTMLExporter;exports.getBlocksChangedByTransaction=b.getBlocksChangedByTransaction;exports.BlockNoteSchema=M.BlockNoteSchema;exports.CustomBlockNoteSchema=M.CustomBlockNoteSchema;exports.checkPageBreakBlocksInSchema=M.checkPageBreakBlocksInSchema;exports.createPageBreakBlockConfig=M.createPageBreakBlockConfig;exports.createPageBreakBlockSpec=M.createPageBreakBlockSpec;exports.getPageBreakSlashMenuItems=M.getPageBreakSlashMenuItems;exports.uploadToTmpFilesDotOrg_DEV_ONLY=M.uploadToTmpFilesDotOrg_DEV_ONLY;exports.withPageBreak=M.withPageBreak;exports.EventEmitter=R.EventEmitter;exports.createExtension=F.createExtension;exports.createStore=F.createStore;exports.BlockNoteEditor=j;exports.Exporter=ho;exports.HTMLToBlocks=z;exports.combineByGroup=ko;exports.createInlineContentSpec=Ue;exports.createInternalHTMLSerializer=ce;exports.expandPMRangeToWords=Be;exports.fixColumnList=D;exports.getBlock=fe;exports.getInlineContentParseRules=oe;exports.getNextBlock=me;exports.getParentBlock=ke;exports.getPrevBlock=he;exports.insertBlocks=ne;exports.isEmptyColumn=_;exports.mappingFactory=mo;exports.markdownToBlocks=ge;exports.markdownToHTML=G;exports.removeAndInsertBlocks=$;exports.removeEmptyColumns=re;exports.selectedFragmentToHTML=W; + //# sourceMappingURL=blocknote.cjs.map +diff --git a/dist/blocknote.js b/dist/blocknote.js +index 0f97dc48ddedfb9661b81c7469ce504dc974e284..e1af7dbc1e590a4cc7cdd4ec921b7b683c3ebb97 100644 +--- a/dist/blocknote.js ++++ b/dist/blocknote.js +@@ -313,7 +313,7 @@ function Qt(o, e, t, n, s) { + let l = document.createTextNode( + i.textContent + ); +- for (const d of i.marks.toReversed()) ++ for (const d of Array.from(i.marks).reverse()) + if (d.type.name in o.schema.styleSpecs) { + const u = o.schema.styleSpecs[d.type.name].implementation.render(d.attrs.stringValue, o); + u.contentDOM.appendChild(l), l = u.dom; +@@ -2037,7 +2037,9 @@ const de = () => { + ] + }) + ); +-let fe = !1; ++const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered"; ++const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis; ++let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]); + function mn(o, e) { + const t = [ + I.ClipboardTextSerializer, +@@ -2062,7 +2064,10 @@ function mn(o, e) { + }).configure({ + defaultProtocol: Ct, + // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 +- protocols: fe ? [] : Bt ++ // Tolaria routes editor link clicks through its guarded native opener. ++ openOnClick: !1, ++ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes. ++ protocols: [] + }), + ...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({ + editor: o +@@ -2112,7 +2117,7 @@ function mn(o, e) { + ), + Do(o) + ]; +- return fe = !0, t; ++ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t; + } + function kn(o, e) { + const t = [ +diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js +index b2761001278486a8b2ac1d10c82420b4994e96d9..eed9fd6fdb9b076dba499ca5ca619ec496d5154f 100644 +--- a/dist/defaultBlocks-DE5GNdJH.js ++++ b/dist/defaultBlocks-DE5GNdJH.js +@@ -1120,7 +1120,15 @@ const Ut = ({ defaultLanguage: e = "text" }) => ({ + ), i.value = t.props.language || e.defaultLanguage || "text", n.isEditable) { + const l = (u) => { + const d = u.target.value; +- n.updateBlock(t.id, { props: { language: d } }); ++ let p; ++ try { ++ p = n.getBlock(t.id); ++ } catch { ++ return; ++ } ++ if (!p) ++ return; ++ n.updateBlock(p.id, { props: { language: d } }); + }; + i.addEventListener("change", l), s = () => i.removeEventListener("change", l); + } else +@@ -1167,9 +1175,7 @@ const Ut = ({ defaultLanguage: e = "text" }) => ({ + const { block: o, nextBlock: r } = t.getTextCursorPosition(); + if (o.type !== "codeBlock") + return !1; +- const { $from: a } = n.selection, s = a.parentOffset === a.parent.nodeSize - 2, i = a.parent.textContent.endsWith(` +- +-`); ++ const { $from: a } = n.selection, s = a.parentOffset === a.parent.nodeSize - 2, i = a.parent.textContent.endsWith("\n "); + if (s && i) { + if (n.delete(a.pos - 2, a.pos), r) + return t.setTextCursorPosition(r, "start"), !0; +@@ -1801,7 +1807,15 @@ const sn = () => ({ + render(e, t) { + const n = document.createDocumentFragment(), o = document.createElement("input"); + o.type = "checkbox", o.checked = e.props.checked, e.props.checked && o.setAttribute("checked", ""), o.addEventListener("change", () => { +- t.updateBlock(e, { props: { checked: !e.props.checked } }); ++ let r; ++ try { ++ r = t.getBlock(e.id); ++ } catch { ++ return; ++ } ++ if (!r) ++ return; ++ t.updateBlock(r, { props: { checked: !r.props.checked } }); + }); + const r = document.createElement("p"), a = document.createElement("div"); + return a.contentEditable = "false", a.appendChild(o), n.appendChild(a), n.appendChild(r), { +@@ -2621,6 +2635,9 @@ class On { + this.state.referencePos = o.getBoundingClientRect().toJSON(), this.emitUpdate(this.pluginState.triggerCharacter); + } + }); ++ M(this, "hideMenu", (t) => { ++ this.state && (this.state.show = !1, this.emitUpdate(t)); ++ }); + M(this, "closeMenu", () => { + this.editor.transact((t) => t.setMeta(B, null)); + }); +@@ -2634,7 +2651,7 @@ class On { + this.editor = t, this.pluginState = void 0, this.emitUpdate = (a) => { + var s; + if (!this.state) +- throw new Error("Attempting to update uninitialized suggestions menu"); ++ return; + n(a, { + ...this.state, + ignoreQueryLength: (s = this.pluginState) == null ? void 0 : s.ignoreQueryLength +@@ -2649,17 +2666,21 @@ class On { + if (!a && !(o !== void 0 && r !== void 0) && !s) + return; + if (this.pluginState = s ? o : r, s || !this.editor.isEditable) { +- this.state && (this.state.show = !1), this.emitUpdate(this.pluginState.triggerCharacter); ++ this.hideMenu(this.pluginState.triggerCharacter); + return; + } + const c = (l = this.rootEl) == null ? void 0 : l.querySelector( + `[data-decoration-id="${this.pluginState.decorationId}"]` + ); +- this.editor.isEditable && c && (this.state = { ++ if (!c) { ++ this.hideMenu(this.pluginState.triggerCharacter); ++ return; ++ } ++ this.state = { + show: !0, + referencePos: c.getBoundingClientRect().toJSON(), + query: this.pluginState.query +- }, this.emitUpdate(this.pluginState.triggerCharacter)); ++ }, this.emitUpdate(this.pluginState.triggerCharacter); + } + destroy() { + var t; +@@ -2761,7 +2782,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => { + if (a === s) { + const c = r.state.doc; + for (const l of t) { +- const u = l.length > 1 ? c.textBetween(a - l.length, a) + i : i; ++ const u = l.length > 1 ? c.textBetween(a - (l.length - 1), a) + i : i; + if (l === u) + return r.dispatch(r.state.tr.insertText(i)), r.dispatch( + r.state.tr.setMeta(B, { +diff --git a/dist/defaultBlocks-DosClM5E.cjs b/dist/defaultBlocks-DosClM5E.cjs +index ea3664446059a2515aa149d10b1e1ee924388300..b19291aacfede6dcedbf26bbe51a5cd35e65d07b 100644 +--- a/dist/defaultBlocks-DosClM5E.cjs ++++ b/dist/defaultBlocks-DosClM5E.cjs +@@ -1,6 +1,4 @@ + "use strict";var Nt=Object.defineProperty;var It=(e,t,n)=>t in e?Nt(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var P=(e,t,n)=>It(e,typeof t!="symbol"?t+"":t,n);const T=require("prosemirror-tables"),S=require("@tiptap/core"),z=require("prosemirror-state"),$=require("prosemirror-view"),ye=require("prosemirror-transform"),j=require("y-prosemirror"),y=require("./BlockNoteExtension-BWw0r8Gy.cjs"),m=require("./blockToNode-CumVjgem.cjs"),Ht=require("@tiptap/extension-bold"),Dt=require("@tiptap/extension-code"),_t=require("@tiptap/extension-italic"),Ot=require("@tiptap/extension-strike"),Vt=require("@tiptap/extension-underline"),R=require("@tiptap/pm/model"),Ft=require("prosemirror-highlight"),Rt=require("prosemirror-highlight/shiki"),N=require("prosemirror-model"),fe=require("@tiptap/pm/state"),ee=require("@tiptap/pm/view"),q=e=>e&&typeof e=="object"&&"default"in e?e:{default:e},Wt=q(Ht),qt=q(Dt),Ut=q(_t),$t=q(Ot),jt=q(Vt),ve=()=>typeof navigator<"u"&&(/Mac/.test(navigator.platform)||/AppleWebKit/.test(navigator.userAgent)&&/Mobile\/\w+/.test(navigator.userAgent));function B(e,t="Ctrl"){return ve()?e.replace("Mod","⌘"):e.replace("Mod",t)}function V(...e){return[...new Set(e.filter(t=>t).join(" ").split(" "))].join(" ")}const Gt=()=>/^((?!chrome|android).)*safari/i.test(navigator.userAgent);function Se(e,t,n,o){const r=document.createElement("div");r.className=V("bn-block-content",n.class),r.setAttribute("data-content-type",e);for(const[s,i]of Object.entries(n))s!=="class"&&r.setAttribute(s,i);const a=document.createElement(t);a.className=V("bn-inline-content",o.class);for(const[s,i]of Object.entries(o))s!=="class"&&a.setAttribute(s,i);return r.appendChild(a),{dom:r,contentDOM:a}}const te=(e,t)=>{let n=m.blockToNode(e,t.pmSchema);n.type.name==="blockContainer"&&(n=n.firstChild);const o=t.pmSchema.nodes[n.type.name].spec.toDOM;if(o===void 0)throw new Error("This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.");const r=o(n);if(typeof r!="object"||!("dom"in r))throw new Error("Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property.");return r};function Ee(e,t="
"){const n=e.querySelectorAll("p");if(n.length>1){const o=n[0];for(let r=1;r{t[n]={default:o.default,keepOnSplit:!0,parseHTML:r=>{const a=r.getAttribute(W(n));if(a===null)return null;if(o.default===void 0&&o.type==="boolean"||o.default!==void 0&&typeof o.default=="boolean")return a==="true"?!0:a==="false"?!1:null;if(o.default===void 0&&o.type==="number"||o.default!==void 0&&typeof o.default=="number"){const s=parseFloat(a);return!Number.isNaN(s)&&Number.isFinite(s)?s:null}return a},renderHTML:r=>r[n]!==o.default?{[W(n)]:r[n]}:{}}}),t}function Me(e,t,n,o){const r=e();if(r===void 0)throw new Error("Cannot find node position");const s=n.state.doc.resolve(r).node().attrs.id;if(!s)throw new Error("Block doesn't have id");const i=t.getBlock(s);if(i.type!==o)throw new Error("Block type does not match");return i}function Z(e,t,n,o,r=!1,a){const s=document.createElement("div");if(a!==void 0)for(const[i,c]of Object.entries(a))i!=="class"&&s.setAttribute(i,c);s.className=V("bn-block-content",(a==null?void 0:a.class)||""),s.setAttribute("data-content-type",t);for(const[i,c]of Object.entries(n)){const u=o[i].default;c!==u&&s.setAttribute(W(i),c)}return r&&s.setAttribute("data-file-block",""),s.appendChild(e.dom),e.contentDOM&&(e.contentDOM.className=V("bn-inline-content",e.contentDOM.className)),{...e,dom:s}}function Le(e,t,n){return{config:{type:e.type,content:e.content,propSchema:t},implementation:{node:e.node,render:te,toExternalHTML:te},extensions:n}}function we(e,t){e.stopEvent=n=>(n.type==="mousedown"&&setTimeout(()=>{t.view.dom.blur()},10),!0)}function Be(e,t){const n=[{tag:"[data-content-type="+e.type+"]",contentElement:".bn-inline-content"}];return t.parse&&n.push({tag:"*",getAttrs(o){var a;if(typeof o=="string")return!1;const r=(a=t.parse)==null?void 0:a.call(t,o);return r===void 0?!1:r},preserveWhitespace:!0,getContent:e.content==="inline"||e.content==="none"?(o,r)=>{var a;if(t.parseContent)return t.parseContent({el:o,schema:r});if(e.content==="inline"){const i=o.cloneNode(!0);return Ee(i,(a=t.meta)!=null&&a.code?` +-`:"
"),R.DOMParser.fromSchema(r).parse(i,{topNode:r.nodes.paragraph.create(),preserveWhitespace:!0}).content}return R.Fragment.empty}:void 0}),n}function Kt(e,t,n,o){var a,s,i,c;const r=t.node||S.Node.create({name:e.type,content:e.content==="inline"?"inline*":e.content==="none"?"":e.content,group:"blockContent",selectable:((a=t.meta)==null?void 0:a.selectable)??!0,isolating:((s=t.meta)==null?void 0:s.isolating)??!0,code:((i=t.meta)==null?void 0:i.code)??!1,defining:((c=t.meta)==null?void 0:c.defining)??!0,priority:o,addAttributes(){return xe(e.propSchema)},parseHTML(){return Be(e,t)},renderHTML({HTMLAttributes:l}){var d;const u=document.createElement("div");return Z({dom:u,contentDOM:e.content==="inline"?u:void 0},e.type,{},e.propSchema,((d=t.meta)==null?void 0:d.fileBlockAccept)!==void 0,l)},addNodeView(){return l=>{var f,x;const u=this.options.editor,d=Me(l.getPos,u,this.editor,e.type),p=((f=this.options.domAttributes)==null?void 0:f.blockContent)||{},h=t.render.call({blockContentDOMAttributes:p,props:l,renderType:"nodeView"},d,u);return((x=t.meta)==null?void 0:x.selectable)===!1&&we(h,this.editor),h}}});if(r.name!==e.type)throw new Error("Node name does not match block type. This is a bug in BlockNote.");return{config:e,implementation:{...t,node:r,render(l,u){var p;const d=((p=r.options.domAttributes)==null?void 0:p.blockContent)||{};return t.render.call({blockContentDOMAttributes:d,props:void 0,renderType:"dom"},l,u)},toExternalHTML:(l,u,d)=>{var h,f;const p=((h=r.options.domAttributes)==null?void 0:h.blockContent)||{};return((f=t.toExternalHTML)==null?void 0:f.call({blockContentDOMAttributes:p},l,u,d))??t.render.call({blockContentDOMAttributes:p,renderType:"dom",props:void 0},l,u)}},extensions:n}}function Xt(e){return e}function E(e,t,n){return(o={})=>{const r=typeof e=="function"?e(o):e,a=typeof t=="function"?t(o):t,s=n?typeof n=="function"?n(o):n:void 0;return{config:r,implementation:{...a,toExternalHTML(i,c,l){var d,p;const u=(d=a.toExternalHTML)==null?void 0:d.call({blockContentDOMAttributes:this.blockContentDOMAttributes},i,c,l);if(u!==void 0)return Z(u,i.type,i.props,r.propSchema,((p=a.meta)==null?void 0:p.fileBlockAccept)!==void 0)},render(i,c){var d;const l=a.render.call({blockContentDOMAttributes:this.blockContentDOMAttributes,renderType:this.renderType,props:this.props},i,c);return Z(l,i.type,i.props,r.propSchema,((d=a.meta)==null?void 0:d.fileBlockAccept)!==void 0,this.blockContentDOMAttributes)}},extensions:s}}}function Qt(e,t,n,o){return e.dom.setAttribute("data-inline-content-type",t),Object.entries(n).filter(([r,a])=>{const s=o[r];return a!==s.default}).map(([r,a])=>[W(r),a]).forEach(([r,a])=>e.dom.setAttribute(r,a)),e.contentDOM&&e.contentDOM.setAttribute("data-editable",""),e}function Yt(e){return{Backspace:({editor:t})=>{const n=t.state.selection.$from;return t.state.selection.empty&&n.node().type.name===e.type&&n.parentOffset===0}}}function Te(e,t){return{config:e,implementation:t}}function Jt(e,t,n){return Te({type:e.name,propSchema:t,content:e.config.content==="inline*"?"styled":"none"},{...n,node:e})}function Ae(e){return Object.fromEntries(Object.entries(e).map(([t,n])=>[t,n.config]))}function Pe(e){return e==="boolean"?{}:{stringValue:{default:void 0,keepOnSplit:!0,parseHTML:t=>t.getAttribute("data-value"),renderHTML:t=>t.stringValue!==void 0?{"data-value":t.stringValue}:{}}}}function F(e,t,n,o){return e.dom.setAttribute("data-style-type",t),o==="string"&&e.dom.setAttribute("data-value",n),e.contentDOM&&e.contentDOM.setAttribute("data-editable",""),e}function oe(e,t){return{config:e,implementation:t}}function _(e,t){return oe({type:e.name,propSchema:t},{mark:e,render(n,o){const r=o.pmSchema.marks[e.name].spec.toDOM;if(r===void 0)throw new Error("This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.");const a=o.pmSchema.mark(e.name,{stringValue:n}),s=R.DOMSerializer.renderSpec(document,r(a,!0));if(typeof s!="object"||!("dom"in s))throw new Error("Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.");return s},toExternalHTML(n,o){const r=o.pmSchema.marks[e.name].spec.toDOM;if(r===void 0)throw new Error("This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.");const a=o.pmSchema.mark(e.name,{stringValue:n}),s=R.DOMSerializer.renderSpec(document,r(a,!0));if(typeof s!="object"||!("dom"in s))throw new Error("Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.");return s}})}function Ne(e){return Object.fromEntries(Object.entries(e).map(([t,n])=>[t,n.config]))}function Ie(e,t){const n=[{tag:`[data-style-type="${e.type}"]`,contentElement:o=>{const r=o;return r.matches("[data-editable]")?r:r.querySelector("[data-editable]")||r}}];return t&&n.push({tag:"*",consuming:!1,getAttrs(o){if(typeof o=="string")return!1;const r=t==null?void 0:t(o);return r===void 0?!1:{stringValue:r}}}),n}function re(e,t){const n=S.Mark.create({name:e.type,addAttributes(){return Pe(e.propSchema)},parseHTML(){return Ie(e,t.parse)},renderHTML({mark:o}){const r=(t.toExternalHTML||t.render)(o.attrs.stringValue);return F(r,e.type,o.attrs.stringValue,e.propSchema)},addMarkView(){return({mark:o})=>{const r=t.render(o.attrs.stringValue);return F(r,e.type,o.attrs.stringValue,e.propSchema)}}});return oe(e,{...t,mark:n,render:o=>{const r=t.render(o);return F(r,e.type,o,e.propSchema)},toExternalHTML:o=>{const r=(t.toExternalHTML||t.render)(o);return F(r,e.type,o,e.propSchema)}})}function He(e,t){let n,o;if(t.firstChild.descendants((r,a)=>n?!1:!De(r)||r.attrs.id!==e?!0:(n=r,o=a+1,!1)),!(n===void 0||o===void 0))return{node:n,posBeforeNode:o}}function De(e){return e.type.isInGroup("bnBlock")}const en=(e,t)=>({tr:n,dispatch:o})=>(o&&K(n,e,t),!0);function K(e,t,n,o,r){const a=m.getBlockInfoFromResolvedPos(e.doc.resolve(t));let s=null;a.blockNoteType==="table"&&(s=_e(e));const i=m.getPmSchema(e);if(o!==void 0&&r!==void 0&&o>r)throw new Error("Invalid replaceFromPos or replaceToPos");const c=i.nodes[a.blockNoteType],l=i.nodes[n.type||a.blockNoteType],u=l.isInGroup("bnBlock")?l:i.nodes.blockContainer;if(a.isBlockContainer&&l.isInGroup("blockContent")){const d=o!==void 0&&o>a.blockContent.beforePos&&oa.blockContent.beforePos&&r0){const r=e.children.map(a=>m.blockToNode(a,o));if(n.childContainer)t.step(new ye.ReplaceStep(n.childContainer.beforePos+1,n.childContainer.afterPos-1,new N.Slice(N.Fragment.from(r),0,0)));else{if(!n.isBlockContainer)throw new Error("impossible");t.insert(n.blockContent.afterPos,o.nodes.blockGroup.createChecked({},r))}}}function nn(e,t,n,o,r){const a=typeof t=="string"?t:t.id,s=He(a,e.doc);if(!s)throw new Error(`Block with ID ${a} not found`);K(e,s.posBeforeNode,n,o,r);const i=e.doc.resolve(s.posBeforeNode+1).node(),c=m.getPmSchema(e);return m.nodeToBlock(i,c)}function _e(e){const t="selection"in e?e.selection:null;if(!(t instanceof z.TextSelection))return null;const n=e.doc.resolve(t.head);let o=-1,r=-1;for(let L=n.depth;L>=0;L--){const M=n.node(L).type.name;if(o<0&&(M==="tableCell"||M==="tableHeader")&&(o=L),M==="table"){r=L;break}}if(o<0||r<0)return null;const a=n.before(o),s=n.before(r),i=e.doc.nodeAt(s);if(!i||i.type.name!=="table")return null;const c=T.TableMap.get(i),l=a-(s+1),u=c.map.indexOf(l);if(u<0)return null;const d=Math.floor(u/c.width),p=u%c.width,f=a+1+1,x=Math.max(0,t.head-f);return{row:d,col:p,offset:x}}function on(e,t,n){var L;if(t.blockNoteType!=="table")return!1;let o=-1;if(t.isBlockContainer)o=e.mapping.map(t.blockContent.beforePos);else{const M=e.mapping.map(t.bnBlock.beforePos),D=M+(((L=e.doc.nodeAt(M))==null?void 0:L.nodeSize)||0);e.doc.nodesBetween(M,D,(b,w)=>b.type.name==="table"?(o=w,!1):!0)}const r=o>=0?e.doc.nodeAt(o):null;if(!r||r.type.name!=="table")return!1;const a=T.TableMap.get(r),s=Math.max(0,Math.min(n.row,a.height-1)),i=Math.max(0,Math.min(n.col,a.width-1)),c=s*a.width+i,l=a.map[c];if(l==null)return!1;const d=o+1+l+1,p=e.doc.nodeAt(d),h=d+1,f=p?p.content.size:0,x=h+Math.max(0,Math.min(n.offset,f));return"selection"in e&&e.setSelection(z.TextSelection.create(e.doc,x)),!0}const A={gray:{text:"#9b9a97",background:"#ebeced"},brown:{text:"#64473a",background:"#e9e5e3"},red:{text:"#e03e3e",background:"#fbe4e4"},orange:{text:"#d9730d",background:"#f6e9d9"},yellow:{text:"#dfab01",background:"#fbf3db"},green:{text:"#4d6461",background:"#ddedea"},blue:{text:"#0b6e99",background:"#ddebf1"},purple:{text:"#6940a5",background:"#eae4f2"},pink:{text:"#ad1a72",background:"#f4dfeb"}},rn={gray:{text:"#bebdb8",background:"#9b9a97"},brown:{text:"#8e6552",background:"#64473a"},red:{text:"#ec4040",background:"#be3434"},orange:{text:"#e3790d",background:"#b7600a"},yellow:{text:"#dfab01",background:"#b58b00"},green:{text:"#6b8b87",background:"#4d6461"},blue:{text:"#0e87bc",background:"#0b6e99"},purple:{text:"#8552d7",background:"#6940a5"},pink:{text:"#da208f",background:"#ad1a72"}},g={backgroundColor:{default:"default"},textColor:{default:"default"},textAlignment:{default:"left",values:["left","center","right","justify"]}},v=e=>{const t={};return e.hasAttribute("data-background-color")?t.backgroundColor=e.getAttribute("data-background-color"):e.style.backgroundColor&&(t.backgroundColor=e.style.backgroundColor),e.hasAttribute("data-text-color")?t.textColor=e.getAttribute("data-text-color"):e.style.color&&(t.textColor=e.style.color),t.textAlignment=g.textAlignment.values.includes(e.style.textAlign)?e.style.textAlign:void 0,t},I=(e,t)=>{e.backgroundColor&&e.backgroundColor!==g.backgroundColor.default&&(t.style.backgroundColor=e.backgroundColor in A?A[e.backgroundColor].background:e.backgroundColor),e.textColor&&e.textColor!==g.textColor.default&&(t.style.color=e.textColor in A?A[e.textColor].text:e.textColor),e.textAlignment&&e.textAlignment!==g.textAlignment.default&&(t.style.textAlign=e.textAlignment)},an=(e="backgroundColor")=>({default:g.backgroundColor.default,parseHTML:t=>t.hasAttribute("data-background-color")?t.getAttribute("data-background-color"):t.style.backgroundColor?t.style.backgroundColor:g.backgroundColor.default,renderHTML:t=>t[e]===g.backgroundColor.default?{}:{"data-background-color":t[e]}}),sn=(e="textColor")=>({default:g.textColor.default,parseHTML:t=>t.hasAttribute("data-text-color")?t.getAttribute("data-text-color"):t.style.color?t.style.color:g.textColor.default,renderHTML:t=>t[e]===g.textColor.default?{}:{"data-text-color":t[e]}}),cn=(e="textAlignment")=>({default:g.textAlignment.default,parseHTML:t=>t.hasAttribute("data-text-alignment")?t.getAttribute("data-text-alignment"):t.style.textAlign?t.style.textAlign:g.textAlignment.default,renderHTML:t=>t[e]===g.textAlignment.default?{}:{"data-text-alignment":t[e]}}),X=(e,t)=>{const n=e.querySelector(t);if(!n)return;const o=e.querySelector("figcaption"),r=(o==null?void 0:o.textContent)??void 0;return{targetElement:n,caption:r}},O=y.createExtension(({editor:e})=>{const t=y.createStore(void 0);function n(){t.setState(void 0)}return{key:"filePanel",store:t,mount({signal:o}){const r=e.onChange(n,!1),a=e.onSelectionChange(n,!1);o.addEventListener("abort",()=>{r(),a()})},closeMenu:n,showMenu(o){t.setState(o)}}}),ln=(e,t,n)=>{const o=document.createElement("div");o.className="bn-add-file-button";const r=document.createElement("div");r.className="bn-add-file-button-icon",n?r.appendChild(n):r.innerHTML='',o.appendChild(r);const a=document.createElement("p");a.className="bn-add-file-button-text",a.innerHTML=e.type in t.dictionary.file_blocks.add_button_text?t.dictionary.file_blocks.add_button_text[e.type]:t.dictionary.file_blocks.add_button_text.file,o.appendChild(a);const s=c=>{c.preventDefault(),c.stopPropagation()},i=()=>{var c;t.isEditable&&((c=t.getExtension(O))==null||c.showMenu(e.id))};return o.addEventListener("mousedown",s,!0),o.addEventListener("click",i,!0),{dom:o,destroy:()=>{o.removeEventListener("mousedown",s,!0),o.removeEventListener("click",i,!0)}}},dn='',un=e=>{const t=document.createElement("div");t.className="bn-file-name-with-icon";const n=document.createElement("div");n.className="bn-file-icon",n.innerHTML=dn,t.appendChild(n);const o=document.createElement("p");return o.className="bn-file-name",o.textContent=e.props.name,t.appendChild(o),{dom:t}},ae=(e,t,n,o)=>{const r=document.createElement("div");if(r.className="bn-file-block-content-wrapper",e.props.url===""){const s=ln(e,t,o);r.appendChild(s.dom);const i=t.onUploadStart(c=>{if(c===e.id){r.removeChild(s.dom);const l=document.createElement("div");l.className="bn-file-loading-preview",l.textContent="Loading...",r.appendChild(l)}});return{dom:r,destroy:()=>{i(),s.destroy()}}}const a={dom:r};if(e.props.showPreview===!1||!n){const s=un(e);r.appendChild(s.dom),a.destroy=()=>{var i;(i=s.destroy)==null||i.call(s)}}else r.appendChild(n.dom);if(e.props.caption){const s=document.createElement("p");s.className="bn-file-caption",s.textContent=e.props.caption,r.appendChild(s)}return a},se=(e,t)=>{const n=document.createElement("figure"),o=document.createElement("figcaption");return o.textContent=t,n.appendChild(e),n.appendChild(o),{dom:n}},Q=(e,t)=>{const n=document.createElement("div"),o=document.createElement("p");return o.textContent=t,n.appendChild(e),n.appendChild(o),{dom:n}},ne=e=>({url:e.src||void 0}),Oe='',Ve=e=>({type:"audio",propSchema:{backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0}},content:"none"}),Fe=(e={})=>t=>{if(t.tagName==="AUDIO"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...ne(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"audio");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...ne(o),backgroundColor:a,caption:r}}},Re=(e={})=>(t,n)=>{const o=document.createElement("div");o.innerHTML=e.icon??Oe;const r=document.createElement("audio");return r.className="bn-audio",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(a=>{r.src=a}):r.src=t.props.url,r.controls=!0,r.contentEditable="false",r.draggable=!1,ae(t,n,{dom:r},o.firstElementChild)},We=(e={})=>(t,n)=>{if(!t.props.url){const r=document.createElement("p");return r.textContent="Add audio",{dom:r}}let o;return t.props.showPreview?(o=document.createElement("audio"),o.src=t.props.url):(o=document.createElement("a"),o.href=t.props.url,o.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(o,t.props.caption):Q(o,t.props.caption):{dom:o}},qe=E(Ve,e=>({meta:{fileBlockAccept:["audio/*"]},parse:Fe(e),render:Re(e),toExternalHTML:We(e),runsBefore:["file"]})),he=Symbol.for("blocknote.shikiParser"),J=Symbol.for("blocknote.shikiHighlighterPromise");function pn(e){const t=globalThis;let n,o,r=!1;const a=s=>{if(!e.createHighlighter)return process.env.NODE_ENV==="development"&&!r&&(console.log("For syntax highlighting of code blocks, you must provide a `createCodeBlockSpec({ createHighlighter: () => ... })` function"),r=!0),[];if(!n)return t[J]=t[J]||e.createHighlighter(),t[J].then(c=>{n=c});const i=ie(e,s.language);return!i||i==="text"||i==="none"||i==="plaintext"||i==="txt"?[]:n.getLoadedLanguages().includes(i)?(o||(o=t[he]||Rt.createParser(n),t[he]=o),o(s)):n.loadLanguage(i)};return Ft.createHighlightPlugin({parser:a,languageExtractor:s=>s.attrs.language,nodeTypes:["codeBlock"]})}const Ue=({defaultLanguage:e="text"})=>({type:"codeBlock",propSchema:{language:{default:e}},content:"inline"}),$e=E(Ue,e=>({meta:{code:!0,defining:!0,isolating:!1},parse:t=>{var r,a;if(t.tagName!=="PRE"||t.childElementCount!==1||((r=t.firstElementChild)==null?void 0:r.tagName)!=="CODE")return;const n=t.firstElementChild;return{language:n.getAttribute("data-language")||((a=n.className.split(" ").find(s=>s.includes("language-")))==null?void 0:a.replace("language-",""))}},parseContent:({el:t,schema:n})=>{const o=R.DOMParser.fromSchema(n),r=t.firstElementChild;return o.parse(r,{preserveWhitespace:"full",topNode:n.nodes.codeBlock.create()}).content},render(t,n){const o=document.createDocumentFragment(),r=document.createElement("pre"),a=document.createElement("code");r.appendChild(a);let s;if(e.supportedLanguages){const i=document.createElement("select");if(Object.entries(e.supportedLanguages??{}).forEach(([l,{name:u}])=>{const d=document.createElement("option");d.value=l,d.text=u,i.appendChild(d)}),i.value=t.props.language||e.defaultLanguage||"text",n.isEditable){const l=u=>{const d=u.target.value;n.updateBlock(t.id,{props:{language:d}})};i.addEventListener("change",l),s=()=>i.removeEventListener("change",l)}else i.disabled=!0;const c=document.createElement("div");c.contentEditable="false",c.appendChild(i),o.appendChild(c)}return o.appendChild(r),{dom:o,contentDOM:a,destroy:()=>{s==null||s()}}},toExternalHTML(t){const n=document.createElement("pre"),o=document.createElement("code");return o.className=`language-${t.props.language}`,o.dataset.language=t.props.language,n.appendChild(o),{dom:n,contentDOM:o}}}),e=>[y.createExtension({key:"code-block-highlighter",prosemirrorPlugins:[pn(e)]}),y.createExtension({key:"code-block-keyboard-shortcuts",keyboardShortcuts:{Delete:({editor:t})=>t.transact(n=>{const{block:o}=t.getTextCursorPosition();if(o.type!=="codeBlock")return!1;const{$from:r}=n.selection;return r.parent.textContent?!1:(t.removeBlocks([o]),!0)}),Tab:({editor:t})=>e.indentLineWithTab===!1?!1:t.transact(n=>{const{block:o}=t.getTextCursorPosition();return o.type==="codeBlock"?(n.insertText(" "),!0):!1}),Enter:({editor:t})=>t.transact(n=>{const{block:o,nextBlock:r}=t.getTextCursorPosition();if(o.type!=="codeBlock")return!1;const{$from:a}=n.selection,s=a.parentOffset===a.parent.nodeSize-2,i=a.parent.textContent.endsWith(` +- +-`);if(s&&i){if(n.delete(a.pos-2,a.pos),r)return t.setTextCursorPosition(r,"start"),!0;const[c]=t.insertBlocks([{type:"paragraph"}],o,"after");return t.setTextCursorPosition(c,"start"),!0}return n.insertText(` +-`),!0}),"Shift-Enter":({editor:t})=>t.transact(()=>{const{block:n}=t.getTextCursorPosition();if(n.type!=="codeBlock")return!1;const[o]=t.insertBlocks([{type:"paragraph"}],n,"after");return t.setTextCursorPosition(o,"start"),!0})},inputRules:[{find:/^```(.*?)\s$/,replace:({match:t})=>{const n=t[1].trim();return{type:"codeBlock",props:{language:{language:ie(e,n)??n}.language},content:[]}}}]})]);function ie(e,t){var n;return(n=Object.entries(e.supportedLanguages??{}).find(([o,{aliases:r}])=>(r==null?void 0:r.includes(t))||o===t))==null?void 0:n[0]}const je=()=>({type:"divider",propSchema:{},content:"none"}),Ge=E(je,{meta:{isolating:!1},parse(e){if(e.tagName==="HR")return{}},render(){return{dom:document.createElement("hr")}}},[y.createExtension({key:"divider-block-shortcuts",inputRules:[{find:new RegExp("^---$"),replace(){return{type:"divider",props:{},content:[]}}}]})]),me=e=>({url:e.src||void 0}),Ze=()=>({type:"file",propSchema:{backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""}},content:"none"}),ze=()=>e=>{if(e.tagName==="EMBED"){if(e.closest("figure"))return;const{backgroundColor:t}=v(e);return{...me(e),backgroundColor:t}}if(e.tagName==="FIGURE"){const t=X(e,"embed");if(!t)return;const{targetElement:n,caption:o}=t,{backgroundColor:r}=v(e);return{...me(n),backgroundColor:r,caption:o}}},Ke=E(Ze,{meta:{fileBlockAccept:["*/*"]},parse:ze(),render(e,t){return ae(e,t)},toExternalHTML(e){if(!e.props.url){const n=document.createElement("p");return n.textContent="Add file",{dom:n}}const t=document.createElement("a");return t.href=e.props.url,t.textContent=e.props.name||e.props.url,e.props.caption?Q(t,e.props.caption):{dom:t}}}),Xe={set:(e,t)=>window.localStorage.setItem(`toggle-${e.id}`,t?"true":"false"),get:e=>window.localStorage.getItem(`toggle-${e.id}`)==="true"},ce=(e,t,n,o=Xe)=>{if("isToggleable"in e.props&&!e.props.isToggleable)return{dom:n};const r=document.createElement("div"),a=document.createElement("div");a.className="bn-toggle-wrapper";const s=document.createElement("button");s.className="bn-toggle-button",s.type="button",s.innerHTML='';const i=f=>f.preventDefault();s.addEventListener("mousedown",i);const c=()=>{var f;a.getAttribute("data-show-children")==="true"?(a.setAttribute("data-show-children","false"),o.set(t.getBlock(e),!1),r.contains(l)&&r.removeChild(l)):(a.setAttribute("data-show-children","true"),o.set(t.getBlock(e),!0),t.isEditable&&((f=t.getBlock(e))==null?void 0:f.children.length)===0&&!r.contains(l)&&r.appendChild(l))};s.addEventListener("click",c),a.appendChild(s),a.appendChild(n);const l=document.createElement("button");l.className="bn-toggle-add-block-button",l.type="button",l.textContent=t.dictionary.toggle_blocks.add_block_button;const u=f=>f.preventDefault();l.addEventListener("mousedown",u);const d=()=>{t.transact(()=>{const f=t.updateBlock(e,{children:[{}]});t.setTextCursorPosition(f.children[0].id,"end"),t.focus()})};l.addEventListener("click",d),r.appendChild(a);let p=e.children.length;const h=t.onChange(()=>{var x;const f=((x=t.getBlock(e))==null?void 0:x.children.length)??0;f>p?(a.getAttribute("data-show-children")==="false"&&(a.setAttribute("data-show-children","true"),o.set(t.getBlock(e),!0)),r.contains(l)&&r.removeChild(l)):f===0&&ff instanceof MutationRecord&&(f.type==="attributes"&&f.target===a&&f.attributeName==="data-show-children"||f.type==="childList"&&(f.addedNodes[0]===l||f.removedNodes[0]===l)),destroy:()=>{s.removeEventListener("mousedown",i),s.removeEventListener("click",c),l.removeEventListener("mousedown",u),l.removeEventListener("click",d),h==null||h()}}},Qe=[1,2,3,4,5,6],fn=e=>({editor:t})=>{const n=t.getTextCursorPosition();return t.schema.blockSchema[n.block.type].content!=="inline"?!1:(t.updateBlock(n.block,{type:"heading",props:{level:e}}),!0)},Ye=({defaultLevel:e=1,levels:t=Qe,allowToggleHeadings:n=!0}={})=>({type:"heading",propSchema:{...g,level:{default:e,values:t},...n?{isToggleable:{default:!1,optional:!0}}:{}},content:"inline"}),Je=E(Ye,({allowToggleHeadings:e=!0}={})=>({meta:{isolating:!1},parse(t){let n;switch(t.tagName){case"H1":n=1;break;case"H2":n=2;break;case"H3":n=3;break;case"H4":n=4;break;case"H5":n=5;break;case"H6":n=6;break;default:return}return{...v(t),level:n}},render(t,n){const o=document.createElement(`h${t.props.level}`);return e?{...ce(t,n,o),contentDOM:o}:{dom:o,contentDOM:o}},toExternalHTML(t){const n=document.createElement(`h${t.props.level}`);return I(t.props,n),{dom:n,contentDOM:n}}}),({levels:e=Qe}={})=>[y.createExtension({key:"heading-shortcuts",keyboardShortcuts:Object.fromEntries(e.map(t=>[`Mod-Alt-${t}`,fn(t)])??[]),inputRules:e.map(t=>({find:new RegExp(`^(#{${t}})\\s$`),replace({match:n}){return{type:"heading",props:{level:n[1].length}}}}))})]),et=(e,t,n,o,r)=>{const{dom:a,destroy:s}=ae(e,t,n,r),i=a;i.style.position="relative",e.props.url&&e.props.showPreview&&(e.props.previewWidth?i.style.width=`${e.props.previewWidth}px`:i.style.width="fit-content");const c=document.createElement("div");c.className="bn-resize-handle",c.style.left="4px";const l=document.createElement("div");l.className="bn-resize-handle",l.style.right="4px";const u=document.createElement("div");u.style.position="absolute",u.style.height="100%",u.style.width="100%";let d,p=e.props.previewWidth;const h=b=>{var ue,pe;if(!d){!t.isEditable&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l));return}let w;const U="touches"in b?b.touches[0].clientX:b.clientX;e.props.textAlignment==="center"?d.handleUsed==="left"?w=d.initialWidth+(d.initialClientX-U)*2:w=d.initialWidth+(U-d.initialClientX)*2:d.handleUsed==="left"?w=d.initialWidth+d.initialClientX-U:w=d.initialWidth+U-d.initialClientX,p=Math.min(Math.max(w,64),((pe=(ue=t.domElement)==null?void 0:ue.firstElementChild)==null?void 0:pe.clientWidth)||Number.MAX_VALUE),i.style.width=`${p}px`},f=b=>{(!b.target||!i.contains(b.target)||!t.isEditable)&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l)),d&&(d=void 0,i.contains(u)&&i.removeChild(u),t.updateBlock(e,{props:{previewWidth:p}}))},x=()=>{t.isEditable&&(o.appendChild(c),o.appendChild(l))},L=b=>{b.relatedTarget===c||b.relatedTarget===l||d||t.isEditable&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l))},M=b=>{b.preventDefault(),i.contains(u)||i.appendChild(u);const w="touches"in b?b.touches[0].clientX:b.clientX;d={handleUsed:"left",initialWidth:i.clientWidth,initialClientX:w}},D=b=>{b.preventDefault(),i.contains(u)||i.appendChild(u);const w="touches"in b?b.touches[0].clientX:b.clientX;d={handleUsed:"right",initialWidth:i.clientWidth,initialClientX:w}};return window.addEventListener("mousemove",h),window.addEventListener("touchmove",h),window.addEventListener("mouseup",f),window.addEventListener("touchend",f),i.addEventListener("mouseenter",x),i.addEventListener("mouseleave",L),c.addEventListener("mousedown",M),c.addEventListener("touchstart",M),l.addEventListener("mousedown",D),l.addEventListener("touchstart",D),{dom:i,destroy:()=>{s==null||s(),window.removeEventListener("mousemove",h),window.removeEventListener("touchmove",h),window.removeEventListener("mouseup",f),window.removeEventListener("touchend",f),i.removeEventListener("mouseenter",x),i.removeEventListener("mouseleave",L),c.removeEventListener("mousedown",M),c.removeEventListener("touchstart",M),l.removeEventListener("mousedown",D),l.removeEventListener("touchstart",D)}}},be=e=>{const t=e.src||void 0,n=e.width||void 0,o=e.alt||void 0;return{url:t,previewWidth:n,name:o}},tt='',nt=(e={})=>({type:"image",propSchema:{textAlignment:g.textAlignment,backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0},previewWidth:{default:void 0,type:"number"}},content:"none"}),ot=(e={})=>t=>{if(t.tagName==="IMG"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...be(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"img");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...be(o),backgroundColor:a,caption:r}}},rt=(e={})=>(t,n)=>{const o=document.createElement("div");o.innerHTML=e.icon??tt;const r=document.createElement("div");r.className="bn-visual-media-wrapper";const a=document.createElement("img");return a.className="bn-visual-media",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(s=>{a.src=s}):a.src=t.props.url,a.alt=t.props.name||t.props.caption||"BlockNote image",a.contentEditable="false",a.draggable=!1,r.appendChild(a),et(t,n,{dom:r},r,o.firstElementChild)},at=(e={})=>(t,n)=>{if(!t.props.url){const r=document.createElement("p");return r.textContent="Add image",{dom:r}}let o;return t.props.showPreview?(o=document.createElement("img"),o.src=t.props.url,o.alt=t.props.name||t.props.caption||"BlockNote image",t.props.previewWidth&&(o.width=t.props.previewWidth)):(o=document.createElement("a"),o.href=t.props.url,o.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(o,t.props.caption):Q(o,t.props.caption):{dom:o}},st=E(nt,e=>({meta:{fileBlockAccept:["image/*"]},parse:ot(e),render:rt(e),toExternalHTML:at(e),runsBefore:["file"]})),gn=(e,t,n)=>({state:o,dispatch:r})=>r?it(o.tr,e,t,n):!0,it=(e,t,n,o)=>{const r=m.getNearestBlockPos(e.doc,t),a=m.getBlockInfo(r);if(!a.isBlockContainer)return!1;const s=m.getPmSchema(e),i=[{type:a.bnBlock.node.type,attrs:o?{...a.bnBlock.node.attrs,id:void 0}:{}},{type:n?a.blockContent.node.type:s.nodes.paragraph,attrs:o?{...a.blockContent.node.attrs}:{}}];return e.split(t,2,i),!0},Y=(e,t)=>{const{blockInfo:n,selectionEmpty:o}=e.transact(s=>({blockInfo:m.getBlockInfoFromTransaction(s),selectionEmpty:s.selection.anchor===s.selection.head}));if(!n.isBlockContainer)return!1;const{bnBlock:r,blockContent:a}=n;return a.node.type.name!==t||!o?!1:a.node.childCount===0?(e.transact(s=>{K(s,r.beforePos,{type:"paragraph",props:{}})}),!0):a.node.childCount>0?e.transact(s=>(s.deleteSelection(),it(s,s.selection.from,!0))):!1};function le(e,t,n){var d,p,h;const o=N.DOMParser.fromSchema(t),r=e,a=document.createElement("div");a.setAttribute("data-node-type","blockGroup");for(const f of Array.from(r.childNodes))a.appendChild(f.cloneNode(!0));let s=o.parse(a,{topNode:t.nodes.blockGroup.create()});((p=(d=s.firstChild)==null?void 0:d.firstChild)==null?void 0:p.type.name)==="checkListItem"&&(s=s.copy(s.content.cut(s.firstChild.firstChild.nodeSize+2)));const i=(h=s.firstChild)==null?void 0:h.firstChild;if(!(i!=null&&i.isTextblock))return N.Fragment.from(s);const c=t.nodes[n].create({},i.content),l=s.content.cut(i.nodeSize+2);if(l.size>0){const f=s.copy(l);return c.content.addToEnd(f)}return c.content}const ct=()=>({type:"bulletListItem",propSchema:{...g},content:"inline"}),lt=E(ct,{meta:{isolating:!1},parse(e){var n;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="UL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="UL"))return v(e)},parseContent:({el:e,schema:t})=>le(e,t,"bulletListItem"),render(){const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"bullet-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"bulletListItem"),"Mod-Shift-8":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"bulletListItem",props:{}}),!0)}},inputRules:[{find:/^\s?[-+*]\s$/,replace({editor:e}){if(m.getBlockInfoFromSelection(e.prosemirrorState).blockNoteType!=="heading")return{type:"bulletListItem",props:{}}}}]})]),dt=()=>({type:"checkListItem",propSchema:{...g,checked:{default:!1,type:"boolean"}},content:"inline"}),ut=E(dt,{meta:{isolating:!1},parse(e){var n;if(e.tagName==="input")return e.closest("[data-content-type]")||e.closest("li")?void 0:e.type==="checkbox"?{checked:e.checked}:void 0;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="UL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="UL")){const o=e.querySelector("input[type=checkbox]")||null;return o===null?void 0:{...v(e),checked:o.checked}}},parseContent:({el:e,schema:t})=>le(e,t,"checkListItem"),render(e,t){const n=document.createDocumentFragment(),o=document.createElement("input");o.type="checkbox",o.checked=e.props.checked,e.props.checked&&o.setAttribute("checked",""),o.addEventListener("change",()=>{t.updateBlock(e,{props:{checked:!e.props.checked}})});const r=document.createElement("p"),a=document.createElement("div");return a.contentEditable="false",a.appendChild(o),n.appendChild(a),n.appendChild(r),{dom:n,contentDOM:r}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("input");n.type="checkbox",n.checked=e.props.checked,e.props.checked&&n.setAttribute("checked","");const o=document.createElement("p");return I(e.props,t),t.appendChild(n),t.appendChild(o),{dom:t,contentDOM:o}},runsBefore:["bulletListItem"]},[y.createExtension({key:"check-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"checkListItem"),"Mod-Shift-9":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"checkListItem",props:{}}),!0)}},inputRules:[{find:/^\s?\[\s*\]\s$/,replace(){return{type:"checkListItem",props:{checked:!1}}}},{find:/^\s?\[[Xx]\]\s$/,replace(){return{type:"checkListItem",props:{checked:!0}}}}]})]);function pt(e,t,n,o){let r=e.firstChild.attrs.start||1,a=!0;const s=!!e.firstChild.attrs.start,i=m.getBlockInfo({posBeforeNode:t,node:e});if(!i.isBlockContainer)throw new Error("impossible");const c=n.doc.resolve(i.bnBlock.beforePos).nodeBefore,l=c?o.get(c):void 0;return l!==void 0?(r=l+1,a=!1):c&&m.getBlockInfo({posBeforeNode:i.bnBlock.beforePos-c.nodeSize,node:c}).blockNoteType==="numberedListItem"&&(r=pt(c,i.bnBlock.beforePos-c.nodeSize,n,o).index+1,a=!1),o.set(e,r),{index:r,isFirst:a,hasStart:s}}function Ce(e,t){const n=new Map,o=t.decorations.map(e.mapping,e.doc),r=[];e.doc.nodesBetween(0,e.doc.nodeSize-2,(s,i)=>{if(s.type.name==="blockContainer"&&s.firstChild.type.name==="numberedListItem"){const{index:c,isFirst:l,hasStart:u}=pt(s,i,e,n);if(o.find(i,i+s.nodeSize,p=>p.index===c&&p.isFirst===l&&p.hasStart===u).length===0){const p=e.doc.nodeAt(i+1);r.push(ee.Decoration.node(i+1,i+1+p.nodeSize,{"data-index":c.toString()}))}}});const a=r.flatMap(s=>o.find(s.from,s.to));return{decorations:o.remove(a).add(e.doc,r)}}const hn=()=>new fe.Plugin({key:new fe.PluginKey("numbered-list-indexing-decorations"),state:{init(e,t){return Ce(t.tr,{decorations:ee.DecorationSet.empty})},apply(e,t){return!e.docChanged&&!e.selectionSet&&t.decorations?t:Ce(e,t)}},props:{decorations(e){var t;return((t=this.getState(e))==null?void 0:t.decorations)??ee.DecorationSet.empty}}}),ft=()=>({type:"numberedListItem",propSchema:{...g,start:{default:void 0,type:"number"}},content:"inline"}),gt=E(ft,{meta:{isolating:!1},parse(e){var n;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="OL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="OL")){const o=parseInt(t.getAttribute("start")||"1"),r=v(e);return e.previousElementSibling||o===1?r:{...r,start:o}}},parseContent:({el:e,schema:t})=>le(e,t,"numberedListItem"),render(){const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"numbered-list-item-shortcuts",inputRules:[{find:/^\s?(\d+)\.\s$/,replace({match:e,editor:t}){if(m.getBlockInfoFromSelection(t.prosemirrorState).blockNoteType==="heading")return;const o=parseInt(e[1]);return{type:"numberedListItem",props:{start:o!==1?o:void 0}}}}],keyboardShortcuts:{Enter:({editor:e})=>Y(e,"numberedListItem"),"Mod-Shift-7":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"numberedListItem",props:{}}),!0)}},prosemirrorPlugins:[hn()]})]),ht=()=>({type:"toggleListItem",propSchema:{...g},content:"inline"}),mt=E(ht,{meta:{isolating:!1},render(e,t){const n=document.createElement("p");return{...ce(e,t,n),contentDOM:n}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"toggle-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"toggleListItem"),"Mod-Shift-6":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"toggleListItem",props:{}}),!0)}}})]),bt=()=>({type:"paragraph",propSchema:g,content:"inline"}),Ct=E(bt,{meta:{isolating:!1},parse:e=>{var t;if(e.tagName==="P"&&(t=e.textContent)!=null&&t.trim())return v(e)},render:()=>{const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("p");return I(e.props,t),{dom:t,contentDOM:t}},runsBefore:["default"]},[y.createExtension({key:"paragraph-shortcuts",keyboardShortcuts:{"Mod-Alt-0":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"paragraph",props:{}}),!0)}}})]),kt=()=>({type:"quote",propSchema:{backgroundColor:g.backgroundColor,textColor:g.textColor},content:"inline"}),yt=E(kt,{meta:{isolating:!1},parse(e){if(e.tagName==="BLOCKQUOTE"){const{backgroundColor:t,textColor:n}=v(e);return{backgroundColor:t,textColor:n}}},render(){const e=document.createElement("blockquote");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("blockquote");return I(e.props,t),{dom:t,contentDOM:t}}},[y.createExtension({key:"quote-block-shortcuts",keyboardShortcuts:{"Mod-Alt-q":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"quote",props:{}}),!0)}},inputRules:[{find:new RegExp("^>\\s$"),replace(){return{type:"quote",props:{}}}}]})]),mn=35,de=120,bn=31,Cn=S.Extension.create({name:"BlockNoteTableExtension",addProseMirrorPlugins:()=>[T.columnResizing({cellMinWidth:mn,defaultCellMinWidth:de,View:null}),T.tableEditing()],addKeyboardShortcuts(){return{Enter:()=>this.editor.state.selection.empty&&this.editor.state.selection.$head.parent.type.name==="tableParagraph"?(this.editor.commands.insertContent({type:"hardBreak"}),!0):!1,Backspace:()=>{const e=this.editor.state.selection,t=e.empty,n=e.$head.parentOffset===0,o=e.$head.node().type.name==="tableParagraph";return t&&n&&o},Tab:()=>this.editor.commands.command(({state:e,dispatch:t,view:n})=>T.goToNextCell(1)(e,t,n)),"Shift-Tab":()=>this.editor.commands.command(({state:e,dispatch:t,view:n})=>T.goToNextCell(-1)(e,t,n))}},extendNodeSchema(e){const t={name:e.name,options:e.options,storage:e.storage};return{tableRole:S.callOrReturn(S.getExtensionField(e,"tableRole",t))}}}),vt={textColor:g.textColor},kn=S.Node.create({name:"tableHeader",addOptions(){return{HTMLAttributes:{}}},content:"tableContent+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:e=>{const t=e.getAttribute("colwidth");return t?t.split(",").map(o=>parseInt(o,10)):null}}}},tableRole:"header_cell",isolating:!0,parseHTML(){return[{tag:"th",getContent:(e,t)=>St(e,t)}]},renderHTML({HTMLAttributes:e}){return["th",S.mergeAttributes(this.options.HTMLAttributes,e),0]}}),yn=S.Node.create({name:"tableCell",addOptions(){return{HTMLAttributes:{}}},content:"tableContent+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:e=>{const t=e.getAttribute("colwidth");return t?t.split(",").map(o=>parseInt(o,10)):null}}}},tableRole:"cell",isolating:!0,parseHTML(){return[{tag:"td",getContent:(e,t)=>St(e,t)}]},renderHTML({HTMLAttributes:e}){return["td",S.mergeAttributes(this.options.HTMLAttributes,e),0]}}),vn=S.Node.create({name:"table",content:"tableRow+",group:"blockContent",tableRole:"table",marks:"deletion insertion modification",isolating:!0,parseHTML(){return[{tag:"table"}]},renderHTML({node:e,HTMLAttributes:t}){var r,a,s;const n=Se(this.name,"table",{...((r=this.options.domAttributes)==null?void 0:r.blockContent)||{},...t},((a=this.options.domAttributes)==null?void 0:a.inlineContent)||{}),o=document.createElement("colgroup");for(const i of e.children[0].children)if(i.attrs.colwidth)for(const l of i.attrs.colwidth){const u=document.createElement("col");l&&(u.style=`width: ${l}px`),o.appendChild(u)}else o.appendChild(document.createElement("col"));return(s=n.dom.firstChild)==null||s.appendChild(o),n},addNodeView(){return({node:e,HTMLAttributes:t})=>{var o;class n extends T.TableView{constructor(a,s,i){super(a,s),this.node=a,this.cellMinWidth=s,this.blockContentHTMLAttributes=i;const c=document.createElement("div");c.className=V("bn-block-content",i.class),c.setAttribute("data-content-type","table");for(const[p,h]of Object.entries(i))p!=="class"&&c.setAttribute(p,h);const l=this.dom,u=document.createElement("div");u.className="tableWrapper-inner",u.appendChild(l.firstChild),l.appendChild(u),c.appendChild(l);const d=document.createElement("div");d.className="table-widgets-container",d.style.position="relative",l.appendChild(d),this.dom=c}ignoreMutation(a){return!a.target.closest(".tableWrapper-inner")||super.ignoreMutation(a)}}return new n(e,de,{...((o=this.options.domAttributes)==null?void 0:o.blockContent)||{},...t})}}}),Sn=S.Node.create({name:"tableParagraph",group:"tableContent",content:"inline*",parseHTML(){return[{tag:"p",getAttrs:e=>{if(typeof e=="string"||!e.textContent||!e.closest("[data-content-type]"))return!1;const t=e.parentElement;return t===null?!1:t.tagName==="TD"||t.tagName==="TH"?{}:!1},node:"tableParagraph"}]},renderHTML({HTMLAttributes:e}){return["p",e,0]}}),En=S.Node.create({name:"tableRow",addOptions(){return{HTMLAttributes:{}}},content:"(tableCell | tableHeader)+",tableRole:"row",marks:"deletion insertion modification",parseHTML(){return[{tag:"tr"}]},renderHTML({HTMLAttributes:e}){return["tr",S.mergeAttributes(this.options.HTMLAttributes,e),0]}});function St(e,t){const o=N.DOMParser.fromSchema(t).parse(e,{topNode:t.nodes.blockGroup.create()}),r=[];return o.content.descendants(a=>{if(a.isInline)return r.push(a),!1}),N.Fragment.fromArray(r)}const Et=()=>Le({node:vn,type:"table",content:"table"},vt,[y.createExtension({key:"table-extensions",tiptapExtensions:[Cn,Sn,kn,yn,En]}),y.createExtension({key:"table-keyboard-delete",keyboardShortcuts:{Backspace:({editor:e})=>{if(!(e.prosemirrorState.selection instanceof T.CellSelection))return!1;const t=e.getTextCursorPosition().block,n=t.content;let o=0;for(const a of n.rows)for(const s of a.cells){if("type"in s&&s.content.length>0||!("type"in s)&&s.length>0)return!1;o++}let r=0;return e.prosemirrorState.selection.forEachCell(()=>{r++}),r{(e.getPrevBlock(t)||e.getNextBlock(t))&&e.setTextCursorPosition(t),e.removeBlocks([t])}),!0)}}})]),ke=e=>{const t=e.src||void 0,n=e.width||void 0;return{url:t,previewWidth:n}},xt='',Mt=e=>({type:"video",propSchema:{textAlignment:g.textAlignment,backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0},previewWidth:{default:void 0,type:"number"}},content:"none"}),Lt=e=>t=>{if(t.tagName==="VIDEO"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...ke(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"video");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...ke(o),backgroundColor:a,caption:r}}},wt=E(Mt,e=>({meta:{fileBlockAccept:["video/*"]},parse:Lt(),render(t,n){const o=document.createElement("div");o.innerHTML=e.icon??xt;const r=document.createElement("div");r.className="bn-visual-media-wrapper";const a=document.createElement("video");return a.className="bn-visual-media",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(s=>{a.src=s}):a.src=t.props.url,a.controls=!0,a.contentEditable="false",a.draggable=!1,a.width=t.props.previewWidth,r.appendChild(a),et(t,n,{dom:r},r,o.firstElementChild)},toExternalHTML(t){if(!t.props.url){const o=document.createElement("p");return o.textContent="Add video",{dom:o}}let n;return t.props.showPreview?(n=document.createElement("video"),n.src=t.props.url,t.props.previewWidth&&(n.width=t.props.previewWidth)):(n=document.createElement("a"),n.href=t.props.url,n.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(n,t.props.caption):Q(n,t.props.caption):{dom:n}},runsBefore:["file"]}));function C(e,t,n){if(!(t in e.schema.blockSpecs))return!1;if(!n)return!0;for(const[o,r]of Object.entries(n)){if(!(o in e.schema.blockSpecs[t].config.propSchema))return!1;if(typeof r=="string"){if(e.schema.blockSpecs[t].config.propSchema[o].default!==void 0&&typeof e.schema.blockSpecs[t].config.propSchema[o].default!==r||e.schema.blockSpecs[t].config.propSchema[o].type!==void 0&&e.schema.blockSpecs[t].config.propSchema[o].type!==r)return!1}else{if(e.schema.blockSpecs[t].config.propSchema[o].default!==r.default||e.schema.blockSpecs[t].config.propSchema[o].default===void 0&&r.default===void 0&&e.schema.blockSpecs[t].config.propSchema[o].type!==r.type||typeof e.schema.blockSpecs[t].config.propSchema[o].values!=typeof r.values)return!1;if(typeof e.schema.blockSpecs[t].config.propSchema[o].values=="object"&&typeof r.values=="object"){for(const a of r.values)if(!e.schema.blockSpecs[t].config.propSchema[o].values.includes(a))return!1}}}return!0}function xn(e,t,n,o){return C(t,n,o)&&e.type===n}function Mn(e){return e instanceof T.CellSelection}const G=new Map;function Ln(e){if(G.has(e))return G.get(e);const t=new ye.Mapping;return e._tiptapEditor.on("transaction",({transaction:n})=>{t.appendMapping(n.mapping)}),e._tiptapEditor.on("destroy",()=>{G.delete(e)}),G.set(e,t),t}function Bt(e,t,n="left"){const o=j.ySyncPluginKey.getState(e.prosemirrorState);if(!o){const a=Ln(e),s=a.maps.length;return()=>a.slice(s).map(t,n==="left"?-1:1)}const r=j.absolutePositionToRelativePosition(t+(n==="right"?1:-1),o.binding.type,o.binding.mapping);return()=>{const a=j.ySyncPluginKey.getState(e.prosemirrorState),s=j.relativePositionToAbsolutePosition(a.doc,a.binding.type,r,a.binding.mapping);if(s===null)throw new Error("Position not found, cannot track positions");return s+(n==="right"?-1:1)}}const wn=S.findParentNode(e=>e.type.name==="blockContainer");class Bn{constructor(t,n,o){P(this,"state");P(this,"emitUpdate");P(this,"rootEl");P(this,"pluginState");P(this,"handleScroll",()=>{var t,n;if((t=this.state)!=null&&t.show){const o=(n=this.rootEl)==null?void 0:n.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`);if(!o)return;this.state.referencePos=o.getBoundingClientRect().toJSON(),this.emitUpdate(this.pluginState.triggerCharacter)}});P(this,"closeMenu",()=>{this.editor.transact(t=>t.setMeta(H,null))});P(this,"clearQuery",()=>{this.pluginState!==void 0&&this.editor._tiptapEditor.chain().focus().deleteRange({from:this.pluginState.queryStartPos()-(this.pluginState.deleteTriggerCharacter?this.pluginState.triggerCharacter.length:0),to:this.editor.transact(t=>t.selection.from)}).run()});var r;this.editor=t,this.pluginState=void 0,this.emitUpdate=a=>{var s;if(!this.state)throw new Error("Attempting to update uninitialized suggestions menu");n(a,{...this.state,ignoreQueryLength:(s=this.pluginState)==null?void 0:s.ignoreQueryLength})},this.rootEl=o.root,(r=this.rootEl)==null||r.addEventListener("scroll",this.handleScroll,!0)}update(t,n){var l;const o=H.getState(n),r=H.getState(t.state),a=o===void 0&&r!==void 0,s=o!==void 0&&r===void 0;if(!a&&!(o!==void 0&&r!==void 0)&&!s)return;if(this.pluginState=s?o:r,s||!this.editor.isEditable){this.state&&(this.state.show=!1),this.emitUpdate(this.pluginState.triggerCharacter);return}const c=(l=this.rootEl)==null?void 0:l.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`);this.editor.isEditable&&c&&(this.state={show:!0,referencePos:c.getBoundingClientRect().toJSON(),query:this.pluginState.query},this.emitUpdate(this.pluginState.triggerCharacter))}destroy(){var t;(t=this.rootEl)==null||t.removeEventListener("scroll",this.handleScroll,!0)}}const H=new z.PluginKey("SuggestionMenuPlugin"),Tt=y.createExtension(({editor:e})=>{const t=[];let n;const o=y.createStore(void 0);return{key:"suggestionMenu",store:o,addTriggerCharacter:r=>{t.push(r)},removeTriggerCharacter:r=>{t.splice(t.indexOf(r),1)},closeMenu:()=>{n==null||n.closeMenu()},clearQuery:()=>{n==null||n.clearQuery()},shown:()=>{var r;return((r=n==null?void 0:n.state)==null?void 0:r.show)||!1},openSuggestionMenu:(r,a)=>{e.headless||(e.focus(),e.transact(s=>{a!=null&&a.deleteTriggerCharacter&&s.insertText(r),s.scrollIntoView().setMeta(H,{triggerCharacter:r,deleteTriggerCharacter:(a==null?void 0:a.deleteTriggerCharacter)||!1,ignoreQueryLength:(a==null?void 0:a.ignoreQueryLength)||!1})}))},prosemirrorPlugins:[new z.Plugin({key:H,view:r=>(n=new Bn(e,(a,s)=>{o.setState({...s,triggerCharacter:a})},r),n),state:{init(){},apply:(r,a,s,i)=>{if(r.selection.$from.parent.type.spec.code)return a;const c=r.getMeta(H);if(typeof c=="object"&&c!==null){a&&(n==null||n.closeMenu());const u=Bt(e,i.selection.from-c.triggerCharacter.length);return{triggerCharacter:c.triggerCharacter,deleteTriggerCharacter:c.deleteTriggerCharacter!==!1,queryStartPos:()=>u()+c.triggerCharacter.length,query:"",decorationId:`id_${Math.floor(Math.random()*4294967295)}`,ignoreQueryLength:c==null?void 0:c.ignoreQueryLength}}if(a===void 0)return a;if(i.selection.from!==i.selection.to||c===null||r.getMeta("focus")||r.getMeta("blur")||r.getMeta("pointer")||a.triggerCharacter!==void 0&&i.selection.from1?c.textBetween(a-l.length,a)+i:i;if(l===u)return r.dispatch(r.state.tr.insertText(i)),r.dispatch(r.state.tr.setMeta(H,{triggerCharacter:u}).scrollIntoView()),!0}}return!1},decorations(r){const a=this.getState(r);if(a===void 0)return null;if(!a.deleteTriggerCharacter){const s=wn(r.selection);if(s)return $.DecorationSet.create(r.doc,[$.Decoration.node(s.pos,s.pos+s.node.nodeSize,{nodeName:"span",class:"bn-suggestion-decorator","data-decoration-id":a.decorationId})])}return $.DecorationSet.create(r.doc,[$.Decoration.inline(a.queryStartPos()-a.triggerCharacter.length,a.queryStartPos(),{nodeName:"span",class:"bn-suggestion-decorator","data-decoration-id":a.decorationId})])}}})]}});function Tn(e){let t=e.getTextCursorPosition().block,n=e.schema.blockSchema[t.type].content;for(;n==="none";){if(t=e.getTextCursorPosition().nextBlock,t===void 0)return;n=e.schema.blockSchema[t.type].content,e.setTextCursorPosition(t,"end")}}function k(e,t){const n=e.getTextCursorPosition().block;if(n.content===void 0)throw new Error("Slash Menu open in a block that doesn't contain content.");let o;return Array.isArray(n.content)&&(n.content.length===1&&m.isStyledTextInlineContent(n.content[0])&&n.content[0].type==="text"&&n.content[0].text==="/"||n.content.length===0)?(o=e.updateBlock(n,t),e.setTextCursorPosition(o)):(o=e.insertBlocks([t],n,"after")[0],e.setTextCursorPosition(e.getTextCursorPosition().nextBlock)),Tn(e),o}function An(e){const t=[];return C(e,"heading",{level:"number"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n<=3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n}})},badge:B(`Mod-Alt-${n}`),key:n===1?"heading":`heading_${n}`,...e.dictionary.slash_menu[n===1?"heading":`heading_${n}`]})}),C(e,"quote")&&t.push({onItemClick:()=>{k(e,{type:"quote"})},key:"quote",...e.dictionary.slash_menu.quote}),C(e,"toggleListItem")&&t.push({onItemClick:()=>{k(e,{type:"toggleListItem"})},badge:B("Mod-Shift-6"),key:"toggle_list",...e.dictionary.slash_menu.toggle_list}),C(e,"numberedListItem")&&t.push({onItemClick:()=>{k(e,{type:"numberedListItem"})},badge:B("Mod-Shift-7"),key:"numbered_list",...e.dictionary.slash_menu.numbered_list}),C(e,"bulletListItem")&&t.push({onItemClick:()=>{k(e,{type:"bulletListItem"})},badge:B("Mod-Shift-8"),key:"bullet_list",...e.dictionary.slash_menu.bullet_list}),C(e,"checkListItem")&&t.push({onItemClick:()=>{k(e,{type:"checkListItem"})},badge:B("Mod-Shift-9"),key:"check_list",...e.dictionary.slash_menu.check_list}),C(e,"paragraph")&&t.push({onItemClick:()=>{k(e,{type:"paragraph"})},badge:B("Mod-Alt-0"),key:"paragraph",...e.dictionary.slash_menu.paragraph}),C(e,"codeBlock")&&t.push({onItemClick:()=>{k(e,{type:"codeBlock"})},badge:B("Mod-Alt-c"),key:"code_block",...e.dictionary.slash_menu.code_block}),C(e,"divider")&&t.push({onItemClick:()=>{k(e,{type:"divider"})},key:"divider",...e.dictionary.slash_menu.divider}),C(e,"table")&&t.push({onItemClick:()=>{k(e,{type:"table",content:{type:"tableContent",rows:[{cells:["","",""]},{cells:["","",""]}]}})},badge:void 0,key:"table",...e.dictionary.slash_menu.table}),C(e,"image",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"image"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"image",...e.dictionary.slash_menu.image}),C(e,"video",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"video"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"video",...e.dictionary.slash_menu.video}),C(e,"audio",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"audio"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"audio",...e.dictionary.slash_menu.audio}),C(e,"file",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"file"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"file",...e.dictionary.slash_menu.file}),C(e,"heading",{level:"number",isToggleable:"boolean"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n<=3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n,isToggleable:!0}})},key:n===1?"toggle_heading":`toggle_heading_${n}`,...e.dictionary.slash_menu[n===1?"toggle_heading":`toggle_heading_${n}`]})}),C(e,"heading",{level:"number"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n>3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n}})},badge:B(`Mod-Alt-${n}`),key:`heading_${n}`,...e.dictionary.slash_menu[`heading_${n}`]})}),t.push({onItemClick:()=>{var n;(n=e.getExtension(Tt))==null||n.openSuggestionMenu(":",{deleteTriggerCharacter:!0,ignoreQueryLength:!0})},key:"emoji",...e.dictionary.slash_menu.emoji}),t}function Pn(e,t){return e.filter(({title:n,aliases:o})=>n.toLowerCase().includes(t.toLowerCase())||o&&o.filter(r=>r.toLowerCase().includes(t.toLowerCase())).length!==0)}const Nn={audio:qe(),bulletListItem:lt(),checkListItem:ut(),codeBlock:$e(),divider:Ge(),file:Ke(),heading:Je(),image:st(),numberedListItem:gt(),paragraph:Ct(),quote:yt(),table:Et(),toggleListItem:mt(),video:wt()},In=re({type:"textColor",propSchema:"string"},{render:()=>{const e=document.createElement("span");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("span");return e!==g.textColor.default&&(t.style.color=e in A?A[e].text:e),{dom:t,contentDOM:t}},parse:e=>{if(e.tagName==="SPAN"&&e.style.color)return e.style.color}}),Hn=re({type:"backgroundColor",propSchema:"string"},{render:()=>{const e=document.createElement("span");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("span");return e!==g.backgroundColor.default&&(t.style.backgroundColor=e in A?A[e].background:e),{dom:t,contentDOM:t}},parse:e=>{if(e.tagName==="SPAN"&&e.style.backgroundColor)return e.style.backgroundColor}}),At={bold:_(Wt.default,"boolean"),italic:_(Ut.default,"boolean"),underline:_(jt.default,"boolean"),strike:_($t.default,"boolean"),code:_(qt.default,"boolean"),textColor:In,backgroundColor:Hn},Dn=Ne(At),Pt={text:{config:"text",implementation:{}},link:{config:"link",implementation:{}}},_n=Ae(Pt);exports.COLORS_DARK_MODE_DEFAULT=rn;exports.COLORS_DEFAULT=A;exports.EMPTY_CELL_HEIGHT=bn;exports.EMPTY_CELL_WIDTH=de;exports.FILE_AUDIO_ICON_SVG=Oe;exports.FILE_IMAGE_ICON_SVG=tt;exports.FILE_VIDEO_ICON_SVG=xt;exports.FilePanelExtension=O;exports.SuggestionMenu=Tt;exports.addDefaultPropsExternalHTML=I;exports.addInlineContentAttributes=Qt;exports.addInlineContentKeyboardShortcuts=Yt;exports.addNodeAndExtensionsToSpec=Kt;exports.addStyleAttributes=F;exports.applyNonSelectableBlockFix=we;exports.audioParse=Fe;exports.audioRender=Re;exports.audioToExternalHTML=We;exports.blockHasType=xn;exports.camelToDataKebab=W;exports.captureCellAnchor=_e;exports.createAudioBlockConfig=Ve;exports.createAudioBlockSpec=qe;exports.createBlockConfig=Xt;exports.createBlockSpec=E;exports.createBlockSpecFromTiptapNode=Le;exports.createBulletListItemBlockConfig=ct;exports.createBulletListItemBlockSpec=lt;exports.createCheckListItemBlockSpec=ut;exports.createCheckListItemConfig=dt;exports.createCodeBlockConfig=Ue;exports.createCodeBlockSpec=$e;exports.createDefaultBlockDOMOutputSpec=Se;exports.createDividerBlockConfig=je;exports.createDividerBlockSpec=Ge;exports.createFileBlockConfig=Ze;exports.createFileBlockSpec=Ke;exports.createHeadingBlockConfig=Ye;exports.createHeadingBlockSpec=Je;exports.createImageBlockConfig=nt;exports.createImageBlockSpec=st;exports.createInlineContentSpecFromTipTapNode=Jt;exports.createInternalInlineContentSpec=Te;exports.createInternalStyleSpec=oe;exports.createNumberedListItemBlockConfig=ft;exports.createNumberedListItemBlockSpec=gt;exports.createParagraphBlockConfig=bt;exports.createParagraphBlockSpec=Ct;exports.createQuoteBlockConfig=kt;exports.createQuoteBlockSpec=yt;exports.createStyleSpec=re;exports.createStyleSpecFromTipTapMark=_;exports.createTableBlockSpec=Et;exports.createToggleListItemBlockConfig=ht;exports.createToggleListItemBlockSpec=mt;exports.createToggleWrapper=ce;exports.createVideoBlockConfig=Mt;exports.createVideoBlockSpec=wt;exports.defaultBlockSpecs=Nn;exports.defaultBlockToHTML=te;exports.defaultInlineContentSchema=_n;exports.defaultInlineContentSpecs=Pt;exports.defaultProps=g;exports.defaultStyleSchema=Dn;exports.defaultStyleSpecs=At;exports.defaultToggledState=Xe;exports.editorHasBlockWithType=C;exports.fileParse=ze;exports.filenameFromURL=Zt;exports.filterSuggestionItems=Pn;exports.formatKeyboardShortcut=B;exports.getBackgroundColorAttribute=an;exports.getBlockFromPos=Me;exports.getDefaultSlashMenuItems=An;exports.getInlineContentSchemaFromSpecs=Ae;exports.getLanguageId=ie;exports.getNodeById=He;exports.getParseRules=Be;exports.getStyleParseRules=Ie;exports.getStyleSchemaFromSpecs=Ne;exports.getTextAlignmentAttribute=cn;exports.getTextColorAttribute=sn;exports.imageParse=ot;exports.imageRender=rt;exports.imageToExternalHTML=at;exports.insertOrUpdateBlockForSlashMenu=k;exports.isAppleOS=ve;exports.isNodeBlock=De;exports.isSafari=Gt;exports.isTableCellSelection=Mn;exports.isVideoUrl=zt;exports.mergeCSSClasses=V;exports.mergeParagraphs=Ee;exports.parseAudioElement=ne;exports.parseDefaultProps=v;exports.propsToAttributes=xe;exports.splitBlockCommand=gn;exports.stylePropsToAttributes=Pe;exports.tablePropSchema=vt;exports.trackPosition=Bt;exports.updateBlock=nn;exports.updateBlockCommand=en;exports.updateBlockTr=K;exports.videoParse=Lt;exports.wrapInBlockStructure=Z; ++`:"
"),R.DOMParser.fromSchema(r).parse(i,{topNode:r.nodes.paragraph.create(),preserveWhitespace:!0}).content}return R.Fragment.empty}:void 0}),n}function Kt(e,t,n,o){var a,s,i,c;const r=t.node||S.Node.create({name:e.type,content:e.content==="inline"?"inline*":e.content==="none"?"":e.content,group:"blockContent",selectable:((a=t.meta)==null?void 0:a.selectable)??!0,isolating:((s=t.meta)==null?void 0:s.isolating)??!0,code:((i=t.meta)==null?void 0:i.code)??!1,defining:((c=t.meta)==null?void 0:c.defining)??!0,priority:o,addAttributes(){return xe(e.propSchema)},parseHTML(){return Be(e,t)},renderHTML({HTMLAttributes:l}){var d;const u=document.createElement("div");return Z({dom:u,contentDOM:e.content==="inline"?u:void 0},e.type,{},e.propSchema,((d=t.meta)==null?void 0:d.fileBlockAccept)!==void 0,l)},addNodeView(){return l=>{var f,x;const u=this.options.editor,d=Me(l.getPos,u,this.editor,e.type),p=((f=this.options.domAttributes)==null?void 0:f.blockContent)||{},h=t.render.call({blockContentDOMAttributes:p,props:l,renderType:"nodeView"},d,u);return((x=t.meta)==null?void 0:x.selectable)===!1&&we(h,this.editor),h}}});if(r.name!==e.type)throw new Error("Node name does not match block type. This is a bug in BlockNote.");return{config:e,implementation:{...t,node:r,render(l,u){var p;const d=((p=r.options.domAttributes)==null?void 0:p.blockContent)||{};return t.render.call({blockContentDOMAttributes:d,props:void 0,renderType:"dom"},l,u)},toExternalHTML:(l,u,d)=>{var h,f;const p=((h=r.options.domAttributes)==null?void 0:h.blockContent)||{};return((f=t.toExternalHTML)==null?void 0:f.call({blockContentDOMAttributes:p},l,u,d))??t.render.call({blockContentDOMAttributes:p,renderType:"dom",props:void 0},l,u)}},extensions:n}}function Xt(e){return e}function E(e,t,n){return(o={})=>{const r=typeof e=="function"?e(o):e,a=typeof t=="function"?t(o):t,s=n?typeof n=="function"?n(o):n:void 0;return{config:r,implementation:{...a,toExternalHTML(i,c,l){var d,p;const u=(d=a.toExternalHTML)==null?void 0:d.call({blockContentDOMAttributes:this.blockContentDOMAttributes},i,c,l);if(u!==void 0)return Z(u,i.type,i.props,r.propSchema,((p=a.meta)==null?void 0:p.fileBlockAccept)!==void 0)},render(i,c){var d;const l=a.render.call({blockContentDOMAttributes:this.blockContentDOMAttributes,renderType:this.renderType,props:this.props},i,c);return Z(l,i.type,i.props,r.propSchema,((d=a.meta)==null?void 0:d.fileBlockAccept)!==void 0,this.blockContentDOMAttributes)}},extensions:s}}}function Qt(e,t,n,o){return e.dom.setAttribute("data-inline-content-type",t),Object.entries(n).filter(([r,a])=>{const s=o[r];return a!==s.default}).map(([r,a])=>[W(r),a]).forEach(([r,a])=>e.dom.setAttribute(r,a)),e.contentDOM&&e.contentDOM.setAttribute("data-editable",""),e}function Yt(e){return{Backspace:({editor:t})=>{const n=t.state.selection.$from;return t.state.selection.empty&&n.node().type.name===e.type&&n.parentOffset===0}}}function Te(e,t){return{config:e,implementation:t}}function Jt(e,t,n){return Te({type:e.name,propSchema:t,content:e.config.content==="inline*"?"styled":"none"},{...n,node:e})}function Ae(e){return Object.fromEntries(Object.entries(e).map(([t,n])=>[t,n.config]))}function Pe(e){return e==="boolean"?{}:{stringValue:{default:void 0,keepOnSplit:!0,parseHTML:t=>t.getAttribute("data-value"),renderHTML:t=>t.stringValue!==void 0?{"data-value":t.stringValue}:{}}}}function F(e,t,n,o){return e.dom.setAttribute("data-style-type",t),o==="string"&&e.dom.setAttribute("data-value",n),e.contentDOM&&e.contentDOM.setAttribute("data-editable",""),e}function oe(e,t){return{config:e,implementation:t}}function _(e,t){return oe({type:e.name,propSchema:t},{mark:e,render(n,o){const r=o.pmSchema.marks[e.name].spec.toDOM;if(r===void 0)throw new Error("This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.");const a=o.pmSchema.mark(e.name,{stringValue:n}),s=R.DOMSerializer.renderSpec(document,r(a,!0));if(typeof s!="object"||!("dom"in s))throw new Error("Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.");return s},toExternalHTML(n,o){const r=o.pmSchema.marks[e.name].spec.toDOM;if(r===void 0)throw new Error("This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.");const a=o.pmSchema.mark(e.name,{stringValue:n}),s=R.DOMSerializer.renderSpec(document,r(a,!0));if(typeof s!="object"||!("dom"in s))throw new Error("Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.");return s}})}function Ne(e){return Object.fromEntries(Object.entries(e).map(([t,n])=>[t,n.config]))}function Ie(e,t){const n=[{tag:`[data-style-type="${e.type}"]`,contentElement:o=>{const r=o;return r.matches("[data-editable]")?r:r.querySelector("[data-editable]")||r}}];return t&&n.push({tag:"*",consuming:!1,getAttrs(o){if(typeof o=="string")return!1;const r=t==null?void 0:t(o);return r===void 0?!1:{stringValue:r}}}),n}function re(e,t){const n=S.Mark.create({name:e.type,addAttributes(){return Pe(e.propSchema)},parseHTML(){return Ie(e,t.parse)},renderHTML({mark:o}){const r=(t.toExternalHTML||t.render)(o.attrs.stringValue);return F(r,e.type,o.attrs.stringValue,e.propSchema)},addMarkView(){return({mark:o})=>{const r=t.render(o.attrs.stringValue);return F(r,e.type,o.attrs.stringValue,e.propSchema)}}});return oe(e,{...t,mark:n,render:o=>{const r=t.render(o);return F(r,e.type,o,e.propSchema)},toExternalHTML:o=>{const r=(t.toExternalHTML||t.render)(o);return F(r,e.type,o,e.propSchema)}})}function He(e,t){let n,o;if(t.firstChild.descendants((r,a)=>n?!1:!De(r)||r.attrs.id!==e?!0:(n=r,o=a+1,!1)),!(n===void 0||o===void 0))return{node:n,posBeforeNode:o}}function De(e){return e.type.isInGroup("bnBlock")}const en=(e,t)=>({tr:n,dispatch:o})=>(o&&K(n,e,t),!0);function K(e,t,n,o,r){const a=m.getBlockInfoFromResolvedPos(e.doc.resolve(t));let s=null;a.blockNoteType==="table"&&(s=_e(e));const i=m.getPmSchema(e);if(o!==void 0&&r!==void 0&&o>r)throw new Error("Invalid replaceFromPos or replaceToPos");const c=i.nodes[a.blockNoteType],l=i.nodes[n.type||a.blockNoteType],u=l.isInGroup("bnBlock")?l:i.nodes.blockContainer;if(a.isBlockContainer&&l.isInGroup("blockContent")){const d=o!==void 0&&o>a.blockContent.beforePos&&oa.blockContent.beforePos&&r0){const r=e.children.map(a=>m.blockToNode(a,o));if(n.childContainer)t.step(new ye.ReplaceStep(n.childContainer.beforePos+1,n.childContainer.afterPos-1,new N.Slice(N.Fragment.from(r),0,0)));else{if(!n.isBlockContainer)throw new Error("impossible");t.insert(n.blockContent.afterPos,o.nodes.blockGroup.createChecked({},r))}}}function nn(e,t,n,o,r){const a=typeof t=="string"?t:t.id,s=He(a,e.doc);if(!s)throw new Error(`Block with ID ${a} not found`);K(e,s.posBeforeNode,n,o,r);const i=e.doc.resolve(s.posBeforeNode+1).node(),c=m.getPmSchema(e);return m.nodeToBlock(i,c)}function _e(e){const t="selection"in e?e.selection:null;if(!(t instanceof z.TextSelection))return null;const n=e.doc.resolve(t.head);let o=-1,r=-1;for(let L=n.depth;L>=0;L--){const M=n.node(L).type.name;if(o<0&&(M==="tableCell"||M==="tableHeader")&&(o=L),M==="table"){r=L;break}}if(o<0||r<0)return null;const a=n.before(o),s=n.before(r),i=e.doc.nodeAt(s);if(!i||i.type.name!=="table")return null;const c=T.TableMap.get(i),l=a-(s+1),u=c.map.indexOf(l);if(u<0)return null;const d=Math.floor(u/c.width),p=u%c.width,f=a+1+1,x=Math.max(0,t.head-f);return{row:d,col:p,offset:x}}function on(e,t,n){var L;if(t.blockNoteType!=="table")return!1;let o=-1;if(t.isBlockContainer)o=e.mapping.map(t.blockContent.beforePos);else{const M=e.mapping.map(t.bnBlock.beforePos),D=M+(((L=e.doc.nodeAt(M))==null?void 0:L.nodeSize)||0);e.doc.nodesBetween(M,D,(b,w)=>b.type.name==="table"?(o=w,!1):!0)}const r=o>=0?e.doc.nodeAt(o):null;if(!r||r.type.name!=="table")return!1;const a=T.TableMap.get(r),s=Math.max(0,Math.min(n.row,a.height-1)),i=Math.max(0,Math.min(n.col,a.width-1)),c=s*a.width+i,l=a.map[c];if(l==null)return!1;const d=o+1+l+1,p=e.doc.nodeAt(d),h=d+1,f=p?p.content.size:0,x=h+Math.max(0,Math.min(n.offset,f));return"selection"in e&&e.setSelection(z.TextSelection.create(e.doc,x)),!0}const A={gray:{text:"#9b9a97",background:"#ebeced"},brown:{text:"#64473a",background:"#e9e5e3"},red:{text:"#e03e3e",background:"#fbe4e4"},orange:{text:"#d9730d",background:"#f6e9d9"},yellow:{text:"#dfab01",background:"#fbf3db"},green:{text:"#4d6461",background:"#ddedea"},blue:{text:"#0b6e99",background:"#ddebf1"},purple:{text:"#6940a5",background:"#eae4f2"},pink:{text:"#ad1a72",background:"#f4dfeb"}},rn={gray:{text:"#bebdb8",background:"#9b9a97"},brown:{text:"#8e6552",background:"#64473a"},red:{text:"#ec4040",background:"#be3434"},orange:{text:"#e3790d",background:"#b7600a"},yellow:{text:"#dfab01",background:"#b58b00"},green:{text:"#6b8b87",background:"#4d6461"},blue:{text:"#0e87bc",background:"#0b6e99"},purple:{text:"#8552d7",background:"#6940a5"},pink:{text:"#da208f",background:"#ad1a72"}},g={backgroundColor:{default:"default"},textColor:{default:"default"},textAlignment:{default:"left",values:["left","center","right","justify"]}},v=e=>{const t={};return e.hasAttribute("data-background-color")?t.backgroundColor=e.getAttribute("data-background-color"):e.style.backgroundColor&&(t.backgroundColor=e.style.backgroundColor),e.hasAttribute("data-text-color")?t.textColor=e.getAttribute("data-text-color"):e.style.color&&(t.textColor=e.style.color),t.textAlignment=g.textAlignment.values.includes(e.style.textAlign)?e.style.textAlign:void 0,t},I=(e,t)=>{e.backgroundColor&&e.backgroundColor!==g.backgroundColor.default&&(t.style.backgroundColor=e.backgroundColor in A?A[e.backgroundColor].background:e.backgroundColor),e.textColor&&e.textColor!==g.textColor.default&&(t.style.color=e.textColor in A?A[e.textColor].text:e.textColor),e.textAlignment&&e.textAlignment!==g.textAlignment.default&&(t.style.textAlign=e.textAlignment)},an=(e="backgroundColor")=>({default:g.backgroundColor.default,parseHTML:t=>t.hasAttribute("data-background-color")?t.getAttribute("data-background-color"):t.style.backgroundColor?t.style.backgroundColor:g.backgroundColor.default,renderHTML:t=>t[e]===g.backgroundColor.default?{}:{"data-background-color":t[e]}}),sn=(e="textColor")=>({default:g.textColor.default,parseHTML:t=>t.hasAttribute("data-text-color")?t.getAttribute("data-text-color"):t.style.color?t.style.color:g.textColor.default,renderHTML:t=>t[e]===g.textColor.default?{}:{"data-text-color":t[e]}}),cn=(e="textAlignment")=>({default:g.textAlignment.default,parseHTML:t=>t.hasAttribute("data-text-alignment")?t.getAttribute("data-text-alignment"):t.style.textAlign?t.style.textAlign:g.textAlignment.default,renderHTML:t=>t[e]===g.textAlignment.default?{}:{"data-text-alignment":t[e]}}),X=(e,t)=>{const n=e.querySelector(t);if(!n)return;const o=e.querySelector("figcaption"),r=(o==null?void 0:o.textContent)??void 0;return{targetElement:n,caption:r}},O=y.createExtension(({editor:e})=>{const t=y.createStore(void 0);function n(){t.setState(void 0)}return{key:"filePanel",store:t,mount({signal:o}){const r=e.onChange(n,!1),a=e.onSelectionChange(n,!1);o.addEventListener("abort",()=>{r(),a()})},closeMenu:n,showMenu(o){t.setState(o)}}}),ln=(e,t,n)=>{const o=document.createElement("div");o.className="bn-add-file-button";const r=document.createElement("div");r.className="bn-add-file-button-icon",n?r.appendChild(n):r.innerHTML='',o.appendChild(r);const a=document.createElement("p");a.className="bn-add-file-button-text",a.innerHTML=e.type in t.dictionary.file_blocks.add_button_text?t.dictionary.file_blocks.add_button_text[e.type]:t.dictionary.file_blocks.add_button_text.file,o.appendChild(a);const s=c=>{c.preventDefault(),c.stopPropagation()},i=()=>{var c;t.isEditable&&((c=t.getExtension(O))==null||c.showMenu(e.id))};return o.addEventListener("mousedown",s,!0),o.addEventListener("click",i,!0),{dom:o,destroy:()=>{o.removeEventListener("mousedown",s,!0),o.removeEventListener("click",i,!0)}}},dn='',un=e=>{const t=document.createElement("div");t.className="bn-file-name-with-icon";const n=document.createElement("div");n.className="bn-file-icon",n.innerHTML=dn,t.appendChild(n);const o=document.createElement("p");return o.className="bn-file-name",o.textContent=e.props.name,t.appendChild(o),{dom:t}},ae=(e,t,n,o)=>{const r=document.createElement("div");if(r.className="bn-file-block-content-wrapper",e.props.url===""){const s=ln(e,t,o);r.appendChild(s.dom);const i=t.onUploadStart(c=>{if(c===e.id){r.removeChild(s.dom);const l=document.createElement("div");l.className="bn-file-loading-preview",l.textContent="Loading...",r.appendChild(l)}});return{dom:r,destroy:()=>{i(),s.destroy()}}}const a={dom:r};if(e.props.showPreview===!1||!n){const s=un(e);r.appendChild(s.dom),a.destroy=()=>{var i;(i=s.destroy)==null||i.call(s)}}else r.appendChild(n.dom);if(e.props.caption){const s=document.createElement("p");s.className="bn-file-caption",s.textContent=e.props.caption,r.appendChild(s)}return a},se=(e,t)=>{const n=document.createElement("figure"),o=document.createElement("figcaption");return o.textContent=t,n.appendChild(e),n.appendChild(o),{dom:n}},Q=(e,t)=>{const n=document.createElement("div"),o=document.createElement("p");return o.textContent=t,n.appendChild(e),n.appendChild(o),{dom:n}},ne=e=>({url:e.src||void 0}),Oe='',Ve=e=>({type:"audio",propSchema:{backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0}},content:"none"}),Fe=(e={})=>t=>{if(t.tagName==="AUDIO"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...ne(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"audio");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...ne(o),backgroundColor:a,caption:r}}},Re=(e={})=>(t,n)=>{const o=document.createElement("div");o.innerHTML=e.icon??Oe;const r=document.createElement("audio");return r.className="bn-audio",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(a=>{r.src=a}):r.src=t.props.url,r.controls=!0,r.contentEditable="false",r.draggable=!1,ae(t,n,{dom:r},o.firstElementChild)},We=(e={})=>(t,n)=>{if(!t.props.url){const r=document.createElement("p");return r.textContent="Add audio",{dom:r}}let o;return t.props.showPreview?(o=document.createElement("audio"),o.src=t.props.url):(o=document.createElement("a"),o.href=t.props.url,o.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(o,t.props.caption):Q(o,t.props.caption):{dom:o}},qe=E(Ve,e=>({meta:{fileBlockAccept:["audio/*"]},parse:Fe(e),render:Re(e),toExternalHTML:We(e),runsBefore:["file"]})),he=Symbol.for("blocknote.shikiParser"),J=Symbol.for("blocknote.shikiHighlighterPromise");function pn(e){const t=globalThis;let n,o,r=!1;const a=s=>{if(!e.createHighlighter)return process.env.NODE_ENV==="development"&&!r&&(console.log("For syntax highlighting of code blocks, you must provide a `createCodeBlockSpec({ createHighlighter: () => ... })` function"),r=!0),[];if(!n)return t[J]=t[J]||e.createHighlighter(),t[J].then(c=>{n=c});const i=ie(e,s.language);return!i||i==="text"||i==="none"||i==="plaintext"||i==="txt"?[]:n.getLoadedLanguages().includes(i)?(o||(o=t[he]||Rt.createParser(n),t[he]=o),o(s)):n.loadLanguage(i)};return Ft.createHighlightPlugin({parser:a,languageExtractor:s=>s.attrs.language,nodeTypes:["codeBlock"]})}const Ue=({defaultLanguage:e="text"})=>({type:"codeBlock",propSchema:{language:{default:e}},content:"inline"}),$e=E(Ue,e=>({meta:{code:!0,defining:!0,isolating:!1},parse:t=>{var r,a;if(t.tagName!=="PRE"||t.childElementCount!==1||((r=t.firstElementChild)==null?void 0:r.tagName)!=="CODE")return;const n=t.firstElementChild;return{language:n.getAttribute("data-language")||((a=n.className.split(" ").find(s=>s.includes("language-")))==null?void 0:a.replace("language-",""))}},parseContent:({el:t,schema:n})=>{const o=R.DOMParser.fromSchema(n),r=t.firstElementChild;return o.parse(r,{preserveWhitespace:"full",topNode:n.nodes.codeBlock.create()}).content},render(t,n){const o=document.createDocumentFragment(),r=document.createElement("pre"),a=document.createElement("code");r.appendChild(a);let s;if(e.supportedLanguages){const i=document.createElement("select");if(Object.entries(e.supportedLanguages??{}).forEach(([l,{name:u}])=>{const d=document.createElement("option");d.value=l,d.text=u,i.appendChild(d)}),i.value=t.props.language||e.defaultLanguage||"text",n.isEditable){const l=u=>{const d=u.target.value;let p;try{p=n.getBlock(t.id)}catch{return}if(!p)return;n.updateBlock(p.id,{props:{language:d}})};i.addEventListener("change",l),s=()=>i.removeEventListener("change",l)}else i.disabled=!0;const c=document.createElement("div");c.contentEditable="false",c.appendChild(i),o.appendChild(c)}return o.appendChild(r),{dom:o,contentDOM:a,destroy:()=>{s==null||s()}}},toExternalHTML(t){const n=document.createElement("pre"),o=document.createElement("code");return o.className=`language-${t.props.language}`,o.dataset.language=t.props.language,n.appendChild(o),{dom:n,contentDOM:o}}}),e=>[y.createExtension({key:"code-block-highlighter",prosemirrorPlugins:[pn(e)]}),y.createExtension({key:"code-block-keyboard-shortcuts",keyboardShortcuts:{Delete:({editor:t})=>t.transact(n=>{const{block:o}=t.getTextCursorPosition();if(o.type!=="codeBlock")return!1;const{$from:r}=n.selection;return r.parent.textContent?!1:(t.removeBlocks([o]),!0)}),Tab:({editor:t})=>e.indentLineWithTab===!1?!1:t.transact(n=>{const{block:o}=t.getTextCursorPosition();return o.type==="codeBlock"?(n.insertText(" "),!0):!1}),Enter:({editor:t})=>t.transact(n=>{const{block:o,nextBlock:r}=t.getTextCursorPosition();if(o.type!=="codeBlock")return!1;const{$from:a}=n.selection,s=a.parentOffset===a.parent.nodeSize-2,i=a.parent.textContent.endsWith("\n ");if(s&&i){if(n.delete(a.pos-2,a.pos),r)return t.setTextCursorPosition(r,"start"),!0;const[c]=t.insertBlocks([{type:"paragraph"}],o,"after");return t.setTextCursorPosition(c,"start"),!0}return n.insertText(` ++`),!0}),"Shift-Enter":({editor:t})=>t.transact(()=>{const{block:n}=t.getTextCursorPosition();if(n.type!=="codeBlock")return!1;const[o]=t.insertBlocks([{type:"paragraph"}],n,"after");return t.setTextCursorPosition(o,"start"),!0})},inputRules:[{find:/^```(.*?)\s$/,replace:({match:t})=>{const n=t[1].trim();return{type:"codeBlock",props:{language:{language:ie(e,n)??n}.language},content:[]}}}]})]);function ie(e,t){var n;return(n=Object.entries(e.supportedLanguages??{}).find(([o,{aliases:r}])=>(r==null?void 0:r.includes(t))||o===t))==null?void 0:n[0]}const je=()=>({type:"divider",propSchema:{},content:"none"}),Ge=E(je,{meta:{isolating:!1},parse(e){if(e.tagName==="HR")return{}},render(){return{dom:document.createElement("hr")}}},[y.createExtension({key:"divider-block-shortcuts",inputRules:[{find:new RegExp("^---$"),replace(){return{type:"divider",props:{},content:[]}}}]})]),me=e=>({url:e.src||void 0}),Ze=()=>({type:"file",propSchema:{backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""}},content:"none"}),ze=()=>e=>{if(e.tagName==="EMBED"){if(e.closest("figure"))return;const{backgroundColor:t}=v(e);return{...me(e),backgroundColor:t}}if(e.tagName==="FIGURE"){const t=X(e,"embed");if(!t)return;const{targetElement:n,caption:o}=t,{backgroundColor:r}=v(e);return{...me(n),backgroundColor:r,caption:o}}},Ke=E(Ze,{meta:{fileBlockAccept:["*/*"]},parse:ze(),render(e,t){return ae(e,t)},toExternalHTML(e){if(!e.props.url){const n=document.createElement("p");return n.textContent="Add file",{dom:n}}const t=document.createElement("a");return t.href=e.props.url,t.textContent=e.props.name||e.props.url,e.props.caption?Q(t,e.props.caption):{dom:t}}}),Xe={set:(e,t)=>window.localStorage.setItem(`toggle-${e.id}`,t?"true":"false"),get:e=>window.localStorage.getItem(`toggle-${e.id}`)==="true"},ce=(e,t,n,o=Xe)=>{if("isToggleable"in e.props&&!e.props.isToggleable)return{dom:n};const r=document.createElement("div"),a=document.createElement("div");a.className="bn-toggle-wrapper";const s=document.createElement("button");s.className="bn-toggle-button",s.type="button",s.innerHTML='';const i=f=>f.preventDefault();s.addEventListener("mousedown",i);const c=()=>{var f;a.getAttribute("data-show-children")==="true"?(a.setAttribute("data-show-children","false"),o.set(t.getBlock(e),!1),r.contains(l)&&r.removeChild(l)):(a.setAttribute("data-show-children","true"),o.set(t.getBlock(e),!0),t.isEditable&&((f=t.getBlock(e))==null?void 0:f.children.length)===0&&!r.contains(l)&&r.appendChild(l))};s.addEventListener("click",c),a.appendChild(s),a.appendChild(n);const l=document.createElement("button");l.className="bn-toggle-add-block-button",l.type="button",l.textContent=t.dictionary.toggle_blocks.add_block_button;const u=f=>f.preventDefault();l.addEventListener("mousedown",u);const d=()=>{t.transact(()=>{const f=t.updateBlock(e,{children:[{}]});t.setTextCursorPosition(f.children[0].id,"end"),t.focus()})};l.addEventListener("click",d),r.appendChild(a);let p=e.children.length;const h=t.onChange(()=>{var x;const f=((x=t.getBlock(e))==null?void 0:x.children.length)??0;f>p?(a.getAttribute("data-show-children")==="false"&&(a.setAttribute("data-show-children","true"),o.set(t.getBlock(e),!0)),r.contains(l)&&r.removeChild(l)):f===0&&ff instanceof MutationRecord&&(f.type==="attributes"&&f.target===a&&f.attributeName==="data-show-children"||f.type==="childList"&&(f.addedNodes[0]===l||f.removedNodes[0]===l)),destroy:()=>{s.removeEventListener("mousedown",i),s.removeEventListener("click",c),l.removeEventListener("mousedown",u),l.removeEventListener("click",d),h==null||h()}}},Qe=[1,2,3,4,5,6],fn=e=>({editor:t})=>{const n=t.getTextCursorPosition();return t.schema.blockSchema[n.block.type].content!=="inline"?!1:(t.updateBlock(n.block,{type:"heading",props:{level:e}}),!0)},Ye=({defaultLevel:e=1,levels:t=Qe,allowToggleHeadings:n=!0}={})=>({type:"heading",propSchema:{...g,level:{default:e,values:t},...n?{isToggleable:{default:!1,optional:!0}}:{}},content:"inline"}),Je=E(Ye,({allowToggleHeadings:e=!0}={})=>({meta:{isolating:!1},parse(t){let n;switch(t.tagName){case"H1":n=1;break;case"H2":n=2;break;case"H3":n=3;break;case"H4":n=4;break;case"H5":n=5;break;case"H6":n=6;break;default:return}return{...v(t),level:n}},render(t,n){const o=document.createElement(`h${t.props.level}`);return e?{...ce(t,n,o),contentDOM:o}:{dom:o,contentDOM:o}},toExternalHTML(t){const n=document.createElement(`h${t.props.level}`);return I(t.props,n),{dom:n,contentDOM:n}}}),({levels:e=Qe}={})=>[y.createExtension({key:"heading-shortcuts",keyboardShortcuts:Object.fromEntries(e.map(t=>[`Mod-Alt-${t}`,fn(t)])??[]),inputRules:e.map(t=>({find:new RegExp(`^(#{${t}})\\s$`),replace({match:n}){return{type:"heading",props:{level:n[1].length}}}}))})]),et=(e,t,n,o,r)=>{const{dom:a,destroy:s}=ae(e,t,n,r),i=a;i.style.position="relative",e.props.url&&e.props.showPreview&&(e.props.previewWidth?i.style.width=`${e.props.previewWidth}px`:i.style.width="fit-content");const c=document.createElement("div");c.className="bn-resize-handle",c.style.left="4px";const l=document.createElement("div");l.className="bn-resize-handle",l.style.right="4px";const u=document.createElement("div");u.style.position="absolute",u.style.height="100%",u.style.width="100%";let d,p=e.props.previewWidth;const h=b=>{var ue,pe;if(!d){!t.isEditable&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l));return}let w;const U="touches"in b?b.touches[0].clientX:b.clientX;e.props.textAlignment==="center"?d.handleUsed==="left"?w=d.initialWidth+(d.initialClientX-U)*2:w=d.initialWidth+(U-d.initialClientX)*2:d.handleUsed==="left"?w=d.initialWidth+d.initialClientX-U:w=d.initialWidth+U-d.initialClientX,p=Math.min(Math.max(w,64),((pe=(ue=t.domElement)==null?void 0:ue.firstElementChild)==null?void 0:pe.clientWidth)||Number.MAX_VALUE),i.style.width=`${p}px`},f=b=>{(!b.target||!i.contains(b.target)||!t.isEditable)&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l)),d&&(d=void 0,i.contains(u)&&i.removeChild(u),t.updateBlock(e,{props:{previewWidth:p}}))},x=()=>{t.isEditable&&(o.appendChild(c),o.appendChild(l))},L=b=>{b.relatedTarget===c||b.relatedTarget===l||d||t.isEditable&&o.contains(c)&&o.contains(l)&&(o.removeChild(c),o.removeChild(l))},M=b=>{b.preventDefault(),i.contains(u)||i.appendChild(u);const w="touches"in b?b.touches[0].clientX:b.clientX;d={handleUsed:"left",initialWidth:i.clientWidth,initialClientX:w}},D=b=>{b.preventDefault(),i.contains(u)||i.appendChild(u);const w="touches"in b?b.touches[0].clientX:b.clientX;d={handleUsed:"right",initialWidth:i.clientWidth,initialClientX:w}};return window.addEventListener("mousemove",h),window.addEventListener("touchmove",h),window.addEventListener("mouseup",f),window.addEventListener("touchend",f),i.addEventListener("mouseenter",x),i.addEventListener("mouseleave",L),c.addEventListener("mousedown",M),c.addEventListener("touchstart",M),l.addEventListener("mousedown",D),l.addEventListener("touchstart",D),{dom:i,destroy:()=>{s==null||s(),window.removeEventListener("mousemove",h),window.removeEventListener("touchmove",h),window.removeEventListener("mouseup",f),window.removeEventListener("touchend",f),i.removeEventListener("mouseenter",x),i.removeEventListener("mouseleave",L),c.removeEventListener("mousedown",M),c.removeEventListener("touchstart",M),l.removeEventListener("mousedown",D),l.removeEventListener("touchstart",D)}}},be=e=>{const t=e.src||void 0,n=e.width||void 0,o=e.alt||void 0;return{url:t,previewWidth:n,name:o}},tt='',nt=(e={})=>({type:"image",propSchema:{textAlignment:g.textAlignment,backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0},previewWidth:{default:void 0,type:"number"}},content:"none"}),ot=(e={})=>t=>{if(t.tagName==="IMG"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...be(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"img");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...be(o),backgroundColor:a,caption:r}}},rt=(e={})=>(t,n)=>{const o=document.createElement("div");o.innerHTML=e.icon??tt;const r=document.createElement("div");r.className="bn-visual-media-wrapper";const a=document.createElement("img");return a.className="bn-visual-media",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(s=>{a.src=s}):a.src=t.props.url,a.alt=t.props.name||t.props.caption||"BlockNote image",a.contentEditable="false",a.draggable=!1,r.appendChild(a),et(t,n,{dom:r},r,o.firstElementChild)},at=(e={})=>(t,n)=>{if(!t.props.url){const r=document.createElement("p");return r.textContent="Add image",{dom:r}}let o;return t.props.showPreview?(o=document.createElement("img"),o.src=t.props.url,o.alt=t.props.name||t.props.caption||"BlockNote image",t.props.previewWidth&&(o.width=t.props.previewWidth)):(o=document.createElement("a"),o.href=t.props.url,o.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(o,t.props.caption):Q(o,t.props.caption):{dom:o}},st=E(nt,e=>({meta:{fileBlockAccept:["image/*"]},parse:ot(e),render:rt(e),toExternalHTML:at(e),runsBefore:["file"]})),gn=(e,t,n)=>({state:o,dispatch:r})=>r?it(o.tr,e,t,n):!0,it=(e,t,n,o)=>{const r=m.getNearestBlockPos(e.doc,t),a=m.getBlockInfo(r);if(!a.isBlockContainer)return!1;const s=m.getPmSchema(e),i=[{type:a.bnBlock.node.type,attrs:o?{...a.bnBlock.node.attrs,id:void 0}:{}},{type:n?a.blockContent.node.type:s.nodes.paragraph,attrs:o?{...a.blockContent.node.attrs}:{}}];return e.split(t,2,i),!0},Y=(e,t)=>{const{blockInfo:n,selectionEmpty:o}=e.transact(s=>({blockInfo:m.getBlockInfoFromTransaction(s),selectionEmpty:s.selection.anchor===s.selection.head}));if(!n.isBlockContainer)return!1;const{bnBlock:r,blockContent:a}=n;return a.node.type.name!==t||!o?!1:a.node.childCount===0?(e.transact(s=>{K(s,r.beforePos,{type:"paragraph",props:{}})}),!0):a.node.childCount>0?e.transact(s=>(s.deleteSelection(),it(s,s.selection.from,!0))):!1};function le(e,t,n){var d,p,h;const o=N.DOMParser.fromSchema(t),r=e,a=document.createElement("div");a.setAttribute("data-node-type","blockGroup");for(const f of Array.from(r.childNodes))a.appendChild(f.cloneNode(!0));let s=o.parse(a,{topNode:t.nodes.blockGroup.create()});((p=(d=s.firstChild)==null?void 0:d.firstChild)==null?void 0:p.type.name)==="checkListItem"&&(s=s.copy(s.content.cut(s.firstChild.firstChild.nodeSize+2)));const i=(h=s.firstChild)==null?void 0:h.firstChild;if(!(i!=null&&i.isTextblock))return N.Fragment.from(s);const c=t.nodes[n].create({},i.content),l=s.content.cut(i.nodeSize+2);if(l.size>0){const f=s.copy(l);return c.content.addToEnd(f)}return c.content}const ct=()=>({type:"bulletListItem",propSchema:{...g},content:"inline"}),lt=E(ct,{meta:{isolating:!1},parse(e){var n;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="UL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="UL"))return v(e)},parseContent:({el:e,schema:t})=>le(e,t,"bulletListItem"),render(){const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"bullet-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"bulletListItem"),"Mod-Shift-8":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"bulletListItem",props:{}}),!0)}},inputRules:[{find:/^\s?[-+*]\s$/,replace({editor:e}){if(m.getBlockInfoFromSelection(e.prosemirrorState).blockNoteType!=="heading")return{type:"bulletListItem",props:{}}}}]})]),dt=()=>({type:"checkListItem",propSchema:{...g,checked:{default:!1,type:"boolean"}},content:"inline"}),ut=E(dt,{meta:{isolating:!1},parse(e){var n;if(e.tagName==="input")return e.closest("[data-content-type]")||e.closest("li")?void 0:e.type==="checkbox"?{checked:e.checked}:void 0;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="UL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="UL")){const o=e.querySelector("input[type=checkbox]")||null;return o===null?void 0:{...v(e),checked:o.checked}}},parseContent:({el:e,schema:t})=>le(e,t,"checkListItem"),render(e,t){const n=document.createDocumentFragment(),o=document.createElement("input");o.type="checkbox",o.checked=e.props.checked,e.props.checked&&o.setAttribute("checked",""),o.addEventListener("change",()=>{let r;try{r=t.getBlock(e.id)}catch{return}if(!r)return;t.updateBlock(r,{props:{checked:!r.props.checked}})});const r=document.createElement("p"),a=document.createElement("div");return a.contentEditable="false",a.appendChild(o),n.appendChild(a),n.appendChild(r),{dom:n,contentDOM:r}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("input");n.type="checkbox",n.checked=e.props.checked,e.props.checked&&n.setAttribute("checked","");const o=document.createElement("p");return I(e.props,t),t.appendChild(n),t.appendChild(o),{dom:t,contentDOM:o}},runsBefore:["bulletListItem"]},[y.createExtension({key:"check-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"checkListItem"),"Mod-Shift-9":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"checkListItem",props:{}}),!0)}},inputRules:[{find:/^\s?\[\s*\]\s$/,replace(){return{type:"checkListItem",props:{checked:!1}}}},{find:/^\s?\[[Xx]\]\s$/,replace(){return{type:"checkListItem",props:{checked:!0}}}}]})]);function pt(e,t,n,o){let r=e.firstChild.attrs.start||1,a=!0;const s=!!e.firstChild.attrs.start,i=m.getBlockInfo({posBeforeNode:t,node:e});if(!i.isBlockContainer)throw new Error("impossible");const c=n.doc.resolve(i.bnBlock.beforePos).nodeBefore,l=c?o.get(c):void 0;return l!==void 0?(r=l+1,a=!1):c&&m.getBlockInfo({posBeforeNode:i.bnBlock.beforePos-c.nodeSize,node:c}).blockNoteType==="numberedListItem"&&(r=pt(c,i.bnBlock.beforePos-c.nodeSize,n,o).index+1,a=!1),o.set(e,r),{index:r,isFirst:a,hasStart:s}}function Ce(e,t){const n=new Map,o=t.decorations.map(e.mapping,e.doc),r=[];e.doc.nodesBetween(0,e.doc.nodeSize-2,(s,i)=>{if(s.type.name==="blockContainer"&&s.firstChild.type.name==="numberedListItem"){const{index:c,isFirst:l,hasStart:u}=pt(s,i,e,n);if(o.find(i,i+s.nodeSize,p=>p.index===c&&p.isFirst===l&&p.hasStart===u).length===0){const p=e.doc.nodeAt(i+1);r.push(ee.Decoration.node(i+1,i+1+p.nodeSize,{"data-index":c.toString()}))}}});const a=r.flatMap(s=>o.find(s.from,s.to));return{decorations:o.remove(a).add(e.doc,r)}}const hn=()=>new fe.Plugin({key:new fe.PluginKey("numbered-list-indexing-decorations"),state:{init(e,t){return Ce(t.tr,{decorations:ee.DecorationSet.empty})},apply(e,t){return!e.docChanged&&!e.selectionSet&&t.decorations?t:Ce(e,t)}},props:{decorations(e){var t;return((t=this.getState(e))==null?void 0:t.decorations)??ee.DecorationSet.empty}}}),ft=()=>({type:"numberedListItem",propSchema:{...g,start:{default:void 0,type:"number"}},content:"inline"}),gt=E(ft,{meta:{isolating:!1},parse(e){var n;if(e.tagName!=="LI")return;const t=e.parentElement;if(t!==null&&(t.tagName==="OL"||t.tagName==="DIV"&&((n=t.parentElement)==null?void 0:n.tagName)==="OL")){const o=parseInt(t.getAttribute("start")||"1"),r=v(e);return e.previousElementSibling||o===1?r:{...r,start:o}}},parseContent:({el:e,schema:t})=>le(e,t,"numberedListItem"),render(){const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"numbered-list-item-shortcuts",inputRules:[{find:/^\s?(\d+)\.\s$/,replace({match:e,editor:t}){if(m.getBlockInfoFromSelection(t.prosemirrorState).blockNoteType==="heading")return;const o=parseInt(e[1]);return{type:"numberedListItem",props:{start:o!==1?o:void 0}}}}],keyboardShortcuts:{Enter:({editor:e})=>Y(e,"numberedListItem"),"Mod-Shift-7":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"numberedListItem",props:{}}),!0)}},prosemirrorPlugins:[hn()]})]),ht=()=>({type:"toggleListItem",propSchema:{...g},content:"inline"}),mt=E(ht,{meta:{isolating:!1},render(e,t){const n=document.createElement("p");return{...ce(e,t,n),contentDOM:n}},toExternalHTML(e){const t=document.createElement("li"),n=document.createElement("p");return I(e.props,t),t.appendChild(n),{dom:t,contentDOM:n}}},[y.createExtension({key:"toggle-list-item-shortcuts",keyboardShortcuts:{Enter:({editor:e})=>Y(e,"toggleListItem"),"Mod-Shift-6":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"toggleListItem",props:{}}),!0)}}})]),bt=()=>({type:"paragraph",propSchema:g,content:"inline"}),Ct=E(bt,{meta:{isolating:!1},parse:e=>{var t;if(e.tagName==="P"&&(t=e.textContent)!=null&&t.trim())return v(e)},render:()=>{const e=document.createElement("p");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("p");return I(e.props,t),{dom:t,contentDOM:t}},runsBefore:["default"]},[y.createExtension({key:"paragraph-shortcuts",keyboardShortcuts:{"Mod-Alt-0":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"paragraph",props:{}}),!0)}}})]),kt=()=>({type:"quote",propSchema:{backgroundColor:g.backgroundColor,textColor:g.textColor},content:"inline"}),yt=E(kt,{meta:{isolating:!1},parse(e){if(e.tagName==="BLOCKQUOTE"){const{backgroundColor:t,textColor:n}=v(e);return{backgroundColor:t,textColor:n}}},render(){const e=document.createElement("blockquote");return{dom:e,contentDOM:e}},toExternalHTML(e){const t=document.createElement("blockquote");return I(e.props,t),{dom:t,contentDOM:t}}},[y.createExtension({key:"quote-block-shortcuts",keyboardShortcuts:{"Mod-Alt-q":({editor:e})=>{const t=e.getTextCursorPosition();return e.schema.blockSchema[t.block.type].content!=="inline"?!1:(e.updateBlock(t.block,{type:"quote",props:{}}),!0)}},inputRules:[{find:new RegExp("^>\\s$"),replace(){return{type:"quote",props:{}}}}]})]),mn=35,de=120,bn=31,Cn=S.Extension.create({name:"BlockNoteTableExtension",addProseMirrorPlugins:()=>[T.columnResizing({cellMinWidth:mn,defaultCellMinWidth:de,View:null}),T.tableEditing()],addKeyboardShortcuts(){return{Enter:()=>this.editor.state.selection.empty&&this.editor.state.selection.$head.parent.type.name==="tableParagraph"?(this.editor.commands.insertContent({type:"hardBreak"}),!0):!1,Backspace:()=>{const e=this.editor.state.selection,t=e.empty,n=e.$head.parentOffset===0,o=e.$head.node().type.name==="tableParagraph";return t&&n&&o},Tab:()=>this.editor.commands.command(({state:e,dispatch:t,view:n})=>T.goToNextCell(1)(e,t,n)),"Shift-Tab":()=>this.editor.commands.command(({state:e,dispatch:t,view:n})=>T.goToNextCell(-1)(e,t,n))}},extendNodeSchema(e){const t={name:e.name,options:e.options,storage:e.storage};return{tableRole:S.callOrReturn(S.getExtensionField(e,"tableRole",t))}}}),vt={textColor:g.textColor},kn=S.Node.create({name:"tableHeader",addOptions(){return{HTMLAttributes:{}}},content:"tableContent+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:e=>{const t=e.getAttribute("colwidth");return t?t.split(",").map(o=>parseInt(o,10)):null}}}},tableRole:"header_cell",isolating:!0,parseHTML(){return[{tag:"th",getContent:(e,t)=>St(e,t)}]},renderHTML({HTMLAttributes:e}){return["th",S.mergeAttributes(this.options.HTMLAttributes,e),0]}}),yn=S.Node.create({name:"tableCell",addOptions(){return{HTMLAttributes:{}}},content:"tableContent+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:e=>{const t=e.getAttribute("colwidth");return t?t.split(",").map(o=>parseInt(o,10)):null}}}},tableRole:"cell",isolating:!0,parseHTML(){return[{tag:"td",getContent:(e,t)=>St(e,t)}]},renderHTML({HTMLAttributes:e}){return["td",S.mergeAttributes(this.options.HTMLAttributes,e),0]}}),vn=S.Node.create({name:"table",content:"tableRow+",group:"blockContent",tableRole:"table",marks:"deletion insertion modification",isolating:!0,parseHTML(){return[{tag:"table"}]},renderHTML({node:e,HTMLAttributes:t}){var r,a,s;const n=Se(this.name,"table",{...((r=this.options.domAttributes)==null?void 0:r.blockContent)||{},...t},((a=this.options.domAttributes)==null?void 0:a.inlineContent)||{}),o=document.createElement("colgroup");for(const i of e.children[0].children)if(i.attrs.colwidth)for(const l of i.attrs.colwidth){const u=document.createElement("col");l&&(u.style=`width: ${l}px`),o.appendChild(u)}else o.appendChild(document.createElement("col"));return(s=n.dom.firstChild)==null||s.appendChild(o),n},addNodeView(){return({node:e,HTMLAttributes:t})=>{var o;class n extends T.TableView{constructor(a,s,i){super(a,s),this.node=a,this.cellMinWidth=s,this.blockContentHTMLAttributes=i;const c=document.createElement("div");c.className=V("bn-block-content",i.class),c.setAttribute("data-content-type","table");for(const[p,h]of Object.entries(i))p!=="class"&&c.setAttribute(p,h);const l=this.dom,u=document.createElement("div");u.className="tableWrapper-inner",u.appendChild(l.firstChild),l.appendChild(u),c.appendChild(l);const d=document.createElement("div");d.className="table-widgets-container",d.style.position="relative",l.appendChild(d),this.dom=c}ignoreMutation(a){return!a.target.closest(".tableWrapper-inner")||super.ignoreMutation(a)}}return new n(e,de,{...((o=this.options.domAttributes)==null?void 0:o.blockContent)||{},...t})}}}),Sn=S.Node.create({name:"tableParagraph",group:"tableContent",content:"inline*",parseHTML(){return[{tag:"p",getAttrs:e=>{if(typeof e=="string"||!e.textContent||!e.closest("[data-content-type]"))return!1;const t=e.parentElement;return t===null?!1:t.tagName==="TD"||t.tagName==="TH"?{}:!1},node:"tableParagraph"}]},renderHTML({HTMLAttributes:e}){return["p",e,0]}}),En=S.Node.create({name:"tableRow",addOptions(){return{HTMLAttributes:{}}},content:"(tableCell | tableHeader)+",tableRole:"row",marks:"deletion insertion modification",parseHTML(){return[{tag:"tr"}]},renderHTML({HTMLAttributes:e}){return["tr",S.mergeAttributes(this.options.HTMLAttributes,e),0]}});function St(e,t){const o=N.DOMParser.fromSchema(t).parse(e,{topNode:t.nodes.blockGroup.create()}),r=[];return o.content.descendants(a=>{if(a.isInline)return r.push(a),!1}),N.Fragment.fromArray(r)}const Et=()=>Le({node:vn,type:"table",content:"table"},vt,[y.createExtension({key:"table-extensions",tiptapExtensions:[Cn,Sn,kn,yn,En]}),y.createExtension({key:"table-keyboard-delete",keyboardShortcuts:{Backspace:({editor:e})=>{if(!(e.prosemirrorState.selection instanceof T.CellSelection))return!1;const t=e.getTextCursorPosition().block,n=t.content;let o=0;for(const a of n.rows)for(const s of a.cells){if("type"in s&&s.content.length>0||!("type"in s)&&s.length>0)return!1;o++}let r=0;return e.prosemirrorState.selection.forEachCell(()=>{r++}),r{(e.getPrevBlock(t)||e.getNextBlock(t))&&e.setTextCursorPosition(t),e.removeBlocks([t])}),!0)}}})]),ke=e=>{const t=e.src||void 0,n=e.width||void 0;return{url:t,previewWidth:n}},xt='',Mt=e=>({type:"video",propSchema:{textAlignment:g.textAlignment,backgroundColor:g.backgroundColor,name:{default:""},url:{default:""},caption:{default:""},showPreview:{default:!0},previewWidth:{default:void 0,type:"number"}},content:"none"}),Lt=e=>t=>{if(t.tagName==="VIDEO"){if(t.closest("figure"))return;const{backgroundColor:n}=v(t);return{...ke(t),backgroundColor:n}}if(t.tagName==="FIGURE"){const n=X(t,"video");if(!n)return;const{targetElement:o,caption:r}=n,{backgroundColor:a}=v(t);return{...ke(o),backgroundColor:a,caption:r}}},wt=E(Mt,e=>({meta:{fileBlockAccept:["video/*"]},parse:Lt(),render(t,n){const o=document.createElement("div");o.innerHTML=e.icon??xt;const r=document.createElement("div");r.className="bn-visual-media-wrapper";const a=document.createElement("video");return a.className="bn-visual-media",n.resolveFileUrl?n.resolveFileUrl(t.props.url).then(s=>{a.src=s}):a.src=t.props.url,a.controls=!0,a.contentEditable="false",a.draggable=!1,a.width=t.props.previewWidth,r.appendChild(a),et(t,n,{dom:r},r,o.firstElementChild)},toExternalHTML(t){if(!t.props.url){const o=document.createElement("p");return o.textContent="Add video",{dom:o}}let n;return t.props.showPreview?(n=document.createElement("video"),n.src=t.props.url,t.props.previewWidth&&(n.width=t.props.previewWidth)):(n=document.createElement("a"),n.href=t.props.url,n.textContent=t.props.name||t.props.url),t.props.caption?t.props.showPreview?se(n,t.props.caption):Q(n,t.props.caption):{dom:n}},runsBefore:["file"]}));function C(e,t,n){if(!(t in e.schema.blockSpecs))return!1;if(!n)return!0;for(const[o,r]of Object.entries(n)){if(!(o in e.schema.blockSpecs[t].config.propSchema))return!1;if(typeof r=="string"){if(e.schema.blockSpecs[t].config.propSchema[o].default!==void 0&&typeof e.schema.blockSpecs[t].config.propSchema[o].default!==r||e.schema.blockSpecs[t].config.propSchema[o].type!==void 0&&e.schema.blockSpecs[t].config.propSchema[o].type!==r)return!1}else{if(e.schema.blockSpecs[t].config.propSchema[o].default!==r.default||e.schema.blockSpecs[t].config.propSchema[o].default===void 0&&r.default===void 0&&e.schema.blockSpecs[t].config.propSchema[o].type!==r.type||typeof e.schema.blockSpecs[t].config.propSchema[o].values!=typeof r.values)return!1;if(typeof e.schema.blockSpecs[t].config.propSchema[o].values=="object"&&typeof r.values=="object"){for(const a of r.values)if(!e.schema.blockSpecs[t].config.propSchema[o].values.includes(a))return!1}}}return!0}function xn(e,t,n,o){return C(t,n,o)&&e.type===n}function Mn(e){return e instanceof T.CellSelection}const G=new Map;function Ln(e){if(G.has(e))return G.get(e);const t=new ye.Mapping;return e._tiptapEditor.on("transaction",({transaction:n})=>{t.appendMapping(n.mapping)}),e._tiptapEditor.on("destroy",()=>{G.delete(e)}),G.set(e,t),t}function Bt(e,t,n="left"){const o=j.ySyncPluginKey.getState(e.prosemirrorState);if(!o){const a=Ln(e),s=a.maps.length;return()=>a.slice(s).map(t,n==="left"?-1:1)}const r=j.absolutePositionToRelativePosition(t+(n==="right"?1:-1),o.binding.type,o.binding.mapping);return()=>{const a=j.ySyncPluginKey.getState(e.prosemirrorState),s=j.relativePositionToAbsolutePosition(a.doc,a.binding.type,r,a.binding.mapping);if(s===null)throw new Error("Position not found, cannot track positions");return s+(n==="right"?-1:1)}}const wn=S.findParentNode(e=>e.type.name==="blockContainer");class Bn{constructor(t,n,o){P(this,"state");P(this,"emitUpdate");P(this,"rootEl");P(this,"pluginState");P(this,"handleScroll",()=>{var t,n;if((t=this.state)!=null&&t.show){const o=(n=this.rootEl)==null?void 0:n.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`);if(!o)return;this.state.referencePos=o.getBoundingClientRect().toJSON(),this.emitUpdate(this.pluginState.triggerCharacter)}});P(this,"closeMenu",()=>{this.editor.transact(t=>t.setMeta(H,null))});P(this,"clearQuery",()=>{this.pluginState!==void 0&&this.editor._tiptapEditor.chain().focus().deleteRange({from:this.pluginState.queryStartPos()-(this.pluginState.deleteTriggerCharacter?this.pluginState.triggerCharacter.length:0),to:this.editor.transact(t=>t.selection.from)}).run()});var r;this.editor=t,this.pluginState=void 0,this.emitUpdate=a=>{var s;if(!this.state)throw new Error("Attempting to update uninitialized suggestions menu");n(a,{...this.state,ignoreQueryLength:(s=this.pluginState)==null?void 0:s.ignoreQueryLength})},this.rootEl=o.root,(r=this.rootEl)==null||r.addEventListener("scroll",this.handleScroll,!0)}update(t,n){var l;const o=H.getState(n),r=H.getState(t.state),a=o===void 0&&r!==void 0,s=o!==void 0&&r===void 0;if(!a&&!(o!==void 0&&r!==void 0)&&!s)return;if(this.pluginState=s?o:r,s||!this.editor.isEditable){this.state&&(this.state.show=!1),this.emitUpdate(this.pluginState.triggerCharacter);return}const c=(l=this.rootEl)==null?void 0:l.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`);this.editor.isEditable&&c&&(this.state={show:!0,referencePos:c.getBoundingClientRect().toJSON(),query:this.pluginState.query},this.emitUpdate(this.pluginState.triggerCharacter))}destroy(){var t;(t=this.rootEl)==null||t.removeEventListener("scroll",this.handleScroll,!0)}}const H=new z.PluginKey("SuggestionMenuPlugin"),Tt=y.createExtension(({editor:e})=>{const t=[];let n;const o=y.createStore(void 0);return{key:"suggestionMenu",store:o,addTriggerCharacter:r=>{t.push(r)},removeTriggerCharacter:r=>{t.splice(t.indexOf(r),1)},closeMenu:()=>{n==null||n.closeMenu()},clearQuery:()=>{n==null||n.clearQuery()},shown:()=>{var r;return((r=n==null?void 0:n.state)==null?void 0:r.show)||!1},openSuggestionMenu:(r,a)=>{e.headless||(e.focus(),e.transact(s=>{a!=null&&a.deleteTriggerCharacter&&s.insertText(r),s.scrollIntoView().setMeta(H,{triggerCharacter:r,deleteTriggerCharacter:(a==null?void 0:a.deleteTriggerCharacter)||!1,ignoreQueryLength:(a==null?void 0:a.ignoreQueryLength)||!1})}))},prosemirrorPlugins:[new z.Plugin({key:H,view:r=>(n=new Bn(e,(a,s)=>{o.setState({...s,triggerCharacter:a})},r),n),state:{init(){},apply:(r,a,s,i)=>{if(r.selection.$from.parent.type.spec.code)return a;const c=r.getMeta(H);if(typeof c=="object"&&c!==null){a&&(n==null||n.closeMenu());const u=Bt(e,i.selection.from-c.triggerCharacter.length);return{triggerCharacter:c.triggerCharacter,deleteTriggerCharacter:c.deleteTriggerCharacter!==!1,queryStartPos:()=>u()+c.triggerCharacter.length,query:"",decorationId:`id_${Math.floor(Math.random()*4294967295)}`,ignoreQueryLength:c==null?void 0:c.ignoreQueryLength}}if(a===void 0)return a;if(i.selection.from!==i.selection.to||c===null||r.getMeta("focus")||r.getMeta("blur")||r.getMeta("pointer")||a.triggerCharacter!==void 0&&i.selection.from1?c.textBetween(a-l.length,a)+i:i;if(l===u)return r.dispatch(r.state.tr.insertText(i)),r.dispatch(r.state.tr.setMeta(H,{triggerCharacter:u}).scrollIntoView()),!0}}return!1},decorations(r){const a=this.getState(r);if(a===void 0)return null;if(!a.deleteTriggerCharacter){const s=wn(r.selection);if(s)return $.DecorationSet.create(r.doc,[$.Decoration.node(s.pos,s.pos+s.node.nodeSize,{nodeName:"span",class:"bn-suggestion-decorator","data-decoration-id":a.decorationId})])}return $.DecorationSet.create(r.doc,[$.Decoration.inline(a.queryStartPos()-a.triggerCharacter.length,a.queryStartPos(),{nodeName:"span",class:"bn-suggestion-decorator","data-decoration-id":a.decorationId})])}}})]}});function Tn(e){let t=e.getTextCursorPosition().block,n=e.schema.blockSchema[t.type].content;for(;n==="none";){if(t=e.getTextCursorPosition().nextBlock,t===void 0)return;n=e.schema.blockSchema[t.type].content,e.setTextCursorPosition(t,"end")}}function k(e,t){const n=e.getTextCursorPosition().block;if(n.content===void 0)throw new Error("Slash Menu open in a block that doesn't contain content.");let o;return Array.isArray(n.content)&&(n.content.length===1&&m.isStyledTextInlineContent(n.content[0])&&n.content[0].type==="text"&&n.content[0].text==="/"||n.content.length===0)?(o=e.updateBlock(n,t),e.setTextCursorPosition(o)):(o=e.insertBlocks([t],n,"after")[0],e.setTextCursorPosition(e.getTextCursorPosition().nextBlock)),Tn(e),o}function An(e){const t=[];return C(e,"heading",{level:"number"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n<=3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n}})},badge:B(`Mod-Alt-${n}`),key:n===1?"heading":`heading_${n}`,...e.dictionary.slash_menu[n===1?"heading":`heading_${n}`]})}),C(e,"quote")&&t.push({onItemClick:()=>{k(e,{type:"quote"})},key:"quote",...e.dictionary.slash_menu.quote}),C(e,"toggleListItem")&&t.push({onItemClick:()=>{k(e,{type:"toggleListItem"})},badge:B("Mod-Shift-6"),key:"toggle_list",...e.dictionary.slash_menu.toggle_list}),C(e,"numberedListItem")&&t.push({onItemClick:()=>{k(e,{type:"numberedListItem"})},badge:B("Mod-Shift-7"),key:"numbered_list",...e.dictionary.slash_menu.numbered_list}),C(e,"bulletListItem")&&t.push({onItemClick:()=>{k(e,{type:"bulletListItem"})},badge:B("Mod-Shift-8"),key:"bullet_list",...e.dictionary.slash_menu.bullet_list}),C(e,"checkListItem")&&t.push({onItemClick:()=>{k(e,{type:"checkListItem"})},badge:B("Mod-Shift-9"),key:"check_list",...e.dictionary.slash_menu.check_list}),C(e,"paragraph")&&t.push({onItemClick:()=>{k(e,{type:"paragraph"})},badge:B("Mod-Alt-0"),key:"paragraph",...e.dictionary.slash_menu.paragraph}),C(e,"codeBlock")&&t.push({onItemClick:()=>{k(e,{type:"codeBlock"})},badge:B("Mod-Alt-c"),key:"code_block",...e.dictionary.slash_menu.code_block}),C(e,"divider")&&t.push({onItemClick:()=>{k(e,{type:"divider"})},key:"divider",...e.dictionary.slash_menu.divider}),C(e,"table")&&t.push({onItemClick:()=>{k(e,{type:"table",content:{type:"tableContent",rows:[{cells:["","",""]},{cells:["","",""]}]}})},badge:void 0,key:"table",...e.dictionary.slash_menu.table}),C(e,"image",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"image"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"image",...e.dictionary.slash_menu.image}),C(e,"video",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"video"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"video",...e.dictionary.slash_menu.video}),C(e,"audio",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"audio"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"audio",...e.dictionary.slash_menu.audio}),C(e,"file",{url:"string"})&&t.push({onItemClick:()=>{var o;const n=k(e,{type:"file"});(o=e.getExtension(O))==null||o.showMenu(n.id)},key:"file",...e.dictionary.slash_menu.file}),C(e,"heading",{level:"number",isToggleable:"boolean"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n<=3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n,isToggleable:!0}})},key:n===1?"toggle_heading":`toggle_heading_${n}`,...e.dictionary.slash_menu[n===1?"toggle_heading":`toggle_heading_${n}`]})}),C(e,"heading",{level:"number"})&&(e.schema.blockSchema.heading.propSchema.level.values||[]).filter(n=>n>3).forEach(n=>{t.push({onItemClick:()=>{k(e,{type:"heading",props:{level:n}})},badge:B(`Mod-Alt-${n}`),key:`heading_${n}`,...e.dictionary.slash_menu[`heading_${n}`]})}),t.push({onItemClick:()=>{var n;(n=e.getExtension(Tt))==null||n.openSuggestionMenu(":",{deleteTriggerCharacter:!0,ignoreQueryLength:!0})},key:"emoji",...e.dictionary.slash_menu.emoji}),t}function Pn(e,t){return e.filter(({title:n,aliases:o})=>n.toLowerCase().includes(t.toLowerCase())||o&&o.filter(r=>r.toLowerCase().includes(t.toLowerCase())).length!==0)}const Nn={audio:qe(),bulletListItem:lt(),checkListItem:ut(),codeBlock:$e(),divider:Ge(),file:Ke(),heading:Je(),image:st(),numberedListItem:gt(),paragraph:Ct(),quote:yt(),table:Et(),toggleListItem:mt(),video:wt()},In=re({type:"textColor",propSchema:"string"},{render:()=>{const e=document.createElement("span");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("span");return e!==g.textColor.default&&(t.style.color=e in A?A[e].text:e),{dom:t,contentDOM:t}},parse:e=>{if(e.tagName==="SPAN"&&e.style.color)return e.style.color}}),Hn=re({type:"backgroundColor",propSchema:"string"},{render:()=>{const e=document.createElement("span");return{dom:e,contentDOM:e}},toExternalHTML:e=>{const t=document.createElement("span");return e!==g.backgroundColor.default&&(t.style.backgroundColor=e in A?A[e].background:e),{dom:t,contentDOM:t}},parse:e=>{if(e.tagName==="SPAN"&&e.style.backgroundColor)return e.style.backgroundColor}}),At={bold:_(Wt.default,"boolean"),italic:_(Ut.default,"boolean"),underline:_(jt.default,"boolean"),strike:_($t.default,"boolean"),code:_(qt.default,"boolean"),textColor:In,backgroundColor:Hn},Dn=Ne(At),Pt={text:{config:"text",implementation:{}},link:{config:"link",implementation:{}}},_n=Ae(Pt);exports.COLORS_DARK_MODE_DEFAULT=rn;exports.COLORS_DEFAULT=A;exports.EMPTY_CELL_HEIGHT=bn;exports.EMPTY_CELL_WIDTH=de;exports.FILE_AUDIO_ICON_SVG=Oe;exports.FILE_IMAGE_ICON_SVG=tt;exports.FILE_VIDEO_ICON_SVG=xt;exports.FilePanelExtension=O;exports.SuggestionMenu=Tt;exports.addDefaultPropsExternalHTML=I;exports.addInlineContentAttributes=Qt;exports.addInlineContentKeyboardShortcuts=Yt;exports.addNodeAndExtensionsToSpec=Kt;exports.addStyleAttributes=F;exports.applyNonSelectableBlockFix=we;exports.audioParse=Fe;exports.audioRender=Re;exports.audioToExternalHTML=We;exports.blockHasType=xn;exports.camelToDataKebab=W;exports.captureCellAnchor=_e;exports.createAudioBlockConfig=Ve;exports.createAudioBlockSpec=qe;exports.createBlockConfig=Xt;exports.createBlockSpec=E;exports.createBlockSpecFromTiptapNode=Le;exports.createBulletListItemBlockConfig=ct;exports.createBulletListItemBlockSpec=lt;exports.createCheckListItemBlockSpec=ut;exports.createCheckListItemConfig=dt;exports.createCodeBlockConfig=Ue;exports.createCodeBlockSpec=$e;exports.createDefaultBlockDOMOutputSpec=Se;exports.createDividerBlockConfig=je;exports.createDividerBlockSpec=Ge;exports.createFileBlockConfig=Ze;exports.createFileBlockSpec=Ke;exports.createHeadingBlockConfig=Ye;exports.createHeadingBlockSpec=Je;exports.createImageBlockConfig=nt;exports.createImageBlockSpec=st;exports.createInlineContentSpecFromTipTapNode=Jt;exports.createInternalInlineContentSpec=Te;exports.createInternalStyleSpec=oe;exports.createNumberedListItemBlockConfig=ft;exports.createNumberedListItemBlockSpec=gt;exports.createParagraphBlockConfig=bt;exports.createParagraphBlockSpec=Ct;exports.createQuoteBlockConfig=kt;exports.createQuoteBlockSpec=yt;exports.createStyleSpec=re;exports.createStyleSpecFromTipTapMark=_;exports.createTableBlockSpec=Et;exports.createToggleListItemBlockConfig=ht;exports.createToggleListItemBlockSpec=mt;exports.createToggleWrapper=ce;exports.createVideoBlockConfig=Mt;exports.createVideoBlockSpec=wt;exports.defaultBlockSpecs=Nn;exports.defaultBlockToHTML=te;exports.defaultInlineContentSchema=_n;exports.defaultInlineContentSpecs=Pt;exports.defaultProps=g;exports.defaultStyleSchema=Dn;exports.defaultStyleSpecs=At;exports.defaultToggledState=Xe;exports.editorHasBlockWithType=C;exports.fileParse=ze;exports.filenameFromURL=Zt;exports.filterSuggestionItems=Pn;exports.formatKeyboardShortcut=B;exports.getBackgroundColorAttribute=an;exports.getBlockFromPos=Me;exports.getDefaultSlashMenuItems=An;exports.getInlineContentSchemaFromSpecs=Ae;exports.getLanguageId=ie;exports.getNodeById=He;exports.getParseRules=Be;exports.getStyleParseRules=Ie;exports.getStyleSchemaFromSpecs=Ne;exports.getTextAlignmentAttribute=cn;exports.getTextColorAttribute=sn;exports.imageParse=ot;exports.imageRender=rt;exports.imageToExternalHTML=at;exports.insertOrUpdateBlockForSlashMenu=k;exports.isAppleOS=ve;exports.isNodeBlock=De;exports.isSafari=Gt;exports.isTableCellSelection=Mn;exports.isVideoUrl=zt;exports.mergeCSSClasses=V;exports.mergeParagraphs=Ee;exports.parseAudioElement=ne;exports.parseDefaultProps=v;exports.propsToAttributes=xe;exports.splitBlockCommand=gn;exports.stylePropsToAttributes=Pe;exports.tablePropSchema=vt;exports.trackPosition=Bt;exports.updateBlock=nn;exports.updateBlockCommand=en;exports.updateBlockTr=K;exports.videoParse=Lt;exports.wrapInBlockStructure=Z; + //# sourceMappingURL=defaultBlocks-DosClM5E.cjs.map +diff --git a/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +index fb993f8309d9509df9b2dea85456dd587157d13a..d6dc4a08714d441ccadc297189a4ff8b20bc26ad 100644 +--- a/src/api/exporters/html/util/serializeBlocksExternalHTML.ts ++++ b/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +@@ -116,7 +116,7 @@ export function serializeInlineContentExternalHTML< + node.textContent, + ); + // Reverse the order of marks to maintain the correct priority. +- for (const mark of node.marks.toReversed()) { ++ for (const mark of Array.from(node.marks).reverse()) { + if (mark.type.name in editor.schema.styleSpecs) { + const newDom = ( + editor.schema.styleSpecs[mark.type.name].implementation +diff --git a/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +index 0f890b77abec5ec6271b1280368a0efdd1be2aa5..9f0d95e21b9758bd04fd70d7fc542d31dff58d25 100644 +--- a/src/api/exporters/html/util/serializeBlocksInternalHTML.ts ++++ b/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +@@ -97,7 +97,7 @@ export function serializeInlineContentInternalHTML< + node.textContent, + ); + // Reverse the order of marks to maintain the correct priority. +- for (const mark of node.marks.toReversed()) { ++ for (const mark of Array.from(node.marks).reverse()) { + if (mark.type.name in editor.schema.styleSpecs) { + const newDom = editor.schema.styleSpecs[ + mark.type.name +diff --git a/src/blocks/Code/block.ts b/src/blocks/Code/block.ts +index dbb7fc33a9add7a96488349876bc56ad60111a3f..299586cf484d5ba8a693478d7b3487ee1da4a12f 100644 +--- a/src/blocks/Code/block.ts ++++ b/src/blocks/Code/block.ts +@@ -134,7 +134,18 @@ export const createCodeBlockSpec = createBlockSpec( + const handleLanguageChange = (event: Event) => { + const language = (event.target as HTMLSelectElement).value; + +- editor.updateBlock(block.id, { props: { language } }); ++ let liveBlock; ++ try { ++ liveBlock = editor.getBlock(block.id); ++ } catch { ++ return; ++ } ++ ++ if (!liveBlock) { ++ return; ++ } ++ ++ editor.updateBlock(liveBlock.id, { props: { language } }); + }; + select.addEventListener("change", handleLanguageChange); + removeSelectChangeListener = () => +diff --git a/src/blocks/ListItem/CheckListItem/block.ts b/src/blocks/ListItem/CheckListItem/block.ts +index b32ee408eaf81ccbabb39e5b0f95a2fa73358b0f..da424ac4f051b1de81b919c9d03897d0989b78e2 100644 +--- a/src/blocks/ListItem/CheckListItem/block.ts ++++ b/src/blocks/ListItem/CheckListItem/block.ts +@@ -83,7 +83,18 @@ export const createCheckListItemBlockSpec = createBlockSpec( + checkbox.setAttribute("checked", ""); + } + checkbox.addEventListener("change", () => { +- editor.updateBlock(block, { props: { checked: !block.props.checked } }); ++ let liveBlock; ++ try { ++ liveBlock = editor.getBlock(block.id); ++ } catch { ++ return; ++ } ++ ++ if (!liveBlock) { ++ return; ++ } ++ ++ editor.updateBlock(liveBlock, { props: { checked: !liveBlock.props.checked } }); + }); + // We use a

tag, because for

  • tags we'd need a
      element to put + // them in to be semantically correct, which we can't have due to the +diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts +index 45f0acf8e5d869eb13a3c3d70e6f67059f86c598..2a4d15c8e8465181cc009ed9912cd284e0930251 100644 +--- a/src/editor/managers/ExtensionManager/extensions.ts ++++ b/src/editor/managers/ExtensionManager/extensions.ts +@@ -84,7 +84,10 @@ export function getDefaultTiptapExtensions( + }).configure({ + defaultProtocol: DEFAULT_LINK_PROTOCOL, + // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 +- protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS, ++ // Tolaria routes editor link clicks through its guarded native opener. ++ openOnClick: false, ++ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes. ++ protocols: [], + }), + ...(Object.values(editor.schema.styleSpecs).map((styleSpec) => { + return styleSpec.implementation.mark.configure({ +diff --git a/src/extensions/SideMenu/MultipleNodeSelection.ts b/src/extensions/SideMenu/MultipleNodeSelection.ts +index cc241f0c35dce86100ee29cc1d26aa9c0c1acaee..95980bc702045cb8da7c6508e9adc4e4ff547dc3 100644 +--- a/src/extensions/SideMenu/MultipleNodeSelection.ts ++++ b/src/extensions/SideMenu/MultipleNodeSelection.ts +@@ -86,4 +86,18 @@ export class MultipleNodeSelection extends Selection { + } + } + +-Selection.jsonID("multiple-node", MultipleNodeSelection); ++try { ++ Selection.jsonID("multiple-node", MultipleNodeSelection); ++} catch (error) { ++ const isDuplicateRegistration = ++ error instanceof RangeError && ++ error.message === "Duplicate use of selection JSON ID multiple-node"; ++ ++ if (!isDuplicateRegistration) { ++ throw error; ++ } ++ ++ Object.defineProperty(MultipleNodeSelection.prototype, "jsonID", { ++ value: "multiple-node", ++ }); ++} +diff --git a/src/extensions/SideMenu/SideMenu.ts b/src/extensions/SideMenu/SideMenu.ts +index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab62805f66 100644 +--- a/src/extensions/SideMenu/SideMenu.ts ++++ b/src/extensions/SideMenu/SideMenu.ts +@@ -194,6 +194,15 @@ export class SideMenuView< + this.emitUpdate(this.state); + }; + ++ private hideMenu = () => { ++ if (!this.state?.show) { ++ return; ++ } ++ ++ this.state.show = false; ++ this.updateState(this.state); ++ }; ++ + updateStateFromMousePos = () => { + if (this.menuFrozen || !this.mousePos) { + return; +@@ -208,10 +217,7 @@ export class SideMenuView< + closestEditor?.element !== this.pmView.dom || + closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS + ) { +- if (this.state?.show) { +- this.state.show = false; +- this.updateState(this.state); +- } ++ this.hideMenu(); + return; + } + +@@ -219,11 +225,7 @@ export class SideMenuView< + + // Closes the menu if the mouse cursor is beyond the editor vertically. + if (!block || !this.editor.isEditable) { +- if (this.state?.show) { +- this.state.show = false; +- this.updateState(this.state); +- } +- ++ this.hideMenu(); + return; + } + +@@ -237,6 +239,15 @@ export class SideMenuView< + } + + this.hoveredBlock = block.node; ++ const hoveredBlockId = this.hoveredBlock.getAttribute("data-id"); ++ const hoveredEditorBlock = hoveredBlockId ++ ? this.editor.getBlock(hoveredBlockId) ++ : undefined; ++ ++ if (!hoveredEditorBlock) { ++ this.hideMenu(); ++ return; ++ } + + // Shows or updates elements. + if (this.editor.isEditable) { +@@ -258,9 +269,7 @@ export class SideMenuView< + blockContentBoundingBox.width, + blockContentBoundingBox.height, + ), +- block: this.editor.getBlock( +- this.hoveredBlock!.getAttribute("data-id")!, +- )!, ++ block: hoveredEditorBlock, + }; + this.updateState(this.state); + } +diff --git a/src/extensions/SuggestionMenu/SuggestionMenu.ts b/src/extensions/SuggestionMenu/SuggestionMenu.ts +index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d751525d528b4e 100644 +--- a/src/extensions/SuggestionMenu/SuggestionMenu.ts ++++ b/src/extensions/SuggestionMenu/SuggestionMenu.ts +@@ -32,7 +32,7 @@ class SuggestionMenuView { + + this.emitUpdate = (menuName: string) => { + if (!this.state) { +- throw new Error("Attempting to update uninitialized suggestions menu"); ++ return; + } + + emitUpdate(menuName, { +@@ -64,6 +64,15 @@ class SuggestionMenuView { + } + }; + ++ private hideMenu = (menuName: string) => { ++ if (!this.state) { ++ return; ++ } ++ ++ this.state.show = false; ++ this.emitUpdate(menuName); ++ }; ++ + update(view: EditorView, prevState: EditorState) { + const prev: SuggestionPluginState = + suggestionMenuPluginKey.getState(prevState); +@@ -84,11 +93,7 @@ class SuggestionMenuView { + this.pluginState = stopped ? prev : next; + + if (stopped || !this.editor.isEditable) { +- if (this.state) { +- this.state.show = false; +- } +- this.emitUpdate(this.pluginState!.triggerCharacter); +- ++ this.hideMenu(this.pluginState!.triggerCharacter); + return; + } + +@@ -96,17 +101,20 @@ class SuggestionMenuView { + `[data-decoration-id="${this.pluginState!.decorationId}"]`, + ); + +- if (this.editor.isEditable && decorationNode) { +- this.state = { +- show: true, +- referencePos: decorationNode +- .getBoundingClientRect() +- .toJSON() as DOMRect, +- query: this.pluginState!.query, +- }; +- +- this.emitUpdate(this.pluginState!.triggerCharacter!); ++ if (!decorationNode) { ++ this.hideMenu(this.pluginState!.triggerCharacter!); ++ return; + } ++ ++ this.state = { ++ show: true, ++ referencePos: decorationNode ++ .getBoundingClientRect() ++ .toJSON() as DOMRect, ++ query: this.pluginState!.query, ++ }; ++ ++ this.emitUpdate(this.pluginState!.triggerCharacter!); + } + + destroy() { +diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts +index 30637742d517bcf136ca562a09a1544d7b03a20d..d1c2164202f3d10378895916f9c2d79ee2c95754 100644 +--- a/src/extensions/TableHandles/TableHandles.ts ++++ b/src/extensions/TableHandles/TableHandles.ts +@@ -99,6 +99,46 @@ function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); + } + ++function isValidTablePosition(tablePos: number | undefined): tablePos is number { ++ return Number.isInteger(tablePos) && tablePos >= 0; ++} ++ ++function isValidCellIndex(index: number | undefined): index is number { ++ return Number.isInteger(index) && index >= 0; ++} ++ ++function isValidRelativeCell(cell: RelativeCellIndices) { ++ return isValidCellIndex(cell.row) && isValidCellIndex(cell.col); ++} ++ ++function resolveCellPosition( ++ state: EditorState, ++ tablePos: number | undefined, ++ relativeCell: RelativeCellIndices, ++) { ++ if (!isValidTablePosition(tablePos) || !isValidRelativeCell(relativeCell)) { ++ return undefined; ++ } ++ ++ try { ++ const tableResolvedPos = state.doc.resolve(tablePos + 1); ++ if (relativeCell.row >= tableResolvedPos.node().childCount) { ++ return undefined; ++ } ++ ++ const rowResolvedPos = state.doc.resolve( ++ tableResolvedPos.posAtIndex(relativeCell.row) + 1, ++ ); ++ if (relativeCell.col >= rowResolvedPos.node().childCount) { ++ return undefined; ++ } ++ ++ return state.doc.resolve(rowResolvedPos.posAtIndex(relativeCell.col)); ++ } catch { ++ return undefined; ++ } ++} ++ + // Finds the DOM element corresponding to the table cell that the target element + // is currently in. If the target element is not in a table cell, returns null. + function domCellAround(target: Element) { +@@ -187,6 +227,32 @@ export class TableHandlesView implements PluginView { + ); + } + ++ hideHandles = ({ ++ resetCell = false, ++ resetDragging = false, ++ } = {}) => { ++ if (!this.state) { ++ return; ++ } ++ ++ if (!this.state.show && !resetCell && !resetDragging) { ++ return; ++ } ++ ++ this.state.show = false; ++ this.state.showAddOrRemoveRowsButton = false; ++ this.state.showAddOrRemoveColumnsButton = false; ++ if (resetDragging) { ++ this.state.draggingState = undefined; ++ } ++ if (resetCell) { ++ this.state.rowIndex = undefined; ++ this.state.colIndex = undefined; ++ this.state.referencePosCell = undefined; ++ } ++ this.emitUpdate(); ++ }; ++ + viewMousedownHandler = () => { + this.mouseState = "down"; + }; +@@ -222,22 +288,12 @@ export class TableHandlesView implements PluginView { + // hide draghandles when selecting text as they could be in the way of the user + this.mouseState = "selecting"; + +- if (this.state?.show) { +- this.state.show = false; +- this.state.showAddOrRemoveRowsButton = false; +- this.state.showAddOrRemoveColumnsButton = false; +- this.emitUpdate(); +- } ++ this.hideHandles(); + return; + } + + if (!target || !this.editor.isEditable) { +- if (this.state?.show) { +- this.state.show = false; +- this.state.showAddOrRemoveRowsButton = false; +- this.state.showAddOrRemoveColumnsButton = false; +- this.emitUpdate(); +- } ++ this.hideHandles(); + return; + } + +@@ -257,11 +313,20 @@ export class TableHandlesView implements PluginView { + | BlockFromConfigNoChildren + | undefined; + +- const pmNodeInfo = this.editor.transact((tr) => +- getNodeById(blockEl.id, tr.doc), +- ); ++ let pmNodeInfo: ReturnType | undefined; ++ try { ++ pmNodeInfo = this.editor.transact((tr) => ++ getNodeById(blockEl.id, tr.doc), ++ ); ++ } catch { ++ // The table DOM can outlive its block while a note reload is settling. ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return; ++ } + if (!pmNodeInfo) { +- throw new Error(`Block with ID ${blockEl.id} not found`); ++ // The table DOM can outlive its block while a note reload is settling. ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return; + } + + const block = nodeToBlock( +@@ -298,7 +363,7 @@ export class TableHandlesView implements PluginView { + + const hideHandles = + // always hide handles when the actively hovered table changed +- this.state?.block.id !== tableBlock.id || ++ this.state?.block?.id !== tableBlock.id || + // make sure we don't hide existing handles (keep col / row index) when + // we're hovering just above or to the right of a table + event.clientX > tableRect.right || +@@ -458,9 +523,9 @@ export class TableHandlesView implements PluginView { + this.state.rowIndex === undefined || + this.state.colIndex === undefined + ) { +- throw new Error( +- "Attempted to drop table row or column, but no table block was hovered prior.", +- ); ++ event.preventDefault(); ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return false; + } + + event.preventDefault(); +@@ -533,7 +598,13 @@ export class TableHandlesView implements PluginView { + } + + // Hide handles if the table block has been removed. +- this.state.block = this.editor.getBlock(this.state.block.id)!; ++ const currentBlock = this.state.block; ++ if (!currentBlock) { ++ this.hideHandles({ resetCell: true, resetDragging: true }); ++ return; ++ } ++ ++ this.state.block = this.editor.getBlock(currentBlock.id)!; + if ( + !this.state.block || + this.state.block.type !== "table" || +@@ -541,11 +612,7 @@ export class TableHandlesView implements PluginView { + // because yjs replaces the element when for example you change the color via the side menu + !this.tableElement?.isConnected + ) { +- this.state.show = false; +- this.state.showAddOrRemoveRowsButton = false; +- this.state.showAddOrRemoveColumnsButton = false; +- this.emitUpdate(); +- ++ this.hideHandles(); + return; + } + +@@ -572,9 +639,8 @@ export class TableHandlesView implements PluginView { + const tableBody = this.tableElement!.querySelector("tbody"); + + if (!tableBody) { +- throw new Error( +- "Table block does not contain a 'tbody' HTML element. This should never happen.", +- ); ++ this.hideHandles({ resetCell: true }); ++ return; + } + + if ( +@@ -796,11 +862,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + if ( + view === undefined || + view.state === undefined || +- view.state.colIndex === undefined ++ view.state.colIndex === undefined || ++ view.tablePos === undefined + ) { +- throw new Error( +- "Attempted to drag table column, but no table block was hovered prior.", +- ); ++ view?.hideHandles({ resetCell: true, resetDragging: true }); ++ return; + } + + view.state.draggingState = { +@@ -837,26 +903,30 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + dataTransfer: DataTransfer | null; + clientY: number; + }) { +- if (view!.state === undefined || view!.state.rowIndex === undefined) { +- throw new Error( +- "Attempted to drag table row, but no table block was hovered prior.", +- ); ++ if ( ++ view === undefined || ++ view.state === undefined || ++ view.state.rowIndex === undefined || ++ view.tablePos === undefined ++ ) { ++ view?.hideHandles({ resetCell: true, resetDragging: true }); ++ return; + } + +- view!.state.draggingState = { ++ view.state.draggingState = { + draggedCellOrientation: "row", +- originalIndex: view!.state.rowIndex, ++ originalIndex: view.state.rowIndex, + mousePos: event.clientY, + }; +- view!.emitUpdate(); ++ view.emitUpdate(); + + editor.transact((tr) => + tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: +- view!.state!.draggingState!.draggedCellOrientation, +- originalIndex: view!.state!.rowIndex, +- newIndex: view!.state!.rowIndex, +- tablePos: view!.tablePos, ++ view.state!.draggingState!.draggedCellOrientation, ++ originalIndex: view.state!.rowIndex, ++ newIndex: view.state!.rowIndex, ++ tablePos: view.tablePos, + }), + ); + +@@ -874,14 +944,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + * used as the row drag handle, and the one used as the column drag handle. + */ + dragEnd() { +- if (view!.state === undefined) { +- throw new Error( +- "Attempted to drag table row, but no table block was hovered prior.", +- ); ++ if (view === undefined || view.state === undefined) { ++ if (!editor.headless) { ++ unsetHiddenDragImage(editor.prosemirrorView.root); ++ } ++ return; + } + +- view!.state.draggingState = undefined; +- view!.emitUpdate(); ++ view.state.draggingState = undefined; ++ view.emitUpdate(); + + editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); + +@@ -938,22 +1009,21 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + throw new Error("Table handles view not initialized"); + } + +- const tableResolvedPos = state.doc.resolve(view.tablePos! + 1); +- const startRowResolvedPos = state.doc.resolve( +- tableResolvedPos.posAtIndex(relativeStartCell.row) + 1, ++ const startCellResolvedPos = resolveCellPosition( ++ state, ++ view.tablePos, ++ relativeStartCell, + ); +- const startCellResolvedPos = state.doc.resolve( +- // No need for +1, since CellSelection expects the position before the cell +- startRowResolvedPos.posAtIndex(relativeStartCell.col), +- ); +- const endRowResolvedPos = state.doc.resolve( +- tableResolvedPos.posAtIndex(relativeEndCell.row) + 1, +- ); +- const endCellResolvedPos = state.doc.resolve( +- // No need for +1, since CellSelection expects the position before the cell +- endRowResolvedPos.posAtIndex(relativeEndCell.col), ++ const endCellResolvedPos = resolveCellPosition( ++ state, ++ view.tablePos, ++ relativeEndCell, + ); + ++ if (!startCellResolvedPos || !endCellResolvedPos) { ++ return undefined; ++ } ++ + // Begin a new transaction to set the selection + const tr = state.tr; + +@@ -975,6 +1045,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + | { orientation: "row"; side: "above" | "below" } + | { orientation: "column"; side: "left" | "right" }, + ) { ++ if ( ++ !view || ++ !isValidTablePosition(view.tablePos) || ++ !isValidCellIndex(index) ++ ) { ++ view?.hideHandles({ resetCell: true }); ++ return; ++ } ++ + editor.exec((beforeState, dispatch) => { + const state = this.setCellSelection( + beforeState, +@@ -983,6 +1062,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + : { row: 0, col: index }, + ); + ++ if (!state) { ++ view?.hideHandles({ resetCell: true }); ++ return false; ++ } ++ + if (direction.orientation === "row") { + if (direction.side === "above") { + return addRowBefore(state, dispatch); +@@ -1006,12 +1090,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + index: RelativeCellIndices["row"] | RelativeCellIndices["col"], + direction: "row" | "column", + ) { ++ if (!isValidCellIndex(index)) { ++ return false; ++ } ++ + if (direction === "row") { + return editor.exec((beforeState, dispatch) => { + const state = this.setCellSelection(beforeState, { + row: index, + col: 0, + }); ++ if (!state) { ++ return false; ++ } + return deleteRow(state, dispatch); + }); + } else { +@@ -1020,6 +1111,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + row: 0, + col: index, + }); ++ if (!state) { ++ return false; ++ } + return deleteColumn(state, dispatch); + }); + } +@@ -1041,6 +1135,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + ) + : beforeState; + ++ if (!state) { ++ return false; ++ } ++ + return mergeCells(state, dispatch); + }); + }, +@@ -1055,6 +1153,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => { + ? this.setCellSelection(beforeState, relativeCellToSplit) + : beforeState; + ++ if (!state) { ++ return false; ++ } ++ + return splitCell(state, dispatch); + }); + }, diff --git a/patches/@blocknote__react@0.46.2.patch b/patches/@blocknote__react@0.46.2.patch new file mode 100644 index 0000000..4164a39 --- /dev/null +++ b/patches/@blocknote__react@0.46.2.patch @@ -0,0 +1,112 @@ +diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js +index d4d36cd..79dd4f0 100644 +--- a/dist/blocknote-react.js ++++ b/dist/blocknote-react.js +@@ -154,8 +154,26 @@ const co = (e) => { + }; + function so(e) { + let t = new DOMRect(); +- const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect(); +- return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n(); ++ const n = () => "getBoundingClientRect" in e ? e.getBoundingClientRect() : e.element instanceof Element ? e.element.getBoundingClientRect() : t; ++ return () => { ++ if (!("element" in e) || !(e.cacheMountedBoundingClientRect ?? !0)) ++ return n(); ++ if (!(e.element instanceof Element)) ++ return n(); ++ if (e.element.isConnected) ++ t = n(); ++ return t; ++ }; ++} ++function __bnSafeDomAtPos(e, t) { ++ const n = e.prosemirrorView; ++ if (!n || n.isDestroyed) ++ return null; ++ try { ++ return n.domAtPos(t); ++ } catch { ++ return null; ++ } + } + const z = (e) => { + var h, b, p; +@@ -193,6 +211,7 @@ const z = (e) => { + ...e.elementProps, + style: { + display: "flex", ++ pointerEvents: c === "close" ? "none" : void 0, + ...(h = e.elementProps) == null ? void 0 : h.style, + zIndex: `calc(var(--bn-ui-base-z-index) + ${((p = (b = e.elementProps) == null ? void 0 : b.style) == null ? void 0 : p.zIndex) || 0})`, + ...n, +@@ -216,9 +235,7 @@ const z = (e) => { + const s = Ue(t, c.doc); + if (!s) + return; +- const { node: a } = r.prosemirrorView.domAtPos( +- s.posBeforeNode + 1 +- ); ++ const a = __bnSafeDomAtPos(r, s.posBeforeNode + 1)?.node; + if (a instanceof Element) + return { + element: a +@@ -2499,7 +2516,14 @@ function Kr(e) { + columns: d + } = e, m = H( + (C) => { +- a(), s(), u == null || u(C); ++ a(); ++ try { ++ s(); ++ } catch (x) { ++ console.warn("Ignored stale suggestion menu query cleanup:", x); ++ return; ++ } ++ u == null || u(C); + }, + [u, a, s] + ), { items: g, usedQuery: f, loadingState: h } = Yt( +@@ -2734,7 +2758,14 @@ function ti(e) { + onItemClick: u + } = e, d = H( + (p) => { +- a(), s(), u == null || u(p); ++ a(); ++ try { ++ s(); ++ } catch (C) { ++ console.warn("Ignored stale suggestion menu query cleanup:", C); ++ return; ++ } ++ u == null || u(p); + }, + [u, a, s] + ), { items: m, usedQuery: g, loadingState: f } = Yt( +@@ -3306,14 +3337,15 @@ const ii = (e, t = 0.3) => { + ); + if (!m) + return {}; +- const g = m.posBeforeNode + 1, f = t.prosemirrorView.domAtPos( ++ const g = m.posBeforeNode + 1, f = __bnSafeDomAtPos( ++ t, + g + 1 +- ).node; ++ )?.node; + if (!(f instanceof Element)) + return {}; + if (d.tableReference = { element: f }, r.rowIndex === void 0 || r.colIndex === void 0) + return d; +- const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = t.prosemirrorView.domAtPos(b + 1).node; ++ const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = __bnSafeDomAtPos(t, b + 1)?.node; + return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = { + element: f, + getBoundingClientRect: () => { +@@ -4371,7 +4403,7 @@ const zi = (e) => { + const a = Ue(t, c.prosemirrorState.doc); + if (!a) + return; +- const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), { node: g } = c.prosemirrorView.domAtPos(m + 1); ++ const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), g = __bnSafeDomAtPos(c, m + 1)?.node; + if (g instanceof Element) + return g; + }, [c, t, n, o]); diff --git a/patches/@tiptap__extension-link@3.19.0.patch b/patches/@tiptap__extension-link@3.19.0.patch new file mode 100644 index 0000000..6737bc2 --- /dev/null +++ b/patches/@tiptap__extension-link@3.19.0.patch @@ -0,0 +1,101 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index b7357fa..c2c59cb 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -36,6 +36,16 @@ var import_core = require("@tiptap/core"); + var import_state = require("@tiptap/pm/state"); + var import_linkifyjs = require("linkifyjs"); + ++var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"]; ++var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered"; ++var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis; ++if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) { ++ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => { ++ (0, import_linkifyjs.registerCustomProtocol)(protocol); ++ }); ++ linkifyGlobalObject[linkifyProtocolFlag] = true; ++} ++ + // src/helpers/whitespace.ts + var UNICODE_WHITESPACE_PATTERN = "[\0- \xA0\u1680\u180E\u2000-\u2029\u205F\u3000]"; + var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN); +@@ -253,7 +263,8 @@ var Link = import_core3.Mark.create({ + }); + }, + onDestroy() { +- (0, import_linkifyjs3.reset)(); ++ // Tolaria keeps a single editor shell alive across note swaps, so linkify's ++ // protocol registry must survive editor teardown and remount cycles. + }, + inclusive() { + return this.options.autolink; +@@ -472,4 +483,4 @@ var index_default = Link; + isAllowedUri, + pasteRegex + }); +-//# sourceMappingURL=index.cjs.map +\ No newline at end of file ++//# sourceMappingURL=index.cjs.map +diff --git a/dist/index.js b/dist/index.js +index d486894..cb8e256 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -13,6 +13,16 @@ var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN); + var UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`); + var UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, "g"); + ++var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"]; ++var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered"; ++var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis; ++if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) { ++ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => { ++ registerCustomProtocol(protocol); ++ }); ++ linkifyGlobalObject[linkifyProtocolFlag] = true; ++} ++ + // src/helpers/autolink.ts + function isValidLinkStructure(tokens) { + if (tokens.length === 1) { +@@ -224,7 +234,8 @@ var Link = Mark.create({ + }); + }, + onDestroy() { +- reset(); ++ // Tolaria keeps a single editor shell alive across note swaps, so linkify's ++ // protocol registry must survive editor teardown and remount cycles. + }, + inclusive() { + return this.options.autolink; +@@ -443,4 +454,4 @@ export { + isAllowedUri, + pasteRegex + }; +-//# sourceMappingURL=index.js.map +\ No newline at end of file ++//# sourceMappingURL=index.js.map +diff --git a/src/link.ts b/src/link.ts +index 839a575..bbef783 100644 +--- a/src/link.ts ++++ b/src/link.ts +@@ -8,6 +8,20 @@ import { clickHandler } from './helpers/clickHandler.js' + import { pasteHandler } from './helpers/pasteHandler.js' + import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js' + ++const PRE_REGISTERED_PROTOCOLS = ['tel', 'callto', 'sms', 'cid', 'xmpp'] as const ++const linkifyProtocolFlag = '__tolariaTiptapLinkifyProtocolsRegistered' ++const linkifyGlobalObject = typeof globalThis === 'undefined' ++ ? undefined ++ : (globalThis as Record) ++ ++if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) { ++ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => { ++ registerCustomProtocol(protocol) ++ }) ++ ++ linkifyGlobalObject[linkifyProtocolFlag] = true ++} ++ + export interface LinkProtocolOptions { + /** + * The protocol scheme to be registered. diff --git a/patches/prosemirror-tables@1.8.5.patch b/patches/prosemirror-tables@1.8.5.patch new file mode 100644 index 0000000..463e2b6 --- /dev/null +++ b/patches/prosemirror-tables@1.8.5.patch @@ -0,0 +1,234 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 6c65eb7d57e207a8cd1f2a2ae3bb99507c405cef..c163932957846385623eb2980bf1141eb6631db1 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) { + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1); + } ++function safeDispatch(view, tr) { ++ try { ++ view.dispatch(tr); ++ return true; ++ } catch (_error) { ++ return false; ++ } ++} ++function safeDomAtPos(view, pos) { ++ try { ++ return view.domAtPos(pos); ++ } catch (_error) { ++ return null; ++ } ++} + function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + var _view$dom$ownerDocume; + if (!view.editable) return false; +@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false; + const cell = view.state.doc.nodeAt(pluginState.activeHandle); + const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { ++ if (width == null) return false; ++ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { + startX: event.clientX, + startWidth: width +- } })); ++ } }))) return false; + function finish(event$1) { + win.removeEventListener("mouseup", finish); + win.removeEventListener("mousemove", move); + const pluginState$1 = columnResizingPluginKey.getState(view.state); + if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) { + updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth)); +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })); ++ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })); + } + } + function move(event$1) { +@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + function currentColWidth(view, cellPos, { colspan, colwidth }) { + const width = colwidth && colwidth[colwidth.length - 1]; + if (width) return width; +- const dom = view.domAtPos(cellPos); +- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan; ++ const dom = safeDomAtPos(view, cellPos); ++ if (!dom) return null; ++ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null; ++ if (!cellDom || typeof cellDom.offsetWidth != "number") return null; ++ let domWidth = cellDom.offsetWidth, parts = colspan; + if (colwidth) { + for (let i = 0; i < colspan; i++) if (colwidth[i]) { + domWidth -= colwidth[i]; + parts--; + } + } +- return domWidth / parts; ++ return parts ? domWidth / parts : null; + } + function domCellAround(target) { + while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode; +@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) { + return Math.max(resizeMinWidth, dragging.startWidth + offset); + } + function updateHandle(view, value) { +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value })); ++ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value })); + } + function updateColumnWidth(view, cell, width) { +- const $cell = view.state.doc.resolve(cell); ++ let $cell; ++ try { ++ $cell = view.state.doc.resolve(cell); ++ } catch (_error) { ++ return false; ++ } + const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1); + const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; + const tr = view.state.tr; +@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) { + colwidth + }); + } +- if (tr.docChanged) view.dispatch(tr); ++ if (tr.docChanged) return safeDispatch(view, tr); ++ return true; + } + function displayColumnWidth(view, cell, width, defaultCellMinWidth) { +- const $cell = view.state.doc.resolve(cell); ++ let $cell; ++ try { ++ $cell = view.state.doc.resolve(cell); ++ } catch (_error) { ++ return false; ++ } + const table = $cell.node(-1), start = $cell.start(-1); + const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; +- let dom = view.domAtPos($cell.start(-1)).node; ++ const domAtPos = safeDomAtPos(view, $cell.start(-1)); ++ let dom = domAtPos && domAtPos.node; + while (dom && dom.nodeName != "TABLE") dom = dom.parentNode; +- if (!dom) return; ++ if (!dom) return false; + updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width); ++ return true; + } + function zeroes(n) { + return Array(n).fill(0); +diff --git a/dist/index.js b/dist/index.js +index 5b4ac25594ba5722409332b1e5c812f108c9bf11..5b811ee70ff2fcb0c7587f838f00bc6fc19e906a 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) { + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1); + } ++function safeDispatch(view, tr) { ++ try { ++ view.dispatch(tr); ++ return true; ++ } catch (_error) { ++ return false; ++ } ++} ++function safeDomAtPos(view, pos) { ++ try { ++ return view.domAtPos(pos); ++ } catch (_error) { ++ return null; ++ } ++} + function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + var _view$dom$ownerDocume; + if (!view.editable) return false; +@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false; + const cell = view.state.doc.nodeAt(pluginState.activeHandle); + const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { ++ if (width == null) return false; ++ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { + startX: event.clientX, + startWidth: width +- } })); ++ } }))) return false; + function finish(event$1) { + win.removeEventListener("mouseup", finish); + win.removeEventListener("mousemove", move); + const pluginState$1 = columnResizingPluginKey.getState(view.state); + if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) { + updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth)); +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })); ++ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })); + } + } + function move(event$1) { +@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) { + function currentColWidth(view, cellPos, { colspan, colwidth }) { + const width = colwidth && colwidth[colwidth.length - 1]; + if (width) return width; +- const dom = view.domAtPos(cellPos); +- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan; ++ const dom = safeDomAtPos(view, cellPos); ++ if (!dom) return null; ++ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null; ++ if (!cellDom || typeof cellDom.offsetWidth != "number") return null; ++ let domWidth = cellDom.offsetWidth, parts = colspan; + if (colwidth) { + for (let i = 0; i < colspan; i++) if (colwidth[i]) { + domWidth -= colwidth[i]; + parts--; + } + } +- return domWidth / parts; ++ return parts ? domWidth / parts : null; + } + function domCellAround(target) { + while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode; +@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) { + return Math.max(resizeMinWidth, dragging.startWidth + offset); + } + function updateHandle(view, value) { +- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value })); ++ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value })); + } + function updateColumnWidth(view, cell, width) { +- const $cell = view.state.doc.resolve(cell); ++ let $cell; ++ try { ++ $cell = view.state.doc.resolve(cell); ++ } catch (_error) { ++ return false; ++ } + const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1); + const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; + const tr = view.state.tr; +@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) { + colwidth + }); + } +- if (tr.docChanged) view.dispatch(tr); ++ if (tr.docChanged) return safeDispatch(view, tr); ++ return true; + } + function displayColumnWidth(view, cell, width, defaultCellMinWidth) { +- const $cell = view.state.doc.resolve(cell); ++ let $cell; ++ try { ++ $cell = view.state.doc.resolve(cell); ++ } catch (_error) { ++ return false; ++ } + const table = $cell.node(-1), start = $cell.start(-1); + const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; +- let dom = view.domAtPos($cell.start(-1)).node; ++ const domAtPos = safeDomAtPos(view, $cell.start(-1)); ++ let dom = domAtPos && domAtPos.node; + while (dom && dom.nodeName != "TABLE") dom = dom.parentNode; +- if (!dom) return; ++ if (!dom) return false; + updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width); ++ return true; + } + function zeroes(n) { + return Array(n).fill(0); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..030db5c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from '@playwright/test' + +const baseURL = process.env.BASE_URL || 'http://localhost:5201' +const claudeCodeOnboardingStorageState = { + cookies: [], + origins: [ + { + origin: baseURL, + localStorage: [ + { name: 'tolaria:claude-code-onboarding-dismissed', value: '1' }, + ], + }, + ], +} + +export default defineConfig({ + testDir: './tests/smoke', + timeout: 20_000, + retries: 2, + workers: 1, + use: { + baseURL, + headless: true, + storageState: claudeCodeOnboardingStorageState, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`, + url: baseURL, + reuseExistingServer: true, + }, +}) diff --git a/playwright.integration.config.ts b/playwright.integration.config.ts new file mode 100644 index 0000000..d46fcec --- /dev/null +++ b/playwright.integration.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/integration', + timeout: 30_000, + retries: 1, + workers: 1, + use: { + baseURL: process.env.BASE_URL || 'http://localhost:5365', + headless: true, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`, + url: process.env.BASE_URL || 'http://localhost:5365', + reuseExistingServer: true, + }, +}) diff --git a/playwright.smoke.config.ts b/playwright.smoke.config.ts new file mode 100644 index 0000000..c11fc6e --- /dev/null +++ b/playwright.smoke.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from '@playwright/test' + +const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741' +const port = new URL(baseURL).port || '41741' +const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER + ? process.env.PLAYWRIGHT_REUSE_SERVER === '1' + : process.env.CI !== 'true' +const claudeCodeOnboardingStorageState = { + cookies: [], + origins: [ + { + origin: baseURL, + localStorage: [ + { name: 'tolaria:claude-code-onboarding-dismissed', value: '1' }, + ], + }, + ], +} + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 1, + workers: 1, + grep: /@smoke/, + use: { + baseURL, + headless: true, + storageState: claudeCodeOnboardingStorageState, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + command: `node scripts/playwright-smoke-server.mjs ${port}`, + url: baseURL, + reuseExistingServer, + timeout: 30_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..4f6e172 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,12993 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@hono/node-server': 1.19.13 + express-rate-limit: 8.2.2 + hono: 4.12.25 + ip-address: 10.1.1 + mermaid>uuid: 11.1.1 + path-to-regexp: 8.4.0 + fast-uri: 3.1.2 + fast-xml-builder: 1.1.7 + flatted: 3.4.2 + minimatch@3.1.2: 3.1.5 + minimatch@3.1.3: 3.1.5 + minimatch@9.0.5: 9.0.9 + minimatch@9.0.6: 9.0.9 + picomatch: 4.0.4 + postcss: 8.5.10 + protobufjs: 7.6.3 + linkify-it: 5.0.1 + qs: 6.15.2 + rollup: 4.59.0 + undici: 7.25.0 + '@blocknote/core>uuid': 11.1.1 + +patchedDependencies: + '@blocknote/code-block@0.46.2': + hash: c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8 + path: patches/@blocknote__code-block@0.46.2.patch + '@blocknote/core@0.46.2': + hash: e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f + path: patches/@blocknote__core@0.46.2.patch + '@blocknote/react@0.46.2': + hash: 67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e + path: patches/@blocknote__react@0.46.2.patch + '@tiptap/extension-link@3.19.0': + hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284 + path: patches/@tiptap__extension-link@3.19.0.patch + prosemirror-tables@1.8.5: + hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b + path: patches/prosemirror-tables@1.8.5.patch + +importers: + + .: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.78.0 + version: 0.78.0(zod@4.3.6) + '@blocknote/code-block': + specifier: ^0.46.2 + version: 0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)) + '@blocknote/core': + specifier: ^0.46.2 + version: 0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0) + '@blocknote/mantine': + specifier: ^0.46.2 + version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@blocknote/react': + specifier: ^0.46.2 + version: 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@codemirror/commands': + specifier: ^6.10.2 + version: 6.10.2 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/lang-sql': + specifier: ^6.10.0 + version: 6.10.0 + '@codemirror/lang-yaml': + specifier: ^6.1.2 + version: 6.1.2 + '@codemirror/language': + specifier: ^6.12.2 + version: 6.12.2 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.39.16 + version: 6.39.16 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) + '@ironcalc/wasm': + specifier: 0.5.4 + version: 0.5.4 + '@ironcalc/workbook': + specifier: 0.5.7 + version: 0.5.7(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 + '@mantine/core': + specifier: ^8.3.14 + version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@sentry/react': + specifier: ^10.47.0 + version: 10.47.0(react@19.2.4) + '@shikijs/langs': + specifier: 3.23.0 + version: 3.23.0 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@tauri-apps/api': + specifier: ^2.10.1 + version: 2.10.1 + '@tauri-apps/plugin-deep-link': + specifier: 2.4.9 + version: 2.4.9 + '@tauri-apps/plugin-dialog': + specifier: ^2.6.0 + version: 2.6.0 + '@tauri-apps/plugin-opener': + specifier: ^2.5.3 + version: 2.5.3 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.0 + '@tiptap/pm': + specifier: 3.22.5 + version: 3.22.5 + '@tldraw/assets': + specifier: 4.5.10 + version: 4.5.10 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + dompurify: + specifier: 3.4.2 + version: 3.4.2 + katex: + specifier: ^0.16.28 + version: 0.16.28 + mermaid: + specifier: ^11.14.0 + version: 11.14.0 + posthog-js: + specifier: ^1.363.5 + version: 1.363.5 + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-day-picker: + specifier: ^9.13.2 + version: 9.13.2(react@19.2.4) + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-virtuoso: + specifier: ^4.18.1 + version: 4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + safe-regex2: + specifier: 5.1.1 + version: 5.1.1 + tailwind-merge: + specifier: ^3.4.1 + version: 3.4.1 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + tldraw: + specifier: ^4.5.10 + version: 4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + unicode-emoji-json: + specifier: ^0.8.0 + version: 0.8.0 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.2 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 + '@tauri-apps/cli': + specifier: ^2.10.0 + version: 2.10.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@translated/lara-cli': + specifier: ^1.3.2 + version: 1.3.2(@types/node@24.10.13) + '@types/node': + specifier: ^24.10.1 + version: 24.10.13 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.4(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(yaml@2.8.3)) + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + eslint: + specifier: ^9.39.1 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + jsdom: + specifier: ^28.0.0 + version: 28.0.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.0 + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@24.10.13)(@types/react@19.2.14)(lightningcss@1.30.2)(postcss@8.5.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(yaml@2.8.3) + ws: + specifier: ^8.19.0 + version: 8.19.0 + + mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.0.0 + version: 1.27.1(zod@4.3.6) + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + ws: + specifier: ^8.20.1 + version: 8.21.0 + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@algolia/abtesting@1.18.1': + resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.52.1': + resolution: {integrity: sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.52.1': + resolution: {integrity: sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.52.1': + resolution: {integrity: sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.52.1': + resolution: {integrity: sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.52.1': + resolution: {integrity: sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.52.1': + resolution: {integrity: sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.52.1': + resolution: {integrity: sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.52.1': + resolution: {integrity: sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.52.1': + resolution: {integrity: sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.52.1': + resolution: {integrity: sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.52.1': + resolution: {integrity: sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.52.1': + resolution: {integrity: sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.52.1': + resolution: {integrity: sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==} + engines: {node: '>= 14.0.0'} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@anthropic-ai/sdk@0.78.0': + resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@blocknote/code-block@0.46.2': + resolution: {integrity: sha512-/jLxECkbJjIx4K0zR0uVVz5AKt/08YGEseYuDun6ACHvW7027YRQIqHThXfMwZuRujijraoPHI3gbLXCaaRRRQ==} + peerDependencies: + '@blocknote/core': ^0.46.2 + + '@blocknote/core@0.46.2': + resolution: {integrity: sha512-p+QFwrTQfQC08f7JoFg9uuwbAUkGhRakCXhHhY63Wd8ARS0ktHfc9eUnJderXQTEtWfHjACjZOyt2FifX6Lalw==} + peerDependencies: + '@hocuspocus/provider': ^2.15.2 || ^3.0.0 + peerDependenciesMeta: + '@hocuspocus/provider': + optional: true + + '@blocknote/mantine@0.46.2': + resolution: {integrity: sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==} + peerDependencies: + '@mantine/core': ^8.3.11 + '@mantine/hooks': ^8.3.11 + '@mantine/utils': ^6.0.22 + react: ^18.0 || ^19.0 || >= 19.0.0-rc + react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc + + '@blocknote/react@0.46.2': + resolution: {integrity: sha512-2cl7MkOSa6Wxun5LFTT0BCeYq3Qw9DCw6VZ9qdtflrnsefds0cDx/SHMVEQygYwF5MhlDmSbIt61hkQ4j2QWSw==} + peerDependencies: + react: ^18.0 || ^19.0 || >= 19.0.0-rc + react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} + + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} + + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} + + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} + + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-yaml@6.1.2': + resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} + + '@codemirror/language@6.12.2': + resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + + '@codemirror/view@6.39.16': + resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==} + + '@csstools/color-helpers@6.0.1': + resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.1': + resolution: {integrity: sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': + resolution: {integrity: sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@exodus/bytes@1.14.1': + resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.17': + resolution: {integrity: sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@handlewithcare/prosemirror-inputrules@0.1.4': + resolution: {integrity: sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==} + peerDependencies: + prosemirror-model: ^1.0.0 + prosemirror-state: ^1.0.0 + prosemirror-view: ^1.0.0 + + '@hono/node-server@1.19.13': + resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: 4.12.25 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/simple-icons@1.2.81': + resolution: {integrity: sha512-Utjw4sPtoVdbpAQAkC4O0cYpt4ehQZYr6aFHhmvdeW8mQwkINyAe0ogTPqNptSSKogZ2lfgXM8zpuhO961Wnng==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.1': + resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ironcalc/wasm@0.5.4': + resolution: {integrity: sha512-XM+sGiMYRj3ZbUmVNiv320olYDaOUBFhFpP57H3uL7VctkFl6FuQggTENlztKiUfwcV/dFwN4wvw5Ld9pqgAKw==} + + '@ironcalc/workbook@0.5.7': + resolution: {integrity: sha512-RHRsh9OkRSs2MU3aDcyjS87mS+OxaQSZsk1gEcsOHmsn9Y0N1o8zUGJPsaniZ7FTCRry8AXZArQq7D3z/dhhgQ==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/css@1.3.1': + resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@mantine/core@8.3.14': + resolution: {integrity: sha512-ZOxggx65Av1Ii1NrckCuqzluRpmmG+8DyEw24wDom3rmwsPg9UV+0le2QTyI5Eo60LzPfUju1KuEPiUzNABIPg==} + peerDependencies: + '@mantine/hooks': 8.3.14 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + + '@mantine/hooks@8.3.14': + resolution: {integrity: sha512-0SbHnGEuHcF2QyjzBBcqidpjNmIb6n7TC3obnhkBToYhUTbMcJSK/8ei/yHtAelridJH4CPeohRlQdc0HajHyQ==} + peerDependencies: + react: ^18.x || ^19.x + + '@mantine/utils@6.0.22': + resolution: {integrity: sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==} + peerDependencies: + react: '>=16.8.0' + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mui/core-downloads-tracker@6.5.0': + resolution: {integrity: sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==} + + '@mui/material@6.5.0': + resolution: {integrity: sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^6.5.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@6.4.9': + resolution: {integrity: sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@6.5.0': + resolution: {integrity: sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@6.5.0': + resolution: {integrity: sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@6.4.9': + resolution: {integrity: sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@posthog/core@1.24.1': + resolution: {integrity: sha512-e8AciAnc6MRFws89ux8lJKFAaI03yEon0ASDoUO7yS91FVqbUGXYekObUUR3LHplcg+pmyiJBI0jolY0SFbGRA==} + + '@posthog/types@1.363.5': + resolution: {integrity: sha512-Eu/aVOZRZE3f09ZGG8qyyo8WiBb0+TRhLAsckPpTaertmD1PSQWxmJQ6gY0je4ibotlJqhX26p2wDPhZTOWqyw==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@sentry-internal/browser-utils@10.47.0': + resolution: {integrity: sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.47.0': + resolution: {integrity: sha512-pdvMmi4dQpX5S/vAAzrhHPIw3T3HjUgDNgUiCBrlp7N9/6zGO2gNPhUnNekP+CjgI/z0rvf49RLqlDenpNrMOg==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.47.0': + resolution: {integrity: sha512-A5OY8friSe6g8WAK4L8IeOPiEd9D3Ps40DzRH5j2f6SUja0t90mKMvHRcRf8zq0d4BkdB+JM7tjOkwxpuv8heA==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.47.0': + resolution: {integrity: sha512-ScdovxP7hJxgMt70+7hFvwT02GIaIUAxdEM/YPsayZBeCoAukPW8WiwztJfoKtsfPyKJ5A6f0H3PIxTPcA9Row==} + engines: {node: '>=18'} + + '@sentry/browser@10.47.0': + resolution: {integrity: sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==} + engines: {node: '>=18'} + + '@sentry/core@10.47.0': + resolution: {integrity: sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==} + engines: {node: '>=18'} + + '@sentry/react@10.47.0': + resolution: {integrity: sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs-precompiled@3.23.0': + resolution: {integrity: sha512-NVFZ+aosM+a90oUmlM8pRZkVZPFgMabCPicOaP0xklM7Lo6U6J9JqKs+ld1Z9K85k1mfQITc6r7VfZiifitPDA==} + engines: {node: '>=20'} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/types@3.22.0': + resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/react-store@0.7.7': + resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + + '@tauri-apps/api@2.10.1': + resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + + '@tauri-apps/cli-darwin-arm64@2.10.0': + resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.10.0': + resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.10.0': + resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.10.0': + resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.10.0': + resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.10.0': + resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.10.0': + resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + + '@tauri-apps/plugin-opener@2.5.3': + resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.0': + resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tiptap/core@3.19.0': + resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==} + peerDependencies: + '@tiptap/pm': ^3.19.0 + + '@tiptap/core@3.22.5': + resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} + peerDependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5': + resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-bold@3.19.0': + resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-bold@3.22.5': + resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-bubble-menu@3.19.0': + resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-bullet-list@3.22.5': + resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-code-block@3.22.5': + resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-code@3.19.0': + resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-code@3.22.5': + resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-document@3.22.5': + resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-dropcursor@3.22.5': + resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-floating-menu@3.19.0': + resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-gapcursor@3.22.5': + resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-hard-break@3.22.5': + resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-heading@3.22.5': + resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-highlight@3.22.5': + resolution: {integrity: sha512-byWruAOKcqRN0OuzVSKqLLrced3M9AZaR2pD1BV3aUZHzMzeBjLBfByh8s4lExH2Z547xQUdHHnUflBQ572I5A==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-horizontal-rule@3.19.0': + resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-horizontal-rule@3.22.5': + resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-italic@3.19.0': + resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-italic@3.22.5': + resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-link@3.19.0': + resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-link@3.22.5': + resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-list-item@3.22.5': + resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list-keymap@3.22.5': + resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list@3.22.5': + resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5': + resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-paragraph@3.19.0': + resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-paragraph@3.22.5': + resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-strike@3.19.0': + resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-strike@3.22.5': + resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-text@3.19.0': + resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-text@3.22.5': + resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-underline@3.19.0': + resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-underline@3.22.5': + resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extensions@3.19.0': + resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extensions@3.22.5': + resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/pm@3.19.0': + resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==} + + '@tiptap/pm@3.22.5': + resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} + + '@tiptap/react@3.19.0': + resolution: {integrity: sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.22.5': + resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} + + '@tldraw/assets@4.5.10': + resolution: {integrity: sha512-nJZ+dZgLcmeEtv4aZv/kEkeY4IU2qrFC8Ayftxql1B+2hXc9ZsjNMKOHyB/W2BsVWdUplZm2qjujKeGNs1VWrw==} + + '@tldraw/editor@4.5.10': + resolution: {integrity: sha512-kM11sDK1ADXATE/wthBDY3ZOriT5Nzpieq1EoPz/HwSMVFuLq5NVmaOPKF+Pt/n43T1S5eLOfMOc7i3zojNc3g==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/state-react@4.5.10': + resolution: {integrity: sha512-gT173dPZUmHksVGDFFNlinai/CfIgvY3ECXGGXMEwpISbHpxInn1u6U7E60z/hmG7MQ2e6kKQq1H7w+VkMeQmg==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/state@4.5.10': + resolution: {integrity: sha512-c7l3/5T16M0p7EJ2GLjpCA2B69abn1790spjsHsfaIWlEzeo29LmTNa906dtkx8b6qPrSUguX6ibE5mEOw7IzA==} + + '@tldraw/store@4.5.10': + resolution: {integrity: sha512-qxOCIRslNBO/02wyN+spC0iwJmNrtEFsPd2j9DoDWJqF1dh1+tneNCbrjJLe/kQHEP4mYugI3jIGPbHrqIr07g==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + + '@tldraw/tlschema@4.5.10': + resolution: {integrity: sha512-QXrCy0+jc5c1Ld5kvvRkUQscX7/bVPL86H+KWz4Of4ajRevketjBGEH6AgoDOGzDoYXwDOrkYjR1W70Xb8/tQA==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/utils@4.5.10': + resolution: {integrity: sha512-aRxpxmx0lIJx/xuwMaBgNxgcYi+okOElWSdZnnV+ZdUrqAfavpe5e1U6qoOdIhn9/iVfOz+PfmEINUNObi/eLg==} + + '@tldraw/validate@4.5.10': + resolution: {integrity: sha512-yEdFMXBD1OoyQrwBz4kgsw6ZtuxA1VL9vDGlsEhmUZx9y8VWODpEDIFMiIZOIBrYB33RLD6Md0tei66AoWKklw==} + + '@translated/lara-cli@1.3.2': + resolution: {integrity: sha512-AVJMvUztFcwc+GdxzlcsbkSU70JrunuTIzRM34MDxK1ZXvJWWTPc7bnS3brll/RLXuzR+c3A8YsDvWc8ck0i3w==} + engines: {node: '>=20'} + hasBin: true + + '@translated/lara@1.9.0': + resolution: {integrity: sha512-ybeozDQ9qasYH7MpvqLKQ/i70lQQifVONQ4eCP31fFl4YWsAmd7Y0hb9AuLgv6oNMo0IgbFXW/HqSA/7Qyr0Rg==} + engines: {node: '>=12.*'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.10.13': + resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.55.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + + '@vitejs/plugin-react@5.1.4': + resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + algoliasearch@5.52.1: + resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} + engines: {node: '>= 14.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chevrotain-allstar@0.4.1: + resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} + peerDependencies: + chevrotain: ^12.0.0 + + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.2: + resolution: {integrity: sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.2: + resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-xml-builder@1.1.7: + resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: 4.0.4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@6.0.1: + resolution: {integrity: sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==} + engines: {node: '>=18'} + hasBin: true + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gettext-parser@8.0.0: + resolution: {integrity: sha512-eFmhDi2xQ+2reMRY2AbJ2oa10uFOl1oyGbAKdCZiNOk94NJHi7aN0OBELSC9v35ZAPQdr+uRBi93/Gu4SlBdrA==} + engines: {node: '>=18'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-mdast@10.1.2: + resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hotkeys-js@3.13.15: + resolution: {integrity: sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + i18next@23.16.8: + resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.1.1: + resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jittered-fractional-indexing@1.0.1: + resolution: {integrity: sha512-OpKFkVr4hU5ivd1ZCjZfHvVpWekraJvcePcMusBmgBmCVQK5JiRCA+4TT1vAUTLqGD9MkhqFwO0l3QspvlZgzw==} + engines: {node: '>=18.0.0'} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdom@28.0.0: + resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + langium@4.2.2: + resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isequalwith@4.4.0: + resolution: {integrity: sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.473.0: + resolution: {integrity: sha512-KW6u5AKeIjkvrxXZ6WuCu9zHE/gEYSXCay+Gre2ZoInD0Je/e3RBtP4OHpJVJ40nDklSvjVKjgH7VU8/e2dzRw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + posthog-js@1.363.5: + resolution: {integrity: sha512-LVjp+c2NRtPG0sRgMzH2/NIoRw5mzRH0TngcezZI7WzH06ngBu3apxfhu/IfFBEDsjPU+S59vrkTzSCqceMVzA==} + + preact@10.29.0: + resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + prosemirror-changeset@2.4.0: + resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-highlight@0.13.1: + resolution: {integrity: sha512-41EwMJDUeFBxizPP1/msQBjDke1YyaTy40w3CGoc7fjXboDBgyhz2LWThwaygL9LkDvBqn4pwBJg1PNtrNilGg==} + peerDependencies: + '@shikijs/types': ^1.29.2 || ^2.0.0 || ^3.0.0 + '@types/hast': ^3.0.0 + highlight.js: ^11.9.0 + lowlight: ^3.1.0 + prosemirror-model: ^1.19.3 + prosemirror-state: ^1.4.3 + prosemirror-transform: ^1.8.0 + prosemirror-view: ^1.32.4 + refractor: ^5.0.0 + sugar-high: ^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 + peerDependenciesMeta: + '@shikijs/types': + optional: true + '@types/hast': + optional: true + highlight.js: + optional: true + lowlight: + optional: true + prosemirror-model: + optional: true + prosemirror-state: + optional: true + prosemirror-transform: + optional: true + prosemirror-view: + optional: true + refractor: + optional: true + sugar-high: + optional: true + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + + protobufjs@7.6.3: + resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + + quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rbush@3.0.1: + resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==} + + react-colorful@5.7.0: + resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + react-day-picker@9.13.2: + resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-number-format@5.4.4: + resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react-virtuoso@4.18.1: + resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + + rehype-minify-whitespace@6.0.2: + resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-remark@10.0.1: + resolution: {integrity: sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwind-merge@3.4.1: + resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldraw@4.5.10: + resolution: {integrity: sha512-dVtS8MVuB3EJ5Gs75mBOxZ8XrKhRMizXrqP5MK3Xv05eJsqUe0R8GqEzowObqYFlu1kkUqIWNkVByUcvYFr+pg==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trim-trailing-lines@2.1.0: + resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + unicode-emoji-json@0.8.0: + resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: 8.5.10 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + web-vitals@5.1.0: + resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.0: + resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@algolia/abtesting@1.18.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/client-abtesting@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-analytics@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-common@5.52.1': {} + + '@algolia/client-insights@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-personalization@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-query-suggestions@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-search@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/ingestion@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/monitoring@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/recommend@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/requester-browser-xhr@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-fetch@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-node-http@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@anthropic-ai/sdk@0.78.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.28.6': {} + + '@babel/runtime@7.29.2': {} + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@blocknote/code-block@0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))': + dependencies: + '@blocknote/core': 0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0) + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/langs-precompiled': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.22.0 + + '@blocknote/core@0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@emoji-mart/data': 1.2.1 + '@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + '@shikijs/types': 3.22.0 + '@tanstack/store': 0.7.7 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-link': 3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + emoji-mart: 5.6.0 + fast-deep-equal: 3.1.3 + hast-util-from-dom: 5.0.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-highlight: 0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6) + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + rehype-format: 5.0.1 + rehype-parse: 9.0.1 + rehype-remark: 10.0.1 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-stringify: 11.0.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + uuid: 11.1.1 + y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + transitivePeerDependencies: + - '@types/hast' + - highlight.js + - lowlight + - refractor + - sugar-high + - supports-color + + '@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@blocknote/core': 0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0) + '@blocknote/react': 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mantine/hooks': 8.3.14(react@19.2.4) + '@mantine/utils': 6.0.22(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-icons: 5.5.0(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@hocuspocus/provider' + - '@types/hast' + - '@types/react' + - '@types/react-dom' + - highlight.js + - lowlight + - refractor + - sugar-high + - supports-color + + '@blocknote/react@0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@blocknote/core': 0.46.2(patch_hash=e7ef987fdf2e5ec2e06d856a4d524dc550f132ba6637d98bd18bade92e71cb7f)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0) + '@emoji-mart/data': 1.2.1 + '@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + '@tanstack/react-store': 0.7.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/use-sync-external-store': 1.5.0 + emoji-mart: 5.6.0 + fast-deep-equal: 3.1.3 + lodash.merge: 4.6.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-icons: 5.5.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@hocuspocus/provider' + - '@types/hast' + - '@types/react' + - '@types/react-dom' + - highlight.js + - lowlight + - refractor + - sugar-high + - supports-color + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@12.0.0': + dependencies: + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 + + '@chevrotain/gast@12.0.0': + dependencies: + '@chevrotain/types': 12.0.0 + + '@chevrotain/regexp-to-ast@12.0.0': {} + + '@chevrotain/types@12.0.0': {} + + '@chevrotain/utils@12.0.0': {} + + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.2': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/json': 1.0.3 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/markdown': 1.6.3 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/python': 1.1.19 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-yaml@6.1.2': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/yaml': 1.0.4 + + '@codemirror/language@6.12.2': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 + crelt: 1.0.6 + + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.39.16': + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@6.0.1': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.1 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@date-fns/tz@1.4.1': {} + + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.52.1)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.52.1)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3) + preact: 10.29.0 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.52.1)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.52.1 + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@emoji-mart/data@1.2.1': {} + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.4) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.4) + '@emotion/utils': 1.4.2 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@exodus/bytes@1.14.1': {} + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/react@0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.10': {} + + '@handlewithcare/prosemirror-inputrules@0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)': + dependencies: + prosemirror-history: 1.5.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@hono/node-server@1.19.13(hono@4.12.25)': + dependencies: + hono: 4.12.25 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/simple-icons@1.2.81': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.1': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.2 + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.10.13)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.13) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/confirm@5.1.21(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/core@10.3.2(@types/node@24.10.13)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.13) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/editor@4.2.23(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/expand@4.0.23(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/external-editor@1.0.3(@types/node@24.10.13)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/number@3.0.23(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/password@4.0.23(@types/node@24.10.13)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/prompts@7.10.1(@types/node@24.10.13)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.10.13) + '@inquirer/confirm': 5.1.21(@types/node@24.10.13) + '@inquirer/editor': 4.2.23(@types/node@24.10.13) + '@inquirer/expand': 4.0.23(@types/node@24.10.13) + '@inquirer/input': 4.3.1(@types/node@24.10.13) + '@inquirer/number': 3.0.23(@types/node@24.10.13) + '@inquirer/password': 4.0.23(@types/node@24.10.13) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.13) + '@inquirer/search': 3.2.2(@types/node@24.10.13) + '@inquirer/select': 4.4.2(@types/node@24.10.13) + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/rawlist@4.1.11(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.13) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/search@3.2.2(@types/node@24.10.13)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.13) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/select@4.4.2(@types/node@24.10.13)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.13) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.13 + + '@inquirer/type@3.0.10(@types/node@24.10.13)': + optionalDependencies: + '@types/node': 24.10.13 + + '@ironcalc/wasm@0.5.4': {} + + '@ironcalc/workbook@0.5.7(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)': + dependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@ironcalc/wasm': 0.5.4 + '@mui/material': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mui/system': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@types/react': 19.2.14 + i18next: 23.16.8 + lucide-react: 0.473.0(react@19.2.4) + react: 19.2.4 + react-colorful: 5.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + react-i18next: 15.7.4(i18next@23.16.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + transitivePeerDependencies: + - '@mui/material-pigment-css' + - react-native + - supports-color + - typescript + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.1': {} + + '@lezer/css@1.3.1': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mantine/hooks': 8.3.14(react@19.2.4) + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-number-format: 5.4.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4) + type-fest: 4.41.0 + transitivePeerDependencies: + - '@types/react' + + '@mantine/hooks@8.3.14(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@mantine/utils@6.0.22(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@marijn/find-cluster-break@1.0.2': {} + + '@mermaid-js/parser@1.1.0': + dependencies: + langium: 4.2.2 + + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.13(hono@4.12.25) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.2(express@5.2.1) + hono: 4.12.25 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@mui/core-downloads-tracker@6.5.0': {} + + '@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/core-downloads-tracker': 6.5.0 + '@mui/system': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.4) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.2.14) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.6 + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@types/react': 19.2.14 + + '@mui/private-theming@6.4.9(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.4) + prop-types: 15.8.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/styled-engine@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.4 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + + '@mui/system@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/private-theming': 6.4.9(@types/react@19.2.14)(react@19.2.4) + '@mui/styled-engine': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.4) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.4 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@types/react': 19.2.14 + + '@mui/types@7.2.24(@types/react@19.2.14)': + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/utils@6.4.9(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/types': 7.2.24(@types/react@19.2.14) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.4 + react-is: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@nodable/entities@2.1.0': {} + + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.6.3 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + + '@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@popperjs/core@2.11.8': {} + + '@posthog/core@1.24.1': + dependencies: + cross-spawn: 7.0.6 + + '@posthog/types@1.363.5': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@remirror/core-constants@3.0.0': {} + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@sentry-internal/browser-utils@10.47.0': + dependencies: + '@sentry/core': 10.47.0 + + '@sentry-internal/feedback@10.47.0': + dependencies: + '@sentry/core': 10.47.0 + + '@sentry-internal/replay-canvas@10.47.0': + dependencies: + '@sentry-internal/replay': 10.47.0 + '@sentry/core': 10.47.0 + + '@sentry-internal/replay@10.47.0': + dependencies: + '@sentry-internal/browser-utils': 10.47.0 + '@sentry/core': 10.47.0 + + '@sentry/browser@10.47.0': + dependencies: + '@sentry-internal/browser-utils': 10.47.0 + '@sentry-internal/feedback': 10.47.0 + '@sentry-internal/replay': 10.47.0 + '@sentry-internal/replay-canvas': 10.47.0 + '@sentry/core': 10.47.0 + + '@sentry/core@10.47.0': {} + + '@sentry/react@10.47.0(react@19.2.4)': + dependencies: + '@sentry/browser': 10.47.0 + '@sentry/core': 10.47.0 + react: 19.2.4 + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs-precompiled@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + oniguruma-to-es: 4.3.5 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@3.22.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/vite@4.1.18(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + + '@tanstack/react-store@0.7.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.7.7 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/store@0.7.7': {} + + '@tauri-apps/api@2.10.1': {} + + '@tauri-apps/api@2.11.0': {} + + '@tauri-apps/cli-darwin-arm64@2.10.0': + optional: true + + '@tauri-apps/cli-darwin-x64@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.10.0': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.10.0': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.10.0': + optional: true + + '@tauri-apps/cli@2.10.0': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.10.0 + '@tauri-apps/cli-darwin-x64': 2.10.0 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 + '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 + '@tauri-apps/cli-linux-arm64-musl': 2.10.0 + '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 + '@tauri-apps/cli-linux-x64-gnu': 2.10.0 + '@tauri-apps/cli-linux-x64-musl': 2.10.0 + '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 + '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 + '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-dialog@2.6.0': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-opener@2.5.3': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-updater@2.10.0': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@tiptap/core@3.19.0(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/pm': 3.19.0 + + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + optional: true + + '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + optional: true + + '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-highlight@3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-link@3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + linkifyjs: 4.3.2 + + '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list@3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/pm@3.19.0': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b) + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/pm@3.22.5': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/react@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.22.5': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tldraw/assets@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + + '@tldraw/editor@4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/state': 4.5.10 + '@tldraw/state-react': 4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/store': 4.5.10(react@19.2.4) + '@tldraw/tlschema': 4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/utils': 4.5.10 + '@tldraw/validate': 4.5.10 + '@use-gesture/react': 10.3.1(react@19.2.4) + classnames: 2.5.1 + eventemitter3: 4.0.7 + idb: 7.1.1 + is-plain-object: 5.0.0 + rbush: 3.0.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' + + '@tldraw/state-react@4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/utils': 4.5.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tldraw/state@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + + '@tldraw/store@4.5.10(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/utils': 4.5.10 + react: 19.2.4 + + '@tldraw/tlschema@4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/store': 4.5.10(react@19.2.4) + '@tldraw/utils': 4.5.10 + '@tldraw/validate': 4.5.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tldraw/utils@4.5.10': + dependencies: + jittered-fractional-indexing: 1.0.1 + lodash.isequal: 4.5.0 + lodash.isequalwith: 4.4.0 + lodash.throttle: 4.1.1 + lodash.uniq: 4.5.0 + + '@tldraw/validate@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + + '@translated/lara-cli@1.3.2(@types/node@24.10.13)': + dependencies: + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/prompts': 7.10.1(@types/node@24.10.13) + '@translated/lara': 1.9.0 + commander: 14.0.3 + dotenv: 17.4.2 + fast-xml-parser: 5.7.2 + flat: 6.0.1 + gettext-parser: 8.0.0 + glob: 11.1.0 + ora: 8.2.0 + picomatch: 4.0.4 + remark: 15.0.1 + remark-gfm: 4.0.1 + string-width: 8.2.1 + unist-util-visit: 5.1.0 + yaml: 2.8.3 + zod: 4.3.6 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@translated/lara@1.9.0': + dependencies: + form-data: 4.0.5 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/ms@2.1.0': {} + + '@types/node@24.10.13': + dependencies: + undici-types: 7.16.0 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-transition-group@4.4.12(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/use-sync-external-store@1.5.0': {} + + '@types/web-bluetooth@0.0.21': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.13 + + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.55.0': + dependencies: + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 + + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.55.0': {} + + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 + debug: 4.4.3 + minimatch: 9.0.9 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.55.0': + dependencies: + '@typescript-eslint/types': 8.55.0 + eslint-visitor-keys: 4.2.1 + + '@ungap/structured-clone@1.3.0': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.4)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.4 + + '@vitejs/plugin-react@5.1.4(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2))(vue@3.5.34(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@24.10.13)(lightningcss@1.30.2) + vue: 3.5.34(typescript@5.9.3) + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(yaml@2.8.3))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(yaml@2.8.3) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.10 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@5.9.3) + + '@vue/shared@3.5.34': {} + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.34(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.9.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.34(typescript@5.9.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.34(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.52.1: + dependencies: + '@algolia/abtesting': 1.18.1 + '@algolia/client-abtesting': 5.52.1 + '@algolia/client-analytics': 5.52.1 + '@algolia/client-common': 5.52.1 + '@algolia/client-insights': 5.52.1 + '@algolia/client-personalization': 5.52.1 + '@algolia/client-query-suggestions': 5.52.1 + '@algolia/client-search': 5.52.1 + '@algolia/ingestion': 1.52.1 + '@algolia/monitoring': 1.52.1 + '@algolia/recommend': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + asynckit@0.4.0: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.19: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + birpc@2.9.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001769: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chardet@2.1.1: {} + + chevrotain-allstar@0.4.1(chevrotain@12.0.0): + dependencies: + chevrotain: 12.0.0 + lodash-es: 4.18.1 + + chevrotain@12.0.0: + dependencies: + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@14.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-js@3.49.0: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + crelt@1.0.6: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.0.27 + css-tree: 3.1.0 + lru-cache: 11.2.6 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.2): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.2 + + cytoscape-fcose@2.2.0(cytoscape@3.33.2): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.2 + + cytoscape@3.33.2: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-is@0.1.4: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.286: {} + + emoji-mart@5.6.0: {} + + emoji-regex-xs@1.0.0: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.2(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expect-type@1.3.0: {} + + express-rate-limit@8.2.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.4.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.2: {} + + fast-xml-builder@1.1.7: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.7 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.4.8: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@6.0.1: {} + + flatted@3.4.2: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fractional-indexing@3.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gettext-parser@8.0.0: + dependencies: + content-type: 1.0.5 + encoding: 0.1.13 + readable-stream: 4.7.0 + safe-buffer: 5.2.1 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + globals@14.0.0: {} + + globals@16.5.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + hachure-fill@0.5.2: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-mdast@10.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + hast-util-phrasing: 3.0.1 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hast-util-whitespace: 3.0.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-hast: 13.2.1 + mdast-util-to-string: 4.0.0 + rehype-minify-whitespace: 6.0.2 + trim-trailing-lines: 2.1.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + highlight.js@11.11.1: {} + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hono@4.12.25: {} + + hookable@5.5.3: {} + + hotkeys-js@3.13.15: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + i18next@23.16.8: + dependencies: + '@babel/runtime': 7.29.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.1.1: {} + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-decimal@2.0.1: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-interactive@2.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-what@5.5.0: {} + + isexe@2.0.0: {} + + isomorphic.js@0.2.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jiti@2.6.1: {} + + jittered-fractional-indexing@1.0.1: + dependencies: + fractional-indexing: 3.2.0 + + jose@6.1.3: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdom@28.0.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.14.1 + cssstyle: 5.3.7 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + katex@0.16.28: + dependencies: + commander: 8.3.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + khroma@2.1.0: {} + + kind-of@6.0.3: {} + + langium@4.2.2: + dependencies: + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.1(chevrotain@12.0.0) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash.isequal@4.5.0: {} + + lodash.isequalwith@4.4.0: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash.uniq@4.5.0: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + + lru-cache@11.2.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.473.0(react@19.2.4): + dependencies: + react: 19.2.4 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + mark.js@8.11.1: {} + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.4: {} + + marked@16.4.2: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.12.2: {} + + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mermaid@11.14.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.1 + '@mermaid-js/parser': 1.1.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.2 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.2) + cytoscape-fcose: 2.2.0(cytoscape@3.33.2) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.2 + katex: 0.16.28 + khroma: 2.1.0 + lodash-es: 4.18.1 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 11.1.1 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minipass@7.1.3: {} + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-releases@2.0.27: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + orderedmap@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-data-parser@0.1.0: {} + + path-exists@4.0.0: {} + + path-expression-matcher@1.5.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + + path-to-regexp@8.4.0: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.10: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + posthog-js@1.363.5: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.24.1 + '@posthog/types': 1.363.5 + core-js: 3.49.0 + dompurify: 3.4.2 + fflate: 0.4.8 + preact: 10.29.0 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.1.0 + + preact@10.29.0: {} + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process@0.11.10: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@7.1.0: {} + + prosemirror-changeset@2.4.0: + dependencies: + prosemirror-transform: 1.11.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-highlight@0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6): + optionalDependencies: + '@shikijs/types': 3.22.0 + '@types/hast': 3.0.4 + highlight.js: 11.11.1 + lowlight: 3.3.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b): + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-transform@1.11.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.6: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + protobufjs@7.6.3: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.10.13 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + query-selector-shadow-dom@1.0.1: {} + + quickselect@2.0.0: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rbush@3.0.1: + dependencies: + quickselect: 2.0.0 + + react-colorful@5.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react-day-picker@9.13.2(react@19.2.4): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.4 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-i18next@15.7.4(i18next@23.16.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 23.16.8 + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-icons@5.5.0(react@19.2.4): + dependencies: + react: 19.2.4 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-is@19.2.6: {} + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-number-format@5.4.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + react: 19.2.4 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react-virtuoso@4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + rehype-minify-whitespace@6.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-minify-whitespace: 1.0.1 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-remark@10.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + hast-util-to-mdast: 10.1.2 + unified: 11.0.5 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.5.0: {} + + rfdc@1.4.1: {} + + robust-predicates@3.0.3: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rope-sequence@1.3.4: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.0 + transitivePeerDependencies: + - supports-color + + rw@1.3.3: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.2.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom-string@1.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + strnum@2.2.3: {} + + style-mod@4.1.3: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + stylis@4.2.0: {} + + stylis@4.4.0: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tabbable@6.4.0: {} + + tailwind-merge@3.4.1: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.0.3: {} + + tldraw@4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-highlight': 3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/starter-kit': 3.22.5 + '@tldraw/editor': 4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/store': 4.5.10(react@19.2.4) + classnames: 2.5.1 + hotkeys-js: 3.13.15 + idb: 7.1.1 + lz-string: 1.5.0 + radix-ui: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + trim-lines@3.0.1: {} + + trim-trailing-lines@2.1.0: {} + + trough@2.2.0: {} + + ts-algebra@2.0.0: {} + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-dedent@2.2.0: {} + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + + undici-types@7.16.0: {} + + undici@7.25.0: {} + + unicode-emoji-json@0.8.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + uuid@11.1.1: {} + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.10 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 24.10.13 + fsevents: 2.3.3 + lightningcss: 1.30.2 + + vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.10.13 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + yaml: 2.8.3 + + vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@24.10.13)(@types/react@19.2.14)(lightningcss@1.30.2)(postcss@8.5.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.81 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2))(vue@3.5.34(typescript@5.9.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.34 + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.9.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@24.10.13)(lightningcss@1.30.2) + vue: 3.5.34(typescript@5.9.3) + optionalDependencies: + postcss: 8.5.10 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(yaml@2.8.3): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.10.13 + jsdom: 28.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + void-elements@3.1.0: {} + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + + vue@3.5.34(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@5.9.3)) + '@vue/shared': 3.5.34 + optionalDependencies: + typescript: 5.9.3 + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-namespaces@2.0.1: {} + + web-vitals@5.1.0: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + + y-protocols@1.0.7(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + yjs: 13.6.29 + + yallist@3.1.1: {} + + yaml@1.10.3: {} + + yaml@2.8.3: {} + + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a65a6ca --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - mcp-server + +ignoredBuiltDependencies: + - esbuild + +patchedDependencies: + '@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch + '@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch + '@tiptap/extension-link@3.19.0': patches/@tiptap__extension-link@3.19.0.patch + prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch diff --git a/public/ai-agent-icons/SOURCES.md b/public/ai-agent-icons/SOURCES.md new file mode 100644 index 0000000..2bf3d3a --- /dev/null +++ b/public/ai-agent-icons/SOURCES.md @@ -0,0 +1,10 @@ +# AI Agent Icons + +- Claude Code: Claude mark from the official Claude wordmark on https://claude.com/product/claude-code +- Codex: OpenAI mark from https://openai.com/favicon.svg +- GitHub Copilot: Tolaria-created monogram using the GitHub Copilot brand name from https://docs.github.com/en/copilot +- OpenCode: official OpenCode brand asset from https://opencode.ai/brand +- Pi: official Pi favicon from https://hey.pi.ai +- Gemini CLI: Google-hosted Gemini sparkle from https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg +- Kiro: official Kiro icon from https://kiro.dev/icon.svg?fe599162bb293ea0 +- Hermes Agent: Tolaria-created monogram using the Hermes Agent brand name from https://hermes-agent.nousresearch.com/docs/getting-started/quickstart diff --git a/public/ai-agent-icons/claude-code.svg b/public/ai-agent-icons/claude-code.svg new file mode 100644 index 0000000..46891e2 --- /dev/null +++ b/public/ai-agent-icons/claude-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/ai-agent-icons/codex.svg b/public/ai-agent-icons/codex.svg new file mode 100644 index 0000000..ab1a579 --- /dev/null +++ b/public/ai-agent-icons/codex.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/ai-agent-icons/copilot.svg b/public/ai-agent-icons/copilot.svg new file mode 100644 index 0000000..f0e86e4 --- /dev/null +++ b/public/ai-agent-icons/copilot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/ai-agent-icons/gemini.svg b/public/ai-agent-icons/gemini.svg new file mode 100644 index 0000000..ecc67df --- /dev/null +++ b/public/ai-agent-icons/gemini.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/ai-agent-icons/hermes.svg b/public/ai-agent-icons/hermes.svg new file mode 100644 index 0000000..7c492e4 --- /dev/null +++ b/public/ai-agent-icons/hermes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/ai-agent-icons/kiro.svg b/public/ai-agent-icons/kiro.svg new file mode 100644 index 0000000..8b97d56 --- /dev/null +++ b/public/ai-agent-icons/kiro.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/ai-agent-icons/opencode.svg b/public/ai-agent-icons/opencode.svg new file mode 100644 index 0000000..670e161 --- /dev/null +++ b/public/ai-agent-icons/opencode.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/ai-agent-icons/pi.svg b/public/ai-agent-icons/pi.svg new file mode 100644 index 0000000..cc74a71 --- /dev/null +++ b/public/ai-agent-icons/pi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..22365c9 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ +🌳 diff --git a/release-notes/stable-v2026.5.2.md b/release-notes/stable-v2026.5.2.md new file mode 100644 index 0000000..430ef5e --- /dev/null +++ b/release-notes/stable-v2026.5.2.md @@ -0,0 +1,18 @@ +## New Features + +- 📋 **Paste Without Formatting** — Paste copied text as plain content without bringing unwanted styling into a note. +- 🇵🇱 **Polish Language Support** — Use Tolaria with a new Polish interface translation. +- 🧭 **Refined Titlebar Navigation** — Navigate with clearer titlebar controls that feel more native on desktop. + +## Improvements + +- ⚡ **Faster Note Loading** — Switch between notes more smoothly by reusing cached note content and parsed editor blocks. +- 🗂️ **Cleaner Sidebar and Menus** — Scan folders, sidebar sections, slash commands, icon choices, and release tabs with cleaner spacing and styling. +- 🖥️ **Better Cross-Platform Window Controls** — Use macOS and Linux window controls that better match each platform. +- ☀️ **Improved Light Mode Editing** — Read code blocks more comfortably when Tolaria is using the light theme. + +## Stability and Fixes + +- This release also improves editor reliability around block dragging, table handles, pasted Markdown, stale side-menu actions, and unrelated vault refreshes. +- Vault, type, and frontmatter handling were hardened to prevent freezes, filename collisions, parse failures, and stale saved-view deletes. +- Startup, Linux AppImage, release CI, and AI/Codex integration fixes are included in the full commit list. diff --git a/release-notes/v2026-05-04.md b/release-notes/v2026-05-04.md new file mode 100644 index 0000000..bfa633e --- /dev/null +++ b/release-notes/v2026-05-04.md @@ -0,0 +1,20 @@ +## New Features + +- 🤖 **Direct AI Model Providers** — Use OpenAI, Anthropic, Gemini, OpenRouter, Ollama, LM Studio, or a custom OpenAI-compatible endpoint directly from Tolaria. +- 🖊️ **Markdown Whiteboards** — Create and edit tldraw-powered whiteboards that live as durable Markdown files in your vault. +- 🧭 **Table of Contents Panel** — Navigate long notes with a lazy, outline-style panel generated from the current document headings. +- 📄 **Readable Release Notes** — View stable releases through curated notes written for users instead of raw commit lists. + +## Improvements + +- 🧩 **Cleaner AI Settings** — Configure provider secrets, local models, and AI targets through a more focused settings experience. +- 💬 **Better Agent Context Handling** — Large AI agent contexts are compacted more safely, and Codex MCP tools are exposed more reliably. +- 🧱 **Improved Editor Blocks** — Code blocks can be copied more easily, block controls feel steadier, and rich exports avoid unsupported browser APIs. +- 🗂️ **Smarter Note and Type Handling** — Renames, type changes, frontmatter placeholders, and path identity now behave more consistently across vault flows. +- 🌍 **Better International Editing** — RTL text direction, IME composition, Korean sidebar copy, and unicode git paths all received focused polish. + +## Stability and Fixes + +- Embedded diagrams, tldraw whiteboard assets, stale checklist toggles, and stale note open or rename flows were hardened to avoid broken editor states. +- Vault recovery, missing active vault handling, fresh-note heading paste, and same-path type collisions now fail more gracefully. +- Linux AppImage startup, MCP resource discovery, release CI, Codacy security findings, and AI provider runtime paths all received stability fixes. diff --git a/release-notes/v2026-05-06.md b/release-notes/v2026-05-06.md new file mode 100644 index 0000000..0b3e2b1 --- /dev/null +++ b/release-notes/v2026-05-06.md @@ -0,0 +1,18 @@ +## New Features +- 🌓 **System Theme Preference** — Let Tolaria follow the operating system light/dark appearance automatically. +- ⚡ **Faster Date Editing** — Edit distant frontmatter dates more quickly with improved date controls. +- ↔️ **Set default note width** — Change default between normal and wide in the settings +- 🗣️ **Set default pluralization** – Enable/disable plurlization of type names in the sidebar, from the settings + +## Improvements +- 🖼️ **Richer Media Handling** — Clipboard images, native image attachments, media previews, and tldraw assets are handled more reliably inside vaults. +- ✍️ **Better Editor Behavior** — Selection, pasted content, numbered lists, wikilinks in tables, IME composition, table handles, and code-block copy actions were refined. +- 🗂️ **Safer Type and Property Flows** — Type filename collisions, built-in note type creation, type-derived placeholders, blank frontmatter properties, and date picker behavior were hardened. +- 🧠 **Smoother AI Panel UX** — AI target selection, composer focus, context compaction, direct provider setup, and agent spawning paths are more robust. +- 🪟 **Cross-Platform Polish** — Linux AppImage startup/input, Windows menu actions, remote credential handling, and window geometry persistence were improved. + +## Stability and Fixes +- Fixed native image attachments in `2e7c5cb433f95d9d10939bcc38453dcc9b4e3ec2`. +- Kept the public installer page visible and improved readable release-note loading. +- Reduced stale-state bugs around vault refreshes, wikilink targets, history diffs, whiteboard blocks, checklist toggles, note rename/open flows, and suggestion cleanup. +- Strengthened release/build reliability with Node heap settings, split quality lanes, CodeScene threshold updates, and broader test coverage. diff --git a/release-notes/v2026-05-07.md b/release-notes/v2026-05-07.md new file mode 100644 index 0000000..073a082 --- /dev/null +++ b/release-notes/v2026-05-07.md @@ -0,0 +1,17 @@ +## New Features + +- 💻 **New website!** – available at [Tolaria.md](http://tolaria.md/), now includes thorough docs and universal search with cmd+k +- 🤖 **Built-in Agent Docs** — Tolaria now bundles product documentation for local AI agents so guidance is available inside the app. + +## Improvements +- 📑 **Table of Contents Shortcut** — Open a note outline more quickly when navigating longer notes, via `cmd+shift+t` +- 🌐 **More Reliable AI Setup** — Local model connections, Gemini launch paths, Windows shims, and Linux MCP server discovery are handled more consistently. +- 🔗 **Better Attachment and Media Paths** — PDF attachments, vault media links, and shared attachment handling now resolve more predictably across editor flows. +- 🎨 **Sharper Editor Details** — Wikilink suggestions, saved-view sort indicators, and type-derived quick-open behavior received focused UI polish. + +## Stability and Fixes +- Git remotes now use macOS Keychain credentials more reliably. +- Switching vaults clears stale editor state more safely, reducing cross-vault confusion. +- Editor rendering is more resilient around stale BlockNote IDs, normalized note openings, and missing whiteboard text measurements. +- Release automation now keeps documentation-site updates out of app releases and preserves download assets more reliably. +- The contributor instructions now make release-readiness checks explicit for CodeScene, coverage, Codacy, localization, analytics, docs, and QA cleanup. diff --git a/release-notes/v2026-05-12.md b/release-notes/v2026-05-12.md new file mode 100644 index 0000000..0ddc076 --- /dev/null +++ b/release-notes/v2026-05-12.md @@ -0,0 +1,21 @@ +## New Features + +- 🧮 **Math Blocks from Slash Commands** — Insert math expressions directly from the editor slash menu. +- 🗓️ **Flexible Date Display** — Choose how dates appear across note lists, properties, and inspector views. +- 🧭 **Mounted Workspaces** — Bring separate vault workspaces into Tolaria while keeping their Git boundaries intact. +- 📂 **Collapsed Sidebar Reopen Button** — Reopen the sidebar more easily after switching to a focused writing layout. + +## Improvements + +- 🖼️ **Better Media and Embed Handling** — Note-relative images, multimedia URLs, file blocks, and rich-editor media reloads behave more consistently. +- 🤖 **More Reliable Local Agent Launches** — Claude, Gemini, OpenCode, and MCP server setup now handle modern CLI behavior and Windows paths more gracefully. +- 🧱 **Smoother Editor Navigation** — Breadcrumb titles, neighborhood toggles, previous-list recovery, note renaming, and editor reloads now preserve more context. +- 🌐 **Cleaner Release and Docs Publishing** — Release pages, GitHub Pages deployment, custom-domain docs, and updater metadata were hardened. + +## Stability and Fixes + +- Whiteboard dialogs, mermaid previews, tldraw context menus, and dark-mode rendering are more resilient. +- The editor now guards more stale-selection, stale-block, paste, tab-switch, and save-before-switch edge cases. +- Security and quality hardening resolved multiple Codacy findings around mock vault APIs, regexes, object injection, focus handling, and history flows. +- Test coverage was expanded for note history edits, telemetry onboarding, rich-editor media reloads, Cmd+N persistence, save-before-switch behavior, and editor draft preservation. +- Internal app orchestration, vault setup, inbox advance, and Git file workflow code was split into smaller focused pieces without changing the released user model. diff --git a/release-notes/v2026-05-14.md b/release-notes/v2026-05-14.md new file mode 100644 index 0000000..c19eb77 --- /dev/null +++ b/release-notes/v2026-05-14.md @@ -0,0 +1,17 @@ +## New Features + +- 🗂️ **Custom Vault Order** — Reorder your vaults so the switcher and workspace controls match the way you actually work. +- 🔌 **Smarter External MCP Setup** — Register Tolaria’s external MCP server with dynamic vault resolution, including mounted-workspace guidance for AppImage users. + +## Improvements + +- ⚡ **Faster Large-Note Opens** — Warm parsed editor blocks ahead of note switches so large notes feel more responsive. +- 🧭 **Clearer Vault and Status Controls** — Use cleaner status-bar sections, vault menus, and workspace settings rows when managing multiple vaults. +- 🐧 **Stronger Linux AppImage Support** — Improve AppImage launch, packaging, FUSE, fcitx input, and MCP subprocess handling across Linux setups. +- 🎨 **More Consistent Interface Icons** — Standardize icons on the Phosphor set for a cleaner and more coherent UI. + +## Stability and Fixes + +- This release fixes relationship equality filters, externally moved inbox notes, selected-vault note creation, and duplicate vault folder pickers. +- Editor reliability is improved around pulled changes, file attachments, CJK code-block copying, table handles after reload, Mermaid errors, and procedure editor schema recovery. +- Whiteboard dialogs, native commit messages, AutoGit author failures, fullscreen navigation, Linux Wayland safeguards, and release history links were hardened. diff --git a/release-notes/v2026-05-18.md b/release-notes/v2026-05-18.md new file mode 100644 index 0000000..1d21c5f --- /dev/null +++ b/release-notes/v2026-05-18.md @@ -0,0 +1,18 @@ +## New Features + +- 🔎 **Faster Note-List Search Controls** — Clear note-list searches directly from the list header while keeping keyboard focus and loading feedback predictable. +- 🧰 **More Granular Git Controls** — Use clearer Git actions for local commit, pull, push, and sync workflows without collapsing everything into one path. +- 🐧 **Linux RPM Download Option** — Stable download pages can expose RPM packages alongside AppImage and other platform installers when release artifacts include them. + +## Improvements + +- 🧭 **Cleaner Update Release Notes Link** — The in-app update banner now opens the public release notes page instead of the old GitHub Pages root. +- 📝 **More Reliable Editor Direction and Spacing** — Mixed right-to-left and left-to-right notes resolve text direction per block, and raw-editor line numbers align more cleanly with content. +- 🧩 **Better View Filtering for Array Properties** — Saved views now match scalar array properties such as tags more consistently after reloads and across renderer/Rust evaluation. +- 🖼️ **Safer Linux AppImage Media Handling** — AppImage builds avoid unstable in-webview audio/video preview paths while preserving stable external-open fallbacks. + +## Stability and Fixes + +- Rich-editor recovery was hardened for invalid list content, embedded attachment paths, file drops, Mermaid reload clicks, aliased wikilinks in Markdown tables, and empty-heading note edits. +- Vault and workspace behavior is steadier around mounted default workspaces, app-owned frontmatter reloads, parsed note-list preload warmups, and built-in note body template removal. +- Release build type safety, CodeScene thresholds, image-toolbar hover access, and multiple patch-review findings were addressed before promotion. diff --git a/release-notes/v2026-05-21.md b/release-notes/v2026-05-21.md new file mode 100644 index 0000000..b60ede8 --- /dev/null +++ b/release-notes/v2026-05-21.md @@ -0,0 +1,19 @@ +## New Features + +- 🧭 **Expanded Note Context Menus** — Right-click notes for faster everyday actions, including opening in a new window, favorites, organization state, Finder reveal, and file path copy. +- 🤖 **More Local AI Agent Options** — Use Kiro as a local AI agent, run Tolaria's MCP server through Bun as well as Node, and launch Pi more reliably from shell-managed installs. +- 📚 **Portent Knowledge-Base Template** — Tolaria's docs now include Portent as a reference template for structuring durable, agent-friendly knowledge bases. +- 🌍 **Belarusian Language Support** — Use Tolaria with new Belarusian interface translations. + +## Improvements + +- ⚡ **Much Faster Note Windows** — Opening a note in a separate window now uses a lighter startup path, avoiding the full main-window load and reducing app-wide stalls. +- 🪟 **More Complete Note Actions** — Note-list actions are now available from the context menu as well as existing command paths, making common file and organization tasks easier to reach. +- 🧩 **Cleaner CLI Agent Runtime Detection** — Shared binary discovery was consolidated so local agent launches handle shell-managed runtimes more consistently. + +## Stability and Fixes + +- Note windows now open reliably from all entry points and no longer stall the whole application during startup. +- Editor reliability is improved around Go code-block highlighting, Mermaid fullscreen zoom, non-Markdown wikilink targets, active-note refresh after external edits, and retained editor memory. +- Vault and workspace behavior is steadier around AutoGit multi-vault pushes, new views in the default workspace, and vault watcher Git symlinks. +- Release build type safety, pnpm patched dependency handling, CodeScene thresholds, and multiple patch-review findings were addressed before promotion. diff --git a/release-notes/v2026-05-25.md b/release-notes/v2026-05-25.md new file mode 100644 index 0000000..4a59db4 --- /dev/null +++ b/release-notes/v2026-05-25.md @@ -0,0 +1,19 @@ +## New Features + +- 🔎 **Create Notes from Quick Open** — Start a new note directly from Quick Open when your search does not already match an existing note. +- 🌓 **Theme-Matched App Icons** — Tolaria can now switch its application icon to match the current light or dark appearance. +- 🧭 **Multi-Vault Type Visibility** — Sidebar type controls now distinguish vault and workspace context more clearly when several vaults are open. + +## Improvements + +- 🔄 **Smarter Multi-Vault Git Sync** — Sync actions now target the active Git-backed vaults more consistently, including mounted workspaces. +- 🪟 **Better Desktop Window Chrome** — Custom window chrome now exposes the desktop menu more reliably and behaves better on Windows. +- 🤖 **More Reliable Local Agent Detection** — Codex, Claude, and OpenCode launch detection handles Windows command shims and Homebrew-style runtime paths more consistently. +- ✂️ **Safer Code Block Copying** — Large fenced-code copies are bounded more carefully so editor clipboard actions stay responsive. + +## Stability and Fixes + +- Editor recovery was hardened around stale BlockNote references, invalid table transforms, insertion-depth errors, side-menu actions, emoji suggestions, and selection toolbar placement. +- Vault and workspace behavior is steadier around empty startup vault reloads, missing frontmatter write paths, active-note refresh focus, and sidebar type visibility across vaults. +- Git and AI workflows are more predictable around selected-repository sync, multi-vault autosync, retained AI panel sessions, and agent target selection. +- Status-bar layout, external URL cancellation, light icon assets, release CodeScene thresholds, native QA guidance, and new smoke coverage were addressed before promotion. diff --git a/release-notes/v2026-05-29.md b/release-notes/v2026-05-29.md new file mode 100644 index 0000000..ce0a3da --- /dev/null +++ b/release-notes/v2026-05-29.md @@ -0,0 +1,21 @@ +## New Features + +- 🤖 **Side AI Workspace** — Work with AI in a dedicated side workspace that keeps conversations and context closer to the note you are editing. +- ↩️ **Action History Undo and Redo** — Note actions now participate in a clearer undo/redo history, making structural edits easier to reverse. +- 📁 **Create Notes Inside Folders** — New notes can be created directly inside the selected folder instead of always starting at the vault root. +- 🌏 **Indonesian Language Support** — Tolaria now includes an Indonesian translation alongside the existing supported languages. + +## Improvements + +- 🔎 **Search Note Body Content** — Note-list search can now find matches inside note bodies, not only titles and visible metadata. +- 🧭 **Vault Item Deep Links** — Links to specific vault items route more reliably across notes, folders, and workspace surfaces. +- ⚡ **Faster AI Agent Startup** — Local AI agent discovery is deferred and parallelized so opening Tolaria does less blocking work up front. +- 🪟 **Refined AI Workspace Layout** — The AI workspace side panel, floating button, conversation titles, and shared sessions feel more stable and focused. +- 🔄 **More Reliable Git Remote Handling** — Git remote detection now treats local-only and name-only configurations more consistently. + +## Stability and Fixes + +- Editor saves, malformed nested blocks, table rendering, math source editing, KaTeX radicals, and whiteboard permission errors were hardened before promotion. +- Search keyboard navigation, quick-open note creation, favorite hover states, sort dropdown positioning, and full app note windows were stabilized. +- Release automation now reuses artifact build workflows more cleanly and handles Windows signing/authenticode paths more defensively. +- Packaged macOS MCP server paths, pending-change filenames with spaces, vault guidance edit status, and multi-vault autosync behavior were repaired. diff --git a/release-notes/v2026-06-01.md b/release-notes/v2026-06-01.md new file mode 100644 index 0000000..fe38dc1 --- /dev/null +++ b/release-notes/v2026-06-01.md @@ -0,0 +1,17 @@ +## New Features + +- 📄 **Export Notes to PDF** — Export the active note directly to a PDF from Tolaria, with the release build carrying the native desktop export path. +- 🧩 **More Code Block Languages** — Rich-editor code blocks now recognize more common grammars, including shell, config, infrastructure, and scripting formats. + +## Improvements + +- 🧮 **Editable Math Source Panel** — Double-click rendered display formulas to edit the underlying math source without turning the formula into normal Markdown text. +- ✍️ **Cleaner Math Editing UX** — Selected math source text stays readable in the editor panel, and the focused panel now avoids the doubled active-border treatment. +- 🤖 **More Focused AI Workspace Internals** — The AI workspace keeps shared sessions and orchestration logic cleaner while preserving the existing side-panel behavior. + +## Stability and Fixes + +- Active notes refresh more reliably after external edits without disturbing clean in-editor state. +- Windows command-shim handling is steadier for npm-launched agent commands. +- PDF export, display-math editing, KaTeX source selection, AI workspace title persistence, and Tauri listener cleanup were hardened before promotion. +- Stable release signing and artifact workflows were tightened so the stable channel can publish the expected macOS, Windows, and Linux bundles. diff --git a/release-notes/v2026-06-06.md b/release-notes/v2026-06-06.md new file mode 100644 index 0000000..94cca6f --- /dev/null +++ b/release-notes/v2026-06-06.md @@ -0,0 +1,18 @@ +## New Features + +- 📝 **AI Agents Can Create Notes Directly** — OpenAI-backed note creation now works inside Tolaria, making agent-driven capture and drafting feel more complete. +- 🧠 **Built-in MCP Note Creation** — Tolaria now exposes a native MCP tool for creating notes, improving automation workflows that operate on the vault. +- 🗺️ **Fullscreen Whiteboards** — Whiteboards can now open in a dedicated fullscreen workspace for more focused visual work. + +## Improvements + +- ✨ **Markdown Highlights Render Properly** — Highlight syntax now displays correctly in the editor, with matching toolbar copy and better visual consistency. +- 🌍 **Better Localization Coverage** — More menus, feedback text, and Chinese file-manager labels are now translated and aligned with the rest of the app. +- 🔧 **Shared MCP Orchestration Internals** — MCP tool execution now runs through a more unified internal path, reducing inconsistency across tools. + +## Stability and Fixes + +- Copying from the rich editor is more reliable, inline math sits correctly in text, Mermaid raw-edit access is restored, and table-row parsing is more resilient. +- Focus and refresh behavior were hardened across note editing, active-note refreshes, title focus, and view filters across multiple workspaces. +- Git identity handling, symlinked gitignored vault paths, sync history surfaces, drag-and-drop guards, Pi agent config recovery, and shell AI-provider inheritance all received targeted fixes. +- MCP and editor error recovery were tightened, including null editor transform handling, stale config tolerance, and lockfile synchronization. diff --git a/release-notes/v2026-06-10.md b/release-notes/v2026-06-10.md new file mode 100644 index 0000000..1d9926f --- /dev/null +++ b/release-notes/v2026-06-10.md @@ -0,0 +1,19 @@ +## New Features + +- 🌍 **Swedish and Ukrainian Localization** — Tolaria now includes Swedish and Ukrainian UI language support, alongside broader persistence fixes for supported language choices. +- 🔗 **Copy Note Git URLs** — Notes can now expose repository-backed Git URLs for easier sharing and source-level handoff. + +## Improvements + +- 🧭 **More Reliable Context Menus** — Note context menus now stay inside the viewport, layer correctly above surrounding UI, and preserve inline removal confirmation behavior. +- 📝 **Better Rich Editor Copying** — Wikilinks are preserved when copying from the rich editor, with safer native-copy fallback behavior for local paths. +- 🎨 **Sharper Code Block Display** — Code block syntax highlighting now refreshes with the current theme more consistently. +- 🧠 **Steadier AI Workspace Startup** — AI agent startup probing and window-event cleanup are more bounded and less prone to stale state. + +## Stability and Fixes + +- Reopened PDF previews now refresh correctly instead of showing stale content. +- Chinese and Korean IME input is more reliable, including first-character entry and composition commits. +- Rich editor recovery was hardened across duplicate block IDs, stale cursor blocks, missing block IDs, stale math updates, stale Tiptap coordinate lookups, NotFound/index-size errors, and plain-null append errors. +- Sidebar, breadcrumb, palette, folder picker, whiteboard permission, image-drop, slash-block, deep-link listener, Pi CLI Windows shim, and workspace-move link stability all received targeted fixes. +- Stable release CI was tightened around Windows Authenticode signing and docs-build skipping. diff --git a/release-notes/v2026-06-14.md b/release-notes/v2026-06-14.md new file mode 100644 index 0000000..f5729de --- /dev/null +++ b/release-notes/v2026-06-14.md @@ -0,0 +1,14 @@ +## Improvements + +- 🧭 **Sharper Menus and Tooltips** — Sidebar, folder, type, and note-list context menus now stay better constrained in narrow or zoomed layouts, with shared positioning logic and more resilient tooltip rendering. +- 🧪 **Stronger Release Gates** — Frontend coverage can now run in shards through the chunk sidecar lane, with hardened local sidecar scripts and hook behavior for faster, more reliable pre-release validation. +- 📝 **More Reliable Editing** — Title focus/selection, fresh-title paste handling, IME punctuation, image links, AI input scrolling, and frontmatter undo after note rename all received targeted stability fixes. +- 🔗 **Better Path Resolution** — Windows MCP script paths, Windows wikilinks, note-relative attachment images, and vault image lookup now resolve more consistently across platforms. + +## Stability and Fixes + +- Code block handling is safer: markdown highlighting preserves equality operators and the outline ignores headings inside fenced code blocks. +- WebKit compatibility was improved for AI responses, GFM markdown rendering, and NotFoundError recovery variants. +- Git author identity lookup is now asynchronous and preserved more reliably in the commit dialog. +- Stale Tldraw block lookups, stale Tauri listener cleanup reports, action tooltip render failures, referenced type deletion, block type mismatch transforms, folder rename blur state, and raw editor tab-swap restore guards were hardened. +- Linux builds now include a desktop category, and stable release workflow coverage around chunk sidecar lanes was expanded. diff --git a/release-notes/v2026-06-23.md b/release-notes/v2026-06-23.md new file mode 100644 index 0000000..b5b0005 --- /dev/null +++ b/release-notes/v2026-06-23.md @@ -0,0 +1,21 @@ +## New Features + +- 📊 **Spreadsheet Notes** — Tolaria can now open and edit plain-text spreadsheet notes, with formula autocomplete, keyboard navigation, copy/paste support, and documentation for the spreadsheet file format. +- 🧠 **Bundled Agent Context** — The app now ships generated agent documentation and exposes a Hermes agent target, making AI-assisted vault work easier to start and more consistent. +- ⚙️ **Update Check Control** — Settings now include a way to disable automatic update checks when users prefer fully manual update control. +- 📝 **Raw Text Highlighting** — Non-Markdown text files can now use extension-based syntax highlighting, with initial support for formats like code and data files. + +## Improvements + +- 🧭 **Stronger Focus Ownership** — Editor, spreadsheet, properties panel, note-list navigation, and workspace selector focus handling now share more resilient ownership guards, reducing accidental blur, lost selection, and stale navigation state. +- 🖼️ **More Reliable Images and Attachments** — Vault-root image embeds, note-relative image lookup, encoded attachment paths, and paths with spaces are handled more consistently. +- 🧩 **Better AI Runtime Plumbing** — Hermes, Pi, MCP, workspace context, and CLI-agent runtime code paths were split and simplified so agent output and runtime resolution are easier to recover from and maintain. +- 📚 **Expanded Documentation** — Spreadsheet concepts, usage guides, file-layout references, frontmatter documentation, and bundled agent-doc pages were updated alongside the new note capabilities. + +## Stability and Fixes + +- Linux/WebKit rendering is more resilient, including NotFoundError recovery, Wayland fallback narrowing, recovered BlockNote render errors, and note-window fallback rejection handling. +- Spreadsheet editing is more stable across formula clipboard sources, focus transitions, dirty row updates, packaged runtime styles, and production WebAssembly loading. +- Note creation, title sync, note rename breadcrumbs, note move wikilink rewrites, type body templates, and create-note failure states received targeted fixes. +- Mermaid labels, markdown highlighting shortcuts, RTL callout quote rails, literal asterisk paste, raw editor tab restore, stale block references, and stationary palette hover behavior were hardened. +- Code health improved across sheet utilities, vault parsing, MCP runtime resolution, frontmatter migration, test harnesses, and multiple UI/helper modules, with CodeScene thresholds ratcheted accordingly. diff --git a/release-notes/v2026-06-26.md b/release-notes/v2026-06-26.md new file mode 100644 index 0000000..30fe351 --- /dev/null +++ b/release-notes/v2026-06-26.md @@ -0,0 +1,16 @@ +## New Features + +- 🧭 **Collapsible Editor Sections** — Long notes are easier to scan because editor headings can now be collapsed and expanded from the side menu. +- 🗂️ **Collection Presentation Foundations** — Tolaria now includes the first collection model and presentation rules needed for focused note sets and future collection views. + +## Improvements + +- 🧠 **More Consistent AI Targeting** — Quick prompts and the AI workspace stay aligned with the active target more reliably, with fewer repeated target updates. +- 🧰 **Better OpenCode Startup Context** — OpenCode launches now inherit the expected shell environment, making configured tools, paths, and provider settings more dependable. +- 🖼️ **Sharper Media and Editor Rendering** — Nested attachment images, malformed preview paths, Mermaid Gantt diagrams, and editor heading typography are handled more cleanly. + +## Stability and Fixes + +- Tooltip recovery, stale side-menu drag drops, AI composer cursor visibility, and side-menu reorder behavior received focused reliability fixes. +- Git remote push classification, organization workflow code health, and local pre-push coverage plumbing were hardened. +- App mocks, autosave tests, raw-editor typing, BlockNote serialization, AI screenshots, and collection resolution coverage were cleaned up so future releases have steadier validation. diff --git a/release-notes/v2026-07-01.md b/release-notes/v2026-07-01.md new file mode 100644 index 0000000..357350f --- /dev/null +++ b/release-notes/v2026-07-01.md @@ -0,0 +1,19 @@ +## New Features + +- 🧱 **Notion-Style Block Navigation** — The rich editor now supports block-level selection, keyboard navigation, and copy flows so structured notes feel more fluid to move through and reorganize. +- 🤖 **Antigravity AI Agent Target** — Tolaria can now launch and stream Antigravity CLI sessions alongside the existing local AI agent targets. +- 🤝 **Sponsor Recognition** — The app and public site now include sponsor presentation surfaces, including the contribute panel and landing-page sponsor section. + +## Improvements + +- 📝 **Smoother Large-Note Editing** — Long notes open, parse, swap, and save more smoothly thanks to faster Markdown conversion paths, editor performance thresholds, and dedicated large-note coverage. +- 🧭 **Better Editor Ergonomics** — Block selection now includes collapsed content correctly, supports clipboard navigation, and keeps side-menu and plus-menu behavior steadier around long or collapsed notes. +- 🧰 **More Portable Configuration** — App configuration now follows XDG-backed paths where appropriate, and Git discovery is more reliable on macOS. +- 🧠 **AI Workflow Controls** — AI agent sessions can be stopped more directly, OpenCode MCP setup is clearer, and provider/agent recovery messages are easier to act on. + +## Stability and Fixes + +- Quick AI prompts no longer trigger a render loop, and new-note typing is better preserved during tab refreshes. +- Nested and gitless vault sync, git repository refreshes, attachment downloads, image conversion, unreadable uploads, and angle-bracket image destinations received targeted reliability fixes. +- Sheets, whiteboards, property overlays, keyboard-layout shortcuts, table handles, side-menu collapse labels, and localized empty-output errors were hardened. +- Codacy findings, Clippy gates, release/test plumbing, locale coverage, telemetry noise, and BlockNote patch maintenance were cleaned up for a steadier stable channel. diff --git a/scripts/appimage-launcher-tools.mjs b/scripts/appimage-launcher-tools.mjs new file mode 100644 index 0000000..30581e1 --- /dev/null +++ b/scripts/appimage-launcher-tools.mjs @@ -0,0 +1,329 @@ +import { spawnSync } from 'node:child_process' +import { error as logError, log } from 'node:console' +import { existsSync } from 'node:fs' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE = + 'this_dir="$(readlink -f "$(dirname "$0")")"' +export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE = + 'this_dir="$(dirname "$(readlink -f "$0")")"' +export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage' +export const REAL_APPIMAGE_PLUGIN_NAME = + 'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage' +export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH = + 'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so' +export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH = + 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2' +export const DEFAULT_APPIMAGE_PLUGIN_URL = + 'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage' + +const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim' +const REQUIRED_APPIMAGE_PATHS = [ + 'AppRun', + APPIMAGE_FCITX_GTK3_IM_MODULE_PATH, + APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH, +] + +export function tauriToolsCacheDir(env = process.env) { + if (env.TOLARIA_TAURI_TOOLS_DIR) { + return resolve(env.TOLARIA_TAURI_TOOLS_DIR) + } + + if (env.XDG_CACHE_HOME) { + return resolve(env.XDG_CACHE_HOME, 'tauri') + } + + if (!env.HOME) { + throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache') + } + + return resolve(env.HOME, '.cache', 'tauri') +} + +export function patchAppRunText(text) { + if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) { + return { + changed: true, + text: text.replaceAll( + BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE, + FIXED_LINUXDEPLOY_APPRUN_DIR_LINE, + ), + } + } + + return { changed: false, text } +} + +export function assertSymlinkSafeAppRunText(text, label = 'AppRun') { + if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) { + throw new Error(`${label} still resolves dirname before following AppRun symlinks`) + } + + if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) { + throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`) + } +} + +export function appImagePluginWrapperSource({ + pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL, +} = {}) { + return `#!/usr/bin/env bash +set -euo pipefail + +# ${WRAPPER_MARKER} +PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}" +FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}" +FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}" + +appdir_from_args() { + local previous="" + + for arg in "$@"; do + if [ "$previous" = "--appdir" ]; then + printf '%s\\n' "$arg" + return 0 + fi + + case "$arg" in + --appdir=*) + printf '%s\\n' "\${arg#--appdir=}" + return 0 + ;; + esac + + previous="$arg" + done +} + +download_real_plugin() { + if [ -x "$REAL_PLUGIN" ]; then + return 0 + fi + + local tmp_plugin="$REAL_PLUGIN.tmp.$$" + rm -f "$tmp_plugin" + mkdir -p "$(dirname -- "$REAL_PLUGIN")" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$tmp_plugin" "$PLUGIN_URL" + else + echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2 + return 1 + fi + + chmod +x "$tmp_plugin" + mv "$tmp_plugin" "$REAL_PLUGIN" +} + +patch_apprun() { + local appdir="\${APPDIR:-}" + + if [ -z "$appdir" ]; then + appdir="$(appdir_from_args "$@" || true)" + fi + + if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then + return 0 + fi + + python3 - "$appdir/AppRun" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +broken = 'this_dir="$(readlink -f "$(dirname "$0")")"' +fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"' +text = path.read_text(encoding="utf-8") + +if broken in text: + path.write_text(text.replace(broken, fixed), encoding="utf-8") + print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr) +elif fixed in text: + pass +elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text: + raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver") +PY +} + +bundle_fcitx_gtk3_module() { + local appdir="\${APPDIR:-}" + + if [ -z "$appdir" ]; then + appdir="$(appdir_from_args "$@" || true)" + fi + + if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then + return 0 + fi + + local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}" + local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu" + + mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir" + cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest" + + local copied_library=0 + shopt -s nullglob + for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do + cp -a "$lib" "$library_dest_dir/" + copied_library=1 + done + shopt -u nullglob + + if [ "$copied_library" -eq 0 ]; then + echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2 + fi +} + +download_real_plugin +patch_apprun "$@" +bundle_fcitx_gtk3_module "$@" +exec "$REAL_PLUGIN" "$@" +` +} + +export async function preparePluginWrapper({ + env = process.env, + toolsDir = tauriToolsCacheDir(env), +} = {}) { + await mkdir(toolsDir, { recursive: true }) + + const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME) + const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME) + + if (existsSync(wrapperPath) && !existsSync(realPluginPath)) { + const existing = await readFile(wrapperPath, 'utf8').catch(() => '') + if (!existing.includes(WRAPPER_MARKER)) { + await mkdir(dirname(realPluginPath), { recursive: true }) + await rename(wrapperPath, realPluginPath) + } + } + + await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8') + await chmod(wrapperPath, 0o755) + + return { realPluginPath, wrapperPath } +} + +export async function validateAppRunFile(path) { + const text = await readFile(path, 'utf8') + assertSymlinkSafeAppRunText(text, path) +} + +function extractAppImagePath(appImage, requiredPath, tempDir) { + const result = spawnSync(appImage, ['--appimage-extract', requiredPath], { + cwd: tempDir, + encoding: 'utf8', + }) + + if (result.status === 0) { + return + } + + throw new Error( + [ + `Failed to extract ${requiredPath} from ${appImage}`, + result.stdout.trim(), + result.stderr.trim(), + ] + .filter(Boolean) + .join('\n'), + ) +} + +function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) { + for (const requiredPath of requiredPaths) { + const extractedPath = join(tempDir, 'squashfs-root', requiredPath) + if (!existsSync(extractedPath)) { + throw new Error(`${appImage} is missing ${requiredPath}`) + } + } +} + +async function validateExtractedAppImage(appImage, tempDir) { + for (const requiredPath of REQUIRED_APPIMAGE_PATHS) { + extractAppImagePath(appImage, requiredPath, tempDir) + } + + await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun')) + assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1)) +} + +export async function validateAppImages(paths) { + if (paths.length === 0) { + throw new Error('At least one AppImage path is required for launcher validation') + } + + for (const appImage of paths.map((path) => resolve(path))) { + const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-')) + try { + await validateExtractedAppImage(appImage, tempDir) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + } +} + +async function preparePluginCommand() { + const { wrapperPath, realPluginPath } = await preparePluginWrapper() + log(`Prepared ${wrapperPath}`) + log(`Real plugin cache: ${realPluginPath}`) +} + +async function validateAppRunFilesCommand(paths) { + for (const path of paths) { + await validateAppRunFile(path) + log(`Validated ${path}`) + } +} + +async function validateAppImagesCommand(paths) { + await validateAppImages(paths) + for (const path of paths) { + log(`Validated AppImage launcher in ${path}`) + } +} + +const COMMANDS = new Map([ + ['prepare-plugin', preparePluginCommand], + ['validate-apprun-file', validateAppRunFilesCommand], + ['validate-appimages', validateAppImagesCommand], +]) + +function usage() { + return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file | validate-appimages ' +} + +async function main() { + const [command, ...args] = process.argv.slice(2) + const handler = COMMANDS.get(command) + + if (!handler) { + throw new Error(usage()) + } + + await handler(args) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + logError(error.message) + process.exit(1) + }) +} diff --git a/scripts/appimage-launcher-tools.test.mjs b/scripts/appimage-launcher-tools.test.mjs new file mode 100644 index 0000000..f8a7559 --- /dev/null +++ b/scripts/appimage-launcher-tools.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, realpathSync } from 'node:fs' +import { + chmod, + mkdir, + mkdtemp, + readFile, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import process from 'node:process' +import test from 'node:test' + +import { + BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE, + FIXED_LINUXDEPLOY_APPRUN_DIR_LINE, + REAL_APPIMAGE_PLUGIN_NAME, + appImagePluginWrapperSource, + patchAppRunText, + preparePluginWrapper, +} from './appimage-launcher-tools.mjs' + +function brokenResolverDir(invokedPath) { + return realpathSync(dirname(invokedPath)) +} + +function fixedResolverDir(invokedPath) { + return dirname(realpathSync(invokedPath)) +} + +test('patches linuxdeploy AppRun wrapper to resolve the invoked path before dirname', () => { + const original = [ + '#! /usr/bin/env bash', + '# autogenerated by linuxdeploy', + BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE, + 'exec "$this_dir"/AppRun.wrapped "$@"', + ].join('\n') + + const patched = patchAppRunText(original) + + assert.equal(patched.changed, true) + assert.equal(patched.text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false) + assert.equal(patched.text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true) +}) + +test('fixed resolver follows absolute and relative symlinks before choosing AppDir', async () => { + const root = await mkdtemp(join(tmpdir(), 'tolaria-apprun-resolver-')) + const appDir = join(root, 'Tolaria.AppDir') + const binDir = join(root, 'bin') + const relativeDir = join(root, 'relative-bin') + const appRun = join(appDir, 'AppRun') + + await mkdir(appDir) + await mkdir(binDir) + await mkdir(relativeDir) + await writeFile(appRun, '#! /usr/bin/env bash\n', 'utf8') + + const absoluteSymlink = join(binDir, 'tolaria') + const relativeSymlink = join(relativeDir, 'tolaria') + + await symlink(appRun, absoluteSymlink) + await symlink(`../${basename(appDir)}/AppRun`, relativeSymlink) + + assert.equal(brokenResolverDir(absoluteSymlink), realpathSync(binDir)) + assert.equal(fixedResolverDir(absoluteSymlink), realpathSync(appDir)) + assert.equal(brokenResolverDir(relativeSymlink), realpathSync(relativeDir)) + assert.equal(fixedResolverDir(relativeSymlink), realpathSync(appDir)) +}) + +test('plugin wrapper patches AppRun before delegating to the real output plugin', async () => { + const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-plugin-')) + const appDir = join(root, 'Tolaria.AppDir') + const appRun = join(appDir, 'AppRun') + const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage') + const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage') + const pluginMarker = join(root, 'plugin-ran') + + await mkdir(appDir) + await writeFile( + appRun, + [ + '#! /usr/bin/env bash', + '# autogenerated by linuxdeploy', + BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE, + 'exec "$this_dir"/AppRun.wrapped "$@"', + ].join('\n'), + 'utf8', + ) + await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8') + await chmod(wrapper, 0o755) + await writeFile( + realPlugin, + `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`, + 'utf8', + ) + await chmod(realPlugin, 0o755) + + const result = spawnSync(wrapper, [], { + encoding: 'utf8', + env: { + ...process.env, + APPDIR: appDir, + TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin, + }, + }) + + assert.equal(result.status, 0, result.stderr) + assert.equal(existsSync(pluginMarker), true) + + const patched = await readFile(appRun, 'utf8') + assert.equal(patched.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false) + assert.equal(patched.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true) +}) + +test('plugin wrapper keeps the delegated appimage plugin basename canonical', async () => { + const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-tools-')) + const { realPluginPath, wrapperPath } = await preparePluginWrapper({ + toolsDir: root, + }) + + assert.equal(basename(wrapperPath), 'linuxdeploy-plugin-appimage.AppImage') + assert.equal( + realPluginPath, + join(root, 'tolaria-real-linuxdeploy-plugin-appimage', 'linuxdeploy-plugin-appimage.AppImage'), + ) + assert.equal(REAL_APPIMAGE_PLUGIN_NAME.endsWith('/linuxdeploy-plugin-appimage.AppImage'), true) + + const wrapper = await readFile(wrapperPath, 'utf8') + assert.equal(wrapper.includes('linuxdeploy-plugin-appimage.real.AppImage'), false) +}) + +test('plugin wrapper bundles fcitx GTK3 input module before sealing AppImage', async () => { + const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-fcitx-')) + const appDir = join(root, 'Tolaria.AppDir') + const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage') + const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage') + const pluginMarker = join(root, 'plugin-ran') + const hostModule = join(root, 'host', 'im-fcitx5.so') + const hostLibraryDir = join(root, 'host-lib') + const hostLibrary = join(hostLibraryDir, 'libFcitx5GClient.so.2') + + await mkdir(appDir) + await mkdir(dirname(hostModule), { recursive: true }) + await mkdir(hostLibraryDir) + await writeFile(hostModule, 'fake fcitx gtk module', 'utf8') + await writeFile(hostLibrary, 'fake fcitx client library', 'utf8') + await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8') + await chmod(wrapper, 0o755) + await writeFile( + realPlugin, + `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`, + 'utf8', + ) + await chmod(realPlugin, 0o755) + + const result = spawnSync(wrapper, [], { + encoding: 'utf8', + env: { + ...process.env, + APPDIR: appDir, + TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin, + TOLARIA_FCITX_GTK3_IM_MODULE: hostModule, + TOLARIA_FCITX_LIBRARY_DIR: hostLibraryDir, + }, + }) + + assert.equal(result.status, 0, result.stderr) + assert.equal(existsSync(pluginMarker), true) + assert.equal( + existsSync( + join( + appDir, + 'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so', + ), + ), + true, + ) + assert.equal(existsSync(join(appDir, 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2')), true) +}) diff --git a/scripts/build-agent-docs.mjs b/scripts/build-agent-docs.mjs new file mode 100644 index 0000000..55f10e3 --- /dev/null +++ b/scripts/build-agent-docs.mjs @@ -0,0 +1,197 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const repoRoot = path.resolve(import.meta.dirname, '..') +const siteRoot = path.join(repoRoot, 'site') +const outputRoot = path.join(repoRoot, 'src-tauri', 'resources', 'agent-docs') + +const sectionOrder = ['start', 'concepts', 'guides', 'templates', 'reference', 'troubleshooting', 'download', 'releases'] +const ignoredDirs = new Set(['.vitepress', 'public', 'node_modules', '.DS_Store']) + +function titleFromSlug(slug) { + return slug + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +function stripFrontmatter(markdown) { + return markdown.replace(/^---\n[\s\S]*?\n---\n/, '') +} + +function firstHeading(markdown, fallback) { + const match = markdown.match(/^#\s+(.+)$/m) + return match?.[1]?.trim() || fallback +} + +export function normalizeDocPath(relativePath) { + return relativePath.replaceAll(path.win32.sep, '/') +} + +export function sectionForFile(relativePath) { + const [firstPart] = relativePath.split('/') + if (firstPart === 'index.md') return 'home' + return firstPart.replace(/\.md$/, '') +} + +async function listMarkdownFiles(dir, base = dir) { + const entries = await readdir(dir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + if (ignoredDirs.has(entry.name)) continue + + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...await listMarkdownFiles(fullPath, base)) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(normalizeDocPath(path.relative(base, fullPath))) + } + } + + return files +} + +function sortDocs(files) { + return files.sort((a, b) => { + const sectionDiff = sectionOrder.indexOf(sectionForFile(a)) - sectionOrder.indexOf(sectionForFile(b)) + if (sectionDiff !== 0) return sectionDiff + return a.localeCompare(b) + }) +} + +function docUrl(relativePath) { + const withoutExt = relativePath.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '') + return `/${withoutExt}`.replace(/\/$/, '/') || '/' +} + +function formatDoc(doc) { + return `# ${doc.title}\n\nSource: ${doc.path}\nURL: ${doc.url}\n\n${doc.content}` +} + +function groupDocsBySection(docs) { + const bySection = new Map() + + for (const doc of docs) { + const docsInSection = bySection.get(doc.section) ?? [] + docsInSection.push(doc) + bySection.set(doc.section, docsInSection) + } + + return bySection +} + +function buildIndex(docs) { + const bySection = groupDocsBySection(docs) + const lines = [ + '# Tolaria Agent Docs', + '', + 'These docs are generated from the public Tolaria documentation for local AI agent lookup.', + '', + 'Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.', + '', + 'When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria\'s default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.', + '', + ] + + for (const section of ['home', ...sectionOrder]) { + const docsInSection = bySection.get(section) + if (!docsInSection?.length) continue + + lines.push(`## ${titleFromSlug(section)}`, '') + for (const doc of docsInSection) { + lines.push(`- [${doc.title}](pages/${doc.path})`) + } + lines.push('') + } + + lines.push('## Generated Files', '') + lines.push('- `all.md`: all public docs concatenated for fast full-context reads.') + lines.push('- `search-index.json`: title, heading, section, path, and URL metadata for quick routing.') + lines.push('- `
      .md`: one compact bundle per docs section.') + lines.push('- `pages/`: one generated Markdown file per public docs page.') + lines.push('') + + return lines.join('\n') +} + +function buildAgentInstructions() { + return `# AGENTS.md - Tolaria Docs Bundle + +This folder contains local, generated Tolaria product docs for AI agents. + +Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions. + +Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read \`pages/templates/portent.md\` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views. + +Recommended lookup flow: + +1. Read the active vault's AGENTS.md for vault-specific conventions. +2. Read this folder's index.md for the docs map. +3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting. + +Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior. +` +} + +function searchIndexFor(doc) { + const headings = [...doc.content.matchAll(/^#{2,3}\s+(.+)$/gm)].map((match) => match[1].trim()) + return { + title: doc.title, + path: `pages/${doc.path}`, + url: doc.url, + section: doc.section, + headings, + } +} + +async function main() { + const files = sortDocs(await listMarkdownFiles(siteRoot)) + const docs = [] + + for (const relativePath of files) { + const raw = await readFile(path.join(siteRoot, relativePath), 'utf8') + const content = stripFrontmatter(raw).trim() + const fallbackTitle = titleFromSlug(path.basename(relativePath, '.md')) + docs.push({ + content, + path: relativePath, + section: sectionForFile(relativePath), + title: firstHeading(content, fallbackTitle), + url: docUrl(relativePath), + }) + } + + await rm(outputRoot, { force: true, recursive: true }) + await mkdir(outputRoot, { recursive: true }) + + await writeFile(path.join(outputRoot, 'AGENTS.md'), buildAgentInstructions()) + await writeFile(path.join(outputRoot, 'index.md'), buildIndex(docs)) + await writeFile(path.join(outputRoot, 'all.md'), docs.map(formatDoc).join('\n\n---\n\n')) + await writeFile(path.join(outputRoot, 'search-index.json'), `${JSON.stringify(docs.map(searchIndexFor), null, 2)}\n`) + + for (const doc of docs) { + const outputPath = path.join(outputRoot, 'pages', doc.path) + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, formatDoc(doc)) + } + + const bySection = groupDocsBySection(docs) + for (const [section, docsInSection] of bySection) { + await writeFile( + path.join(outputRoot, `${section}.md`), + docsInSection.map(formatDoc).join('\n\n---\n\n'), + ) + } + + console.log(`Generated ${docs.length} agent docs in ${path.relative(repoRoot, outputRoot)}`) +} + +const entrypointUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '' + +if (import.meta.url === entrypointUrl) { + main().catch((error) => { + console.error(error) + process.exit(1) + }) +} diff --git a/scripts/build-agent-docs.test.mjs b/scripts/build-agent-docs.test.mjs new file mode 100644 index 0000000..48299ba --- /dev/null +++ b/scripts/build-agent-docs.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { normalizeDocPath, sectionForFile } from './build-agent-docs.mjs' + +test('normalizes Windows doc paths before section grouping', () => { + const docPath = normalizeDocPath('concepts\\ai.md') + + assert.equal(docPath, 'concepts/ai.md') + assert.equal(sectionForFile(docPath), 'concepts') +}) diff --git a/scripts/build-release-download-page.ts b/scripts/build-release-download-page.ts new file mode 100644 index 0000000..d51179e --- /dev/null +++ b/scripts/build-release-download-page.ts @@ -0,0 +1,39 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import { + buildStableDownloadRedirectPage, + resolveStableDownloadTargets, +} from '../src/utils/releaseDownloadPage' + +function getArg(flag: string): string { + const index = process.argv.indexOf(flag) + const value = index >= 0 ? process.argv[index + 1] : null + + if (!value) { + throw new Error(`Missing required argument: ${flag}`) + } + + return value +} + +function readLatestReleasePayload(filePath: string): unknown { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return {} + } +} + +const latestJsonPath = resolve(getArg('--latest-json')) +const releasesJsonPath = resolve(getArg('--releases-json')) +const outputFilePath = resolve(getArg('--output-file')) +const latestPayload = readLatestReleasePayload(latestJsonPath) +const releasesPayload = readLatestReleasePayload(releasesJsonPath) +const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload) +const html = buildStableDownloadRedirectPage(downloads) + +mkdirSync(dirname(outputFilePath), { recursive: true }) +writeFileSync(outputFilePath, html) + +console.log(`Stable download page written to ${outputFilePath}`) diff --git a/scripts/build-release-history-page.ts b/scripts/build-release-history-page.ts new file mode 100644 index 0000000..b014de4 --- /dev/null +++ b/scripts/build-release-history-page.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage' + +function getArg(flag: string): string { + const index = process.argv.indexOf(flag) + const value = index >= 0 ? process.argv[index + 1] : null + + if (!value) { + throw new Error(`Missing required argument: ${flag}`) + } + + return value +} + +function readReleasePayload(filePath: string): unknown { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return [] + } +} + +const releasesJsonPath = resolve(getArg('--releases-json')) +const outputFilePath = resolve(getArg('--output-file')) +const releasesPayload = readReleasePayload(releasesJsonPath) +const html = buildReleaseHistoryPage(releasesPayload) + +mkdirSync(dirname(outputFilePath), { recursive: true }) +writeFileSync(outputFilePath, html) + +console.log(`Release history page written to ${outputFilePath}`) diff --git a/scripts/bundle-mcp-server.mjs b/scripts/bundle-mcp-server.mjs new file mode 100644 index 0000000..81956d9 --- /dev/null +++ b/scripts/bundle-mcp-server.mjs @@ -0,0 +1,45 @@ +/** + * Bundle the mcp-server Node.js files into self-contained CJS bundles + * that can be shipped as Tauri resources inside the .app bundle. + * + * Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js} + */ +import { build } from 'esbuild' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { mkdirSync, writeFileSync } from 'fs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = join(__dirname, '..') +const SRC = join(ROOT, 'mcp-server') +const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server') + +mkdirSync(OUT, { recursive: true }) + +// Tell Node.js that this directory contains CJS bundles, even if the +// root package.json declares "type": "module". +writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' })) + +const shared = { + platform: 'node', + bundle: true, + format: 'cjs', + target: 'node18', + // Mark optional native bindings as external — ws works fine without them + external: ['bufferutil', 'utf-8-validate'], + logLevel: 'warning', +} + +await build({ + ...shared, + entryPoints: [join(SRC, 'index.js')], + outfile: join(OUT, 'index.js'), +}) + +await build({ + ...shared, + entryPoints: [join(SRC, 'ws-bridge.js')], + outfile: join(OUT, 'ws-bridge.js'), +}) + +console.log('mcp-server bundled → src-tauri/resources/mcp-server/') diff --git a/scripts/editor-performance-benchmark.mjs b/scripts/editor-performance-benchmark.mjs new file mode 100644 index 0000000..1629c5e --- /dev/null +++ b/scripts/editor-performance-benchmark.mjs @@ -0,0 +1,521 @@ +#!/usr/bin/env node +/* global document, fetch, HTMLElement, performance, requestAnimationFrame, Response, setTimeout, URL, window */ + +import { spawn } from 'node:child_process' +import console from 'node:console' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import process from 'node:process' +import { resolve } from 'node:path' +import { chromium } from '@playwright/test' +import { + printSummary, + printThresholdFailures, + readThresholds, + thresholdFailures, + updateThresholds, + writeThresholds, +} from './editor-performance-thresholds.mjs' + +const rootDir = process.cwd() +const defaultThresholdsPath = resolve(rootDir, '.editor-performance-thresholds.json') +const defaultPort = '41742' +const scenarios = { + small: { sectionCount: 5, title: 'Perf Small Note' }, + large: { sectionCount: 460, title: 'Perf Large Note' }, +} +const defaultScenarioNames = Object.keys(scenarios) +const metricLabels = { + blockApplyMs: 'block apply', + blockResolveMs: 'block resolve', + editFrameMs: 'edit frame', + editorVisibleMs: 'editor visible', + firstContentMs: 'first content rendered', + fullAppliedMs: 'full note applied', + noteOpenEditorSwapMs: 'note open editor swap', + noteOpenTotalMs: 'note open total', +} + +function defaultOptions() { + return { + baseUrl: process.env.BASE_URL ?? '', + headful: false, + iterations: positiveInteger(process.env.EDITOR_PERF_ITERATIONS ?? '5', 'EDITOR_PERF_ITERATIONS'), + port: process.env.EDITOR_PERF_PORT ?? defaultPort, + scenarioNames: defaultScenarioNames, + thresholdsPath: defaultThresholdsPath, + update: false, + } +} + +const flagOptions = { + '--headful': parsed => { + parsed.headful = true + }, + '--update': parsed => { + parsed.update = true + }, +} + +const valueOptions = { + '--base-url': (parsed, value) => { + parsed.baseUrl = value + }, + '--iterations': (parsed, value) => { + parsed.iterations = positiveInteger(value, '--iterations') + }, + '--port': (parsed, value) => { + parsed.port = value + }, + '--scenario': (parsed, value) => { + parsed.scenarioNames = value.split(',').filter(Boolean) + }, + '--thresholds': (parsed, value) => { + parsed.thresholdsPath = value + }, +} + +function parseArgs(args) { + const parsed = defaultOptions() + for (let index = 0; index < args.length; index += 1) { + index = parseArg(parsed, args, index) + } + + validateScenarioNames(parsed.scenarioNames) + return parsed +} + +function parseArg(parsed, args, index) { + const arg = args[index] + if (arg === '--') return index + if (arg === '--help' || arg === '-h') exitWithHelp(0) + if (flagOptions[arg]) { + flagOptions[arg](parsed) + return index + } + if (valueOptions[arg]) { + valueOptions[arg](parsed, requiredValue(args, index, arg)) + return index + 1 + } + console.error(`Unknown argument: ${arg}`) + exitWithHelp(2) + return index +} + +function validateScenarioNames(scenarioNames) { + for (const scenarioName of scenarioNames) { + if (!(scenarioName in scenarios)) { + console.error(`Unknown scenario: ${scenarioName}`) + process.exit(2) + } + } +} + +function exitWithHelp(code) { + printHelp() + process.exit(code) +} + +const options = parseArgs(process.argv.slice(2)) +const thresholdsPath = resolve(rootDir, options.thresholdsPath) +let devServer = null +let stoppingDevServer = false + +function requiredValue(args, index, name) { + const value = args[index + 1] + if (!value || value.startsWith('--')) { + console.error(`${name} requires a value`) + process.exit(2) + } + return value +} + +function positiveInteger(value, name) { + if (/^[1-9][0-9]*$/.test(String(value))) return Number(value) + console.error(`${name} must be a positive integer`) + process.exit(2) +} + +function printHelp() { + console.log(`Usage: pnpm perf:editor [options] + +Options: + --base-url Reuse an existing dev server instead of starting Vite. + --iterations Runs per scenario. Default: 5. + --scenario Comma-separated scenarios: small,large. Default: both. + --thresholds Threshold JSON path. Default: .editor-performance-thresholds.json. + --update Ratchet stored baselines and thresholds from the current run. + --headful Run Chromium headed for debugging. +`) +} + +function median(values) { + const numeric = values.filter(value => typeof value === 'number' && Number.isFinite(value)) + if (numeric.length === 0) return null + const sorted = [...numeric].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle] +} + +function round(value) { + return value === null ? null : Math.round(value * 10) / 10 +} + +function largeMarkdown(sectionCount, title) { + const paragraphs = Array.from({ length: sectionCount }, (_, index) => { + const ordinal = index + 1 + return [ + `## Section ${ordinal}`, + '', + `Paragraph ${ordinal} keeps the large editor path realistic with **bold text**, *italic text*, `, + `a wikilink to [[Build Laputa App]], and a [reference link](https://example.com/${ordinal}). `, + 'The text is intentionally long enough to push the source past the worker-backed parser threshold.', + ].join('') + }) + + return [ + '---', + `title: ${title}`, + 'type: Note', + '---', + '', + `# ${title}`, + '', + ...paragraphs, + ].join('\n') +} + +function syntheticEntry({ markdown, title }) { + return { + aliases: [], + archived: false, + belongsTo: [], + color: null, + createdAt: Math.floor(Date.now() / 1000) - 60, + favorite: false, + favoriteIndex: null, + fileSize: markdown.length, + filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`, + hasH1: true, + icon: null, + isA: 'Note', + listPropertiesDisplay: [], + modifiedAt: Math.floor(Date.now() / 1000) + 60, + order: null, + organized: false, + outgoingLinks: ['build-laputa-app'], + path: `/Users/luca/Laputa/${title.toLowerCase().replace(/\s+/g, '-')}.md`, + properties: {}, + relationships: {}, + relatedTo: [], + sidebarLabel: null, + snippet: 'Synthetic note for editor performance benchmarking.', + sort: null, + status: null, + template: null, + title, + view: null, + visible: null, + wordCount: 10 * Math.max(1, Math.floor(markdown.length / 280)), + } +} + +async function waitForServer(url) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + try { + const response = await fetch(url) + if (response.ok) return + } catch (error) { + void error + } + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + throw new Error(`Timed out waiting for dev server: ${url}`) +} + +async function startDevServer() { + if (options.baseUrl) return options.baseUrl + + const baseUrl = `http://127.0.0.1:${options.port}` + const viteCacheDir = resolve(tmpdir(), `tolaria-editor-perf-vite-${options.port}`) + devServer = spawn( + 'pnpm', + ['dev', '--host', '127.0.0.1', '--port', options.port, '--strictPort'], + { + cwd: rootDir, + env: { ...process.env, TOLARIA_VITE_CACHE_DIR: viteCacheDir }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + devServer.stdout?.on('data', chunk => process.stdout.write(`[perf-server] ${chunk}`)) + devServer.stderr?.on('data', chunk => process.stderr.write(`[perf-server] ${chunk}`)) + devServer.on('exit', (code, signal) => { + if (stoppingDevServer) return + if (signal || code === 0) return + console.error(`[perf-server] exited with status ${code}`) + }) + + await waitForServer(baseUrl) + return baseUrl +} + +function stopDevServer() { + if (!devServer || devServer.killed) return + stoppingDevServer = true + devServer.stdout?.removeAllListeners('data') + devServer.stderr?.removeAllListeners('data') + devServer.kill('SIGTERM') +} + +async function installSyntheticVault(page, entry, markdown) { + await page.addInitScript(({ syntheticEntryValue, syntheticMarkdown }) => { + const jsonResponse = value => new Response(JSON.stringify(value), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + const requestPath = (input) => { + const rawUrl = typeof input === 'string' + ? input + : input && typeof input === 'object' && 'url' in input + ? input.url + : String(input) + return new URL(rawUrl, window.location.href).pathname + } + const originalFetch = window.fetch.bind(window) + const syntheticResponses = { + '/api/vault/all-content': () => jsonResponse([{ content: syntheticMarkdown, path: syntheticEntryValue.path }]), + '/api/vault/content': () => jsonResponse({ content: syntheticMarkdown }), + '/api/vault/entry': () => jsonResponse(syntheticEntryValue), + '/api/vault/list': () => jsonResponse([syntheticEntryValue]), + '/api/vault/ping': () => new Response('ok', { status: 200 }), + '/api/vault/search': () => jsonResponse([syntheticEntryValue]), + } + window.fetch = async (input, init) => { + const responseFactory = syntheticResponses[requestPath(input)] + if (responseFactory) return responseFactory() + return originalFetch(input, init) + } + + const withSyntheticEntry = (result) => { + const entries = Array.isArray(result) ? result : [] + return [ + syntheticEntryValue, + ...entries.filter(candidate => candidate.path !== syntheticEntryValue.path), + ] + } + const matchesSyntheticPath = args => args?.path === syntheticEntryValue.path + const handlerPatches = { + get_note_content: original => args => ( + matchesSyntheticPath(args) ? syntheticMarkdown : original?.(args) ?? '' + ), + list_vault: original => args => withSyntheticEntry(original?.(args)), + reload_vault: original => args => withSyntheticEntry(original?.(args)), + reload_vault_entry: original => args => ( + matchesSyntheticPath(args) ? syntheticEntryValue : original?.(args) + ), + validate_note_content: original => args => ( + matchesSyntheticPath(args) + ? args.content === syntheticMarkdown + : Boolean(original?.(args)) + ), + } + + const patchHandlers = (handlers) => { + if (!handlers || handlers.__editorPerformancePatched) return handlers ?? null + for (const [name, createHandler] of Object.entries(handlerPatches)) { + handlers[name] = createHandler(handlers[name]) + } + handlers.__editorPerformancePatched = true + return handlers + } + + let handlersRef = patchHandlers(window.__mockHandlers) + Object.defineProperty(window, '__mockHandlers', { + configurable: true, + get() { + return handlersRef ?? undefined + }, + set(value) { + handlersRef = patchHandlers(value) + }, + }) + }, { syntheticEntryValue: entry, syntheticMarkdown: markdown }) +} + +async function measureEditFrame(page) { + return await page.evaluate(async () => { + const root = document.querySelector('.bn-editor') + const editable = root?.querySelector('[contenteditable="true"]') ?? root + if (!root || !(editable instanceof HTMLElement)) return null + + editable.focus() + const startedAt = performance.now() + document.execCommand('insertText', false, 'x') + await new Promise(resolveFrame => requestAnimationFrame(() => resolveFrame())) + return performance.now() - startedAt + }) +} + +function durationFromLog(logs, pattern) { + for (const line of logs) { + const match = line.match(pattern) + if (match?.[1]) return Number(match[1]) + } + return null +} + +function parsePerfMetrics(perfLogs) { + return { + blockApplyMs: durationFromLog(perfLogs, /editorBlockApply .* duration=([\d.]+)ms/), + blockResolveMs: durationFromLog(perfLogs, /editorBlockResolve .* duration=([\d.]+)ms/), + noteOpenEditorSwapMs: durationFromLog(perfLogs, /noteOpen .* editorSwap=([\d.]+)ms/), + noteOpenTotalMs: durationFromLog(perfLogs, /noteOpen .* total=([\d.]+)ms/), + } +} + +async function runIteration({ baseUrl, browser, index, scenario, scenarioName }) { + const markdown = largeMarkdown(scenario.sectionCount, scenario.title) + const entry = syntheticEntry({ markdown, title: scenario.title }) + const context = await browser.newContext() + const page = await context.newPage() + const perfLogs = [] + page.on('console', (message) => { + const text = message.text() + if (text.includes('[perf]')) perfLogs.push(text) + }) + + await installSyntheticVault(page, entry, markdown) + await page.goto(baseUrl) + await page.waitForLoadState('domcontentloaded') + await page.getByText('Set up later', { exact: true }).click({ timeout: 12_000 }).catch(() => {}) + + const title = page.getByText(scenario.title, { exact: true }).first() + await title.waitFor({ state: 'visible', timeout: 30_000 }) + + const startedAt = await page.evaluate(() => performance.now()) + await title.click() + + await page.locator('.editor__blocknote-container').waitFor({ state: 'visible', timeout: 30_000 }) + await page.locator('.bn-editor').waitFor({ state: 'visible', timeout: 30_000 }) + const editorVisibleAt = await page.evaluate(() => performance.now()) + + await page.waitForFunction(() => { + const editor = document.querySelector('.bn-editor') + return editor?.textContent?.includes('Section 1') === true + }, undefined, { timeout: 30_000 }) + const firstContentAt = await page.evaluate(() => performance.now()) + + await page.waitForFunction((expectedSectionCount) => { + const editor = document.querySelector('.bn-editor') + return editor?.textContent?.includes(`Section ${expectedSectionCount}`) === true + }, scenario.sectionCount, { timeout: 30_000 }) + const fullAppliedAt = await page.evaluate(() => performance.now()) + + await page.locator('.bn-editor').click({ timeout: 10_000 }) + const editFrameMs = [] + for (let sample = 0; sample < 8; sample += 1) { + const value = await measureEditFrame(page) + if (typeof value === 'number') editFrameMs.push(value) + await page.waitForTimeout(80) + } + + await context.close() + return { + ...parsePerfMetrics(perfLogs), + editFrameMs, + editorVisibleMs: editorVisibleAt - startedAt, + firstContentMs: firstContentAt - startedAt, + fullAppliedMs: fullAppliedAt - startedAt, + index, + perfLogs, + scenario: scenarioName, + } +} + +function summarizeScenario(scenarioName, scenario, runs) { + const editFrameSamples = runs.flatMap(run => run.editFrameMs) + const medians = { + blockApplyMs: round(median(runs.map(run => run.blockApplyMs))), + blockResolveMs: round(median(runs.map(run => run.blockResolveMs))), + editFrameMs: round(median(editFrameSamples)), + editorVisibleMs: round(median(runs.map(run => run.editorVisibleMs))), + firstContentMs: round(median(runs.map(run => run.firstContentMs))), + fullAppliedMs: round(median(runs.map(run => run.fullAppliedMs))), + noteOpenEditorSwapMs: round(median(runs.map(run => run.noteOpenEditorSwapMs))), + noteOpenTotalMs: round(median(runs.map(run => run.noteOpenTotalMs))), + } + + return { + contentBytes: largeMarkdown(scenario.sectionCount, scenario.title).length, + medians, + runs: runs.map(run => ({ + ...run, + blockApplyMs: round(run.blockApplyMs), + blockResolveMs: round(run.blockResolveMs), + editFrameMs: run.editFrameMs.map(round), + editorVisibleMs: round(run.editorVisibleMs), + firstContentMs: round(run.firstContentMs), + fullAppliedMs: round(run.fullAppliedMs), + noteOpenEditorSwapMs: round(run.noteOpenEditorSwapMs), + noteOpenTotalMs: round(run.noteOpenTotalMs), + })), + scenario: scenarioName, + sectionCount: scenario.sectionCount, + } +} + +async function runBenchmarks(baseUrl) { + const browser = await chromium.launch({ headless: !options.headful }) + const summaries = {} + try { + for (const scenarioName of options.scenarioNames) { + const scenario = scenarios[scenarioName] + console.log(`[perf] scenario=${scenarioName} sections=${scenario.sectionCount}`) + const runs = [] + for (let index = 1; index <= options.iterations; index += 1) { + const run = await runIteration({ baseUrl, browser, index, scenario, scenarioName }) + runs.push(run) + console.log( + `[perf] ${scenarioName} run=${index} ` + + `visible=${round(run.editorVisibleMs)}ms ` + + `first=${round(run.firstContentMs)}ms ` + + `full=${round(run.fullAppliedMs)}ms ` + + `edit=${round(median(run.editFrameMs))}ms`, + ) + } + summaries[scenarioName] = summarizeScenario(scenarioName, scenario, runs) + } + } finally { + await browser.close() + } + return summaries +} + +const startedBaseUrl = await startDevServer() +try { + const summaries = await runBenchmarks(startedBaseUrl) + const thresholds = await readThresholds(thresholdsPath) + const activeThresholds = options.update ? updateThresholds(thresholds, summaries) : thresholds + + printSummary({ metricLabels, summaries, thresholds: activeThresholds }) + + if (options.update) { + await writeThresholds(thresholdsPath, activeThresholds) + console.log(`\nUpdated ${thresholdsPath}`) + } + + const failures = thresholdFailures(activeThresholds, summaries) + let exitCode = 0 + if (failures.length > 0) { + printThresholdFailures({ failures, metricLabels }) + exitCode = 1 + } + + await rm(resolve(rootDir, 'test-results'), { recursive: true, force: true }) + if (exitCode !== 0) process.exit(exitCode) +} finally { + stopDevServer() +} diff --git a/scripts/editor-performance-thresholds.mjs b/scripts/editor-performance-thresholds.mjs new file mode 100644 index 0000000..edfa512 --- /dev/null +++ b/scripts/editor-performance-thresholds.mjs @@ -0,0 +1,113 @@ +import console from 'node:console' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' + +const thresholdDescription = 'Ratcheted editor performance budgets for synthetic small and large note opens. Lower is better; maxMs values should only move down unless intentionally rebaselined.' + +export async function readThresholds(thresholdsPath) { + if (!existsSync(thresholdsPath)) { + return { scenarios: {}, version: 1 } + } + return JSON.parse(await readFile(thresholdsPath, 'utf8')) +} + +export async function writeThresholds(thresholdsPath, thresholds) { + await writeFile(thresholdsPath, `${JSON.stringify(thresholds, null, 2)}\n`) +} + +export function updateThresholds(thresholds, summaries) { + const next = { + ...thresholds, + description: thresholdDescription, + scenarios: { ...thresholds.scenarios }, + updatedAt: new Date().toISOString(), + version: 1, + } + + for (const [scenarioName, summary] of Object.entries(summaries)) { + next.scenarios[scenarioName] = updatedScenarioThreshold(thresholds, scenarioName, summary) + } + + return next +} + +export function thresholdFailures(thresholds, summaries) { + return Object.entries(summaries).flatMap(([scenarioName, summary]) => ( + scenarioThresholdFailures(thresholds, scenarioName, summary) + )) +} + +export function printSummary({ metricLabels, summaries, thresholds, writeLine = console.log }) { + for (const [scenarioName, summary] of Object.entries(summaries)) { + writeLine(`\n${scenarioName} (${summary.contentBytes} bytes, ${summary.sectionCount} sections)`) + for (const [metricName, value] of currentMetricEntries(summary)) { + writeLine(summaryMetricLine({ + label: metricLabels[metricName] ?? metricName, + maxMs: thresholds.scenarios?.[scenarioName]?.metrics?.[metricName]?.maxMs, + value, + })) + } + } +} + +export function printThresholdFailures({ failures, metricLabels, writeLine = console.error }) { + writeLine('\nEditor performance thresholds failed:') + for (const failure of failures) { + const label = metricLabels[failure.metricName] ?? failure.metricName + writeLine(` ${failure.scenarioName} ${label}: ${failure.value}ms > ${failure.maxMs}ms`) + } +} + +function updatedScenarioThreshold(thresholds, scenarioName, summary) { + const previousScenario = thresholds.scenarios?.[scenarioName] ?? {} + return { + contentBytes: summary.contentBytes, + metrics: updatedMetricThresholds(previousScenario.metrics ?? {}, summary), + sectionCount: summary.sectionCount, + } +} + +function updatedMetricThresholds(previousMetrics, summary) { + return Object.fromEntries(currentMetricEntries(summary).map(([metricName, value]) => [ + metricName, + { + baselineMs: value, + maxMs: ratchetedMax(metricName, previousMetrics[metricName], value), + }, + ])) +} + +function currentMetricEntries(summary) { + return Object.entries(summary.medians) + .filter(([, value]) => typeof value === 'number' && Number.isFinite(value)) +} + +function ratchetedMax(metricName, existingMetric, value) { + const observedBudget = metricName === 'editFrameMs' + ? Math.ceil(Math.max(value * 2.5, value + 8, 16)) + : Math.ceil(Math.max(value * 1.35, value + 25)) + if (!existingMetric?.maxMs) return observedBudget + return Math.min(existingMetric.maxMs, observedBudget) +} + +function scenarioThresholdFailures(thresholds, scenarioName, summary) { + return currentMetricEntries(summary) + .map(([metricName, value]) => metricFailure(thresholds, scenarioName, metricName, value)) + .filter(Boolean) +} + +function metricFailure(thresholds, scenarioName, metricName, value) { + const maxMs = thresholds.scenarios?.[scenarioName]?.metrics?.[metricName]?.maxMs + if (typeof maxMs !== 'number' || value <= maxMs) return null + return { + maxMs, + metricName, + scenarioName, + value, + } +} + +function summaryMetricLine({ label, maxMs, value }) { + const suffix = typeof maxMs === 'number' ? ` / max ${maxMs}ms` : '' + return ` ${label.padEnd(24)} ${String(value).padStart(6)}ms${suffix}` +} diff --git a/scripts/generate_demo_vault.py b/scripts/generate_demo_vault.py new file mode 100644 index 0000000..8372933 --- /dev/null +++ b/scripts/generate_demo_vault.py @@ -0,0 +1,1197 @@ +#!/usr/bin/env python3 +"""Generate a large synthetic vault for scale and performance checks. + +Creates a realistic 2-year knowledge vault (Q1 2024 - Q4 2025) for a +fictional persona based on Luca Rossi, founder of Refactoring. + +The curated `demo-vault-v2/` fixture is intentionally small and lives in git. +This script generates the larger corpus on demand outside that checked-in QA +fixture. + +Usage: + python3 scripts/generate_demo_vault.py + python3 scripts/generate_demo_vault.py --output /tmp/demo-vault-large +""" + +import argparse +import random +import shutil +from datetime import date, timedelta +from pathlib import Path + +random.seed(42) + +DEFAULT_VAULT = Path(__file__).resolve().parent.parent / "generated-fixtures" / "demo-vault-large" +VAULT = DEFAULT_VAULT +SUBDIRS = [ + "area", "responsibility", "measure", "target", "goal", "year", + "quarter", "month", "project", "experiment", "procedure", "task", + "person", "topic", "event", "evergreen", "note", +] +COUNTS: dict[str, int] = {} + +# ── Quarter / month mappings ───────────────────────────────────── +QUARTER_SLUGS = ["24q1", "24q2", "24q3", "24q4", "25q1", "25q2", "25q3", "25q4"] +Q_YEAR = {q: ("2024" if q.startswith("24") else "2025") for q in QUARTER_SLUGS} +Q_LABEL = { + "24q1": "Q1 2024", "24q2": "Q2 2024", "24q3": "Q3 2024", "24q4": "Q4 2024", + "25q1": "Q1 2025", "25q2": "Q2 2025", "25q3": "Q3 2025", "25q4": "Q4 2025", +} +Q_MONTHS = { + "24q1": ["2024-01", "2024-02", "2024-03"], "24q2": ["2024-04", "2024-05", "2024-06"], + "24q3": ["2024-07", "2024-08", "2024-09"], "24q4": ["2024-10", "2024-11", "2024-12"], + "25q1": ["2025-01", "2025-02", "2025-03"], "25q2": ["2025-04", "2025-05", "2025-06"], + "25q3": ["2025-07", "2025-08", "2025-09"], "25q4": ["2025-10", "2025-11", "2025-12"], +} +Q_START = { + "24q1": "2024-01-01", "24q2": "2024-04-01", "24q3": "2024-07-01", "24q4": "2024-10-01", + "25q1": "2025-01-01", "25q2": "2025-04-01", "25q3": "2025-07-01", "25q4": "2025-10-01", +} +MONTH_NAMES = [ + "", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] +MONTH_RATINGS = ["😄", "😄", "🤩", "😄", "😐", "🤩", "😄", "😄", + "🤩", "😄", "😐", "😄", "🤩", "😄", "😄", "🤩", + "😄", "😐", "🤩", "😄", "😄", "🤩", "😄", "😄"] + +# Subscriber trajectory: (start, end) per quarter +SUB_TRAJ = { + "24q1": (35000, 38000), "24q2": (38000, 42000), + "24q3": (42000, 47000), "24q4": (47000, 53000), + "25q1": (53000, 59000), "25q2": (59000, 66000), + "25q3": (66000, 75000), "25q4": (75000, 85000), +} +# Revenue trajectory: monthly EUR at quarter end +REV_TRAJ = { + "24q1": 8000, "24q2": 10000, "24q3": 12000, "24q4": 14000, + "25q1": 15000, "25q2": 17000, "25q3": 19000, "25q4": 22000, +} + +# ── Helpers ────────────────────────────────────────────────────── +_UNQUOTED = { + "Open", "Done", "Draft", "Published", "Abandoned", "Behind", + "Year", "Quarter", "Month", "Area", "Responsibility", "Measure", + "Target", "Goal", "Project", "Experiment", "Procedure", "Task", + "Person", "Topic", "Event", "Evergreen", "Note", + "Weekly", "Bi-weekly", "Monthly", "Quarterly", "Daily", +} + + +def wl(slug: str) -> str: + return f"[[{slug}]]" + + +def fm(fields: dict) -> str: + lines = ["---"] + for k, v in fields.items(): + if isinstance(v, list): + inner = ", ".join(f'"{i}"' for i in v) + lines.append(f"{k}: [{inner}]") + elif isinstance(v, (int, float)): + lines.append(f"{k}: {v}") + elif isinstance(v, str) and v in _UNQUOTED: + lines.append(f"{k}: {v}") + else: + lines.append(f'{k}: "{v}"') + lines.append("---") + return "\n".join(lines) + + +def write_md(subdir: str, slug: str, fields: dict, body: str): + path = VAULT / subdir / f"{slug}.md" + path.write_text(fm(fields) + "\n" + body.rstrip() + "\n", encoding="utf-8") + COUNTS[subdir] = COUNTS.get(subdir, 0) + 1 + + +def month_slug_to_q(ms: str) -> str: + y, m = ms.split("-") + qi = (int(m) - 1) // 3 + 1 + return f"{y[2:]}{'' if y == '2024' else ''}q{qi}" if y == "2024" else f"{y[2:]}q{qi}" + + +# ── AREAS ──────────────────────────────────────────────────────── +# (slug, name, responsibility_slugs) +AREAS = [ + ("area-building", "Building", [ + "responsibility-grow-newsletter", "responsibility-sponsorships", + "responsibility-content-production", "responsibility-podcast", + "responsibility-team-management"]), + ("area-health", "Health", ["responsibility-health-fitness"]), + ("area-personal", "Personal", []), + ("area-learning", "Learning", ["responsibility-learning"]), + ("area-finance", "Finance", ["responsibility-personal-finance"]), +] + +# ── RESPONSIBILITIES ───────────────────────────────────────────── +# (slug, name, area, measures, procedures, body) +RESPONSIBILITIES = [ + ("responsibility-grow-newsletter", "Grow Newsletter", "area-building", + ["measure-subscribers", "measure-open-rate"], + ["procedure-monthly-subscriber-metrics", "procedure-referral-program", + "procedure-welcome-email-sequence", "procedure-seo-content-optimization"], + "Growing the Refactoring newsletter subscriber base through organic content, SEO, referrals, and strategic partnerships.\n\n## KPIs\n- Subscribers: target 100k by end 2025\n- Open rate: maintain >45%"), + ("responsibility-sponsorships", "Sponsorships", "area-building", + ["measure-sponsorship-mrr", "measure-close-rate"], + ["procedure-monthly-sponsor-report", "procedure-quarterly-sponsor-outreach", + "procedure-sponsor-onboarding", "procedure-invoice-processing", "procedure-sponsor-renewal"], + "Selling and managing sponsorships for Refactoring. Building long-term relationships with B2B tech companies.\n\n## KPIs\n- MRR: grow from €8k to €22k\n- Close rate: maintain >30%"), + ("responsibility-content-production", "Content Production", "area-building", + ["measure-articles-per-week", "measure-essay-quality-score"], + ["procedure-weekly-newsletter", "procedure-monthly-pillar-planning", + "procedure-social-media-scheduling", "procedure-newsletter-ab-testing", + "procedure-content-calendar-review", "procedure-editorial-review", + "procedure-evergreen-content-audit", "procedure-newsletter-metrics-weekly"], + "Publishing weekly essays and newsletter editions. Maintaining high editorial quality while shipping consistently.\n\n## KPIs\n- Articles per week: 1 newsletter + 1 essay minimum\n- Quality score: reader feedback >4.5/5"), + ("responsibility-podcast", "Podcast", "area-building", + ["measure-podcast-downloads", "measure-podcast-episodes-per-month"], + ["procedure-podcast-recording", "procedure-podcast-guest-outreach", + "procedure-podcast-editing", "procedure-podcast-show-notes", "procedure-podcast-analytics"], + "Running the Refactoring podcast — bi-weekly episodes with tech leaders on engineering culture, leadership, and building.\n\n## KPIs\n- Downloads per episode: target 5k+\n- Episodes per month: 2"), + ("responsibility-team-management", "Team Management", "area-building", + ["measure-team-nps", "measure-task-completion-rate"], + ["procedure-weekly-team-sync", "procedure-biweekly-1on1-matteo", + "procedure-biweekly-1on1-paco", "procedure-biweekly-1on1-sara", + "procedure-quarterly-team-retro"], + "Managing Matteo (partnerships), Paco (operations), and Sara (editor). Building a small but high-performing team.\n\n## KPIs\n- Team NPS: >8\n- Task completion rate: >85%"), + ("responsibility-health-fitness", "Health & Fitness", "area-health", + ["measure-resting-hr", "measure-cycling-km-per-month"], + ["procedure-weekly-cycling-block", "procedure-gym-routine", + "procedure-monthly-health-review", "procedure-race-preparation"], + "Staying fit through cycling, gym, and good nutrition. Training for gran fondos and maintaining energy for work.\n\n## KPIs\n- Resting HR: <55 bpm\n- Cycling: 300+ km/month in season"), + ("responsibility-personal-finance", "Personal Finance", "area-finance", + ["measure-net-worth", "measure-savings-rate"], + ["procedure-monthly-portfolio-review", "procedure-quarterly-financial-planning"], + "Managing investments, savings, and financial planning. Building long-term wealth through index funds and diversification.\n\n## KPIs\n- Savings rate: >30% of income\n- Net worth: track monthly"), + ("responsibility-learning", "Learning", "area-learning", + ["measure-books-per-month", "measure-evergreen-notes-created"], + ["procedure-weekly-reading-session", "procedure-evergreen-note-writing"], + "Reading widely, studying deeply, and creating evergreen notes. Focused on non-fiction: business, technology, science, and self-improvement.\n\n## KPIs\n- Books per month: 2+\n- Evergreen notes: 3+ per month"), +] + +# ── MEASURES ───────────────────────────────────────────────────── +# (slug, name, responsibility, unit) +MEASURES = [ + ("measure-subscribers", "Newsletter Subscribers", "responsibility-grow-newsletter", "subscribers"), + ("measure-open-rate", "Newsletter Open Rate", "responsibility-grow-newsletter", "percent"), + ("measure-sponsorship-mrr", "Sponsorship MRR", "responsibility-sponsorships", "EUR/month"), + ("measure-close-rate", "Sponsorship Close Rate", "responsibility-sponsorships", "percent"), + ("measure-articles-per-week", "Articles Per Week", "responsibility-content-production", "articles"), + ("measure-essay-quality-score", "Essay Quality Score", "responsibility-content-production", "score (1-5)"), + ("measure-podcast-downloads", "Podcast Downloads", "responsibility-podcast", "downloads/episode"), + ("measure-podcast-episodes-per-month", "Podcast Episodes Per Month", "responsibility-podcast", "episodes"), + ("measure-team-nps", "Team NPS", "responsibility-team-management", "score (1-10)"), + ("measure-task-completion-rate", "Task Completion Rate", "responsibility-team-management", "percent"), + ("measure-resting-hr", "Resting Heart Rate", "responsibility-health-fitness", "bpm"), + ("measure-cycling-km-per-month", "Cycling Km Per Month", "responsibility-health-fitness", "km"), + ("measure-net-worth", "Net Worth", "responsibility-personal-finance", "EUR"), + ("measure-savings-rate", "Savings Rate", "responsibility-personal-finance", "percent"), + ("measure-books-per-month", "Books Per Month", "responsibility-learning", "books"), + ("measure-evergreen-notes-created", "Evergreen Notes Created", "responsibility-learning", "notes/month"), +] + +# ── TOPICS ─────────────────────────────────────────────────────── +# (slug, name, description) +TOPICS = [ + ("topic-ai-ml", "AI & Machine Learning", "Notes and ideas about AI, LLMs, and machine learning applications in content and business."), + ("topic-newsletter-growth", "Newsletter Growth", "Strategies, experiments, and learnings about growing an email newsletter audience."), + ("topic-content-strategy", "Content Strategy", "How to plan, create, and distribute content that resonates with a technical audience."), + ("topic-cycling-training", "Cycling Training", "Training plans, nutrition, gear, and race preparation for road cycling."), + ("topic-personal-finance", "Personal Finance & Investing", "Index funds, portfolio allocation, savings strategies, and financial independence."), + ("topic-b2b-marketing", "B2B Marketing", "Marketing strategies for reaching developers, engineering leaders, and technical decision-makers."), + ("topic-developer-tools", "Developer Tools", "The landscape of developer tools, devex, and the business of selling to engineers."), + ("topic-productivity-systems", "Productivity Systems", "Personal knowledge management, task systems, note-taking, and deep work practices."), + ("topic-writing", "Writing", "The craft of writing clearly, consistently, and for a technical audience."), + ("topic-podcasting", "Podcasting", "Production, guest selection, promotion, and monetization of a tech podcast."), + ("topic-team-leadership", "Team Leadership", "Managing small teams, async work, 1:1s, feedback, and building culture remotely."), + ("topic-mental-health", "Mental Health", "Stress management, work-life balance, and psychological wellbeing as a founder."), + ("topic-nutrition", "Nutrition", "Eating well as an endurance athlete and knowledge worker. Meal prep, macros, and habits."), + ("topic-italian-startups", "Italian Startup Ecosystem", "The state of tech startups in Italy — funding, talent, culture, and opportunities."), + ("topic-open-source", "Open Source", "Contributing to and building on open-source software. Community, licensing, and sustainability."), + ("topic-data-engineering", "Data Engineering", "Data pipelines, analytics infrastructure, and the modern data stack."), + ("topic-product-management", "Product Management", "Product thinking, prioritization frameworks, and building what users need."), + ("topic-saas-business", "SaaS Business Models", "Recurring revenue, churn, pricing, and the economics of software businesses."), + ("topic-public-speaking", "Public Speaking", "Preparing talks, managing nerves, and communicating ideas on stage."), + ("topic-music-guitar", "Music & Guitar", "Playing guitar, learning music theory, and the joy of making music."), + ("topic-reading-books", "Reading & Books", "Book recommendations, reading strategies, and how to retain what you read."), + ("topic-cooking", "Cooking", "Italian cooking, meal prep, and experimenting in the kitchen."), + ("topic-travel", "Travel", "Trips, conferences abroad, and exploring new cities."), + ("topic-running", "Running", "Casual running, trail running, and cross-training for cycling."), + ("topic-sleep-recovery", "Sleep & Recovery", "Sleep hygiene, recovery protocols, and the science of rest."), +] + +# ── PERSONS ────────────────────────────────────────────────────── +# (slug, name, tier, tags, bio) +PERSONS = [ + # Self + ("person-luca-rossi", "Luca Rossi", "1st 🥇", ["Self"], + "Founder of Refactoring, a B2B tech newsletter and podcast. Based in Milan. Cyclist, guitarist, reader."), + # Team + ("person-matteo-cellini", "Matteo Cellini", "1st 🥇", ["Team"], + "Head of Partnerships at Refactoring. Joined in 2022. Manages sponsor relationships and revenue growth."), + ("person-paco-furiani", "Paco Furiani", "1st 🥇", ["Team"], + "Head of Operations at Refactoring. Keeps everything running — billing, tools, workflows, and logistics."), + ("person-sara-ricci", "Sara Ricci", "1st 🥇", ["Team"], + "Editor at Refactoring. Hired in Q2 2024. Sharp eye for structure and clarity in technical writing."), + ("person-marco-bianchi", "Marco Bianchi", "2nd 🥈", ["Team"], + "Freelance developer. Helps with the Refactoring website, landing pages, and tooling."), + # Partner + ("person-giulia-marchetti", "Giulia Marchetti", "1st 🥇", ["Personal"], + "Luca's girlfriend. Met in early 2024. Works as a UX researcher at a Milan fintech. Loves hiking and contemporary art."), + # Family + ("person-elena-rossi", "Elena Rossi", "1st 🥇", ["Family"], + "Luca's sister. Lives in Rome. Works in publishing. They talk every week."), + ("person-roberto-rossi", "Roberto Rossi", "1st 🥇", ["Family"], + "Luca's father. Retired engineer. Lives near Lake Como. Passionate about woodworking."), + ("person-maria-colombo", "Maria Colombo", "1st 🥇", ["Family"], + "Luca's mother. Retired teacher. Lives near Lake Como. Amazing cook — Luca's pasta recipes come from her."), + ("person-antonio-marchetti", "Antonio Marchetti", "2nd 🥈", ["Family"], + "Giulia's brother. Architect based in Turin. They see each other at family gatherings."), + ("person-nonna-lucia", "Nonna Lucia", "1st 🥇", ["Family"], + "Luca's grandmother. 87 years old, lives in Lecco. Luca visits her monthly. Best risotto in Lombardy."), + # Friends + ("person-davide-conti", "Davide Conti", "2nd 🥈", ["Friend"], + "College friend, software engineer at a Milan startup. They grab dinner regularly and talk tech."), + ("person-alessandro-ferrari", "Alessandro Ferrari", "2nd 🥈", ["Friend"], + "Cycling buddy. They ride together on weekends and do gran fondos together."), + ("person-chiara-romano", "Chiara Romano", "2nd 🥈", ["Friend"], + "UX designer at a design agency. Met through the Milan tech scene. Great conversations about product."), + ("person-federico-moretti", "Federico Moretti", "2nd 🥈", ["Friend"], + "Startup founder, runs a small devtools company. They swap founder war stories over aperitivo."), + ("person-valentina-rizzo", "Valentina Rizzo", "2nd 🥈", ["Friend"], + "Journalist covering Italian tech. Occasionally writes about Refactoring. Good source for ecosystem news."), + ("person-andrea-colombo", "Andrea Colombo", "2nd 🥈", ["Friend"], + "Works in finance. Luca's go-to person for investment discussions and portfolio sanity checks."), + ("person-silvia-mancini", "Silvia Mancini", "3rd 🥉", ["Friend"], + "Doctor, friend from university. They catch up every few months. Good grounding influence."), + ("person-tommaso-greco", "Tommaso Greco", "2nd 🥈", ["Friend"], + "Musician and music teacher. They jam together occasionally — Luca on guitar, Tommaso on keys."), + ("person-elisa-barbieri", "Elisa Barbieri", "3rd 🥉", ["Friend"], + "High school teacher, old friend. They meet at group dinners in Milan."), + ("person-gianluca-esposito", "Gianluca Esposito", "2nd 🥈", ["Friend"], + "Chef, runs a small restaurant in Navigli. Luca's favourite place for a weeknight dinner."), + ("person-francesca-marino", "Francesca Marino", "3rd 🥉", ["Friend"], + "Photographer. Took the photos for the Refactoring website and brand."), + ("person-lorenzo-galli", "Lorenzo Galli", "3rd 🥉", ["Friend"], + "Lawyer, handles Refactoring's contracts. Efficient and straightforward."), + ("person-marta-pellegrini", "Marta Pellegrini", "3rd 🥉", ["Friend"], + "Friend from the gym. Personal trainer by profession. Helped Luca design his strength program."), + ("person-nicola-fabbri", "Nicola Fabbri", "3rd 🥉", ["Friend"], + "Neighbor, retired university professor (philosophy). Great conversations on the terrace."), + ("person-giulia-conti", "Giulia Conti", "3rd 🥉", ["Friend"], + "Giulia Marchetti's best friend. They often hang out as a group on weekends."), + ("person-stefano-villa", "Stefano Villa", "3rd 🥉", ["Friend"], + "Old colleague from Luca's pre-Refactoring days. Now a VP Eng at a Milan scale-up."), + ("person-anna-fontana", "Anna Fontana", "3rd 🥉", ["Friend"], + "Runs a yoga studio near Luca's apartment. Giulia introduced them."), + ("person-mattia-de-luca", "Mattia De Luca", "3rd 🥉", ["Friend"], + "Davide's roommate. Data engineer. They sometimes all go out together."), + # Podcast guests (30) + ("person-marcus-weber", "Marcus Weber", "3rd 🥉", ["Podcast Guest"], + "Software architect, author of 'Scaling Teams'. Episode on engineering culture."), + ("person-elena-konstantinou", "Elena Konstantinou", "3rd 🥉", ["Podcast Guest"], + "VP Engineering at a European fintech unicorn. Episode on scaling engineering orgs."), + ("person-raj-patel", "Raj Patel", "3rd 🥉", ["Podcast Guest"], + "Founder of DevToolsCo. Episode on building developer tools."), + ("person-anna-lindberg", "Anna Lindberg", "3rd 🥉", ["Podcast Guest"], + "Product lead at a Nordic fintech. Episode on product-led growth."), + ("person-yusuf-osman", "Yusuf Osman", "3rd 🥉", ["Podcast Guest"], + "Staff engineer, distributed systems. Episode on system design at scale."), + ("person-clara-dupont", "Clara Dupont", "3rd 🥉", ["Podcast Guest"], + "CTO of a French SaaS startup. Episode on technical leadership."), + ("person-hiroshi-tanaka", "Hiroshi Tanaka", "3rd 🥉", ["Podcast Guest"], + "Principal engineer, observability. Episode on debugging production systems."), + ("person-priya-sharma", "Priya Sharma", "3rd 🥉", ["Podcast Guest"], + "Engineering manager, growth team. Episode on experimentation culture."), + ("person-diego-santos", "Diego Santos", "3rd 🥉", ["Podcast Guest"], + "Founder of a LatAm developer platform. Episode on global dev communities."), + ("person-katja-mueller", "Katja Mueller", "3rd 🥉", ["Podcast Guest"], + "VP Engineering, German enterprise. Episode on legacy modernization."), + ("person-paolo-bergamo", "Paolo Bergamo", "3rd 🥉", ["Podcast Guest"], + "CTO of an Italian edtech. Episode on building tech in Italy."), + ("person-marco-cecconi", "Marco Cecconi", "3rd 🥉", ["Podcast Guest"], + "Engineering director, gaming. Episode on high-performance engineering teams."), + ("person-francesca-deluca", "Francesca De Luca", "3rd 🥉", ["Podcast Guest"], + "Product lead, fintech Milan. Episode on product management in regulated industries."), + ("person-massimo-artusi", "Massimo Artusi", "3rd 🥉", ["Podcast Guest"], + "Open source community leader. Episode on sustainability in OSS."), + ("person-simone-bianchi", "Simone Bianchi", "3rd 🥉", ["Podcast Guest"], + "Platform architect. Episode on internal developer platforms."), + ("person-nina-petersen", "Nina Petersen", "3rd 🥉", ["Podcast Guest"], + "AI researcher, Copenhagen. Episode on practical AI in production."), + ("person-tom-richardson", "Tom Richardson", "3rd 🥉", ["Podcast Guest"], + "CEO of a developer tools startup. Episode on founder-led sales."), + ("person-natalie-chang", "Natalie Chang", "3rd 🥉", ["Podcast Guest"], + "Investor, deep tech. Episode on what VCs look for in B2B SaaS."), + ("person-sarah-oconnor", "Sarah O'Connor", "3rd 🥉", ["Podcast Guest"], + "Engineering director. Episode on engineering career ladders."), + ("person-adeel-khan", "Adeel Khan", "3rd 🥉", ["Podcast Guest"], + "Staff engineer, ML platform. Episode on ML infrastructure."), + ("person-alberto-ferro", "Alberto Ferro", "3rd 🥉", ["Podcast Guest"], + "Author on software craftsmanship. Episode on code quality and testing."), + ("person-matteo-gentile", "Matteo Gentile", "3rd 🥉", ["Podcast Guest"], + "Node.js contributor. Episode on open-source contributions."), + ("person-piergiorgio-conte", "Piergiorgio Conte", "3rd 🥉", ["Podcast Guest"], + "CTO, Italian enterprise software. Episode on digital transformation."), + ("person-lucia-martinez", "Lucia Martinez", "3rd 🥉", ["Podcast Guest"], + "Tech journalist, Madrid. Episode on covering the European tech scene."), + ("person-james-murphy", "James Murphy", "3rd 🥉", ["Podcast Guest"], + "Founder, bootstrapped SaaS. Episode on bootstrapping vs. VC funding."), + ("person-david-eriksson", "David Eriksson", "3rd 🥉", ["Podcast Guest"], + "CTO, Stockholm startup. Episode on remote-first engineering."), + ("person-patrick-nguyen", "Patrick Nguyen", "3rd 🥉", ["Podcast Guest"], + "CEO, API platform. Episode on API-first business models."), + ("person-emilia-hoffmann", "Emilia Hoffmann", "3rd 🥉", ["Podcast Guest"], + "AI startup founder, Berlin. Episode on AI product-market fit."), + ("person-benedetta-vitali", "Benedetta Vitali", "3rd 🥉", ["Podcast Guest"], + "Startup founder, Italy. Episode on building a startup in southern Europe."), + ("person-andrea-provaglio", "Andrea Provaglio", "3rd 🥉", ["Podcast Guest"], + "Agile coach and author. Episode on agile beyond the buzzwords."), + # Sponsors / partner contacts (15) + ("person-james-mitchell", "James Mitchell", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Linear. Runs their developer marketing."), + ("person-anna-kowalski", "Anna Kowalski", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Vercel. Manages newsletter sponsorship programs."), + ("person-thomas-mueller", "Thomas Mueller", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Datadog. Focused on developer audience reach."), + ("person-lisa-chen", "Lisa Chen", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Notion. Runs B2B content partnerships."), + ("person-michael-brown", "Michael Brown", "3rd 🥉", ["Sponsor"], + "Sponsor contact at GitHub. Developer relations and sponsorships."), + ("person-sophie-laurent", "Sophie Laurent", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Figma. Design and developer marketing."), + ("person-kenji-tanaka", "Kenji Tanaka", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Supabase. Growth and partnerships."), + ("person-rachel-green", "Rachel Green", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Retool. Developer content sponsorships."), + ("person-carlos-mendez", "Carlos Mendez", "3rd 🥉", ["Sponsor"], + "Sponsor contact at PlanetScale. Database and developer marketing."), + ("person-emma-wilson", "Emma Wilson", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Clerk. Auth and identity marketing."), + ("person-peter-schmidt", "Peter Schmidt", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Raycast. Productivity tools marketing."), + ("person-yuki-sato", "Yuki Sato", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Fly.io. Infrastructure marketing."), + ("person-olivia-martinez", "Olivia Martinez", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Lemon Squeezy. Creator economy partnerships."), + ("person-henrik-johansson", "Henrik Johansson", "3rd 🥉", ["Sponsor"], + "Sponsor contact at PostHog. Product analytics and developer marketing."), + ("person-david-kim", "David Kim", "3rd 🥉", ["Sponsor"], + "Sponsor contact at Railway. Cloud platform partnerships."), +] + +TEAM_SLUGS = ["person-matteo-cellini", "person-paco-furiani", "person-sara-ricci"] +SPONSOR_PERSONS = [p[0] for p in PERSONS if "Sponsor" in p[3]] +PODCAST_GUESTS = [p[0] for p in PERSONS if "Podcast Guest" in p[3]] +FRIEND_SLUGS = [p[0] for p in PERSONS if "Friend" in p[3]] +FAMILY_SLUGS = [p[0] for p in PERSONS if "Family" in p[3]] +PERSONAL_CONTACTS = FRIEND_SLUGS + FAMILY_SLUGS + ["person-giulia-marchetti"] + +PERSONAL_CONTACTS = FRIEND_SLUGS + FAMILY_SLUGS + ["person-giulia-marchetti"] + +# ── PROJECTS ───────────────────────────────────────────────────── +# (slug, title, quarter, responsibility, status, body) +PROJECTS = [ + ("24q1-launch-sponsorship-packages","Launch Sponsorship Packages","24q1","responsibility-sponsorships","Done","Define and launch the first structured sponsorship tiers. Create media kit and pricing deck."), + ("24q1-redesign-newsletter-template","Redesign Newsletter Template","24q1","responsibility-content-production","Done","Redesign the Refactoring email layout for better readability and visual hierarchy."), + ("24q1-plan-cycling-season","Plan 2024 Cycling Season","24q1","responsibility-health-fitness","Done","Plan races, training blocks, and equipment upgrades for the 2024 cycling season."), + ("24q1-set-investing-framework","Set Up Investing Framework","24q1","responsibility-personal-finance","Done","Define a personal investment policy statement and set up automated monthly contributions to index funds."), + ("24q1-podcast-season-1","Podcast Season 1 Launch","24q1","responsibility-podcast","Done","Launch the Refactoring podcast with 6 episodes recorded in advance. Focus: engineering culture."), + ("24q2-hire-editor","Hire Editor (Sara)","24q2","responsibility-team-management","Done","Hire a part-time editor to raise newsletter quality and free up writing time."), + ("24q2-build-podcast-landing-page","Build Podcast Landing Page","24q2","responsibility-podcast","Done","Create a dedicated landing page for the Refactoring podcast with episode archive and subscribe CTA."), + ("24q2-10-pillar-articles","Write 10 Pillar Articles","24q2","responsibility-content-production","Done","Write 10 in-depth articles targeting high-traffic SEO terms in the developer/engineering space."), + ("24q2-spring-gran-fondo","Spring Gran Fondo 2024","24q2","responsibility-health-fitness","Done","Train for and complete the Granfondo di Varese — 130km route, 2200m elevation."), + ("24q2-sponsor-crm","Set Up Sponsor CRM","24q2","responsibility-sponsorships","Done","Build an Airtable CRM to track sponsor outreach, deal status, invoices, and renewal dates."), + ("24q3-premium-tier","Launch Premium Newsletter Tier","24q3","responsibility-grow-newsletter","Abandoned","Experiment with a paid tier. Abandoned after 6 weeks due to low conversion."), + ("24q3-codemotion-talk","Speak at Codemotion Milan","24q3","responsibility-content-production","Done","Prepare and deliver a 30-min talk on newsletter growth for technical founders."), + ("24q3-summer-reading-sprint","Summer Reading Sprint","24q3","responsibility-learning","Done","Read 6 books in July-August. Topics: history, business, and philosophy of science."), + ("24q3-podcast-season-2","Podcast Season 2","24q3","responsibility-podcast","Done","Record and release 8 episodes focused on engineering leadership and org design."), + ("24q3-new-sponsor-verticals","Expand Sponsor Verticals","24q3","responsibility-sponsorships","Done","Target cloud infra, devtools, and AI/ML companies as new sponsor categories."), + ("24q4-annual-review-process","Team Annual Review","24q4","responsibility-team-management","Done","Design and run the first structured annual review for Matteo, Paco, and Sara."), + ("24q4-sponsor-dashboard","Build Sponsor Dashboard","24q4","responsibility-sponsorships","Done","Build an Airtable dashboard giving sponsors real-time click and performance data."), + ("24q4-laputa-start","Start Laputa App Project","24q4","responsibility-learning","Done","Begin building Laputa — a custom Tauri desktop app for managing the personal knowledge vault."), + ("24q4-black-friday-campaign","Black Friday Newsletter Campaign","24q4","responsibility-grow-newsletter","Done","Run a curated Black Friday campaign with tool recommendations. +1200 subscribers in 3 days."), + ("24q4-cycling-year-review","Cycling Year in Review 2024","24q4","responsibility-health-fitness","Done","Review the 2024 cycling season and plan 2025: races, training peaks, and gear."), + ("25q1-laputa-v1","Laputa App V1","25q1","responsibility-learning","Done","Ship the first working version of Laputa with 4-panel layout, inspector, and quick open."), + ("25q1-newsletter-seo-sprint","Newsletter SEO Sprint","25q1","responsibility-grow-newsletter","Done","30-day SEO sprint: 5 high-traffic articles + internal linking overhaul."), + ("25q1-strength-program","New Strength Program","25q1","responsibility-health-fitness","Done","Start a 12-week strength program to complement cycling and prevent injury."), + ("25q1-rate-increase","Increase Sponsorship Rates Q2","25q1","responsibility-sponsorships","Done","Raise ad rates by 25% for Q2 based on audience growth and click-through data."), + ("25q1-referral-program","Newsletter Referral Program","25q1","responsibility-grow-newsletter","Done","Launch a referral program rewarding subscribers who bring in new readers."), + ("25q2-reach-70k","Reach 70k Subscribers","25q2","responsibility-grow-newsletter","Done","Growth sprint to 70k: referral push + partnership with 3 complementary newsletters."), + ("25q2-podcast-season-3","Podcast Season 3","25q2","responsibility-podcast","Done","10 episodes on building in public, founder journeys, and product-led growth."), + ("25q2-team-retreat","Team Retreat Milan","25q2","responsibility-team-management","Done","First in-person team retreat: 2 days of strategy, workshops, and dinner."), + ("25q2-laputa-v2","Laputa App V2","25q2","responsibility-learning","Done","V2 with BlockNote editor, wiki-links autocomplete, and redesigned theme system."), + ("25q2-dolomites-trip","Cycling Trip: Dolomites","25q2","responsibility-health-fitness","Done","5-day cycling trip with Alessandro. Stelvio + Mortirolo. 650km total."), + ("25q3-ebook","Write Newsletter Growth E-book","25q3","responsibility-content-production","Done","15,000-word e-book on newsletter growth for technical founders. Free lead magnet."), + ("25q3-community-launch","Launch Refactoring Community","25q3","responsibility-grow-newsletter","Open","Private Discord for premium subscribers. Still in soft launch."), + ("25q3-podcast-season-4","Podcast Season 4","25q3","responsibility-podcast","Open","Theme: AI and the changing face of software engineering. 8 episodes planned."), + ("25q3-leaddev-london","LeadDev London 2025","25q3","responsibility-content-production","Done","Attend LeadDev London: meet guests, write post-conference piece, speak on a panel."), + ("25q3-peak-training","Summer Cycling Peak Training","25q3","responsibility-health-fitness","Done","Peak training block for September gran fondo. Hit 420km in August."), + ("25q4-year-review-2025","2025 Annual Review","25q4","responsibility-team-management","Open","Full 2025 retrospective for team and personal year review."), + ("25q4-reach-85k","Reach 85k Subscribers","25q4","responsibility-grow-newsletter","Open","Year-end push through partnerships and referral program expansion."), + ("25q4-2026-sponsors","2026 Sponsorship Pipeline","25q4","responsibility-sponsorships","Open","Pre-sell Q1-Q2 2026 sponsorships. Target: 80% sold before Dec 31."), + ("25q4-laputa-v3","Laputa App V3","25q4","responsibility-learning","Open","V3: mobile sync, AI note linking, and quick capture from menu bar."), + ("25q4-financial-review","2025 Financial Review","25q4","responsibility-personal-finance","Open","Annual review: savings rate, portfolio performance, 2026 targets."), +] + +# ── EXPERIMENTS ─────────────────────────────────────────────────── +EXPERIMENTS = [ + ("24q2-video-format-test","Video Format Experiment","24q2","Abandoned","Tried a short-form video series explaining technical concepts. Dropped after 3 videos — too time-intensive for the return."), + ("24q2-stock-screener","Vibe-coding a Stock Screener","24q2","Done","Built a stock screener in Python over a weekend to test EMA bounce strategies on US equities."), + ("24q3-morning-journaling","Morning Journaling Habit","24q3","Done","Tried daily morning journaling for 8 weeks. Mixed results — useful in stressful periods, harder to maintain when calm."), + ("24q4-linkedin-crossposting","LinkedIn Cross-posting Experiment","24q4","Done","Repurposed newsletter essays on LinkedIn for 6 weeks. +800 followers, modest referral traffic."), + ("25q1-paid-newsletter-trial","Paid Newsletter Trial","25q1","Abandoned","Tested a Substack-style paid tier. Conversion too low to justify the overhead."), + ("25q3-discord-community-soft","Discord Community Soft Launch","25q3","Open","Soft-launched the Refactoring community with 50 beta subscribers. Still iterating on engagement."), +] + +# ── GOALS ───────────────────────────────────────────────────────── +GOALS = [ + ("2024-reach-50k-subscribers","Reach 50k Subscribers","2024","Done","End 2024 with 50,000+ newsletter subscribers. Achieved 53,000 by December."), + ("2024-double-revenue","Double Sponsorship Revenue","2024","Done","Grow MRR from €7k to €14k+ through better packages and more sponsors."), + ("2024-complete-two-gran-fondos","Complete Two Gran Fondos","2024","Done","Finish at least two gran fondos in the 2024 cycling season."), + ("2024-read-24-books","Read 24 Books in 2024","2024","Behind","Target: 2 books/month. Actual: 18. Good effort, missed the target."), + ("2024-launch-podcast","Launch Refactoring Podcast","2024","Done","Launch and sustain the podcast through at least 2 seasons."), + ("2025-reach-85k-subscribers","Reach 85k Subscribers","2025","Open","End 2025 with 85,000+ subscribers. On track at 75k in Q3."), + ("2025-reach-22k-mrr","Reach €22k MRR","2025","Open","Grow sponsorship revenue to €22k/month by end of year."), + ("2025-ship-laputa","Ship Laputa App","2025","Done","Build and use Laputa v1 as my daily knowledge management tool."), + ("2025-ride-stelvio","Ride the Stelvio Pass","2025","Done","Complete a full ascent of the Stelvio — bucket list cycling goal."), + ("2025-read-20-books","Read 20 Books in 2025","2025","Open","Target: 20 books. At 14 in Q3. Achievable."), +] + +# ── EVERGREEN TOPICS AND BODIES ─────────────────────────────────── +EVERGREENS = [ + ("the-compound-effect-in-knowledge-work","The Compound Effect in Knowledge Work",["topic-productivity-systems","topic-writing"], + "Small, consistent intellectual investments compound dramatically over time. A newsletter writer who publishes 50 essays a year builds an insurmountable lead over one who publishes 10 — not just in volume, but in clarity of thought, audience trust, and SEO authority.\n\nThe key insight is that the compounding happens in the *system*, not in any single piece. Each essay trains your thinking, attracts new readers, and creates internal links for future pieces. After 200 essays, you're not writing 200 times better — but you are writing with the advantage of 200 previous attempts behind you.\n\nThis is why consistency beats intensity. A newsletter sent every Monday for 3 years is more valuable than 50 brilliant newsletters sent sporadically."), + ("newsletter-growth-is-about-trust","Newsletter Growth Is About Trust",["topic-newsletter-growth","topic-content-strategy"], + "The metric that actually predicts newsletter growth isn't subscriber count, open rate, or even click rate — it's whether readers feel like the author is genuinely on their side.\n\nTrust compounds. A reader who opens 40 consecutive newsletters before clicking anything is still more valuable than a subscriber who clicks on issue #1 and unsubscribes on issue #3. Long-term readers become advocates, paying customers, and referral sources.\n\nThe implication: optimize for retention before acquisition. A 60% open rate with 1,000 subscribers will grow faster than a 20% open rate with 5,000."), + ("small-teams-scale-through-systems","Small Teams Scale Through Systems, Not Headcount",["topic-team-leadership","topic-saas-business"], + "The instinct when workload grows is to hire. But hiring is slow, expensive, and introduces coordination overhead. The better first move is almost always to systematize.\n\nA team of 3 with good systems — documented processes, clear ownership, async-first communication — can often outperform a team of 10 without them. The difference is decision latency and context overhead.\n\nThis is especially true in content businesses, where the core work is creative and can't easily be parallelized. Better to double the quality of what 3 people produce than to dilute quality by hiring 3 more."), + ("training-load-and-knowledge-work","Training Load and Knowledge Work",["topic-cycling-training","topic-productivity-systems"], + "Endurance athletes understand that adaptation happens during recovery, not during training. You get stronger by stressing the system, then allowing it to rebuild stronger. Skip recovery and you get injured or overtrained.\n\nKnowledge work follows the same principle. Deep focus creates cognitive load — stress on your attention system. Without recovery (sleep, walks, boredom), you don't consolidate learning, and creativity degrades.\n\nThe best writers, programmers, and thinkers I know are obsessive about protecting recovery. Not because they're lazy, but because they understand the adaptation model."), + ("b2b-content-is-trust-not-traffic","B2B Content Is About Trust, Not Traffic",["topic-b2b-marketing","topic-content-strategy"], + "The classic content marketing mistake in B2B is optimizing for pageviews. But in B2B software, the buying cycle is long, the decision-makers are sophisticated, and the research is deep.\n\nWhat actually moves enterprise deals is trust built over months or years of consistent, genuinely useful content. A VP Engineering who has read your newsletter for 18 months before their budget cycle starts is a fundamentally different prospect than one who found you via Google last week.\n\nThis is why the ROI of B2B content is almost always underestimated when measured on short time horizons."), + ("index-funds-and-intellectual-humility","Index Funds as an Act of Intellectual Humility",["topic-personal-finance"], + "Choosing index funds over stock picking isn't financial laziness — it's a philosophical position about knowledge and markets.\n\nThe efficient market hypothesis, imperfect as it is, says that publicly available information is already priced in. When I buy a stock because I think it's undervalued, I'm saying I know something the combined intelligence of millions of market participants, with full access to company data and professional analysis tools, has missed.\n\nIndex funds are the humble answer: I don't know which stocks will outperform, so I'll own all of them. The humility is the strategy."), + ("the-two-types-of-hard","The Two Types of Hard",["topic-productivity-systems","topic-mental-health"], + "There are two kinds of difficulty in creative work. The first is the hard of skill — you literally don't know how to do the thing yet. The second is the hard of resistance — you know how, but starting feels uncomfortable.\n\nMistaking one for the other is dangerous. If you think resistance is skill gap, you'll spend time learning when you should be shipping. If you think skill gap is resistance, you'll force output when you actually need to learn.\n\nThe tell: skill-gap hard gets easier with study and practice. Resistance hard gets easier with starting. Both are real; neither should be romanticized."), + ("podcasting-is-relationship-building","Podcasting Is Relationship Building at Scale",["topic-podcasting","topic-b2b-marketing"], + "A podcast guest who has a good experience will often become an advocate for your brand in ways that are hard to manufacture any other way. They share the episode with their audience, mention it in talks, and remember you warmly when your paths cross again.\n\nThe distribution value of a podcast episode is real but often secondary to the relationship value. Some of Refactoring's best sponsor introductions came from guests who had been on the show and recommended us to a contact.\n\nThis reframes how to think about guest selection. The right question isn't just 'who has the biggest audience?' but 'who do I want a real relationship with?'"), + ("writing-for-clarity-vs-writing-for-credit","Writing for Clarity vs. Writing for Credit",["topic-writing"], + "There are two failure modes in technical writing. The first is jargon-as-camouflage: using complexity to signal expertise, so the reader can't tell if you actually know what you're talking about. The second is oversimplification-as-humility: stripping out necessary nuance to seem accessible.\n\nClarity is harder than either. It requires understanding a topic well enough to choose exactly which complexity to preserve and which to discard — and being honest about which parts you don't fully understand yourself.\n\nThe best technical writers I've read are ruthlessly honest about the limits of their knowledge. This is what earns trust."), + ("on-founder-energy-management","On Founder Energy Management",["topic-mental-health","topic-productivity-systems"], + "Most advice about founder productivity focuses on time management. But time is a renewable resource — you get 24 hours every day. Energy isn't.\n\nThe founders I know who sustain high output for years are almost universally careful about energy: they protect sleep, have physical activity habits, and are selective about which meetings they take. Not because they're less dedicated, but because they've learned that a fresh 4-hour work block produces more than a tired 10-hour one.\n\nThis isn't about self-care as a trend. It's about treating yourself as the primary asset in your company and investing accordingly."), + ("the-sponsorship-relationship","The Sponsor Relationship Is a Long Game",["topic-saas-business","topic-b2b-marketing"], + "Most newsletters treat sponsors transactionally: one slot, one check, move on. The newsletters that build lasting sponsor revenue think differently.\n\nA sponsor who tries your audience once and gets good results will come back. A sponsor who tries once and gets mediocre results will not — regardless of whether the ROI was there. The relationship matters as much as the numbers.\n\nThis means investing in sponsor success even beyond what's contractually required. Proactive reporting, creative suggestions, introductions to relevant readers. The goal is for the sponsor to feel like a partner, not a customer."), + ("knowledge-management-is-not-filing","Knowledge Management Is Not Filing",["topic-productivity-systems","topic-reading-books"], + "The dominant metaphor for personal knowledge management is the filing cabinet: put things in labeled folders, retrieve when needed. This metaphor is mostly wrong.\n\nKnowledge becomes useful through connection, not storage. An idea you've filed carefully but never connected to your other ideas is almost as useless as an idea you forgot.\n\nThe goal of a note-taking system should be to maximize the probability of unexpected connections — serendipitous collisions between ideas from different domains. This is why spatial proximity (notes near related notes) and links matter more than tags or folders."), + ("cycling-teaches-patience","Cycling Teaches Patience",["topic-cycling-training"], + "Road cycling has a specific kind of suffering that's worth reflecting on. You can't sprint a 4-hour gran fondo. If you go out too hard in hour one, you pay for it in hour three. The only strategy is to hold your power steady and let the race come to you.\n\nThis is completely unlike how most knowledge workers operate. We're trained to sprint — to push hard when motivated, coast when not. But sustainable high output looks more like cycling: consistent effort, below maximum, sustained over long periods.\n\nThe athletes who finish best in long races are rarely the ones who looked strongest at the start."), + ("open-source-as-marketing","Open Source as Marketing",["topic-open-source","topic-developer-tools"], + "The most successful developer tools companies have figured out something the traditional software industry took decades to learn: giving your software away isn't charity, it's distribution.\n\nHashiCorp, Elastic, Redis — all built massive developer adoption through open-source and monetized through enterprise features, support, and cloud hosting. The open-source version is a top-of-funnel lead generation engine.\n\nFor developer tools, open source has become so effective that a closed-source tool now carries an implicit trust deficit it has to overcome."), + ("newsletter-subject-lines","Newsletter Subject Lines Are User Experience",["topic-newsletter-growth","topic-writing"], + "A subject line isn't marketing copy — it's the first screen of your product. It sets expectations, attracts the right readers, and repels the wrong ones.\n\nThe biggest mistake I see in newsletter subject lines is optimizing for opens over fit. Clickbait subject lines improve short-term open rates and hurt long-term engagement. Readers who open expecting one thing and find another don't become loyal readers.\n\nThe best subject lines I've used are boring but accurate. They tell you exactly what you're going to read. High-fit readers open, low-fit readers skip, and the engaged list that results is worth more than the inflated raw number."), + ("recovery-week-in-training","The Importance of Recovery Weeks",["topic-cycling-training","topic-sleep-recovery"], + "Every serious cycling training plan includes a recovery week every 3-4 weeks: significantly reduced volume, same intensity. This isn't optional.\n\nWithout recovery weeks, training stress accumulates until the athlete either gets sick, injured, or simply stops improving. The body adapts during recovery, not during training. Training is just the stimulus.\n\nI've made the mistake of skipping recovery weeks when training was going well — 'why back off if I feel strong?' Invariably, the 5th or 6th week of consecutive load is when something breaks. Respect the cycle."), + ("the-real-job-of-a-newsletter","The Real Job of a Newsletter",["topic-content-strategy","topic-newsletter-growth"], + "People subscribe to newsletters for different reasons: to learn, to stay informed, to feel part of a community, to be entertained, to have a trusted filter. But they stay subscribed for one reason: the newsletter reliably delivers on the implicit promise it made when they subscribed.\n\nThe biggest growth lever for any newsletter isn't content, distribution, or growth hacks — it's understanding the real job the reader is hiring the newsletter to do, and doing that job consistently well.\n\nFor Refactoring, the job is: 'help me think more clearly about engineering, leadership, and building software at scale, without taking too much of my time.'"), + ("ai-wont-replace-thinking","AI Won't Replace Thinking — It Will Raise the Bar",["topic-ai-ml","topic-writing"], + "The first generation of AI writing tools made it possible to produce content without thinking. The next generation is making it impossible to compete without thinking — deeply.\n\nWhen everyone can generate serviceable prose in seconds, the competitive advantage shifts to the things AI can't replicate: genuine expertise, authentic perspective, earned trust, and original insight. Surface-level content becomes worthless. Depth becomes more valuable.\n\nFor writers with real knowledge and real opinions, this is good news. For those who were providing formatting around thin ideas, the reckoning is here."), + ("on-consistency-in-creative-work","On Consistency in Creative Work",["topic-writing","topic-productivity-systems"], + "The most counterintuitive lesson from 5 years of publishing weekly: the newsletters I agonized over for days are not consistently better than the ones I wrote in 3 hours.\n\nWhat does correlate with quality is freshness of thinking — whether I'm saying something I actually believe and find interesting, rather than something I think I should say.\n\nConsistency forces this freshness. When you have to ship every week, you can't hide behind a blank page. You develop a practice of noticing: what actually caught my attention this week? What made me change my mind? That noticing becomes the raw material."), + ("why-b2b-newsletters-work","Why B2B Newsletters Work",["topic-newsletter-growth","topic-b2b-marketing"], + "B2B newsletters occupy a unique position in the attention economy: they're invited into the inbox, not pushed. The reader actively chose to receive them. This opt-in dynamic is unlike almost every other marketing channel.\n\nThe implication: the conversion funnel is inverted compared to advertising. You don't need to interrupt; you need to deserve the slot. Readers who find a B2B newsletter worth reading self-select as high-intent. They're not casual browsers; they're professionals investing time in staying current.\n\nThis is why the economics of a B2B newsletter sponsor can be so attractive. You're not renting attention; you're being introduced to a curated audience that has already demonstrated professional intent."), + ("investing-in-yourself-vs-markets","Investing in Yourself vs. Markets",["topic-personal-finance"], + "The standard personal finance advice is to maximize your savings rate and invest in index funds. This is correct for most people. But there's a prior question for founders and early-career professionals: what's the expected return on investing in yourself versus the market?\n\nIf a €5,000 course or coaching program increases your earning power by €10,000/year, that's a 200% annual return — far above market rates. The window for these investments narrows as your career matures.\n\nIndex funds are the right answer when skill development opportunities are exhausted. Most founders and ambitious professionals haven't reached that point yet."), + ("what-makes-a-good-podcast-guest","What Makes a Good Podcast Guest",["topic-podcasting"], + "The best podcast guests are not the most famous or the most accomplished. They're the most specific.\n\nA guest who can speak from direct experience about one particular problem — how they restructured their engineering team after a disastrous product launch, exactly what they changed in their hiring process after a bad hire — is far more valuable than a guest who can speak generally about leadership at scale.\n\nThis has changed how I approach guest outreach. Instead of starting with 'who is impressive?', I start with 'what specific thing happened to this person that would be genuinely useful to our listeners?'"), + ("sleeping-more-is-a-superpower","Sleeping More Is a Competitive Advantage",["topic-sleep-recovery","topic-mental-health"], + "The tech industry glorifies sleep deprivation. 'Sleep when you're dead.' '80-hour weeks.' These are cultural artifacts from an era when we didn't understand what sleep deprivation does to cognitive performance.\n\nSleep-deprived people are bad at judging their own impairment. They think they're functioning at 80% when they're actually at 50%. They make more errors, have worse judgment, and are less creative — and they don't know it.\n\nConsistently sleeping 7-8 hours doesn't make you less productive. It makes you more productive per hour, with better judgment and lower error rates. The math nearly always works out."), + ("the-saas-metric-that-matters","The SaaS Metric That Actually Matters",["topic-saas-business"], + "Everyone talks about MRR, ARR, and churn rate. These are important. But the metric that actually tells you whether your business is healthy is net revenue retention (NRR) — the percentage of revenue from last year's customers you're retaining this year, including expansions.\n\nNRR > 100% means your existing customers are spending more than they were. This is the signature of product-market fit: customers who stay and spend more because the product keeps improving and becoming more embedded in their workflows.\n\nA business with 80% NRR is structurally fragile. A business with 120% NRR is almost impossible to kill."), + ("italian-startup-ecosystem-observations","Observations on the Italian Startup Ecosystem",["topic-italian-startups"], + "Italy produces exceptional technical talent — some of the best engineers in Europe come from Politecnico di Milano and La Sapienza. But the startup ecosystem is immature in specific ways that are interesting to understand.\n\nThe first is risk appetite. Italian professional culture, shaped partly by the importance of stable employment and family obligations, is less tolerant of career risk than US or UK equivalents. This makes recruiting for equity difficult and exit timelines different.\n\nThe second is capital infrastructure. The VC ecosystem is smaller and more conservative than Northern Europe. The best Italian founders often move to Berlin, London, or Amsterdam to raise Series A — not because Italy is bad, but because the capital isn't there yet."), + ("reading-more-by-reading-better","Read More by Reading Better",["topic-reading-books"], + "The bottleneck in most people's reading isn't speed — it's selection and retention. They read too many books they don't finish and retain too little of what they do.\n\nThree things have improved my reading dramatically:\n\n1. **Permission to stop.** If a book hasn't earned its next chapter by page 50, I stop. Life is too short for dutiful reading.\n\n2. **Writing after reading.** I write a brief note — what was the core claim? Do I believe it? What does it connect to? This takes 20 minutes and dramatically improves retention.\n\n3. **Re-reading deliberately.** I re-read a handful of books every year that have proven worth re-reading. Each time I find something new."), +] + +# ── READING NOTES ───────────────────────────────────────────────── +NOTES = [ + ("note-on-writing-well","On Writing Well","William Zinsser","topic-writing","https://example.com/writing-well","Classic guide to clear, non-fiction writing. Core insight: clutter is the disease of American writing. Surgery is the cure. Every word must earn its place."), + ("note-never-split-difference","Never Split the Difference","Chris Voss","topic-b2b-marketing","https://example.com/never-split","FBI negotiation techniques applied to everyday situations. Key: mirroring, labeling emotions, and calibrated questions. Changed how I run sponsor negotiations."), + ("note-thinking-fast-and-slow","Thinking, Fast and Slow","Daniel Kahneman","topic-productivity-systems","https://example.com/thinking","System 1 (fast, intuitive) vs System 2 (slow, deliberate) thinking. The biases section is essential — anchoring, availability heuristic, loss aversion."), + ("note-building-a-second-brain","Building a Second Brain","Tiago Forte","topic-productivity-systems","https://example.com/second-brain","CODE framework: Capture, Organize, Distill, Express. Useful scaffold but I disagree with the heavy emphasis on 'projects' as the organizing principle."), + ("note-zero-to-one","Zero to One","Peter Thiel","topic-saas-business","https://example.com/zero-to-one","Secrets are the basis of unique businesses. The competition is for losers framing. Useful contrarian lens even where I disagree."), + ("note-atomic-habits","Atomic Habits","James Clear","topic-productivity-systems","https://example.com/atomic-habits","Systems over goals. Identity-based habits. The 2-minute rule for building habits. Implementation intentions. Applied to morning reading habit and cycling training consistency."), + ("note-the-hard-thing-about-hard-things","The Hard Thing About Hard Things","Ben Horowitz","topic-team-leadership","https://example.com/hard-things","Honest account of the unglamorous parts of running a company. The 'struggle' chapter is the best thing written about what being a founder actually feels like."), + ("note-show-your-work","Show Your Work","Austin Kleon","topic-writing","https://example.com/show-your-work","Share your process, not just the output. Build an audience by being a learner in public. Short, actionable, useful for anyone who creates."), + ("note-deep-work","Deep Work","Cal Newport","topic-productivity-systems","https://example.com/deep-work","The ability to focus without distraction is rare and valuable. Shallow work is easy to replicate. Deep work is the skill of the 21st century. Applies to writing and coding especially."), + ("note-essentialism","Essentialism","Greg McKeown","topic-productivity-systems","https://example.com/essentialism","Less but better. The disciplined pursuit of less. Useful antidote to the 'more is more' founder mindset. Helped me say no to more things."), + ("note-the-lean-startup","The Lean Startup","Eric Ries","topic-saas-business","https://example.com/lean-startup","Build-measure-learn loop. Validated learning. Minimum viable product. Foundational for product thinking even if the terminology is overused."), + ("note-grit","Grit","Angela Duckworth","topic-mental-health","https://example.com/grit","Passion and perseverance over talent. Long-term goal commitment is the differentiator. Useful framework for understanding why some cyclists improve and others plateau."), + ("note-the-art-of-learning","The Art of Learning","Josh Waitzkin","topic-productivity-systems","https://example.com/art-of-learning","Learning curves, chunking, investment in loss. Waitzkin's description of how he learns new skills is the most useful thing I've read on deliberate practice."), + ("note-good-strategy-bad-strategy","Good Strategy Bad Strategy","Richard Rumelt","topic-saas-business","https://example.com/good-strategy","Strategy is diagnosis + guiding policy + coherent actions. Most 'strategy' is just goals dressed up as strategy. The kernel model is genuinely useful."), + ("note-range","Range","David Epstein","topic-reading-books","https://example.com/range","Generalists often outperform specialists in complex, unpredictable environments. The winding path to expertise is underrated. Good counter to the 10,000-hour rule absolutism."), + ("note-the-courage-to-be-disliked","The Courage to Be Disliked","Kishimi & Koga","topic-mental-health","https://example.com/courage","Adlerian psychology in Socratic dialogue form. Task separation: focus only on your own tasks, not others' responses. Changed how I think about negative feedback."), + ("note-how-minds-change","How Minds Change","David McRaney","topic-productivity-systems","https://example.com/how-minds-change","The science of belief change. Deep canvassing, motivational interviewing, SIFT. Useful for anyone trying to communicate with people who disagree."), + ("note-on-the-shortness-of-life","On the Shortness of Life","Seneca","topic-mental-health","https://example.com/shortness","Life is not short — we waste it. The Stoic framing of time as the only truly scarce resource. Annual re-read for me."), + ("note-the-innovators-dilemma","The Innovator's Dilemma","Clayton Christensen","topic-saas-business","https://example.com/innovators-dilemma","Disruptive vs sustaining innovation. Established companies fail not from incompetence but from rational decisions that work against them when the market shifts."), + ("note-man-search-for-meaning","Man's Search for Meaning","Viktor Frankl","topic-mental-health","https://example.com/frankl","Logotherapy: meaning as the primary human motivation. The experience of finding meaning even in extreme suffering. Changed how I think about difficulty."), + ("note-the-willpower-instinct","The Willpower Instinct","Kelly McGonigal","topic-mental-health","https://example.com/willpower","Willpower is a physiological resource that depletes. The stress response undermines it. Mindfulness and 'pause and plan' response to strengthen it. Applied to diet and training adherence."), + ("note-makers-schedule-managers","Maker's Schedule Manager's Schedule","Paul Graham","topic-productivity-systems","https://example.com/makers-schedule","Makers need long uninterrupted blocks. Managers live in 1-hour chunks. Meetings are cheap for managers, expensive for makers. Shaped how I structure my week."), + ("note-thinking-in-bets","Thinking in Bets","Annie Duke","topic-personal-finance","https://example.com/thinking-bets","Resulting: judging decisions by outcomes is a mistake. Good decisions have bad outcomes. Bad decisions have good outcomes. Luck matters. Separate decision quality from outcome quality."), + ("note-the-mom-test","The Mom Test","Rob Fitzpatrick","topic-saas-business","https://example.com/mom-test","How to talk to customers without them lying to you. Ask about their life, not your idea. Past behavior over future intentions. Essential for anyone doing user research."), + ("note-radical-candor","Radical Candor","Kim Scott","topic-team-leadership","https://example.com/radical-candor","Care personally, challenge directly. Ruinous empathy is the most common management failure mode. Framework for feedback conversations with Matteo, Paco, Sara."), + ("note-the-obstacle-is-the-way","The Obstacle Is the Way","Ryan Holiday","topic-mental-health","https://example.com/obstacle","Stoic philosophy applied to adversity. The obstacle itself is the path forward. Useful mental frame for dealing with difficult periods."), + ("note-so-good-they-cant-ignore","So Good They Can't Ignore You","Cal Newport","topic-productivity-systems","https://example.com/so-good","Career capital theory: rare and valuable skills create rare and valuable careers. 'Follow your passion' is bad advice. Deliberate practice is the path."), + ("note-traffic-secrets","Traffic Secrets","Russell Brunson","topic-newsletter-growth","https://example.com/traffic","Dream customer avatar, hook-story-offer framework, finding where attention lives. Useful for thinking about top-of-funnel newsletter growth."), + ("note-the-effective-executive","The Effective Executive","Peter Drucker","topic-team-leadership","https://example.com/effective-executive","What effective executives actually do: manage time, focus on contribution, exploit strengths. Timeless despite being from 1967. Annual re-read."), + ("note-born-to-run","Born to Run","Christopher McDougall","topic-running","https://example.com/born-to-run","The Tarahumara ultrarunners and the barefoot running movement. Made me think differently about natural movement patterns."), +] + + +def reset_vault(): + COUNTS.clear() + if VAULT.exists(): + shutil.rmtree(VAULT) + for sub in SUBDIRS: + (VAULT / sub).mkdir(parents=True, exist_ok=True) + + +def generate_years(): + for year in ["2024", "2025"]: + quarters = [q for q in QUARTER_SLUGS if q.startswith(year[2:])] + write_md("year", year, { + "aliases": [year], + "Is A": "Year", + "Created at": f"{year}-01-01", + "Has": [wl(q) for q in quarters], + }, f"# {year}\nAnother year of building Refactoring, shipping content, and growing the audience. Review written in December.") + + +def generate_quarters(): + for q in QUARTER_SLUGS: + projects_in_q = [p[0] for p in PROJECTS if p[2] == q] + write_md("quarter", q, { + "aliases": [Q_LABEL[q]], + "Is A": "Quarter", + "Created at": Q_START[q], + "Belongs to": wl(Q_YEAR[q]), + "Has": [wl(p) for p in projects_in_q], + "Status": "Done" if q < "25q4" else "Open", + }, f"# {Q_LABEL[q]}\nQuarterly review for {Q_LABEL[q]}. See projects and targets below.") + + +def generate_months(): + rating_cycle = ["😄", "🤩", "😄", "😄", "😐", "🤩", "😄", "🤩", "😄", "😐", "😄", "😄"] + month_tones = ["difficult", "solid", "great", "mixed"] + month_idx = 0 + + for q in QUARTER_SLUGS: + for month_slug in Q_MONTHS[q]: + year, month = month_slug.split("-") + month_name = MONTH_NAMES[int(month)] + rating = rating_cycle[month_idx % len(rating_cycle)] + tone = month_tones[month_idx % len(month_tones)] + write_md("month", month_slug, { + "aliases": [f"{month_name} {year}"], + "Is A": "Month", + "Created at": f"{month_slug}-28", + "Belongs to": wl(q), + "Rating": rating, + }, f"# {month_name} {year}\nMonthly review. A {tone} month overall.") + month_idx += 1 + + +def generate_areas(): + for slug, name, responsibility_slugs in AREAS: + write_md("area", slug, { + "aliases": [name], + "Is A": "Area", + "Has": [wl(resp) for resp in responsibility_slugs], + }, f"# {name}\nOne of the core life/work areas.") + + +def generate_responsibilities(): + for slug, name, area, measures, procedures, body in RESPONSIBILITIES: + write_md("responsibility", slug, { + "aliases": [name], + "Is A": "Responsibility", + "Belongs to": wl(area), + "Has Measures": [wl(measure) for measure in measures], + "Has Procedures": [wl(proc) for proc in procedures], + "Status": "Open", + }, f"# {name}\n{body}") + + +def generate_measures(): + for slug, name, responsibility, unit in MEASURES: + write_md("measure", slug, { + "aliases": [name], + "Is A": "Measure", + "Belongs to": wl(responsibility), + "Unit": unit, + }, f"# {name}\nTracked monthly via spreadsheet. Unit: {unit}.") + + +def generate_targets(): + open_quarters = {"25q3", "25q4"} + + for quarter in QUARTER_SLUGS: + _, subscribers_goal = SUB_TRAJ[quarter] + revenue_goal = REV_TRAJ[quarter] + quarter_status = "Open" if quarter in open_quarters else "Done" + + actual_subscribers = random.randint(subscribers_goal - 800, subscribers_goal + 500) + subscribers_status = "Done" if actual_subscribers >= subscribers_goal else "Behind" + write_md("target", f"target-subscribers-{quarter}", { + "aliases": [f"Subscribers {Q_LABEL[quarter]}"], + "Is A": "Target", + "Belongs to": wl(quarter), + "Measure": wl("measure-subscribers"), + "Goal value": subscribers_goal, + "Actual value": actual_subscribers if quarter not in open_quarters else None, + "Status": quarter_status if quarter in open_quarters else subscribers_status, + }, f"# Subscribers Target {Q_LABEL[quarter]}\nTarget: {subscribers_goal:,} subscribers by end of quarter.") + + actual_revenue = random.randint(int(revenue_goal * 0.9), int(revenue_goal * 1.15)) + revenue_status = "Done" if actual_revenue >= revenue_goal else "Behind" + write_md("target", f"target-revenue-{quarter}", { + "aliases": [f"Revenue {Q_LABEL[quarter]}"], + "Is A": "Target", + "Belongs to": wl(quarter), + "Measure": wl("measure-sponsorship-mrr"), + "Goal value": revenue_goal, + "Actual value": actual_revenue if quarter not in open_quarters else None, + "Status": quarter_status if quarter in open_quarters else revenue_status, + }, f"# Revenue Target {Q_LABEL[quarter]}\nTarget: €{revenue_goal:,}/month MRR by end of quarter.") + + hr_goal = 54 - QUARTER_SLUGS.index(quarter) // 2 + actual_hr = random.randint(hr_goal - 2, hr_goal + 3) + hr_status = "Done" if actual_hr <= hr_goal else "Behind" + write_md("target", f"target-resting-hr-{quarter}", { + "aliases": [f"Resting HR {Q_LABEL[quarter]}"], + "Is A": "Target", + "Belongs to": wl(quarter), + "Measure": wl("measure-resting-hr"), + "Goal value": hr_goal, + "Actual value": actual_hr if quarter not in open_quarters else None, + "Status": quarter_status if quarter in open_quarters else hr_status, + }, f"# Resting HR Target {Q_LABEL[quarter]}\nTarget: resting HR < {hr_goal} bpm.") + + actual_books = random.randint(4, 7) if quarter not in open_quarters else None + write_md("target", f"target-books-{quarter}", { + "aliases": [f"Books {Q_LABEL[quarter]}"], + "Is A": "Target", + "Belongs to": wl(quarter), + "Measure": wl("measure-books-per-month"), + "Goal value": 6, + "Actual value": actual_books, + "Status": quarter_status, + }, f"# Books Target {Q_LABEL[quarter]}\nTarget: 6 books in the quarter (2/month).") + + +def generate_goals(): + for slug, name, year, status, body in GOALS: + write_md("goal", slug, { + "aliases": [name], + "Is A": "Goal", + "Belongs to": wl(year), + "Status": status, + }, f"# {name}\n{body}") + + +def generate_projects(): + for slug, title, quarter, responsibility, status, body in PROJECTS: + write_md("project", slug, { + "aliases": [title], + "Is A": "Project", + "Belongs to": wl(quarter), + "Advances": wl(responsibility), + "Status": status, + "Owner": wl("person-luca-rossi"), + }, f"# {title}\n{body}") + + +def generate_experiments(): + for slug, title, quarter, status, body in EXPERIMENTS: + write_md("experiment", slug, { + "aliases": [title], + "Is A": "Experiment", + "Belongs to": wl(quarter), + "Status": status, + "Owner": wl("person-luca-rossi"), + }, f"# {title}\n{body}") + + +def build_procedure_map(): + return { + "procedure-weekly-newsletter": ("Weekly Newsletter", "responsibility-content-production", "Weekly", "Draft, edit, and publish the weekly Refactoring newsletter. Includes essay writing, curated links, and sponsor block."), + "procedure-monthly-subscriber-metrics": ("Monthly Subscriber Metrics Review", "responsibility-grow-newsletter", "Monthly", "Review subscriber growth, churn, open rates, and click rates. Update the tracking spreadsheet."), + "procedure-referral-program": ("Referral Program Management", "responsibility-grow-newsletter", "Weekly", "Check referral program performance. Reward top referrers. A/B test referral copy."), + "procedure-welcome-email-sequence": ("Welcome Email Sequence Review", "responsibility-grow-newsletter", "Monthly", "Review and update the onboarding email sequence for new subscribers."), + "procedure-seo-content-optimization": ("SEO Content Optimization", "responsibility-grow-newsletter", "Monthly", "Update existing articles with new keywords, internal links, and improved structure."), + "procedure-monthly-sponsor-report": ("Monthly Sponsor Report", "responsibility-sponsorships", "Monthly", "Prepare and send monthly performance reports to active sponsors. Include clicks, opens, and feedback."), + "procedure-quarterly-sponsor-outreach": ("Quarterly Sponsor Outreach", "responsibility-sponsorships", "Quarterly", "Identify and contact 20+ potential sponsors each quarter. Use Airtable to track pipeline."), + "procedure-sponsor-onboarding": ("Sponsor Onboarding", "responsibility-sponsorships", "As needed", "Onboard new sponsors: brief, creative review, scheduling, and invoice."), + "procedure-invoice-processing": ("Invoice Processing", "responsibility-sponsorships", "Monthly", "Send invoices to sponsors and follow up on outstanding payments."), + "procedure-sponsor-renewal": ("Sponsor Renewal Process", "responsibility-sponsorships", "Quarterly", "Review upcoming sponsor contract expirations and initiate renewal conversations."), + "procedure-monthly-pillar-planning": ("Monthly Content Planning", "responsibility-content-production", "Monthly", "Plan the next month's newsletter topics, essays, and pillar article schedule."), + "procedure-social-media-scheduling": ("Social Media Scheduling", "responsibility-content-production", "Weekly", "Schedule LinkedIn and Twitter posts repurposing newsletter content."), + "procedure-newsletter-ab-testing": ("Newsletter A/B Testing", "responsibility-content-production", "Bi-weekly", "Run subject line and content A/B tests. Analyze results and document learnings."), + "procedure-content-calendar-review": ("Content Calendar Review", "responsibility-content-production", "Weekly", "Review and update the content calendar. Ensure 4-week runway of planned topics."), + "procedure-editorial-review": ("Editorial Review", "responsibility-content-production", "Weekly", "Review Sara's editing suggestions. Give feedback and approve final drafts."), + "procedure-evergreen-content-audit": ("Evergreen Content Audit", "responsibility-content-production", "Quarterly", "Audit existing evergreen articles for accuracy, updated links, and improvement opportunities."), + "procedure-newsletter-metrics-weekly": ("Weekly Newsletter Metrics", "responsibility-content-production", "Weekly", "Review open rate, click rate, and unsubscribes for the latest newsletter edition."), + "procedure-podcast-recording": ("Podcast Recording", "responsibility-podcast", "Bi-weekly", "Record bi-weekly podcast episode. Prep call with guest, record 60-90 min, send for editing."), + "procedure-podcast-guest-outreach": ("Podcast Guest Outreach", "responsibility-podcast", "Monthly", "Identify and contact 5 potential guests. Maintain a 3-month booking horizon."), + "procedure-podcast-editing": ("Podcast Editing Review", "responsibility-podcast", "Bi-weekly", "Review Paco's edit of the latest episode. Approve or request changes."), + "procedure-podcast-show-notes": ("Podcast Show Notes", "responsibility-podcast", "Bi-weekly", "Write show notes and episode summary for the Refactoring website and newsletter."), + "procedure-podcast-analytics": ("Podcast Analytics Review", "responsibility-podcast", "Monthly", "Review download numbers, listener retention, and episode performance by topic."), + "procedure-weekly-team-sync": ("Weekly Team Sync", "responsibility-team-management", "Weekly", "Monday 10am team sync. Agenda: blockers, priorities, coordination."), + "procedure-biweekly-1on1-matteo": ("1:1 with Matteo", "responsibility-team-management", "Bi-weekly", "Bi-weekly 1:1 with Matteo. Cover: sponsor pipeline, blockers, personal growth, feedback."), + "procedure-biweekly-1on1-paco": ("1:1 with Paco", "responsibility-team-management", "Bi-weekly", "Bi-weekly 1:1 with Paco. Cover: operations updates, tooling, process improvements, feedback."), + "procedure-biweekly-1on1-sara": ("1:1 with Sara", "responsibility-team-management", "Bi-weekly", "Bi-weekly 1:1 with Sara. Cover: content quality, workload, professional development, feedback."), + "procedure-quarterly-team-retro": ("Quarterly Team Retrospective", "responsibility-team-management", "Quarterly", "End-of-quarter team retrospective: what went well, what didn't, what to change."), + "procedure-weekly-cycling-block": ("Weekly Cycling Training Block", "responsibility-health-fitness", "Weekly", "3 rides per week: Tuesday (intervals), Thursday (endurance), Saturday (long ride)."), + "procedure-gym-routine": ("Gym Strength Routine", "responsibility-health-fitness", "Weekly", "2x/week gym: Monday and Wednesday. Compound lifts + core work."), + "procedure-monthly-health-review": ("Monthly Health Review", "responsibility-health-fitness", "Monthly", "Track resting HR, weight, HRV, sleep quality. Adjust training load if needed."), + "procedure-race-preparation": ("Race Preparation", "responsibility-health-fitness", "As needed", "1-week taper before a gran fondo: reduce volume, maintain intensity, focus on nutrition and sleep."), + "procedure-monthly-portfolio-review": ("Monthly Portfolio Review", "responsibility-personal-finance", "Monthly", "Review investment portfolio performance. Rebalance if drift > 5%. Track savings rate."), + "procedure-quarterly-financial-planning": ("Quarterly Financial Planning", "responsibility-personal-finance", "Quarterly", "Quarterly financial review: income, expenses, savings rate, and progress toward net worth target."), + "procedure-weekly-reading-session": ("Weekly Reading Session", "responsibility-learning", "Weekly", "Sunday morning: 2-3 hours of focused reading. No phone, coffee, and a good book."), + "procedure-evergreen-note-writing": ("Evergreen Note Writing", "responsibility-learning", "Weekly", "Write 1-2 evergreen notes per week from recent reading, conversations, or observations."), + } + + +def generate_procedures(): + for proc_slug, (proc_name, responsibility, cadence, body) in build_procedure_map().items(): + write_md("procedure", proc_slug, { + "aliases": [proc_name], + "Is A": "Procedure", + "Belongs to": wl(responsibility), + "Cadence": cadence, + "Owner": wl("person-luca-rossi"), + }, f"# {proc_name}\n{body}") + + +def build_task_templates(): + return [ + ("Write Q{q} retrospective", "24q1", "Done"), + ("Update website About page", "24q1", "Done"), + ("Fix broken links in newsletter archive", "24q1", "Done"), + ("Set up new analytics dashboard", "24q2", "Done"), + ("Create sponsor media kit PDF", "24q2", "Done"), + ("Update editorial guidelines for Sara", "24q2", "Done"), + ("Research gran fondo training plans", "24q2", "Done"), + ("Write podcast pitch template", "24q3", "Done"), + ("Review and update pricing page", "24q3", "Done"), + ("Archive Q2 sponsor contracts", "24q3", "Done"), + ("Create onboarding checklist for new subscribers", "24q3", "Done"), + ("Set up automated welcome sequence", "24q4", "Done"), + ("Prepare Q4 sponsor renewal emails", "24q4", "Done"), + ("Write 2024 annual review post", "24q4", "Done"), + ("Update team role descriptions", "24q4", "Done"), + ("Create content library for repurposing", "24q4", "Done"), + ("Set up new Airtable base for 2025 sponsors", "25q1", "Done"), + ("Review and update newsletter footer", "25q1", "Done"), + ("Write referral program landing page copy", "25q1", "Done"), + ("Research new podcast guest categories", "25q1", "Done"), + ("Update cycling training plan for spring", "25q1", "Done"), + ("Create Q2 sponsor pitch deck", "25q1", "Done"), + ("Archive 2024 financial records", "25q1", "Done"), + ("Migrate newsletter archive to new template", "25q2", "Done"), + ("Write e-book outline", "25q2", "Done"), + ("Set up community Discord structure", "25q2", "Done"), + ("Research LeadDev London conference", "25q2", "Done"), + ("Update podcast description and tags", "25q2", "Done"), + ("Review team compensation packages", "25q2", "Done"), + ("Create sponsor case studies", "25q3", "Done"), + ("Write e-book first draft (ch 1-5)", "25q3", "Done"), + ("Plan London trip logistics", "25q3", "Done"), + ("Update website with new subscriber milestone", "25q3", "Done"), + ("Prepare Q4 content calendar", "25q3", "Done"), + ("Review portfolio allocation Q3", "25q3", "Done"), + ("Write 2026 sponsorship prospectus", "25q4", "Open"), + ("Plan 2026 editorial calendar", "25q4", "Open"), + ("Update team handbook", "25q4", "Open"), + ("Book travel for January conferences", "25q4", "Open"), + ("Write end-of-year letter to subscribers", "25q4", "Open"), + ("Review and renew tool subscriptions", "25q4", "Open"), + ("Archive 2025 sponsor contracts", "25q4", "Open"), + ("Set up 2026 tracking spreadsheets", "25q4", "Open"), + ("Update podcast listing on all platforms", "25q4", "Open"), + ("Write Q4 retrospective", "25q4", "Open"), + ] + + +def generate_tasks(): + for index, (title, quarter, status) in enumerate(build_task_templates(), start=1): + rendered_title = title.replace("{q}", quarter.upper()) + write_md("task", f"task-{quarter}-{index:02d}", { + "aliases": [rendered_title], + "Is A": "Task", + "Belongs to": wl(quarter), + "Status": status, + "Owner": wl("person-luca-rossi"), + }, f"# {rendered_title}\n") + + +def generate_people(): + for slug, name, tier, tags, bio in PERSONS: + write_md("person", slug, { + "aliases": [name], + "Is A": "Person", + "Tier": tier, + "Tags": tags, + }, f"# {name}\n{bio}") + + +def generate_topics(): + for slug, name, description in TOPICS: + write_md("topic", slug, { + "aliases": [name], + "Is A": "Topic", + }, f"# {name}\n{description}") + + +def quarter_label_for_date(current_date: date) -> str: + if current_date.month < 4: + return "1" + if current_date.month < 7: + return "2" + if current_date.month < 10: + return "3" + return "4" + + +def person_name(slug: str) -> str: + return next((person[1] for person in PERSONS if person[0] == slug), slug) + + +def in_biweekly_window(current_date: date, start_date: date) -> bool: + return (current_date - start_date).days % 14 < 7 + + +def write_event( + slug_prefix: str, + title: str, + current_date: date, + tags: list[str], + body: str, + related: list[str] | None = None, +): + day_slug = current_date.isoformat() + fields = { + "aliases": [f"{title} — {day_slug}"], + "Is A": "Event", + "Date": day_slug, + "Belongs to": wl(current_date.strftime("%Y-%m")), + } + if related is not None: + fields["Related to"] = related + fields["Tags"] = tags + write_md("event", f"{slug_prefix}-{day_slug}", fields, f"# {title} — {day_slug}\n{body}") + + +def generate_team_sync_event(current_date: date) -> int: + team = [wl("person-matteo-cellini"), wl("person-paco-furiani")] + if current_date >= date(2024, 4, 1): + team.append(wl("person-sara-ricci")) + write_event( + "event-team-sync", + "Team sync", + current_date, + ["Work"], + "Weekly Monday team alignment. Covered priorities, blockers, and sponsor updates.", + team, + ) + return 1 + + +def generate_cycling_interval_event(current_date: date) -> int: + write_event( + "event-cycling", + "Cycling intervals", + current_date, + ["Health", "Sport"], + "60-min interval session. 4x8min at threshold power.", + [wl("person-luca-rossi")], + ) + return 1 + + +def generate_wednesday_event(current_date: date) -> int: + if in_biweekly_window(current_date, date(2024, 1, 3)): + write_event( + "event-gym", + "Gym", + current_date, + ["Health"], + "Strength training. Squat, deadlift, pull-ups. 75 minutes.", + ) + return 1 + + quarter_label = quarter_label_for_date(current_date) + write_event( + "event-1on1-matteo", + "1:1 Matteo", + current_date, + ["Work"], + f"Bi-weekly 1:1. Covered sponsor pipeline and Q{quarter_label} priorities.", + [wl("person-matteo-cellini")], + ) + return 1 + + +def generate_cycling_endurance_event(current_date: date) -> int: + write_event( + "event-cycling-endurance", + "Cycling endurance", + current_date, + ["Health", "Sport"], + "90-min endurance ride at zone 2. Avg HR 135.", + ) + return 1 + + +def generate_friday_workout_or_sponsor_event(current_date: date) -> int: + if in_biweekly_window(current_date, date(2024, 1, 5)): + sponsor = random.choice(SPONSOR_PERSONS) + write_event( + "event-sponsor-call", + "Sponsor call", + current_date, + ["Work"], + "Sponsor discovery/renewal call. Discussed placement and campaign goals.", + [wl(sponsor)], + ) + return 1 + + write_event( + "event-gym-fri", + "Gym", + current_date, + ["Health"], + "Strength session. Bench press, rows, overhead press. Core work.", + ) + return 1 + + +def generate_long_ride_event(current_date: date) -> int: + relations = [wl("person-luca-rossi")] + ride_note = "Solo long ride." + if random.random() < 0.4: + relations = [wl("person-luca-rossi"), wl("person-alessandro-ferrari")] + ride_note = "Long ride with Alessandro." + distance = random.randint(80, 130) + elevation = random.randint(800, 2000) + write_event( + "event-long-ride", + "Long ride", + current_date, + ["Health", "Sport"], + f"{ride_note} {distance}km, {elevation}m elevation.", + relations, + ) + return 1 + + +def generate_sunday_event(current_date: date) -> int: + if in_biweekly_window(current_date, date(2024, 1, 7)): + write_event( + "event-reading", + "Reading session", + current_date, + ["Learning"], + "Sunday morning reading block. 2 hours with coffee.", + ) + return 1 + + write_event( + "event-family-call", + "Family call", + current_date, + ["Personal", "Family"], + "Sunday family call with Elena and parents.", + [wl("person-elena-rossi"), wl("person-roberto-rossi")], + ) + return 1 + + +WEEKDAY_EVENT_GENERATORS = { + 0: generate_team_sync_event, + 1: generate_cycling_interval_event, + 2: generate_wednesday_event, + 3: generate_cycling_endurance_event, + 4: generate_friday_workout_or_sponsor_event, + 5: generate_long_ride_event, + 6: generate_sunday_event, +} + + +def generate_regular_weekday_event(current_date: date) -> int: + return WEEKDAY_EVENT_GENERATORS[current_date.weekday()](current_date) + + +def generate_monthly_paco_event(current_date: date) -> int: + if current_date.weekday() != 3 or current_date.day > 7: + return 0 + write_event( + "event-1on1-paco", + "1:1 Paco", + current_date, + ["Work"], + "Monthly 1:1. Operations review, tooling updates, and process improvements.", + [wl("person-paco-furiani")], + ) + return 1 + + +def generate_nonna_visit_event(current_date: date) -> int: + if current_date.weekday() != 6 or current_date.day < 24: + return 0 + write_event( + "event-nonna-visit", + "Visita dalla Nonna", + current_date, + ["Personal", "Family"], + "Visita mensile alla nonna a Lecco. Pranzo con risotto.", + [wl("person-nonna-lucia")], + ) + return 1 + + +def generate_podcast_recording_event(current_date: date) -> int: + if current_date.weekday() != 3 or current_date < date(2024, 2, 1): + return 0 + if not in_biweekly_window(current_date, date(2024, 2, 1)): + return 0 + + guest = random.choice(PODCAST_GUESTS) + write_event( + "event-podcast-rec", + "Podcast recording", + current_date, + ["Work", "Podcast"], + f"Recorded episode with {person_name(guest)}. Great conversation.", + [wl(guest)], + ) + return 1 + + +def generate_dinner_event(current_date: date) -> int: + if current_date.weekday() != 4 or random.random() >= 0.5: + return 0 + + friend = random.choice(FRIEND_SLUGS + ["person-giulia-marchetti"]) + write_event( + "event-dinner", + "Dinner", + current_date, + ["Personal"], + f"Evening dinner with {person_name(friend)}.", + [wl(friend)], + ) + return 1 + + +def generate_extra_events(current_date: date) -> int: + return ( + generate_monthly_paco_event(current_date) + + generate_nonna_visit_event(current_date) + + generate_podcast_recording_event(current_date) + + generate_dinner_event(current_date) + ) + + +def generate_events(): + current_date = date(2024, 1, 1) + end_date = date(2025, 12, 31) + event_count = 0 + + while current_date <= end_date and event_count < 650: + event_count += generate_regular_weekday_event(current_date) + event_count += generate_extra_events(current_date) + current_date += timedelta(days=1) + + +def generate_evergreens(): + for slug, title, topics, body in EVERGREENS: + write_md("evergreen", slug, { + "aliases": [title], + "Is A": "Evergreen", + "Topics": [wl(topic) for topic in topics], + "Status": "Published", + }, f"# {title}\n{body}") + + +def generate_notes(): + for slug, title, author, topic, url, body in NOTES: + write_md("note", slug, { + "aliases": [title], + "Is A": "Note", + "Author": author, + "Topics": [wl(topic)], + "URL": url, + }, f"# {title}\n*{author}*\n\n{body}") + + +def print_summary(): + total = sum(COUNTS.values()) + print(f"\n✅ Large fixture generated at: {VAULT}\n") + print(f"{'Type':<25} {'Count':>6}") + print("-" * 33) + for note_type, count in sorted(COUNTS.items()): + print(f"{note_type:<25} {count:>6}") + print("-" * 33) + print(f"{'TOTAL':<25} {total:>6}") + + +def generate_all(output_path: Path | None = None): + global VAULT + + if output_path is not None: + VAULT = output_path + + random.seed(42) + reset_vault() + + steps = [ + generate_years, + generate_quarters, + generate_months, + generate_areas, + generate_responsibilities, + generate_measures, + generate_targets, + generate_goals, + generate_projects, + generate_experiments, + generate_procedures, + generate_tasks, + generate_people, + generate_topics, + generate_events, + generate_evergreens, + generate_notes, + ] + for step in steps: + step() + + print_summary() + + +def parse_args(): + parser = argparse.ArgumentParser(description="Generate the large synthetic Tolaria fixture.") + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_VAULT, + help=f"Output directory for the generated vault (default: {DEFAULT_VAULT})", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + generate_all(args.output.resolve()) diff --git a/scripts/playwright-smoke-server.mjs b/scripts/playwright-smoke-server.mjs new file mode 100644 index 0000000..842c879 --- /dev/null +++ b/scripts/playwright-smoke-server.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const port = process.argv[2] ?? process.env.PORT ?? '41741' +const viteCacheDir = process.env.TOLARIA_VITE_CACHE_DIR ?? join(tmpdir(), `tolaria-vite-smoke-${port}`) + +const child = spawn( + 'pnpm', + ['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'], + { + cwd: process.cwd(), + env: { + ...process.env, + TOLARIA_VITE_CACHE_DIR: viteCacheDir, + }, + stdio: ['pipe', 'inherit', 'inherit'], + }, +) + +function forwardSignal(signal) { + if (child.killed) return + child.kill(signal) +} + +process.on('SIGINT', () => forwardSignal('SIGINT')) +process.on('SIGTERM', () => forwardSignal('SIGTERM')) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 1) +}) diff --git a/scripts/run-vitest-coverage-shards.mjs b/scripts/run-vitest-coverage-shards.mjs new file mode 100644 index 0000000..e86c5ea --- /dev/null +++ b/scripts/run-vitest-coverage-shards.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import console from 'node:console' +import { readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import os from 'node:os' +import process from 'node:process' +import { resolve } from 'node:path' + +const rootDir = process.cwd() +const forwardedArgs = process.argv.slice(2) +const totalShards = positiveInteger(process.env.FRONTEND_COVERAGE_SHARDS ?? '2', 'FRONTEND_COVERAGE_SHARDS') +const concurrency = positiveInteger( + process.env.FRONTEND_COVERAGE_CONCURRENCY ?? String(totalShards), + 'FRONTEND_COVERAGE_CONCURRENCY', +) +const runId = `${Date.now()}-${process.pid}` +const shardRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-shards', runId) +const finalCoverageDir = resolve(rootDir, 'coverage') +const coverageRequire = createCoverageRequire() +const { createCoverageMap } = coverageRequire('istanbul-lib-coverage') +const libReport = coverageRequire('istanbul-lib-report') +const reports = coverageRequire('istanbul-reports') +const thresholdPercent = Number(process.env.VITEST_COVERAGE_THRESHOLD ?? '70') +const thresholds = { + lines: metricThreshold('LINES'), + functions: metricThreshold('FUNCTIONS'), + branches: metricThreshold('BRANCHES'), + statements: metricThreshold('STATEMENTS'), +} + +function positiveInteger(value, name) { + if (/^[1-9][0-9]*$/.test(value)) { + return Number(value) + } + + console.error(`${name} must be a positive integer`) + process.exit(2) +} + +function metricThreshold(metricName) { + const value = process.env[`VITEST_COVERAGE_${metricName}_THRESHOLD`] + return value === undefined ? thresholdPercent : Number(value) +} + +function createCoverageRequire() { + const require = createRequire(import.meta.url) + const coveragePackagePath = require.resolve('@vitest/coverage-v8/package.json') + return createRequire(coveragePackagePath) +} + +function shardLabel(shardIndex) { + return `${shardIndex}/${totalShards}` +} + +function shardCoverageDir(shardIndex) { + return resolve(shardRoot, `shard-${shardIndex}`) +} + +async function clearVitestCache() { + const exitCode = await spawnCommand('clear-cache', 'pnpm', ['exec', 'vitest', '--clearCache'], process.env) + if (exitCode !== 0) { + throw new Error(`Vitest cache clear failed with exit code ${exitCode}`) + } +} + +function runShard(shardIndex) { + const env = { + ...process.env, + VITEST_COVERAGE_FINAL_DIR: shardCoverageDir(shardIndex), + VITEST_COVERAGE_SHARD: shardLabel(shardIndex), + VITEST_COVERAGE_SKIP_CLEAR_CACHE: '1', + VITEST_COVERAGE_SKIP_THRESHOLDS: '1', + } + + return spawnCommand( + `coverage-${shardIndex}`, + process.execPath, + ['scripts/run-vitest-coverage.mjs', '--coverage.reporter=json', ...forwardedArgs], + env, + ) +} + +function spawnCommand(name, command, args, env) { + return new Promise((resolveExit, rejectExit) => { + console.log(`[${name}] started`) + const child = spawn(command, args, { + cwd: rootDir, + env, + stdio: ['inherit', 'pipe', 'pipe'], + }) + + child.stdout?.on('data', (chunk) => process.stdout.write(`[${name}] ${chunk}`)) + child.stderr?.on('data', (chunk) => process.stderr.write(`[${name}] ${chunk}`)) + child.on('error', rejectExit) + child.on('exit', (code, signal) => { + if (signal) { + rejectExit(new Error(`${name} exited via signal: ${signal}`)) + return + } + + const exitCode = code ?? 1 + console.log(`[${name}] exited with status ${exitCode}`) + resolveExit(exitCode) + }) + }) +} + +async function runShardBatch(firstShard) { + const shardIndexes = [] + for ( + let shardIndex = firstShard; + shardIndex <= totalShards && shardIndexes.length < concurrency; + shardIndex += 1 + ) { + shardIndexes.push(shardIndex) + } + + const results = await Promise.all(shardIndexes.map((shardIndex) => runShard(shardIndex))) + return results.every((exitCode) => exitCode === 0) +} + +async function runShards() { + let firstShard = 1 + + while (firstShard <= totalShards) { + if (!(await runShardBatch(firstShard))) { + console.error(`Coverage shard artifacts preserved at ${shardRoot}`) + process.exit(1) + } + + firstShard += concurrency + } +} + +async function readShardCoverage(shardIndex) { + const coveragePath = resolve(shardCoverageDir(shardIndex), 'coverage-final.json') + return JSON.parse(await readFile(coveragePath, 'utf8')) +} + +async function mergeCoverage() { + const coverageMap = createCoverageMap({}) + + for (let shardIndex = 1; shardIndex <= totalShards; shardIndex += 1) { + coverageMap.merge(await readShardCoverage(shardIndex)) + } + + return coverageMap +} + +async function writeCoverageReports(coverageMap) { + await rm(finalCoverageDir, { recursive: true, force: true }) + const context = libReport.createContext({ + coverageMap, + dir: finalCoverageDir, + }) + + for (const reportName of ['text', 'json', 'html', 'lcov']) { + reports.create(reportName).execute(context) + } +} + +function printCoverageSummary(summary) { + for (const metric of ['lines', 'functions', 'branches', 'statements']) { + const item = summary[metric] + console.log( + `${metric.padEnd(10)} ${String(item.pct).padStart(6)}% ` + + `(${item.covered}/${item.total}, threshold ${thresholds[metric]}%)`, + ) + } +} + +function checkCoverageThresholds(coverageMap) { + const summary = coverageMap.getCoverageSummary().toJSON() + printCoverageSummary(summary) + + const failures = Object.entries(thresholds) + .filter(([metric, threshold]) => summary[metric].pct < threshold) + + if (failures.length === 0) { + return + } + + for (const [metric, threshold] of failures) { + console.error( + `Coverage for ${metric} (${summary[metric].pct}%) does not meet threshold ${threshold}%`, + ) + } + + process.exit(1) +} + +await clearVitestCache() +await runShards() + +const coverageMap = await mergeCoverage() +await writeCoverageReports(coverageMap) +checkCoverageThresholds(coverageMap) +await rm(shardRoot, { recursive: true, force: true }) diff --git a/scripts/run-vitest-coverage.mjs b/scripts/run-vitest-coverage.mjs new file mode 100644 index 0000000..f67231f --- /dev/null +++ b/scripts/run-vitest-coverage.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { cp, mkdir, rm } from 'node:fs/promises' +import console from 'node:console' +import os from 'node:os' +import process from 'node:process' +import { resolve } from 'node:path' +import { spawn } from 'node:child_process' + +const rootDir = process.cwd() +const finalCoverageDir = resolve(rootDir, process.env.VITEST_COVERAGE_FINAL_DIR ?? 'coverage') +const coverageRunRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-runs') +const forwardedArgs = process.argv.slice(2) +const hasFileParallelismOverride = forwardedArgs.some((arg) => + arg === '--fileParallelism' || arg === '--no-file-parallelism' +) +const hasMaxWorkersOverride = forwardedArgs.some((arg) => + arg === '--maxWorkers' || arg.startsWith('--maxWorkers=') +) +const hasShardOverride = forwardedArgs.some((arg) => + arg === '--shard' || arg.startsWith('--shard=') +) +const defaultMaxWorkers = resolveDefaultMaxWorkers() +const coverageShard = process.env.VITEST_COVERAGE_SHARD?.trim() ?? '' +const skipCoverageThresholds = process.env.VITEST_COVERAGE_SKIP_THRESHOLDS === '1' +const skipClearCache = process.env.VITEST_COVERAGE_SKIP_CLEAR_CACHE === '1' +const maxAttempts = 2 + +// Standalone pnpm installs ship a native binary, so npm_execpath points at +// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path +// is something node can actually load as a module. +const packageManagerExec = process.env.npm_execpath +const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec) +const command = isJsExecpath ? process.execPath : 'pnpm' +const baseCommandArgs = isJsExecpath + ? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage'] + : ['exec', 'vitest', 'run', '--coverage'] +const clearCacheCommandArgs = isJsExecpath + ? [packageManagerExec, 'exec', 'vitest', '--clearCache'] + : ['exec', 'vitest', '--clearCache'] + +function isKnownVitestInternalStateFlake(output) { + return output.includes('Vitest failed to access its internal state.') + && /Test Files\s+\d+\s+passed\s+\(\d+\)/.test(output) + && /Tests\s+\d+\s+passed\s+\(\d+\)/.test(output) +} + +function appendCapturedOutput(output, chunk) { + const nextOutput = output + chunk + return nextOutput.length > 200_000 ? nextOutput.slice(-200_000) : nextOutput +} + +function resolveDefaultMaxWorkers() { + const value = process.env.VITEST_COVERAGE_MAX_WORKERS?.trim() + + if (!value) { + return '4' + } + + if (/^[1-9][0-9]*%?$/.test(value)) { + return value + } + + console.warn(`Ignoring invalid VITEST_COVERAGE_MAX_WORKERS=${JSON.stringify(value)}; using 4`) + return '4' +} + +function isValidCoverageShard(value) { + return /^[1-9][0-9]*\/[1-9][0-9]*$/.test(value) +} + +function coverageThresholdOverrideArgs() { + return [ + '--coverage.thresholds.lines=0', + '--coverage.thresholds.functions=0', + '--coverage.thresholds.branches=0', + '--coverage.thresholds.statements=0', + ] +} + +async function runCoverageAttempt(attempt) { + const runId = `${Date.now()}-${process.pid}-${attempt}` + const runCoverageDir = resolve(coverageRunRoot, runId) + const runCoverageTempDir = resolve(runCoverageDir, '.tmp') + + await mkdir(runCoverageDir, { recursive: true }) + // Vitest writes per-worker coverage shards under reportsDirectory/.tmp. + await mkdir(runCoverageTempDir, { recursive: true }) + if (!skipClearCache) { + await clearVitestCache() + } + + const commandArgs = [ + ...baseCommandArgs, + // Keep coverage fast enough for CI while avoiding the unbounded worker + // contention that makes a few DOM-heavy suites time out under full + // file parallelism. Callers can still opt into serial or wider runs. + ...(hasFileParallelismOverride ? [] : ['--fileParallelism']), + ...(hasMaxWorkersOverride ? [] : [`--maxWorkers=${defaultMaxWorkers}`]), + ...(coverageShard && !hasShardOverride ? [`--shard=${coverageShard}`] : []), + ...(skipCoverageThresholds ? coverageThresholdOverrideArgs() : []), + `--coverage.reportsDirectory=${runCoverageDir}`, + ...forwardedArgs, + ] + let output = '' + + const exitCode = await new Promise((resolveExit, rejectExit) => { + const child = spawn(command, commandArgs, { + cwd: rootDir, + env: { + ...process.env, + VITEST_COVERAGE_DIR: runCoverageDir, + }, + stdio: ['inherit', 'pipe', 'pipe'], + }) + + const handleOutput = (stream, target) => { + if (!stream) return + stream.setEncoding('utf8') + stream.on('data', (chunk) => { + target.write(chunk) + output = appendCapturedOutput(output, chunk) + }) + } + + handleOutput(child.stdout, process.stdout) + handleOutput(child.stderr, process.stderr) + + child.on('error', rejectExit) + child.on('exit', (code, signal) => { + if (signal) { + rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`)) + return + } + + resolveExit(code ?? 1) + }) + }) + + return { + exitCode, + output, + runCoverageDir, + } +} + +async function clearVitestCache() { + const exitCode = await new Promise((resolveExit, rejectExit) => { + const child = spawn(command, clearCacheCommandArgs, { + cwd: rootDir, + env: process.env, + stdio: 'inherit', + }) + + child.on('error', rejectExit) + child.on('exit', (code, signal) => { + if (signal) { + rejectExit(new Error(`Vitest cache clear exited via signal: ${signal}`)) + return + } + + resolveExit(code ?? 1) + }) + }) + + if (exitCode !== 0) { + throw new Error(`Vitest cache clear failed with exit code ${exitCode}`) + } +} + +if (coverageShard && !isValidCoverageShard(coverageShard)) { + console.error(`Invalid VITEST_COVERAGE_SHARD=${JSON.stringify(coverageShard)}; expected index/total`) + process.exit(2) +} + +let finalRun = null + +for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const run = await runCoverageAttempt(attempt) + finalRun = run + + if (run.exitCode === 0) { + await rm(finalCoverageDir, { recursive: true, force: true }) + await cp(run.runCoverageDir, finalCoverageDir, { + force: true, + recursive: true, + }) + await rm(run.runCoverageDir, { recursive: true, force: true }) + process.exit(0) + } + + // Retry once when Vitest itself flakes after a fully passing suite. + if (attempt < maxAttempts && isKnownVitestInternalStateFlake(run.output)) { + console.error(`Vitest hit a known internal-state teardown flake on attempt ${attempt}; retrying once...`) + await rm(run.runCoverageDir, { recursive: true, force: true }) + continue + } + + break +} + +console.error(`Vitest coverage artifacts preserved at ${finalRun.runCoverageDir}`) +process.exit(finalRun.exitCode) diff --git a/scripts/serve-demo.mjs b/scripts/serve-demo.mjs new file mode 100644 index 0000000..07944e2 --- /dev/null +++ b/scripts/serve-demo.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/** + * Production static server for Laputa App demo. + * Serves dist/ + handles /api/vault/* routes for browser testing. + */ + +import http from 'http' +import { log } from 'console' +import { + closeSync, + createReadStream, + fstatSync, + openSync, + opendirSync, + readFileSync, +} from 'fs' +import path from 'path' +import { fileURLToPath, URL } from 'url' +import matter from 'gray-matter' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const DIST_DIR = path.join(__dirname, '..', 'dist') +const REPO_DIR = path.resolve(__dirname, '..') +const PORT = 5173 +const DEDICATED_FRONTMATTER_KEYS = new Set([ + 'aliases', + 'Is A', + 'Belongs to', + 'Related to', + 'Status', + 'Owner', + 'Cadence', + 'Created at', +]) + +function isAllowedPath(p) { + return isInsideRelativePath(path.relative(REPO_DIR, p)) +} + +function isInsideRelativePath(relative) { + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function resolveInside(root, target) { + const normalizedTarget = path.normalize(target) + if (path.isAbsolute(normalizedTarget)) return null + const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`) + return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null +} + +function readUtf8File(filePath) { + const fd = openSync(filePath, 'r') + try { + return readFileSync(fd, 'utf-8') + } finally { + closeSync(fd) + } +} + +function pathStats(filePath) { + const fd = openSync(filePath, 'r') + try { + return fstatSync(fd) + } finally { + closeSync(fd) + } +} + +function pathExists(filePath) { + try { + pathStats(filePath) + return true + } catch { + return false + } +} + +function directoryEntries(dir) { + const directory = opendirSync(dir) + try { + const entries = [] + let entry = directory.readSync() + while (entry) { + entries.push(entry) + entry = directory.readSync() + } + return entries + } finally { + directory.closeSync() + } +} + +function streamFile(filePath) { + const fd = openSync(filePath, 'r') + return createReadStream(null, { fd, autoClose: true }) +} + +function staticAssetPath(url) { + const pathname = new URL(url, 'http://localhost').pathname + const requested = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '') + return resolveInside(DIST_DIR, requested) ?? path.normalize(`${DIST_DIR}${path.sep}index.html`) +} + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2':'font/woff2', + '.json': 'application/json', +} + +function findMarkdownFiles(dir) { + const results = [] + try { + for (const entry of directoryEntries(dir)) { + const full = resolveInside(dir, entry.name) + if (!full) continue + if (entry.isDirectory()) results.push(...findMarkdownFiles(full)) + else if (entry.name.endsWith('.md')) results.push(full) + } + } catch {} + return results +} + +function extractWikiLinks(value) { + if (!value) return [] + const str = Array.isArray(value) ? value.join(' ') : String(value) + return [...str.matchAll(/\[\[([^\]]+)\]\]/g)].map(m => `[[${m[1]}]]`) +} + +function frontmatterRelationships(frontmatter) { + const relationships = {} + for (const [key, value] of Object.entries(frontmatter)) { + if (DEDICATED_FRONTMATTER_KEYS.has(key)) continue + const links = extractWikiLinks(value) + if (links.length) relationships[key] = links + } + return relationships +} + +function aliasesFrom(frontmatter) { + if (Array.isArray(frontmatter.aliases)) return frontmatter.aliases + return frontmatter.aliases ? [frontmatter.aliases] : [] +} + +function markdownBodyText(content) { + return content.replace(/---[\s\S]*?---/, '').trim() +} + +function markdownTitle(bodyText, aliases, filePath) { + const h1 = bodyText.match(/^#\s+(.+)/m)?.[1] + return h1 || aliases[0] || path.basename(filePath, '.md') +} + +function createdAtMillis(frontmatter) { + return frontmatter['Created at'] ? new Date(frontmatter['Created at']).getTime() : null +} + +function snippetFrom(bodyText) { + return bodyText + .replace(/^#+\s+.+/gm, '') + .replace(/\n+/g, ' ') + .trim() + .slice(0, 200) +} + +function parseMarkdownFile(filePath) { + try { + const raw = readUtf8File(filePath) + const { data: fm, content } = matter(raw) + const stat = pathStats(filePath) + const bodyText = markdownBodyText(content) + const aliases = aliasesFrom(fm) + + return { + path: filePath, + filename: path.basename(filePath), + title: markdownTitle(bodyText, aliases, filePath), + isA: fm['Is A'] ?? null, + aliases, + belongsTo: extractWikiLinks(fm['Belongs to']), + relatedTo: extractWikiLinks(fm['Related to']), + status: fm['Status'] ?? null, + owner: fm['Owner'] ?? null, + cadence: fm['Cadence'] ?? null, + modifiedAt: stat.mtimeMs, + createdAt: createdAtMillis(fm), + fileSize: stat.size, + snippet: snippetFrom(bodyText), + relationships: frontmatterRelationships(fm), + } + } catch { return null } +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(payload)) +} + +function badPath(res) { + sendJson(res, 400, { error: 'bad path' }) +} + +function existingAllowedPath(params) { + const requestedPath = params.searchParams.get('path') + return requestedPath && isAllowedPath(requestedPath) && pathExists(requestedPath) + ? requestedPath + : null +} + +function handleVaultPing(_params, res) { + sendJson(res, 200, { ok: true }) +} + +function handleVaultList(params, res) { + const dir = existingAllowedPath(params) + if (!dir) return badPath(res) + const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean) + sendJson(res, 200, entries) +} + +function handleVaultContent(params, res) { + const file = existingAllowedPath(params) + if (!file) return badPath(res) + sendJson(res, 200, { content: readUtf8File(file) }) +} + +function allVaultContent(dir) { + const map = {} + for (const filePath of findMarkdownFiles(dir)) { + try { map[filePath] = readUtf8File(filePath) } catch {} + } + return map +} + +function handleVaultAllContent(params, res) { + const dir = existingAllowedPath(params) + if (!dir) return badPath(res) + sendJson(res, 200, allVaultContent(dir)) +} + +const VAULT_API_ROUTES = new Map([ + ['/api/vault/ping', handleVaultPing], + ['/api/vault/list', handleVaultList], + ['/api/vault/content', handleVaultContent], + ['/api/vault/all-content', handleVaultAllContent], +]) + +function serveVaultApi(url, res) { + const params = new URL(url, 'http://localhost') + const handler = VAULT_API_ROUTES.get(params.pathname) + if (!handler) return false + handler(params, res) + return true +} + +const server = http.createServer((req, res) => { + const url = req.url ?? '/' + + // API routes + if (url.startsWith('/api/vault/')) { + if (!serveVaultApi(url, res)) { + res.writeHead(404); res.end() + } + return + } + + // Static files + let filePath = staticAssetPath(url) + if (!pathExists(filePath) || pathStats(filePath).isDirectory()) { + filePath = path.normalize(`${DIST_DIR}${path.sep}index.html`) // SPA fallback + } + const ext = path.extname(filePath) + res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' }) + streamFile(filePath).pipe(res) +}) + +server.listen(PORT, '0.0.0.0', () => { + log(`✅ Laputa demo server running on http://0.0.0.0:${PORT}`) + log(` Tailscale: https://mac-mini.tail7cbc15.ts.net`) +}) diff --git a/scripts/validate-locales.mjs b/scripts/validate-locales.mjs new file mode 100644 index 0000000..3b5e906 --- /dev/null +++ b/scripts/validate-locales.mjs @@ -0,0 +1,149 @@ +import { + closeSync, fstatSync, openSync, opendirSync, readFileSync, +} from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const localesDir = path.join(root, 'src/lib/locales') +const sourcePath = path.join(localesDir, 'en.json') + +function readCatalog(filePath) { + return JSON.parse(readUtf8File(filePath)) +} + +function readUtf8File(filePath) { + const fd = openSync(filePath, 'r') + try { + return readFileSync(fd, 'utf8') + } finally { + closeSync(fd) + } +} + +function directoryFiles(dirPath) { + const dir = opendirSync(dirPath) + try { + const files = [] + let entry = dir.readSync() + while (entry) { + if (entry.isFile()) files.push(entry.name) + entry = dir.readSync() + } + return files + } finally { + dir.closeSync() + } +} + +function ensureDirectory(dirPath) { + const fd = openSync(dirPath, 'r') + try { + if (!fstatSync(fd).isDirectory()) { + throw new Error(`${dirPath} is not a directory`) + } + } finally { + closeSync(fd) + } +} + +function isFlatObject(value) { + if (!value) return false + if (typeof value !== 'object') return false + return !Array.isArray(value) +} + +function assertFlatStringCatalog(locale, catalog) { + if (!isFlatObject(catalog)) { + throw new Error(`${locale}: expected a flat object of translation keys`) + } + + for (const [key, value] of Object.entries(catalog)) { + if (typeof value !== 'string') { + throw new Error(`${locale}: key "${key}" must map to a string`) + } + } +} + +function missingKeys(sourceKeys, localeKeys) { + const localeKeySet = new Set(localeKeys) + return sourceKeys.filter((key) => !localeKeySet.has(key)) +} + +function extraKeys(sourceKeys, localeKeys) { + const sourceKeySet = new Set(sourceKeys) + return localeKeys.filter((key) => !sourceKeySet.has(key)) +} + +function placeholders(value) { + return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort() +} + +function sameValues(left, right) { + if (left.length !== right.length) return false + return left.every((value, index) => value === right[index]) +} + +function formatValues(values) { + return values.length === 0 ? 'none' : values.join(', ') +} + +function placeholderIssues(locale, sourceCatalog, catalog) { + const issues = [] + + for (const [key, sourceValue] of Object.entries(sourceCatalog)) { + if (!(key in catalog)) continue + + const sourcePlaceholders = placeholders(sourceValue) + const localePlaceholders = placeholders(catalog[key]) + if (sameValues(sourcePlaceholders, localePlaceholders)) continue + + issues.push( + `${locale}: key "${key}" placeholders differ ` + + `(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`, + ) + } + + return issues +} + +const sourceCatalog = readCatalog(sourcePath) +assertFlatStringCatalog('en', sourceCatalog) + +const sourceKeys = Object.keys(sourceCatalog).sort() +ensureDirectory(localesDir) +const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json')) +const issues = [] + +for (const file of localeFiles) { + const locale = file.replace(/\.json$/, '') + const filePath = path.join(localesDir, file) + const catalog = readCatalog(filePath) + + assertFlatStringCatalog(locale, catalog) + + if (locale === 'en') continue + + const keys = Object.keys(catalog).sort() + const missing = missingKeys(sourceKeys, keys) + const extra = extraKeys(sourceKeys, keys) + + if (missing.length > 0) { + issues.push(`${locale}: missing ${missing.length} key(s)`) + } + if (extra.length > 0) { + issues.push(`${locale}: extra ${extra.length} key(s)`) + } + + issues.push(...placeholderIssues(locale, sourceCatalog, catalog)) +} + +if (issues.length > 0) { + console.error('Locale validation failed:') + for (const issue of issues) { + console.error(`- ${issue}`) + } + process.exit(1) +} + +console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`) diff --git a/site/.vitepress/config.ts b/site/.vitepress/config.ts new file mode 100644 index 0000000..a86c39e --- /dev/null +++ b/site/.vitepress/config.ts @@ -0,0 +1,119 @@ +import { defineConfig } from "vitepress"; + +const base = process.env.VITEPRESS_BASE ?? "/"; + +export default defineConfig({ + title: "Tolaria", + description: + "Tolaria is a local-first Markdown knowledge base with native relationships, Git history, and AI workflows.", + base, + ignoreDeadLinks: [/^\/download\/?(?:index)?$/, /^\/releases\/?(?:index)?$/], + cleanUrls: true, + head: [ + ["link", { rel: "icon", type: "image/png", href: `${base}landing/favicon.png` }], + ["meta", { property: "og:title", content: "Tolaria" }], + [ + "meta", + { + property: "og:description", + content: + "A second brain for the AI era. Free forever, local-first, Markdown-based, Git-ready, and AI-friendly.", + }, + ], + ], + themeConfig: { + logo: { src: "/landing/tolaria-icon.png", alt: "Tolaria" }, + nav: [ + { text: "Start", link: "/start/install" }, + { text: "Concepts", link: "/concepts/vaults" }, + { text: "Guides", link: "/guides/capture-a-note" }, + { text: "Templates", link: "/templates/portent" }, + { text: "Downloads", link: "https://tolaria.md/download/", target: "_self", noIcon: true }, + ], + search: { + provider: "local", + }, + sidebar: [ + { + text: "Start Here", + items: [ + { text: "Install Tolaria", link: "/start/install" }, + { text: "First Launch", link: "/start/first-launch" }, + { text: "Getting Started Vault", link: "/start/getting-started-vault" }, + { text: "Open Or Create A Vault", link: "/start/open-or-create-vault" }, + ], + }, + { + text: "Concepts", + items: [ + { text: "Vaults", link: "/concepts/vaults" }, + { text: "Notes", link: "/concepts/notes" }, + { text: "Editor", link: "/concepts/editor" }, + { text: "Spreadsheets", link: "/concepts/spreadsheets" }, + { text: "Properties", link: "/concepts/properties" }, + { text: "Types", link: "/concepts/types" }, + { text: "Relationships", link: "/concepts/relationships" }, + { text: "Files And Media", link: "/concepts/files-and-media" }, + { text: "Inbox", link: "/concepts/inbox" }, + { text: "Git", link: "/concepts/git" }, + { text: "AI", link: "/concepts/ai" }, + ], + }, + { + text: "Guides", + items: [ + { text: "Capture A Note", link: "/guides/capture-a-note" }, + { text: "Organize The Inbox", link: "/guides/organize-inbox" }, + { text: "Use Wikilinks", link: "/guides/use-wikilinks" }, + { text: "Use Spreadsheets", link: "/guides/use-spreadsheets" }, + { text: "Create Types", link: "/guides/create-types" }, + { text: "Build Custom Views", link: "/guides/build-custom-views" }, + { text: "Connect A Git Remote", link: "/guides/connect-a-git-remote" }, + { text: "Manage Git", link: "/guides/commit-and-push" }, + { text: "Use The AI", link: "/guides/use-ai-panel" }, + { text: "Configure AI Models", link: "/guides/configure-ai-models" }, + { text: "Use The Table Of Contents", link: "/guides/use-table-of-contents" }, + { text: "Use Media Previews", link: "/guides/use-media-previews" }, + { text: "Manage Display Preferences", link: "/guides/manage-display-preferences" }, + { text: "Use The Command Palette", link: "/guides/use-command-palette" }, + ], + }, + { + text: "Templates", + items: [ + { text: "Portent", link: "/templates/portent" }, + ], + }, + { + text: "Reference", + items: [ + { text: "Supported Platforms", link: "/reference/supported-platforms" }, + { text: "File Layout", link: "/reference/file-layout" }, + { text: "Frontmatter Fields", link: "/reference/frontmatter-fields" }, + { text: "Spreadsheet File Format", link: "/reference/spreadsheet-format" }, + { text: "Spreadsheet Formulas", link: "/reference/spreadsheet-functions" }, + { text: "View Filters", link: "/reference/view-filters" }, + { text: "Keyboard Shortcuts", link: "/reference/keyboard-shortcuts" }, + { text: "Release Channels", link: "/reference/release-channels" }, + { text: "Contribute", link: "/reference/contribute" }, + { text: "Docs Maintenance", link: "/reference/docs-maintenance" }, + ], + }, + { + text: "Troubleshooting", + items: [ + { text: "Vault Not Loading", link: "/troubleshooting/vault-not-loading" }, + { text: "Git Authentication", link: "/troubleshooting/git-auth" }, + { text: "AI Agent Not Found", link: "/troubleshooting/ai-agent-not-found" }, + { text: "Model Provider Connection", link: "/troubleshooting/model-provider-connection" }, + { text: "Sync Conflicts", link: "/troubleshooting/sync-conflicts" }, + ], + }, + ], + footer: { + message: "Free and open source. Local-first, Git-first, and Markdown-based.", + copyright: + "Tolaria is AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project trademark policy.", + }, + }, +}); diff --git a/site/.vitepress/theme/LandingHome.vue b/site/.vitepress/theme/LandingHome.vue new file mode 100644 index 0000000..48dcf2c --- /dev/null +++ b/site/.vitepress/theme/LandingHome.vue @@ -0,0 +1,1285 @@ + + + + + + + diff --git a/site/.vitepress/theme/Layout.vue b/site/.vitepress/theme/Layout.vue new file mode 100644 index 0000000..0923308 --- /dev/null +++ b/site/.vitepress/theme/Layout.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/site/.vitepress/theme/assets/RefactoringSans.otf b/site/.vitepress/theme/assets/RefactoringSans.otf new file mode 100644 index 0000000..837a776 Binary files /dev/null and b/site/.vitepress/theme/assets/RefactoringSans.otf differ diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts new file mode 100644 index 0000000..f9c2390 --- /dev/null +++ b/site/.vitepress/theme/index.ts @@ -0,0 +1,12 @@ +import DefaultTheme from "vitepress/theme"; +import LandingHome from "./LandingHome.vue"; +import Layout from "./Layout.vue"; +import "./styles.css"; + +export default { + extends: DefaultTheme, + Layout, + enhanceApp({ app }) { + app.component("LandingHome", LandingHome); + }, +}; diff --git a/site/.vitepress/theme/styles.css b/site/.vitepress/theme/styles.css new file mode 100644 index 0000000..c856add --- /dev/null +++ b/site/.vitepress/theme/styles.css @@ -0,0 +1,240 @@ +@font-face { + font-family: "RefactoringSans"; + src: url("./assets/RefactoringSans.otf") format("opentype"); + font-display: swap; + font-style: normal; + font-weight: 400 900; +} + +:root { + --vp-font-family-base: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --vp-font-family-mono: "SF Mono", "Fira Code", ui-monospace, monospace; + --tolaria-font-brand: + "RefactoringSans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + --tolaria-bg: #faf9f5; + --tolaria-surface: #ffffff; + --tolaria-surface-muted: #f7f6f3; + --tolaria-text: #1a1a18; + --tolaria-text-secondary: #6b6b60; + --tolaria-text-muted: #9b9b90; + --tolaria-border: #e5e5e0; + --tolaria-blue: #155dff; + --tolaria-blue-hover: #4a5ad6; + --tolaria-blue-soft: #e8eeff; + --vp-c-bg: var(--tolaria-surface); + --vp-c-bg-alt: var(--tolaria-surface-muted); + --vp-c-bg-elv: var(--tolaria-surface); + --vp-c-bg-soft: var(--tolaria-surface-muted); + --vp-c-text-1: var(--tolaria-text); + --vp-c-text-2: var(--tolaria-text-secondary); + --vp-c-text-3: var(--tolaria-text-muted); + --vp-c-border: var(--tolaria-border); + --vp-c-divider: var(--tolaria-border); + --vp-c-brand-1: var(--tolaria-blue); + --vp-c-brand-2: var(--tolaria-blue-hover); + --vp-c-brand-3: var(--tolaria-blue); + --vp-c-brand-soft: var(--tolaria-blue-soft); + --vp-button-brand-bg: var(--tolaria-blue); + --vp-button-brand-hover-bg: var(--tolaria-blue-hover); + --vp-button-brand-border: var(--tolaria-blue); + --vp-code-bg: #eeeeea; + --vp-code-color: var(--tolaria-text-secondary); +} + +.dark { + --tolaria-bg: #1f1e1b; + --tolaria-surface: #23221f; + --tolaria-surface-muted: #191814; + --tolaria-text: #e6e1d8; + --tolaria-text-secondary: #b8b1a6; + --tolaria-text-muted: #7f776d; + --tolaria-border: #34322d; + --tolaria-blue: #78a4ff; + --tolaria-blue-hover: #9bbeff; + --tolaria-blue-soft: rgba(120, 164, 255, 0.16); + --vp-c-bg: #1f1e1b; + --vp-c-bg-alt: #191814; + --vp-c-bg-elv: #23221f; + --vp-c-bg-soft: #23221f; + --vp-c-text-1: #e6e1d8; + --vp-c-text-2: #b8b1a6; + --vp-c-text-3: #7f776d; + --vp-c-border: #34322d; + --vp-c-divider: #34322d; + --vp-c-brand-1: #78a4ff; + --vp-c-brand-2: #9bbeff; + --vp-c-brand-3: #78a4ff; + --vp-c-brand-soft: rgba(120, 164, 255, 0.16); + --vp-code-bg: #2d2b27; + --vp-code-color: #d8d1c6; +} + +html, +body, +#app { + background: var(--vp-c-bg); +} + +html.tolaria-landing-page, +html.tolaria-landing-page body, +html.tolaria-landing-page #app { + background: var(--tolaria-bg); +} + +.VPNavBarTitle .logo { + width: auto; + height: 28px; +} + +.VPNavBarTitle .title { + color: var(--vp-c-text-1); + font-family: var(--tolaria-font-brand); + font-size: 22px; + font-weight: 800; + letter-spacing: 0; +} + +.tolaria-landing-shell { + --vp-c-bg: var(--tolaria-bg); + --vp-nav-bg-color: var(--tolaria-bg); +} + +.tolaria-landing-shell .VPNav, +.tolaria-landing-shell .VPNavBar, +.tolaria-landing-shell .VPNavBar:not(.has-sidebar):not(.home.top), +.tolaria-landing-shell .VPNavBar:not(.home.top) .content-body, +.tolaria-landing-shell .VPNavBar .content-body { + background: var(--tolaria-bg); + background-color: var(--tolaria-bg); + backdrop-filter: blur(14px); +} + +.tolaria-landing-shell .VPNavBar .wrapper, +.tolaria-landing-shell .VPNavBar .container { + width: min(100%, 1280px); + max-width: 1280px; + margin-right: auto; + margin-left: auto; +} + +.tolaria-landing-shell .VPNavBar .wrapper { + padding-right: 20px; + padding-left: 20px; +} + +.tolaria-landing-shell .landing-container { + width: min(100%, 1280px); +} + +@media (min-width: 768px) { + .tolaria-landing-shell .VPNavBar .wrapper { + padding-right: 40px; + padding-left: 40px; + } +} + +.tolaria-landing-shell .VPNavBar .divider, +.tolaria-landing-shell .VPNavBar .divider-line { + display: none; + background-color: transparent; +} + +.tolaria-landing-shell .VPNavBar .divider-line { + opacity: 0; + transition: opacity 160ms ease; +} + +.VPNavBar { + position: relative; + z-index: calc(var(--vp-z-index-nav) + 2); +} + +.tolaria-landing-shell .VPLocalNav { + display: none; +} + +.VPNavScreen { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + bottom: auto !important; + height: calc(100vh - var(--vp-nav-height) - var(--vp-layout-top-height, 0px)) !important; + z-index: calc(var(--vp-z-index-nav) + 1); + background: var(--vp-c-bg); +} + +.tolaria-scrolled .tolaria-landing-shell .VPNav { + box-shadow: 0 10px 24px rgba(26, 26, 24, 0.06); +} + +.tolaria-scrolled .tolaria-landing-shell .VPNavBar .divider-line { + opacity: 1; +} + +.DocSearch-Button { + border-radius: 999px; +} + +.tolaria-landing-shell .DocSearch-Button { + border: 1px solid var(--tolaria-border); + background: var(--tolaria-surface); +} + +.vp-doc h1, +.vp-doc h2, +.vp-doc h3 { + letter-spacing: 0; +} + +.vp-doc a, +.VPNavBarMenuLink.active, +.VPLink.active { + color: var(--vp-c-brand-1); +} + +.vp-doc table { + display: table; + width: 100%; +} + +.vp-doc th { + color: var(--vp-c-text-1); + background: var(--vp-c-bg-soft); +} + +.vp-doc td, +.vp-doc th { + border-color: var(--vp-c-divider); +} + +.tolaria-landing-shell .VPContent, +.tolaria-landing-shell .VPPage, +.tolaria-landing-shell .VPDoc, +.tolaria-landing-shell .VPDoc .container, +.tolaria-landing-shell .VPDoc .content, +.tolaria-landing-shell .VPDoc .content-container, +.tolaria-landing-shell .VPDoc .main, +.tolaria-landing-shell .vp-doc { + max-width: none; + padding: 0; + margin: 0; +} + +@media (min-width: 960px) { + .tolaria-landing-shell .VPContent { + padding-top: var(--vp-nav-height); + } +} + +.tolaria-landing-shell .vp-doc > div { + width: 100%; +} + +.tolaria-landing-shell .vp-doc a { + text-decoration: none; +} + +.tolaria-landing-shell .VPFooter { + display: none; +} diff --git a/site/concepts/ai.md b/site/concepts/ai.md new file mode 100644 index 0000000..bead033 --- /dev/null +++ b/site/concepts/ai.md @@ -0,0 +1,32 @@ +# AI + +Tolaria has two AI paths: coding agents that can use tools to inspect and edit a vault, and direct model targets that answer in chat mode from note context. + +## Coding Agents + +The AI panel can stream supported local CLI agents through Tolaria's normalized event layer. Current targets include Claude Code, Codex, OpenCode, Pi, and Antigravity CLI when they are installed on the machine. + +Coding agents can run in: + +- **Vault Safe** mode, limited to file, search, and edit tools. +- **Power User** mode, which can allow local shell commands scoped to the active vault for agents that support shell access. + +## Direct Models + +Direct model targets run in chat mode. They receive the active note, linked context, and conversation history, but they do not receive vault-write tools or shell access. + +Supported provider shapes include: + +- Local models through Ollama or LM Studio. +- Hosted providers such as OpenAI, Anthropic, Gemini, and OpenRouter. +- Custom OpenAI-compatible endpoints. + +## External MCP Setup + +Tolaria exposes an MCP server for external tools. The setup flow can write Tolaria's MCP entry into Claude Code, Antigravity CLI, Cursor, and a generic MCP config path, and it can also copy the exact JSON snippet for manual setup. + +MCP setup is explicit. Closing the dialog leaves third-party config files untouched. + +## Why Git Matters For AI + +AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work. diff --git a/site/concepts/editor.md b/site/concepts/editor.md new file mode 100644 index 0000000..b07bc39 --- /dev/null +++ b/site/concepts/editor.md @@ -0,0 +1,23 @@ +# Editor + +Tolaria offers a rich editor for daily writing and a raw Markdown mode for exact file control. Both modes write back to the same Markdown file. + +## Rich Editing + +The rich editor supports blocks, slash commands, wikilinks, tables, code blocks, images, Mermaid diagrams, LaTeX-style math, and markdown-backed whiteboards. + +Use it when you want to write and reorganize quickly without thinking about Markdown syntax. + +## Raw Mode + +Raw mode shows the Markdown source directly. Use it when you need to edit YAML frontmatter, repair unusual Markdown, or make an exact text change. + +Toggle raw mode with `Cmd+\` on macOS or `Ctrl+\` on Windows and Linux. + +## Table Of Contents + +The table of contents panel builds an outline from headings in the current note. It is useful for long notes, procedures, research files, and generated documents. Toggle it with `Cmd+Shift+T` on macOS or `Ctrl+Shift+T` on Windows and Linux. + +## Width + +Notes can use normal or wide editor width. Set the default in Settings, or override an individual note from the editor toolbar. diff --git a/site/concepts/files-and-media.md b/site/concepts/files-and-media.md new file mode 100644 index 0000000..e88854c --- /dev/null +++ b/site/concepts/files-and-media.md @@ -0,0 +1,34 @@ +# Files And Media + +Tolaria starts with Markdown notes, but a vault can also contain images, PDFs, media files, whiteboards, and other local files. + +## Mermaid Diagrams + +Use Mermaid code blocks when a note needs a diagram that should stay plain text and versionable. + +````md +```mermaid +flowchart LR + Idea --> Draft --> Review --> Publish +``` +```` + +Tolaria renders Mermaid diagrams in the editor while keeping the source in Markdown. + +## Attachments + +Images pasted into the editor are saved into the vault as normal files. They remain portable and can be opened by other tools. + +## Previews + +Tolaria can preview common image files, PDFs, and supported media files in the app. Files without an in-app preview can still be opened in the default system app. + +Settings control whether PDFs, images, and unsupported files appear in All Notes. Folder browsing still shows files in their folders. + +## Whiteboards + +Whiteboards use tldraw in the editor, but their durable representation stays in Markdown. That keeps them inside the vault and versioned by Git with the rest of your notes. + +## Git Boundary + +If generated or local-only files are ignored by Git, Tolaria can hide them from notes, search, quick open, and folders. Use this when build artifacts or private local files should not behave like vault content. diff --git a/site/concepts/git.md b/site/concepts/git.md new file mode 100644 index 0000000..f389a34 --- /dev/null +++ b/site/concepts/git.md @@ -0,0 +1,29 @@ +# Git + +Git is Tolaria's recommended history and sync layer. Tolaria can work with plain Markdown folders, and Git unlocks local history, recovery, remote backup, and multi-device workflows when you want them. + +Tolaria acts as a lightweight Git client for your vault. You can review changes, commit, pull, push, and inspect history without leaving the app. + +## What Tolaria Uses Git For + +- Whole-vault commit history. +- Current diff for the vault. +- Per-note history. +- Current diff for an individual note. +- Pull and push. +- Conflict detection and resolution. +- Remote connection for local-only vaults. + +## History And Diffs + +Each note can show its own history and current diff, so you can understand how that file changed over time or what is unsaved relative to Git. + +Tolaria also shows a history of the whole vault. Use it when you want to review broader changes across multiple notes before committing or syncing. + +## Local Commits + +You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured. + +## Remotes + +Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work. diff --git a/site/concepts/inbox.md b/site/concepts/inbox.md new file mode 100644 index 0000000..8c22e5a --- /dev/null +++ b/site/concepts/inbox.md @@ -0,0 +1,23 @@ +# Inbox + +The Inbox is for notes that have been captured but not yet organized. + +## Why It Exists + +Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later. + +The Inbox workflow is optional. Turn it off in Settings > Workflow if you prefer every note to appear organized by default. + +## Organizing Inbox Notes + +When reviewing the Inbox: + +1. Give the note a clear H1. +2. Set its `type`. +3. Add status, dates, or URL if useful. +4. Add relationships with wikilinks or frontmatter fields. +5. Move it into a folder only if the folder adds value. + +## Healthy Inbox Habit + +Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate. diff --git a/site/concepts/notes.md b/site/concepts/notes.md new file mode 100644 index 0000000..ed1316a --- /dev/null +++ b/site/concepts/notes.md @@ -0,0 +1,36 @@ +# Notes + +A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation. + +## Anatomy + +```md +--- +type: Project +status: Active +belongs_to: + - "[[workspace]]" +--- + +# Launch Documentation + +Draft the public Tolaria docs and keep them close to code changes. +``` + +## Titles + +The first H1 is the note title. Tolaria uses that title wherever the note is displayed: note lists, search results, wikilink suggestions, relationship pickers, tabs, and window titles. + +The title is separate from the filename. The filename stays visible in the breadcrumb so you can see the file on disk, and you can rename it independently when needed. + +Use the breadcrumb action to rename the file to match the title. New untitled notes can also auto-rename from the first H1 the first time they get a real title. Turn this behavior off in Settings > Vault Content > Titles & Filenames if you prefer filenames to stay unchanged until you rename them manually. + +## Body Links + +Use `[[wikilinks]]` to connect notes from the body. Tolaria shows autocomplete suggestions while you type, and links can resolve by filename or title. + +## Frontmatter + +Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body. + +Some notes can be displayed with specialized editors while keeping the same file-first model. A note with `_display: sheet` opens as a spreadsheet and stores its cells in a CSV-like body, while `type` remains available for organization. See [Spreadsheets](/concepts/spreadsheets). diff --git a/site/concepts/properties.md b/site/concepts/properties.md new file mode 100644 index 0000000..baa6aa4 --- /dev/null +++ b/site/concepts/properties.md @@ -0,0 +1,26 @@ +# Properties + +Properties are frontmatter fields that Tolaria can display, filter, and edit. + +## Suggested Properties + +Suggested properties are the fields Tolaria knows how to create quickly from the Properties panel. When a suggested property is missing, the panel shows a shortcut to add it with the right editor. + +| Field | Purpose | +| --- | --- | +| `type` | Groups the note into a type such as Project, Person, or Topic. | +| `status` | Tracks lifecycle state such as Active, Done, or Blocked. | +| `url` | Stores a canonical external link. | +| `date` | Represents a single date. | + +## System Properties + +Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing. + +Examples include `_icon`, `_color`, `_order`, `_sidebar_label`, `_width`, and `_pinned_properties` on type documents or notes. + +## Property Editing + +The Properties panel is the safest place to edit structured properties. Toggle it with `Cmd+Shift+I` on macOS or `Ctrl+Shift+I` on Windows and Linux. + +Date fields use Tolaria's picker, relationship fields can use wikilinks, and raw Markdown mode is available when you need direct control over YAML. diff --git a/site/concepts/relationships.md b/site/concepts/relationships.md new file mode 100644 index 0000000..424f9fa --- /dev/null +++ b/site/concepts/relationships.md @@ -0,0 +1,32 @@ +# Relationships + +Relationships make a vault feel like a graph instead of a pile of documents. + +## Relationship Fields + +Any frontmatter field containing wikilinks can become a relationship. Relationship fields can point to one note or to an array of notes. + +```yaml +belongs_to: + - "[[product-work]]" +related_to: + - "[[documentation]]" + - "[[editor-research]]" +blocked_by: + - "[[release-process]]" + - "[[sync-conflicts]]" +``` + +Tolaria supports default relationship fields out of the box: `belongs_to`, `has`, and `related_to`. It also detects custom relationship fields dynamically when they contain wikilinks. + +Default relationships have automatically computed inverses. If a note says it `belongs_to` a project, the project can show that note under its inverse `has` relationship without you writing the reverse link by hand. `related_to` works as a lateral relationship in both directions. + +These outgoing and inverse relationships appear in the Properties panel and in Neighborhood mode, where the note list becomes a graph view around the selected note. + +## Body Links Versus Relationship Fields + +Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, Neighborhood mode, or the Properties panel. + +## Backlinks + +Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context. diff --git a/site/concepts/spreadsheets.md b/site/concepts/spreadsheets.md new file mode 100644 index 0000000..b8fdc7e --- /dev/null +++ b/site/concepts/spreadsheets.md @@ -0,0 +1,138 @@ +# Spreadsheets + +Tolaria sheets are spreadsheet notes. They keep the same file-first model as other notes, but a note with `_display: sheet` opens in a spreadsheet editor instead of the rich text editor. The note's `type` remains available for organization. + +The durable file is still Markdown with YAML frontmatter. The body is CSV-like text containing cell inputs and formulas, and spreadsheet presentation state is stored as plain YAML under `_sheet`. + +## Read Next + +- [Use Spreadsheets](/guides/use-spreadsheets) for the editing workflow. +- [Spreadsheet File Format](/reference/spreadsheet-format) for the plain-text storage contract. +- [Spreadsheet Formulas](/reference/spreadsheet-functions) for formula syntax, autocomplete, and IronCalc function families. + +## Why Sheet Notes + +Sheets are useful when information is better modeled as rows, columns, and formulas than as prose. Examples include budgets, revenue models, inventories, editorial calendars, lightweight trackers, and analytical scratchpads. + +Tolaria does not store sheets as opaque workbook binaries. A sheet should remain: + +- readable in a text editor +- diffable in Git +- editable by humans and AI agents +- available offline +- connected to the rest of the vault through types, properties, relationships, and wikilinks + +## One Note, One Sheet + +A sheet note is a single sheet. Tolaria does not expose multiple tabs inside one note. + +When a model needs more than one table, create more than one sheet note and connect them with wikilinks or cross-sheet formulas. This keeps each file small, legible, and aligned with Tolaria's graph model. + +For example: + +- `newsletter-revenue.md` +- `sponsorship-pipeline.md` +- `refactoring-business-plan.md` + +Each can be a normal `_display: sheet` note, and formulas can reference cells in another sheet note with Tolaria's wikilink cell syntax. + +## Editing + +The interactive sheet editor is backed by IronCalc. Tolaria uses IronCalc for spreadsheet behavior and formula evaluation, then adapts the workbook back to Tolaria's plain-text note format. + +In the sheet editor: + +- cell inputs that start with `=` are formulas +- non-formula cells can contain normal text, numbers, dates, and `[[wikilinks]]` +- typing `[[` in a cell opens the same note autocomplete concept used elsewhere in Tolaria +- typing a formula function name opens inline formula autocomplete for the bundled IronCalc function catalog +- right-clicking a selection exposes core formatting controls such as number formats, decimal precision, bold, italic, and clear formatting + +Keyboard basics follow spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` with arrows extends the selection +- `Enter` starts editing the active cell +- `Escape` exits cell editing while keeping focus in the sheet +- `Delete` or `Backspace` clears the selected range +- copy and paste should preserve formulas, including Tolaria cross-sheet references + +## Wikilinks In Cells + +Wikilinks in non-formula cells are stored as normal Tolaria wikilinks: + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +The cell still behaves like a spreadsheet cell, but the value remains a vault link that Tolaria can understand. + +## Note Reference Formulas + +Tolaria adds a sheet-note reference syntax on top of IronCalc formulas: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=[[refactoring-business-plan]].$C$18 +``` + +The target before the dot is a normal Tolaria wikilink target. For another sheet note, the part after the dot is an A1-style cell address. + +Relative and absolute references work like spreadsheet references when copied: + +- `[[revenue]].B5` can shift when pasted to another cell +- `[[revenue]].$B$5` stays fixed +- `[[revenue]].B$5` fixes the row +- `[[revenue]].$B5` fixes the column + +This is not the same as an IronCalc workbook tab reference. It is Tolaria-specific syntax for referencing another sheet note in the vault. + +Current cross-sheet formulas resolve single cells. Ranges across sheet notes are not a stable file-format feature yet, so prefer composing them from explicit cell references or keeping range formulas inside the same sheet note. + +Sheet formulas can also read scalar frontmatter properties from a note: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +``` + +This keeps sheet models connected to ordinary Tolaria metadata without requiring a saved view or query. Unresolved, ambiguous, or non-scalar property references show spreadsheet errors. + +## Storage + +A minimal sheet note looks like this: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 + cells: + E6: + num_fmt: "0.00%" +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +Growth,,=(C5-B5)/B5,=(D5-C5)/C5,=(E5-B5)/B5 +``` + +Normal frontmatter stays normal Tolaria metadata. `_sheet` is system metadata for the spreadsheet editor and is hidden from normal property editing. + +For the full storage contract, see [Spreadsheet File Format](/reference/spreadsheet-format). + +## Formulas + +Tolaria delegates formula calculation to IronCalc. IronCalc aims for Excel-compatible formulas, while its project documentation still describes it as work in progress. For Tolaria-specific formula behavior and the autocomplete function catalog, see [Spreadsheet Formulas](/reference/spreadsheet-functions). diff --git a/site/concepts/types.md b/site/concepts/types.md new file mode 100644 index 0000000..777a3c2 --- /dev/null +++ b/site/concepts/types.md @@ -0,0 +1,51 @@ +# Types + +Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create. + +## Type Field + +The `type:` field assigns a note to a type. + +```yaml +type: Project +``` + +Tolaria does not infer type from folder location. Moving a file into another folder does not change its type. + +## Prefer Types Over Folders + +Types are the preferred way to group notes in Tolaria. Folders are supported for existing vaults and fallback organization, but Tolaria is built around types and relationships because they carry stronger meaning than file paths. + +Use types for semantic groups such as Projects, People, Topics, Procedures, Events, and Essays. Use relationships to connect notes across those groups. This gives Tolaria better structure for navigation, filtering, properties, templates, and future automation than folder location alone. + +## Type Documents + +Type documents are Markdown notes with `type: Type` in frontmatter. They describe how a type should appear and what new notes of that type should start with. + +```yaml +--- +type: Type +_icon: folder +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +Type templates can live in the Type document's `template` frontmatter field. When a hand-edited Type body contains template-like structure after its own `# TypeName` heading, Tolaria also uses that body content as the new-note template. Plain descriptive body text stays documentation-only. + +## What Types Control + +- Sidebar grouping. +- Type icon and color. +- Sidebar order and label. +- Pinned properties. +- New-note templates. + +## New Note Defaults + +Type documents can define empty properties and relationships. When you create a new note of that type, Tolaria shows placeholders for those fields so you can fill them in from the Properties panel. + +If a type document gives a property a value, that value becomes the default for new notes of that type. For example, a Project type can define `status: Active` so every new project starts active until you change it. diff --git a/site/concepts/vaults.md b/site/concepts/vaults.md new file mode 100644 index 0000000..ddc4761 --- /dev/null +++ b/site/concepts/vaults.md @@ -0,0 +1,45 @@ +# Vaults + +A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files. + +## Core Rules + +- Notes are Markdown files. +- YAML frontmatter provides structure. +- Attachments are normal files inside the vault. +- Type definitions and saved views are also files. +- Git can track history and support remote sync. + +## Why Local Files Matter + +Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git. + +Tolaria should never become the only way to read your data. + +## Git Is A Capability + +A plain folder of Markdown files can open as a vault. Git-backed vaults unlock history, changes, commits, pull, push, conflict handling, and remote setup. + +If a folder is not a Git repository, Tolaria can initialize Git when you explicitly ask it to. It avoids initializing broad personal folders such as Desktop, Documents, or Downloads unless they are clearly dedicated vault folders. + +## Multiple Vaults At The Same Time + +Tolaria can load multiple registered vaults into one unified graph. Enable this from `Settings` -> `Vaults` -> `Use multiple vaults at the same time`. + +After the option is enabled, open the bottom-left vault menu to include or exclude vaults from the graph. Included vaults appear together in note lists, search, quick open, backlinks, and wikilink navigation. Each note keeps a compact vault badge when Tolaria needs to disambiguate where it lives. + +The selected vault still matters. Git status, commits, sync, folder navigation, saved views, and vault repair actions stay scoped to the current repository. Use `Manage vaults` from the vault menu or the Vaults settings section to rename vaults, choose colors, and set the default destination for new notes. + +Cross-vault wikilinks use the target vault's stable alias when needed, for example `[[team/projects/alpha]]`. Links inside the same vault stay normal vault-relative links. + +## App State Versus Vault State + +Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation. + +| Vault state | App state | +| --- | --- | +| Type icons and colors | Editor zoom | +| Saved views | Window size | +| Pinned properties | Recent vault list | +| Relationship conventions | Local cache | +| Vault AI guidance files | AI target selection | diff --git a/site/guides/build-custom-views.md b/site/guides/build-custom-views.md new file mode 100644 index 0000000..6e94229 --- /dev/null +++ b/site/guides/build-custom-views.md @@ -0,0 +1,25 @@ +# Build Custom Views + +Custom views are saved filters for recurring questions. + +## Good View Candidates + +- Active projects. +- People without a recent follow-up. +- Drafts ready for review. +- Notes changed this week. +- Events in a date range. + +## View Definition + +Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data. + +## Filters + +Custom views can use nested conditions, similar to Notion or Airtable filter groups. Combine `all` and `any` logic when a view needs to answer a more precise question than a single field filter can express. + +Date filters support dynamic natural-language values such as `today`, `yesterday`, or `one week ago`. Use these for views that should keep moving over time, such as recent work, stale follow-ups, or upcoming events. + +## Design The Question First + +Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens. diff --git a/site/guides/capture-a-note.md b/site/guides/capture-a-note.md new file mode 100644 index 0000000..7bee504 --- /dev/null +++ b/site/guides/capture-a-note.md @@ -0,0 +1,18 @@ +# Capture A Note + +Use capture when you need to get an idea into the vault before you know where it belongs. + +## Steps + +1. Press `Cmd+N` on macOS or `Ctrl+N` on Windows and Linux. +2. Write a clear H1. +3. Add the rough content. +4. Leave structure for later if you are still thinking. + +## Capture Well + +Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review. + +## When To Add Structure Immediately + +Add structure while capturing when the note's type or relationships are already obvious. Otherwise, capture the idea first and organize it later. diff --git a/site/guides/commit-and-push.md b/site/guides/commit-and-push.md new file mode 100644 index 0000000..0017ce9 --- /dev/null +++ b/site/guides/commit-and-push.md @@ -0,0 +1,23 @@ +# Manage Git Manually Or With AutoGit + +Tolaria can act as a lightweight Git client for a Git-enabled vault. You can manage commits and pushes yourself, or enable AutoGit to create conservative checkpoints after editing pauses or when the app is no longer active. + +## Manual Git + +1. Open the Git or changes surface. +2. Review changed files. +3. Write a short commit message. +4. Commit locally. +5. Push when a remote is configured. + +If the remote has changed, pull first and resolve any conflicts. If the vault has no remote, manual commits still give you local history, diffs, and rollback. + +## AutoGit + +AutoGit is available in Settings for Git-enabled vaults. When enabled, Tolaria automatically commits and pushes saved local changes after an idle pause or after the app becomes inactive. + +Use AutoGit when you want the safety of regular checkpoints without interrupting capture or editing. You can still inspect each note's current diff, review note history, and browse the whole-vault history before making larger manual commits. + +## Use Small Commits + +Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits. diff --git a/site/guides/configure-ai-models.md b/site/guides/configure-ai-models.md new file mode 100644 index 0000000..a77379b --- /dev/null +++ b/site/guides/configure-ai-models.md @@ -0,0 +1,25 @@ +# Configure AI Models + +Use model providers when you want chat over note context without giving an agent vault-write tools. + +## Local Models + +Local model targets are for tools such as Ollama and LM Studio. They usually need a base URL and model ID, and they usually do not need an API key. + +## API Models + +API model targets are for hosted providers such as OpenAI, Anthropic, Gemini, OpenRouter, or another OpenAI-compatible endpoint. + +Tolaria does not store provider API keys in vault settings. Choose one of the supported key paths: + +- Save the key locally on this device. +- Read the key from an environment variable. +- Use no key for local providers that do not require one. + +## Test The Connection + +After adding a provider, use the test action in Settings. A successful test means Tolaria reached the endpoint and the model replied. + +## Select The Target + +Once configured, choose the model from the AI target selector or set it as the default AI target in Settings. diff --git a/site/guides/connect-a-git-remote.md b/site/guides/connect-a-git-remote.md new file mode 100644 index 0000000..4fb1285 --- /dev/null +++ b/site/guides/connect-a-git-remote.md @@ -0,0 +1,23 @@ +# Connect A Git Remote + +Connect a remote when you want backup or sync beyond the current machine. + +## Before You Start + +Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials. + +## Steps + +1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette. +2. Paste the remote URL. +3. Confirm the remote name. +4. Fetch or push according to the app prompt. + +## Recommended Auth + +- SSH keys. +- GitHub CLI authentication. +- Existing Git credential helpers. +- macOS Keychain credentials for HTTPS remotes on macOS. + +If authentication fails, see [Git Authentication](/troubleshooting/git-auth). diff --git a/site/guides/create-types.md b/site/guides/create-types.md new file mode 100644 index 0000000..1fac152 --- /dev/null +++ b/site/guides/create-types.md @@ -0,0 +1,35 @@ +# Create Types + +Create a type when several notes share the same role in your system. + +## Steps + +1. Run `New Type` from the command palette, or click `+` in the Types header in the sidebar. +2. Give the type a clear name. +3. Add optional icon, color, sidebar order, sidebar label, pinned properties, suggested fields, default values, or a new-note template. + +You can also right-click a type in the sidebar to change its icon and color. + +```yaml +--- +type: Type +_icon: briefcase +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +## Use Types Sparingly + +A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead. + +## Templates + +Type documents can include a Markdown template for new notes of that type. Keep templates small and useful: a heading, a few expected fields, and the first checklist are usually enough. + +You can store the template in the Type document's `template` frontmatter field. When hand-editing the Type document body, content after the Type note's own `# TypeName` heading is also used as the new-note template if it looks like template structure such as field labels, secondary headings, or checklist starters. Plain descriptive body text is ignored. + +Type documents can also define fields for new notes. Empty properties and relationships become placeholders in new notes of that type. Properties with values become defaults for new notes of that type. diff --git a/site/guides/manage-display-preferences.md b/site/guides/manage-display-preferences.md new file mode 100644 index 0000000..ebf3068 --- /dev/null +++ b/site/guides/manage-display-preferences.md @@ -0,0 +1,26 @@ +# Manage Display Preferences + +Display preferences live in local app settings unless a setting is intentionally stored in the note or vault. + +## Theme + +Choose Light, Dark, or System in Settings. System follows the operating system appearance at runtime. + +You can also switch theme mode from the command palette. + +## Note Width + +Set the default rich-editor width in Settings: + +- **Normal** for focused writing. +- **Wide** for tables, diagrams, dense notes, and generated documents. + +An individual note can override the default width from the editor toolbar. That override is stored as `_width` in the note frontmatter. + +## Sidebar Labels + +Tolaria can pluralize type names in the sidebar. Turn this off in Settings if your type names should be shown exactly as written, or use `_sidebar_label` on a type document for an explicit label. + +## Vault Content + +Settings also control whether Gitignored files and non-Markdown file categories are visible in the app. Use these controls to keep generated or local-only files out of regular note workflows. diff --git a/site/guides/organize-inbox.md b/site/guides/organize-inbox.md new file mode 100644 index 0000000..9b17580 --- /dev/null +++ b/site/guides/organize-inbox.md @@ -0,0 +1,31 @@ +# Organize The Inbox + +Inbox review turns quick captures into usable knowledge. + +## Remove A Note From Inbox + +When a note is organized enough, mark it as organized. Use `Cmd+E` on macOS or `Ctrl+E` on Windows and Linux, or click the organize action in the breadcrumb bar. + +That action is what removes the note from Inbox. If auto-advance is enabled in Settings > Workflow, Tolaria opens the next Inbox item immediately after you mark the current note organized. + +## Review Checklist + +- Rename unclear notes. +- Add or correct the first H1. +- Set `type`. +- Add `status` for actionable notes. +- Add `belongs_to`, `related_to`, or other relationship fields when useful. +- Archive or delete notes that no longer matter. + +## Make Notes Navigable + +A note is organized when you can answer: + +- What kind of thing is this? +- What is it connected to? +- What is this useful for? +- What will I do with it? + +## Avoid Over-Structuring + +Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation. diff --git a/site/guides/use-ai-panel.md b/site/guides/use-ai-panel.md new file mode 100644 index 0000000..7b707a9 --- /dev/null +++ b/site/guides/use-ai-panel.md @@ -0,0 +1,38 @@ +# Use The AI + +Tolaria gives you two ways to ask for AI help: open the AI panel for an ongoing conversation, or prompt directly from the editor with `Cmd+K` followed by a space. + +## Choose How To Prompt + +- **AI panel** is best for longer conversations, agent work, and requests that need visible back-and-forth. +- **Inline prompt** is best when you are already writing. Press `Cmd+K`, type a space, then write the prompt you want the AI to handle from the current note context. + +## Choose A Target + +Open Settings and choose the default AI target: + +- **Coding agent** for tool-backed vault editing through Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. +- **Local model** for Ollama or LM Studio chat over note context. +- **API model** for OpenAI, Anthropic, Gemini, OpenRouter, or an OpenAI-compatible endpoint. + +If a coding agent is missing, install it and reopen Tolaria or switch to another target. + +## Permission Mode + +Coding agents support per-vault permission modes: + +- **Vault Safe** keeps agents limited to file, search, and edit tools. +- **Power User** can allow shell commands for agents that support them. + +Direct model targets always stay in chat mode. They can use note context, but they cannot edit vault files through tools. + +## Good Requests + +- "Find notes related to this project." +- "Summarize what changed in this note." +- "Draft a weekly review from these linked notes." +- "Update this checklist based on the current project status." + +## Review Changes + +AI edits are file edits. Review them with Tolaria's diff and Git history before committing. diff --git a/site/guides/use-command-palette.md b/site/guides/use-command-palette.md new file mode 100644 index 0000000..410eeb3 --- /dev/null +++ b/site/guides/use-command-palette.md @@ -0,0 +1,26 @@ +# Use The Command Palette + +The command palette is the fastest way to move around Tolaria. + +Open it with: + +- `Cmd+K` on macOS. +- `Ctrl+K` on Linux and Windows. + +## Common Commands + +- New Note. +- Search. +- Open Settings. +- Reload Vault. +- Add Remote. +- Open Getting Started Vault. +- Toggle Raw Mode. +- Toggle Table of Contents. +- Toggle AI Panel. +- Use Light, Dark, or System theme. +- Open in New Window. + +## Keyboard-First Workflow + +Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows. diff --git a/site/guides/use-media-previews.md b/site/guides/use-media-previews.md new file mode 100644 index 0000000..ae39adf --- /dev/null +++ b/site/guides/use-media-previews.md @@ -0,0 +1,25 @@ +# Use Media Previews + +Media previews let you inspect vault files without leaving Tolaria. + +## Open A File + +Select an image, PDF, media file, or unsupported file from a folder or file list. Tolaria opens supported files in the app and offers an external-open action for files that should use the system default app. + +## All Notes Visibility + +Open Settings to choose whether non-Markdown files appear in All Notes: + +- PDFs. +- Images. +- Unsupported files. + +Folder browsing still shows files in their folders even when a category is hidden from All Notes. + +## Attachments + +When you paste or drop an image into a note, Tolaria copies it into the vault and references the copied file from Markdown. + +## Troubleshooting + +If a preview does not render, open the file in the default app to confirm the file is valid, then check whether the file is inside the active vault and not blocked by operating-system permissions. diff --git a/site/guides/use-spreadsheets.md b/site/guides/use-spreadsheets.md new file mode 100644 index 0000000..8ac389d --- /dev/null +++ b/site/guides/use-spreadsheets.md @@ -0,0 +1,146 @@ +# Use Spreadsheets + +Tolaria spreadsheets are sheet notes: Markdown files with frontmatter and a CSV-like body that open in a spreadsheet editor when their `Display as` value is `Sheet`. + +Use a sheet note when a model needs rows, columns, calculations, or repeated numeric editing. Use a normal note when the main artifact is prose. + +## Create A Sheet + +Use the command palette action `New Sheet`, or create/open a note and set its `Display as` to `Sheet` from the Properties panel. `Type` remains separate and can still be `Note`, `Project`, `Responsibility`, or any other Tolaria type. + +When a note is a sheet: + +- the YAML frontmatter remains available for type, status, relationships, wikilinks, and custom properties +- `_display: sheet` tells Tolaria to display the note with the spreadsheet editor +- the body is the sheet itself +- there is no rich-text body around the table +- the editor switches from the text editor to the spreadsheet editor + +## Enter Values + +Click a cell and type a value. Non-formula values can be text, numbers, dates, or wikilinks. + +Press `Enter` on a selected cell to edit the cell. Press `Escape` while editing to leave cell editing and keep focus in the sheet. + +Use `Delete` or `Backspace` to clear the selected cell or range. + +## Enter Formulas + +Formulas start with `=`. + +```txt +=B2+B3-B4 +=SUM(B2:D2) +=ROUND(E6, 2) +=IF(E6>0, "Up", "Down") +``` + +Tolaria shows inline formula autocomplete while you type. The autocomplete list is built from the implemented function catalog in the bundled IronCalc engine; formula evaluation is still handled by IronCalc. + +See [Spreadsheet Formulas](/reference/spreadsheet-functions) for syntax, supported examples, and links to the full IronCalc formula reference. + +## Select And Edit Ranges + +The sheet editor follows spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` plus arrow keys extends the selection +- drag to select a range +- copy and paste preserves formulas where possible +- cut and paste moves formulas and shifts relative references +- right-click a selected cell or range to apply formatting + +Right-click actions apply to the current selection. Keep a multi-cell selection active before opening the context menu when you want to format several cells together. + +## Format Cells + +Use the context menu for common formatting: + +- number formats such as plain numbers, currency, and percentages +- decimal precision +- bold and italic text +- alignment and clearing formatting when available + +Formatting is stored as plain YAML under `_sheet`, not in an opaque workbook blob. For example, percentage formatting for `E6` is stored as: + +```yaml +_sheet: + cells: + E6: + num_fmt: "0.00%" +``` + +See [Spreadsheet File Format](/reference/spreadsheet-format) for the full storage model. + +## Add Wikilinks + +Type `[[` in a cell to open note autocomplete. + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +When the cell is not being edited, Tolaria renders the wikilink like other note links. When you edit the cell, the raw `[[wikilink]]` syntax is shown again. + +Command-click a wikilink in a sheet cell to open the linked note. + +## Reference Another Note + +Formulas can read a cell from another sheet note with Tolaria's wikilink cell syntax: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The part inside `[[...]]` resolves like a normal Tolaria wikilink. The part after the dot is an A1-style cell reference. + +Use absolute markers when copying formulas: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet references currently resolve single cells. Keep range formulas inside one sheet note. + +Formulas can read scalar frontmatter properties from a note with dot notation: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +Numbers, booleans, and text properties can be used in formulas. Missing or ambiguous note targets, missing properties, and non-scalar values such as lists or nested objects show as spreadsheet errors. + +## Work With The Raw File + +A sheet file remains readable text: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +``` + +When editing this file with scripts or AI agents, parse the body as CSV and preserve formulas as formulas. Do not replace formulas with displayed values. diff --git a/site/guides/use-table-of-contents.md b/site/guides/use-table-of-contents.md new file mode 100644 index 0000000..bb3663d --- /dev/null +++ b/site/guides/use-table-of-contents.md @@ -0,0 +1,23 @@ +# Use The Table Of Contents + +The table of contents panel helps you navigate long notes by heading. + +## Open It + +Use the editor toolbar, the command palette, or the shortcut: + +- `Cmd+Shift+T` on macOS. +- `Ctrl+Shift+T` on Windows and Linux. + +## How It Works + +Tolaria builds the outline from the current note's headings. The panel updates as the note changes and can jump to sections in the editor. + +## Good Uses + +- Long procedures. +- Meeting notes with many sections. +- Research notes. +- Generated documents that need review. + +If a note has no useful headings, add clear H2 and H3 sections rather than relying on a long uninterrupted document. diff --git a/site/guides/use-wikilinks.md b/site/guides/use-wikilinks.md new file mode 100644 index 0000000..b84fa11 --- /dev/null +++ b/site/guides/use-wikilinks.md @@ -0,0 +1,24 @@ +# Use Wikilinks + +Wikilinks connect notes by name. + +```md +This project belongs to [[content-systems]] and is related to [[git-workflows]]. +``` + +## Link From The Body + +Use body links when the connection is part of the sentence you are writing. + +## Link From Frontmatter + +Use frontmatter links when the relationship should become structured metadata. + +```yaml +related_to: + - "[[git-workflows]]" +``` + +## Keep Links Stable + +Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps you pick the right target while you type. diff --git a/site/index.md b/site/index.md new file mode 100644 index 0000000..367f19c --- /dev/null +++ b/site/index.md @@ -0,0 +1,10 @@ +--- +layout: page +sidebar: false +aside: false +landing: true +title: Tolaria +description: A second brain for the AI era. Free forever. +--- + + diff --git a/site/public/CNAME b/site/public/CNAME new file mode 100644 index 0000000..68c7e08 --- /dev/null +++ b/site/public/CNAME @@ -0,0 +1 @@ +tolaria.md diff --git a/site/public/landing/Ai chat 3.png b/site/public/landing/Ai chat 3.png new file mode 100644 index 0000000..6ecc00d Binary files /dev/null and b/site/public/landing/Ai chat 3.png differ diff --git a/site/public/landing/Block editor.png b/site/public/landing/Block editor.png new file mode 100644 index 0000000..757d8df Binary files /dev/null and b/site/public/landing/Block editor.png differ diff --git a/site/public/landing/Luca hello.jpeg b/site/public/landing/Luca hello.jpeg new file mode 100644 index 0000000..20d4426 Binary files /dev/null and b/site/public/landing/Luca hello.jpeg differ diff --git a/site/public/landing/ananth.webp b/site/public/landing/ananth.webp new file mode 100644 index 0000000..33b7a99 Binary files /dev/null and b/site/public/landing/ananth.webp differ diff --git a/site/public/landing/favicon.png b/site/public/landing/favicon.png new file mode 100644 index 0000000..46ac81d Binary files /dev/null and b/site/public/landing/favicon.png differ diff --git a/site/public/landing/git-history.png b/site/public/landing/git-history.png new file mode 100644 index 0000000..a50ab4f Binary files /dev/null and b/site/public/landing/git-history.png differ diff --git a/site/public/landing/gregor.webp b/site/public/landing/gregor.webp new file mode 100644 index 0000000..ea50568 Binary files /dev/null and b/site/public/landing/gregor.webp differ diff --git a/site/public/landing/jordan.webp b/site/public/landing/jordan.webp new file mode 100644 index 0000000..81c8330 Binary files /dev/null and b/site/public/landing/jordan.webp differ diff --git a/site/public/landing/les-saintes.jpg b/site/public/landing/les-saintes.jpg new file mode 100644 index 0000000..fe7cdbf Binary files /dev/null and b/site/public/landing/les-saintes.jpg differ diff --git a/site/public/landing/pulse.png b/site/public/landing/pulse.png new file mode 100644 index 0000000..933a9cd Binary files /dev/null and b/site/public/landing/pulse.png differ diff --git a/site/public/landing/relationships.png b/site/public/landing/relationships.png new file mode 100644 index 0000000..280fbd7 Binary files /dev/null and b/site/public/landing/relationships.png differ diff --git a/site/public/landing/simply-files.png b/site/public/landing/simply-files.png new file mode 100644 index 0000000..fd289b0 Binary files /dev/null and b/site/public/landing/simply-files.png differ diff --git a/site/public/landing/sponsors/SOURCES.md b/site/public/landing/sponsors/SOURCES.md new file mode 100644 index 0000000..ecfc90c --- /dev/null +++ b/site/public/landing/sponsors/SOURCES.md @@ -0,0 +1,12 @@ +# Sponsor Logo Sources + +These wordmarks are used in the README sponsor table and the landing-page sponsor section. + +| Sponsor | Source | +|---|---| +| Codacy | `https://www.codacy.com/` header asset: `Codacylogo.svg` | +| CodeScene | `https://codescene.com/` header SVG | +| CircleCI | `https://brand.circleci.com/613faff00/p/14ba30-circleci-brand` logo SVG | +| Unblocked | `https://getunblocked.com/` header/footer logo SVGs | + +`*-dark.svg` files are dark wordmark variants for light surfaces. `*-light.svg` files are white or light wordmark variants for dark surfaces. diff --git a/site/public/landing/sponsors/circleci-dark.svg b/site/public/landing/sponsors/circleci-dark.svg new file mode 100644 index 0000000..cceda54 --- /dev/null +++ b/site/public/landing/sponsors/circleci-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/landing/sponsors/circleci-light.svg b/site/public/landing/sponsors/circleci-light.svg new file mode 100644 index 0000000..f9607e3 --- /dev/null +++ b/site/public/landing/sponsors/circleci-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/landing/sponsors/codacy-dark.svg b/site/public/landing/sponsors/codacy-dark.svg new file mode 100644 index 0000000..882c2c5 --- /dev/null +++ b/site/public/landing/sponsors/codacy-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/site/public/landing/sponsors/codacy-light.svg b/site/public/landing/sponsors/codacy-light.svg new file mode 100644 index 0000000..6b74afc --- /dev/null +++ b/site/public/landing/sponsors/codacy-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/site/public/landing/sponsors/codescene-dark.svg b/site/public/landing/sponsors/codescene-dark.svg new file mode 100644 index 0000000..2cc8968 --- /dev/null +++ b/site/public/landing/sponsors/codescene-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/landing/sponsors/codescene-light.svg b/site/public/landing/sponsors/codescene-light.svg new file mode 100644 index 0000000..56dcb59 --- /dev/null +++ b/site/public/landing/sponsors/codescene-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/landing/sponsors/unblocked-dark.svg b/site/public/landing/sponsors/unblocked-dark.svg new file mode 100644 index 0000000..049daef --- /dev/null +++ b/site/public/landing/sponsors/unblocked-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/public/landing/sponsors/unblocked-light.svg b/site/public/landing/sponsors/unblocked-light.svg new file mode 100644 index 0000000..50bd778 --- /dev/null +++ b/site/public/landing/sponsors/unblocked-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/public/landing/tolaria-icon.png b/site/public/landing/tolaria-icon.png new file mode 100644 index 0000000..5aad8cf Binary files /dev/null and b/site/public/landing/tolaria-icon.png differ diff --git a/site/public/landing/tolaria-screenshot-dark.png b/site/public/landing/tolaria-screenshot-dark.png new file mode 100644 index 0000000..d4d94a1 Binary files /dev/null and b/site/public/landing/tolaria-screenshot-dark.png differ diff --git a/site/public/landing/tolaria-screenshot.png b/site/public/landing/tolaria-screenshot.png new file mode 100644 index 0000000..739d51b Binary files /dev/null and b/site/public/landing/tolaria-screenshot.png differ diff --git a/site/public/landing/track changes and push.png b/site/public/landing/track changes and push.png new file mode 100644 index 0000000..433fa4c Binary files /dev/null and b/site/public/landing/track changes and push.png differ diff --git a/site/public/landing/wikilinks.png b/site/public/landing/wikilinks.png new file mode 100644 index 0000000..6fb7568 Binary files /dev/null and b/site/public/landing/wikilinks.png differ diff --git a/site/public/landing/yaml frontmatter.png b/site/public/landing/yaml frontmatter.png new file mode 100644 index 0000000..bddaf4d Binary files /dev/null and b/site/public/landing/yaml frontmatter.png differ diff --git a/site/public/tolaria-icon.svg b/site/public/tolaria-icon.svg new file mode 100644 index 0000000..8d3de9a --- /dev/null +++ b/site/public/tolaria-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/site/reference/contribute.md b/site/reference/contribute.md new file mode 100644 index 0000000..dae2c11 --- /dev/null +++ b/site/reference/contribute.md @@ -0,0 +1,32 @@ +# Contribute + +Tolaria is free and open source, and any kind of help is useful. Pick the path that matches what you want to do. + +## Newsletter + +[Refactoring](https://refactoring.fm/) is Luca's newsletter and community for engineers building better teams and software with AI. Subscribing is the best way to support Tolaria. + +## Sponsors + +Tolaria is supported by a panel of tools Luca uses every day to keep the project healthy, tested, and ready for AI-assisted development: + +- [Codacy](https://www.codacy.com/) +- [CodeScene](https://codescene.com/) +- [CircleCI](https://circleci.com/) +- [Unblocked](https://getunblocked.com/) + +## Feature Requests + +Use the [product board](https://tolaria.canny.io/) for feature ideas. Search first, upvote existing ideas, and create a new post when the request is genuinely new. + +## Discussions + +Use [GitHub Discussions](https://github.com/refactoringhq/tolaria/discussions) for questions, conversations, show and tell, and broader community context. + +## Contribute Code + +Small, focused pull requests are welcome. Check the product board first so you build the right thing, then open a PR on [GitHub](https://github.com/refactoringhq/tolaria/pulls). The [contributing guide](https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md) explains the local workflow. + +## Report A Bug + +Use [GitHub Issues](https://github.com/refactoringhq/tolaria/issues) for bugs. Include what happened, what you expected, and clear reproduction steps. If you are reporting from inside Tolaria, use the Contribute panel to copy sanitized diagnostics and attach them to the issue. diff --git a/site/reference/docs-maintenance.md b/site/reference/docs-maintenance.md new file mode 100644 index 0000000..7efa5d5 --- /dev/null +++ b/site/reference/docs-maintenance.md @@ -0,0 +1,38 @@ +# Docs Maintenance + +The public docs live in the app repo so documentation changes can ship with behavior changes. + +## Update Docs When You Change + +- A Tauri command. +- A new component or hook that changes user behavior. +- A data model or frontmatter convention. +- Git, AI, onboarding, or release behavior. +- Public release pages, download metadata, or updater channels. +- Platform support. +- Keyboard shortcuts. + +## Suggested Workflow + +1. Make the code change. +2. Update the matching concept, guide, or reference page. +3. Add a troubleshooting page if the change creates a new failure mode. +4. Run `pnpm docs:build`. +5. Check the home page, search, release/download links, and changed docs pages in a browser. + +## Page Types + +| Type | Purpose | +| --- | --- | +| Start | Helps a new user get into the app. | +| Concepts | Explains mental models. | +| Guides | Teaches workflows. | +| Reference | Gives stable facts and tables. | +| Troubleshooting | Starts from a symptom and ends with recovery. | + +## Review Checklist + +- Does the page describe current behavior? +- Does it mention macOS primary and Windows/Linux supported-early status when platform support matters? +- Are links relative and VitePress-compatible? +- Can a user discover the page with local search? diff --git a/site/reference/file-layout.md b/site/reference/file-layout.md new file mode 100644 index 0000000..9708059 --- /dev/null +++ b/site/reference/file-layout.md @@ -0,0 +1,43 @@ +# File Layout + +Tolaria is not opinionated about folder structure. It finds notes recursively across the whole vault, stores new notes in the root by default, and uses types and relationships for real organization. + +```txt +my-vault/ + project-alpha.md + weekly-review.md + research/ + source-notes.md + attachments/ + diagram.png + source.pdf + project.md + person.md + views/ + active-projects.yml +``` + +## Root Notes + +Tolaria works well with a flat vault. Folders are optional and can be useful for compatibility with other tools, but they are not required for people, projects, topics, or any other note category. + +Type is not inferred from folder location. It comes from frontmatter, and relationships are expressed with wikilinks in fields. That is what Tolaria uses for the sidebar, Properties panel, search, custom views, and neighborhood navigation. + +## Special Folders + +| Folder | Purpose | +| --- | --- | +| `views/` | Saved custom views. | +| `attachments/` | Images and other attached files. | + +PDFs, images, and other non-Markdown files stay as normal files. Folder browsing can show them in place, and Settings controls whether PDFs, images, and unsupported files appear in All Notes. + +Whiteboards are Markdown files with durable tldraw data, so they belong with notes rather than in `attachments/`. + +Spreadsheets are also Markdown files. A note with `_display: sheet` stores ordinary frontmatter plus a CSV-like body and opens in the sheet editor. + +Type definitions are Markdown notes with `type: Type` in frontmatter. New type documents are normal notes, and existing type documents in older folders still work. + +## Git Files + +If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes. diff --git a/site/reference/frontmatter-fields.md b/site/reference/frontmatter-fields.md new file mode 100644 index 0000000..ae271ba --- /dev/null +++ b/site/reference/frontmatter-fields.md @@ -0,0 +1,30 @@ +# Frontmatter Fields + +Tolaria uses conventions instead of a required schema. + +| Field | Meaning | +| --- | --- | +| `type` | The note's entity type. | +| `status` | Lifecycle state. | +| `icon` | Per-note icon. | +| `url` | External URL. | +| `date` | Single date. | +| `belongs_to` | Parent relationship. | +| `related_to` | Lateral relationship. | +| `has` | Contained relationship. | +| `_width` | Per-note editor width override. | +| `_display` | Display mode. Omit for text notes; use `sheet` for spreadsheet notes. | +| `_icon`, `_color` | Type or note appearance metadata. | +| `_sidebar_label`, `_order` | Type sidebar label and order. | +| `_pinned_properties` | Properties pinned for a type. | +| `_sheet` | Sheet-note presentation metadata such as grid settings, column widths, row heights, and cell formatting. | + +## Custom Fields + +You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship. + +## System Fields + +Fields starting with `_` are reserved for system behavior and hidden from standard property editing. They remain plain YAML, so they can still be inspected or changed in raw mode when needed. + +Nested keys under a system field are also system-owned. For example, `_sheet.cells.B6.num_fmt` belongs to the sheet editor and should not appear as a normal user property. diff --git a/site/reference/keyboard-shortcuts.md b/site/reference/keyboard-shortcuts.md new file mode 100644 index 0000000..b5b02f5 --- /dev/null +++ b/site/reference/keyboard-shortcuts.md @@ -0,0 +1,24 @@ +# Keyboard Shortcuts + +| Shortcut | Action | +| --- | --- | +| `Cmd+K` / `Ctrl+K` | Open command palette. | +| `Cmd+P` / `Ctrl+P` | Quick open notes and files. | +| `Cmd+N` / `Ctrl+N` | Create a new note. | +| `Cmd+S` / `Ctrl+S` | Save current note. | +| `Cmd+F` / `Ctrl+F` | Find in the current note. | +| `Cmd+Shift+F` / `Ctrl+Shift+F` | Search the vault. | +| `Cmd+Shift+V` / `Ctrl+Shift+V` | Paste without formatting. | +| `Cmd+\` / `Ctrl+\` | Toggle raw Markdown mode. | +| `Cmd+Shift+T` / `Ctrl+Shift+T` | Toggle table of contents. | +| `Cmd+Shift+I` / `Ctrl+Shift+I` | Toggle Properties panel. | +| `Cmd+Shift+L` / `Ctrl+Shift+L` | Toggle AI panel. | +| `Cmd+[` / `Alt+Left` | Navigate back when available. | +| `Cmd+]` / `Alt+Right` | Navigate forward when available. | +| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. | +| `Cmd+D` / `Ctrl+D` | Toggle favorite for the current note. | +| `Cmd+E` / `Ctrl+E` | Mark the current Inbox note organized. | + +Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations. + +Use the command palette to discover the current command set. diff --git a/site/reference/release-channels.md b/site/reference/release-channels.md new file mode 100644 index 0000000..5b136c6 --- /dev/null +++ b/site/reference/release-channels.md @@ -0,0 +1,36 @@ +# Release Channels + +Tolaria publishes Stable and Alpha release metadata to GitHub Pages. + +## Stable + +Stable follows manually promoted releases. This is the right channel for normal use. + +The stable updater metadata lives at: + +```txt +/stable/latest.json +``` + +The public download page points at the latest stable release. + +## Alpha + +Alpha follows pushes to `main`. It receives fixes and features earlier, but it can be rougher than Stable. + +The alpha updater metadata lives at: + +```txt +/alpha/latest.json +``` + +Compatibility endpoints also point to the alpha metadata: + +```txt +/latest.json +/latest-canary.json +``` + +## Before Switching + +Commit or push important vault changes before changing release channel or installing an update. Your notes are local files, but a clean Git state makes recovery simpler. diff --git a/site/reference/spreadsheet-format.md b/site/reference/spreadsheet-format.md new file mode 100644 index 0000000..f9ad9da --- /dev/null +++ b/site/reference/spreadsheet-format.md @@ -0,0 +1,162 @@ +# Spreadsheet File Format + +Sheet notes are Markdown files with YAML frontmatter and a CSV-like body. The note uses `_display: sheet` when it should open in the spreadsheet editor. The `type` field remains ordinary semantic metadata. + +For editing workflows, see [Use Spreadsheets](/guides/use-spreadsheets). For formula syntax and function references, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +## Structure + +```md +--- +type: Project +_display: sheet +tags: + - planning +_sheet: + show_grid_lines: true + frozen_rows: 1 + frozen_columns: 1 + columns: + A: + width: 180 + rows: + "1": + height: 32 + cells: + E6: + num_fmt: "0.00%" + bold: true +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Expenses,650,700,760,=SUM(B3:D3) +Net,=B2-B3,=C2-C3,=D2-D3,=SUM(B4:D4) +Growth,,=(C4-B4)/B4,=(D4-C4)/C4,=(E4-B4)/B4 +``` + +The frontmatter stores note metadata. The body stores rows and cells. There is no Markdown table wrapper, fenced code block, or embedded workbook blob. + +## Frontmatter + +All ordinary Tolaria fields remain available: + +- `type` +- `status` +- `date` +- `tags` +- `url` +- relationship fields such as `belongs_to`, `related_to`, and custom wikilink properties + +The `_display: sheet` field is the display-as marker. Omit it for ordinary text notes. + +The `_sheet` key is reserved for spreadsheet presentation metadata. It follows the same system-field convention as other underscore-prefixed Tolaria fields: hidden from normal property editing, but visible and editable in raw source. + +## Body + +The body is CSV-like text: + +- rows are separated by line breaks +- cells are separated by commas +- cells containing commas, quotes, or line breaks are quoted +- quotes inside quoted cells are escaped by doubling them +- empty trailing rows and columns may be omitted on save + +Any cell whose input starts with `=` is treated as a formula. Other cells are treated as literal values. + +## `_sheet` Metadata + +Tolaria stores spreadsheet presentation state in `_sheet` as plain YAML. + +| Field | Meaning | +| --- | --- | +| `show_grid_lines` | Whether grid lines are shown. | +| `frozen_rows` | Number of frozen rows from the top. | +| `frozen_columns` | Number of frozen columns from the left. | +| `columns..width` | Custom column width, keyed by column letter such as `A` or `BC`. | +| `rows."".height` | Custom row height, keyed by one-based row number. | +| `cells..num_fmt` | Number format code for a cell. | +| `cells..bold` | Bold text style. | +| `cells..italic` | Italic text style. | +| `cells..underline` | Underline text style. | +| `cells..strike` | Strikethrough text style. | +| `cells..font_size` | Font size. | +| `cells..font_color` | Text color. | +| `cells..fill_color` | Cell fill color. | +| `cells..horizontal_align` | Horizontal alignment. | +| `cells..vertical_align` | Vertical alignment. | +| `cells..wrap_text` | Text wrapping. | +| `cells..border_top` | Top border style. | +| `cells..border_right` | Right border style. | +| `cells..border_bottom` | Bottom border style. | +| `cells..border_left` | Left border style. | + +Cell metadata is keyed by A1-style cell addresses such as `A1`, `B12`, or `AA30`. + +Border values are stored as a style name with an optional color, for example: + +```yaml +border_bottom: "thin #d0d7de" +``` + +## Number Formats + +Number formats are stored in `num_fmt` using spreadsheet-style format codes. Common examples: + +| Format | Example output | +| --- | --- | +| `#,##0` | `1,250` | +| `#,##0.00` | `1,250.50` | +| `0.00%` | `12.35%` | +| `$#,##0.00` | `$1,250.50` | +| `yyyy-mm-dd` | `2026-06-15` | + +These formats affect presentation, not the underlying cell input in the CSV body. + +## Markdown Style Import + +When Tolaria imports a non-formula CSV cell, simple Markdown wrappers can seed initial styles: + +| Cell text | Stored value | Style | +| --- | --- | --- | +| `**Revenue**` | `Revenue` | bold | +| `_Estimate_` | `Estimate` | italic | +| `***Total***` | `Total` | bold and italic | +| `~~Removed~~` | `Removed` | strike | + +After save, the style belongs in `_sheet` metadata and the body keeps the unwrapped text. + +## Wikilinks + +Non-formula cells can store normal Tolaria wikilinks: + +```csv +Account,Source +Newsletter,[[newsletter-revenue]] +Sponsors,[[sponsorship-pipeline]] +``` + +Formula cells can reference another sheet note with Tolaria's cross-sheet syntax: + +```txt +=[[newsletter-revenue]].B5 +=ROUND([[business-plan]].$E$12, 2) +=[[device]].power.watts +``` + +Cross-sheet cell references resolve another sheet note by wikilink target, then read a single A1-style cell. Frontmatter references resolve one note by wikilink target, then read a scalar property path after the dot. Missing, ambiguous, circular, very deep, or non-scalar references are treated as unresolved and surface as spreadsheet errors. + +## Guidance For Agents And Scripts + +When editing a sheet note programmatically: + +- preserve the YAML frontmatter delimiter and ordinary Tolaria fields +- keep `_display: sheet` when the file should display as a spreadsheet +- keep spreadsheet presentation state under `_sheet` +- parse and serialize the body as CSV, not by splitting on every comma manually +- preserve formulas as formulas, including `[[sheet]].A1` and `[[note]].property.path` references +- avoid converting formulas to their displayed values +- quote CSV cells when they contain commas, quotes, or line breaks +- do not add workbook tabs inside one note; create another note with `_display: sheet` instead +- do not store opaque binary workbook state in the Markdown file + +If a script cannot safely preserve `_sheet`, it should leave that block untouched and edit only the CSV body cells it understands. diff --git a/site/reference/spreadsheet-functions.md b/site/reference/spreadsheet-functions.md new file mode 100644 index 0000000..1931ff1 --- /dev/null +++ b/site/reference/spreadsheet-functions.md @@ -0,0 +1,186 @@ +# Spreadsheet Formulas + +Formula cells start with `=` and are evaluated by IronCalc through Tolaria's sheet editor. + +Tolaria adds vault-aware sheet references on top of the normal spreadsheet formula model. Everything else should be treated as IronCalc formula behavior. IronCalc aims for Excel-compatible formulas, but the upstream project is still evolving, so verify advanced formulas against the IronCalc docs when precision matters. + +## Basic Syntax + +| Syntax | Meaning | +| --- | --- | +| `=B2+B3-B4` | Arithmetic over cells. | +| `=SUM(B2:D2)` | Function call over a range. | +| `=ROUND(E6, 2)` | Function call with arguments. | +| `=IF(E6>0, "Up", "Down")` | Conditional expression. | +| `=$B$2` | Absolute column and row reference. | +| `=B$2` | Relative column, absolute row. | +| `=$B2` | Absolute column, relative row. | +| `=B2:D10` | A range in the current sheet note. | +| `="Q" & 1` | Text concatenation. | + +Use parentheses when a model depends on precedence: + +```txt +=(B2+B3-B4)/B5 +``` + +## Tolaria Note References + +Tolaria supports wikilink cell references for values that live in another sheet note: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The target inside `[[...]]` resolves like a normal Tolaria wikilink. The cell address after the dot uses A1 notation. + +Absolute markers follow spreadsheet copy behavior: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet cell references currently resolve single cells. Keep range formulas inside one sheet note until cross-note ranges are explicitly supported. + +Formulas can also read scalar frontmatter properties from a specific note: + +```txt +=[[device.md]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +The target resolves like a wikilink, and the dot path reads nested frontmatter keys. Numbers, booleans, and strings become formula literals. Missing notes, ambiguous note targets, missing properties, arrays, maps, and other non-scalar values resolve to `#N/A`. + +A first segment that looks like an A1 cell address, such as `B2`, is treated as a cross-sheet cell reference. Use property names that do not collide with A1 notation for frontmatter formulas. + +## Autocomplete Functions + +Tolaria's formula autocomplete exposes the implemented function catalog from the bundled IronCalc engine. The current catalog has 195 functions. + +The dropdown shows a small ranked set of matches while you type. Keep typing to narrow the result list. Function names with digits and dots, such as `BIN2DEC` and `ERFC.PRECISE`, are supported. + +### Logical + +`AND`, `FALSE`, `IF`, `IFERROR`, `IFNA`, `IFS`, `NOT`, `OR`, `SWITCH`, `TRUE`, `XOR` + +### Math and trigonometry + +`ABS`, `ACOS`, `ACOSH`, `ASIN`, `ASINH`, `ATAN`, `ATAN2`, `ATANH`, `COS`, `COSH`, `PI`, `POWER`, `PRODUCT`, `RAND`, `RANDBETWEEN`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`, `SIN`, `SINH`, `SQRT`, `SQRTPI`, `SUM`, `SUMIF`, `SUMIFS`, `TAN`, `TANH`, `SUBTOTAL` + +### Lookup and reference + +`CHOOSE`, `COLUMN`, `COLUMNS`, `HLOOKUP`, `INDEX`, `INDIRECT`, `LOOKUP`, `MATCH`, `OFFSET`, `ROW`, `ROWS`, `VLOOKUP`, `XLOOKUP` + +### Text + +`CONCAT`, `CONCATENATE`, `EXACT`, `FIND`, `LEFT`, `LEN`, `LOWER`, `MID`, `REPT`, `RIGHT`, `SEARCH`, `SUBSTITUTE`, `T`, `TEXT`, `TEXTAFTER`, `TEXTBEFORE`, `TEXTJOIN`, `TRIM`, `UNICODE`, `UPPER`, `VALUE`, `VALUETOTEXT` + +### Information + +`ERROR.TYPE`, `FORMULATEXT`, `ISBLANK`, `ISERR`, `ISERROR`, `ISEVEN`, `ISFORMULA`, `ISLOGICAL`, `ISNA`, `ISNONTEXT`, `ISNUMBER`, `ISODD`, `ISREF`, `ISTEXT`, `NA`, `SHEET`, `TYPE` + +### Statistical + +`AVERAGE`, `AVERAGEA`, `AVERAGEIF`, `AVERAGEIFS`, `COUNT`, `COUNTA`, `COUNTBLANK`, `COUNTIF`, `COUNTIFS`, `GEOMEAN`, `MAX`, `MAXIFS`, `MIN`, `MINIFS` + +### Date and time + +`DATE`, `DAY`, `EDATE`, `EOMONTH`, `MONTH`, `NOW`, `TODAY`, `YEAR` + +### Financial + +`CUMIPMT`, `CUMPRINC`, `DB`, `DDB`, `DOLLARDE`, `DOLLARFR`, `EFFECT`, `FV`, `IPMT`, `IRR`, `ISPMT`, `MIRR`, `NOMINAL`, `NPER`, `NPV`, `PDURATION`, `PMT`, `PPMT`, `PV`, `RATE`, `RRI`, `SLN`, `SYD`, `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD`, `XIRR`, `XNPV` + +### Engineering + +`BESSELI`, `BESSELJ`, `BESSELK`, `BESSELY`, `BIN2DEC`, `BIN2HEX`, `BIN2OCT`, `BITAND`, `BITLSHIFT`, `BITOR`, `BITRSHIFT`, `BITXOR`, `COMPLEX`, `CONVERT`, `DEC2BIN`, `DEC2HEX`, `DEC2OCT`, `DELTA`, `ERF`, `ERF.PRECISE`, `ERFC`, `ERFC.PRECISE`, `GESTEP`, `HEX2BIN`, `HEX2DEC`, `HEX2OCT`, `IMABS`, `IMAGINARY`, `IMARGUMENT`, `IMCONJUGATE`, `IMCOS`, `IMCOSH`, `IMCOT`, `IMCSC`, `IMCSCH`, `IMDIV`, `IMEXP`, `IMLN`, `IMLOG10`, `IMLOG2`, `IMPOWER`, `IMPRODUCT`, `IMREAL`, `IMSEC`, `IMSECH`, `IMSIN`, `IMSINH`, `IMSQRT`, `IMSUB`, `IMSUM`, `IMTAN`, `OCT2BIN`, `OCT2DEC`, `OCT2HEX` + +## Examples + +### Totals + +```txt +=SUM(B2:D2) +=B2+B3-B4 +=SUM(B2:D2)-SUM(B4:D4) +``` + +### Growth And Percentages + +```txt +=(C5-B5)/B5 +=IF(B5=0, 0, (C5-B5)/B5) +=ROUND((C5-B5)/B5, 4) +``` + +Format the result as a percentage with a cell `num_fmt` such as `0.00%`. + +### Conditional Logic + +```txt +=IF(E5>10000, "On track", "Review") +=IFS(E5>10000, "High", E5>5000, "Medium", TRUE, "Low") +=IFERROR(B5/B4, 0) +``` + +### Dates + +```txt +=TODAY() +=DATE(2026, 6, 15) +=YEAR(TODAY()) +=MONTH(TODAY()) +``` + +### Text + +```txt +=CONCAT(A2, " - ", B2) +=UPPER(A2) +=TRIM(A2) +=TEXT(B2, "$#,##0.00") +``` + +### Lookup + +```txt +=INDEX(B2:E10, 3, 2) +=MATCH("Revenue", A2:A20, 0) +=VLOOKUP("Revenue", A2:E20, 5, FALSE) +=XLOOKUP("Revenue", A2:A20, E2:E20) +``` + +### Cross-Sheet Model + +```txt +=[[newsletter-revenue]].E5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=IF([[business-plan]].$E$12>0, [[business-plan]].$E$12, 0) +``` + +## IronCalc Function Families + +IronCalc documents formulas by category. Use these upstream pages for detailed syntax and examples. The upstream documentation may include newer functions that are not yet present in Tolaria's bundled IronCalc version. + +| Family | Link | +| --- | --- | +| Lookup and reference | [IronCalc lookup and reference](https://docs.ironcalc.com/functions/lookup-and-reference.html) | +| Financial | [IronCalc financial](https://docs.ironcalc.com/functions/financial.html) | +| Engineering | [IronCalc engineering](https://docs.ironcalc.com/functions/engineering.html) | +| Database | [IronCalc database](https://docs.ironcalc.com/functions/database.html) | +| Statistical | [IronCalc statistical](https://docs.ironcalc.com/functions/statistical.html) | +| Text | [IronCalc text](https://docs.ironcalc.com/functions/text.html) | +| Math and trigonometry | [IronCalc math and trigonometry](https://docs.ironcalc.com/functions/math-and-trigonometry.html) | +| Logical | [IronCalc logical](https://docs.ironcalc.com/functions/logical.html) | +| Date and time | [IronCalc date and time](https://docs.ironcalc.com/functions/date-and-time.html) | +| Information | [IronCalc information](https://docs.ironcalc.com/functions/information.html) | + +IronCalc also documents [value types](https://docs.ironcalc.com/features/value-types.html), [error types](https://docs.ironcalc.com/features/error-types.html), [optional arguments](https://docs.ironcalc.com/features/optional-arguments.html), and [formatting values](https://docs.ironcalc.com/features/formatting-values.html). + +For current upstream gaps, see IronCalc's [unsupported features](https://docs.ironcalc.com/features/unsupported-features.html). diff --git a/site/reference/supported-platforms.md b/site/reference/supported-platforms.md new file mode 100644 index 0000000..8b2b1c4 --- /dev/null +++ b/site/reference/supported-platforms.md @@ -0,0 +1,23 @@ +# Supported Platforms + +Tolaria is a desktop app built with Tauri. Releases currently target macOS, Windows, and Linux. + +| Platform | Current support | Notes | +| --- | --- | --- | +| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. | +| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. | + +## Support Policy + +Primary support means the platform is part of normal development and release validation. Supported, early means release artifacts exist and the app is expected to work, but platform-specific bugs can take longer to diagnose than macOS issues. + +## Reporting Platform Bugs + +Include: + +- Tolaria version. +- Operating system and version. +- CPU architecture. +- Whether the vault is local-only or connected to a remote. +- Steps to reproduce. diff --git a/site/reference/view-filters.md b/site/reference/view-filters.md new file mode 100644 index 0000000..f72a26e --- /dev/null +++ b/site/reference/view-filters.md @@ -0,0 +1,33 @@ +# View Filters + +View filters define saved lists of notes. + +## Common Filter Ideas + +| Goal | Filter direction | +| --- | --- | +| Active projects | `type` is Project and `status` is Active | +| Drafts | `type` is Article and `status` is Draft | +| People follow-up | `type` is Person and date is before today | +| Recent work | modified date is within a recent range | + +## Sorting + +Useful sorts include: + +- Recently modified first. +- Title ascending. +- Status ascending. +- A custom property ascending or descending. + +## Operators + +Saved views can combine filters for text, dates, relationship fields, and frontmatter values. Relative date expressions are useful for views such as notes changed this week or people that need follow-up. + +Regex filters are available for power-user cases. Keep them narrow and test them on a small view first. + +## Keep Views Focused + +A view should answer one recurring question. If it becomes too broad, split it into two views. + +You can also customize view appearance with the same kind of icon and color controls used by types. diff --git a/site/start/first-launch.md b/site/start/first-launch.md new file mode 100644 index 0000000..09c42d1 --- /dev/null +++ b/site/start/first-launch.md @@ -0,0 +1,35 @@ +# First Launch + +The first launch flow is designed to get you into a real vault quickly without hiding the local-first model. + +## What You Choose + +Tolaria asks whether you want to: + +- Create or clone the Getting Started vault. +- Open an existing local vault. +- Create a new empty vault. + +The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes. + +## What Tolaria Creates + +Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose. + +| Data | Stored in | +| --- | --- | +| Notes and attachments | Your vault folder | +| Type definitions and saved views | Your vault folder | +| Window size, zoom, recent vaults | Local app settings | +| Cache data | Rebuildable local cache | + +## First Commands To Try + +- `Cmd+K` / `Ctrl+K`: open the command palette. +- `New Note`: create a note in the current vault. +- `Open Getting Started Vault`: clone the public sample vault. +- `Reload Vault`: rescan files after external edits. + +## AI Setup Prompt + +Tolaria can show an optional AI agents prompt after a vault is open. It checks common local install locations for supported coding agents and gives you setup paths, but you can dismiss it and use Tolaria without AI. diff --git a/site/start/getting-started-vault.md b/site/start/getting-started-vault.md new file mode 100644 index 0000000..15aef18 --- /dev/null +++ b/site/start/getting-started-vault.md @@ -0,0 +1,32 @@ +# Getting Started Vault + +The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started). + +It exists to show Tolaria's conventions without requiring you to restructure your own notes first. + +## What It Demonstrates + +- Markdown notes with YAML frontmatter. +- Types such as Project, Person, Topic, and Procedure. +- Wikilinks in note bodies. +- Relationship fields in frontmatter. +- A local Git repository that can be connected to a remote later. +- Vault guidance files for AI agents. + +## Local-Only By Default + +When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later. + +To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette. + +Tolaria also repairs starter-vault guidance files when needed. `AGENTS.md` is the canonical guidance file, `CLAUDE.md` is kept as a compatibility shim, and `GEMINI.md` is only created when you explicitly restore Antigravity/Gemini guidance. + +## Use It Alongside Your Own Vaults + +You can keep the Getting Started vault open while working in your own notes. Enable `Settings` -> `Vaults` -> `Use multiple vaults at the same time`, then use the bottom-left vault menu to include both the sample vault and your real vault in the unified graph. + +This lets search, quick open, note lists, backlinks, and wikilink navigation span both vaults. Git actions still stay scoped to each vault's own repository, and new notes go to the default vault you choose in `Manage vaults`. + +## When To Move On + +After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start. You can remove the sample from Tolaria's vault list later without deleting its files from disk. diff --git a/site/start/install.md b/site/start/install.md new file mode 100644 index 0000000..1eceda5 --- /dev/null +++ b/site/start/install.md @@ -0,0 +1,40 @@ +# Install Tolaria + +Tolaria publishes desktop builds for macOS, Windows, and Linux. macOS is the primary day-to-day development target, with Windows and Linux builds supported through the release pipeline and fixed as platform issues are found. + +## Download + +Use the latest stable release unless you are intentionally testing pre-release builds: + +- Download the latest stable build +- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases) +- Read the release notes + +## Homebrew + +On macOS you can install the cask: + +```bash +brew install --cask tolaria +``` + +## Platform Status + +| Platform | Status | Notes | +| --- | --- | --- | +| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. | +| Windows | Supported, early | NSIS installers and updater bundles are Tauri-signed. Authenticode publisher signing will be added after Windows certificate provisioning; company-managed SmartScreen, Defender, or WDAC policies can still require IT approval before install. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. | + +See [Supported Platforms](/reference/supported-platforms) for the current support policy. + +## Managed Windows Devices + +Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, install Tolaria through your normal software approval path if policy blocks unsigned or unknown-publisher installers. After Authenticode provisioning is complete, validate that the downloaded installer has a valid Tolaria publisher signature before installing. + +## After Installing + +1. Open Tolaria. +2. Choose the Getting Started vault if you want a guided sample. +3. Or open an existing folder of Markdown files as a vault. +4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows. diff --git a/site/start/open-or-create-vault.md b/site/start/open-or-create-vault.md new file mode 100644 index 0000000..10c6c8d --- /dev/null +++ b/site/start/open-or-create-vault.md @@ -0,0 +1,30 @@ +# Open Or Create A Vault + +A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata. + +## Open An Existing Folder + +Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists. + +Good starting points: + +- A folder of plain Markdown files. +- An Obsidian-style vault. +- A Git repository containing notes. +- A copy of the Getting Started vault. + +## Create A New Vault + +Choose a new empty folder if you want Tolaria conventions from the start. New notes and optional type definitions are created as Markdown files. + +## Use More Than One Vault + +You do not have to merge everything into one folder. Register each local folder as its own vault, then turn on `Use multiple vaults at the same time` in `Settings` -> `Vaults`. + +Once enabled, the bottom-left vault menu lets you include vaults in the unified graph. Search, quick open, wikilinks, and note lists can span the included vaults, while Git sync and commits remain tied to each vault's own repository. + +## Git Is Recommended, Not Required + +Tolaria works well with a plain folder of Markdown files. You can open, edit, organize, and search notes without making the vault a Git repository. + +Git is recommended when you want local history, diff views, recovery, pull, push, and remote sync without a proprietary backend. If a vault is not already a repository, Tolaria can initialize one when you explicitly ask it to. diff --git a/site/templates/portent.md b/site/templates/portent.md new file mode 100644 index 0000000..b216121 --- /dev/null +++ b/site/templates/portent.md @@ -0,0 +1,72 @@ +# Portent + +[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases. + +It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first. + +## Core Questions + +Portent favors convention over configuration. Instead of asking "where should this go?", it asks: + +- What is this? +- What is it useful for? +- Is it captured, organized, or archived? + +Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views. + +## Types + +Portent defines eight default types. + +PORT types are actionable: + +- Project +- Operation +- Responsibility +- Task + +ENTP types are non-actionable knowledge records: + +- Event +- Note +- Topic +- Person + +These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first. + +## Relationships + +Portent models knowledge as a graph. The two default relationships are: + +- `belongs_to`: primary ownership, composition, or context. +- `related_to`: a looser semantic connection. + +In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app. + +## Lifecycle + +Portent separates capture from organization: + +1. Capture information quickly so it is not lost. +2. Organize it by assigning a type and useful relationships. +3. Archive it when it has served its purpose. + +Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available. + +## Why Use It + +A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately. + +Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals. + +## Start From The Template + +The fastest starting point is the Portent template vault: + +- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template) + +Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup. + +## Learn More + +Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes. diff --git a/site/troubleshooting/ai-agent-not-found.md b/site/troubleshooting/ai-agent-not-found.md new file mode 100644 index 0000000..27f0e3c --- /dev/null +++ b/site/troubleshooting/ai-agent-not-found.md @@ -0,0 +1,23 @@ +# AI Agent Not Found + +Tolaria can only launch local CLI agents that are installed and discoverable. + +## Symptoms + +- The AI panel says no supported agent is available. +- Claude Code or another agent works in one shell but not in Tolaria. + +## Checks + +Open a terminal and run the agent command directly. For Claude Code: + +```bash +claude --version +``` + +If the command fails, install or repair the agent first. + +## Path Issues + +Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell. + diff --git a/site/troubleshooting/git-auth.md b/site/troubleshooting/git-auth.md new file mode 100644 index 0000000..9c6e00f --- /dev/null +++ b/site/troubleshooting/git-auth.md @@ -0,0 +1,26 @@ +# Git Authentication + +Tolaria uses system Git authentication. It does not manage provider passwords directly. + +## Symptoms + +- Push fails. +- Pull asks for credentials repeatedly. +- Remote fetch works in one terminal but not in Tolaria. + +## Checks + +1. Open a terminal. +2. `cd` into the vault. +3. Run `git remote -v`. +4. Run `git fetch`. + +If `git fetch` fails in the terminal, fix system Git auth first. + +## Common Fixes + +- Sign in with GitHub CLI. +- Configure SSH keys. +- Update the remote URL. +- Check your credential helper. + diff --git a/site/troubleshooting/model-provider-connection.md b/site/troubleshooting/model-provider-connection.md new file mode 100644 index 0000000..eddec6a --- /dev/null +++ b/site/troubleshooting/model-provider-connection.md @@ -0,0 +1,25 @@ +# Model Provider Connection + +Use this checklist when a local or API model provider does not connect. + +## Local Providers + +For Ollama or LM Studio: + +1. Start the local model server. +2. Confirm the base URL in Tolaria matches the server. +3. Confirm the model ID is installed and loaded by the provider. +4. Use the Settings test action again. + +## API Providers + +For hosted providers: + +1. Confirm the provider kind and endpoint. +2. Confirm the model ID exists for your account. +3. Confirm the API key is saved locally or available in the configured environment variable. +4. Avoid storing secrets in the vault. + +## Chat Mode Boundary + +Direct model targets run in chat mode. If you need file-editing tools, use a coding agent target such as Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. diff --git a/site/troubleshooting/sync-conflicts.md b/site/troubleshooting/sync-conflicts.md new file mode 100644 index 0000000..6c1eff7 --- /dev/null +++ b/site/troubleshooting/sync-conflicts.md @@ -0,0 +1,20 @@ +# Sync Conflicts + +Sync conflicts happen when local and remote changes touch the same content. + +## What To Do + +1. Stop editing the conflicted note. +2. Open the conflict resolver if Tolaria presents it. +3. Review both sides. +4. Choose the correct content or merge manually. +5. Commit the resolved file. +6. Push again. + +## Prevent Conflicts + +- Pull before starting work on another device. +- Push after meaningful sessions. +- Keep AI-generated edits in small commits. +- Avoid editing the same note on multiple devices at the same time. + diff --git a/site/troubleshooting/vault-not-loading.md b/site/troubleshooting/vault-not-loading.md new file mode 100644 index 0000000..c96506d --- /dev/null +++ b/site/troubleshooting/vault-not-loading.md @@ -0,0 +1,25 @@ +# Vault Not Loading + +Use this checklist when Tolaria cannot open or refresh a vault. + +## Check The Folder + +- Confirm the folder exists. +- Confirm the folder contains readable files. +- Confirm Tolaria has permission to access the folder. +- Try opening a smaller test vault to isolate the issue. + +## Check Git + +If the vault is a Git repository, verify it is not in a broken state: + +```bash +git status +``` + +Resolve interrupted merges or corrupted repository state before retrying. + +## Reload + +Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem. + diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..158496e --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,8 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas + +# Generated by build scripts +/resources/mcp-server/ +/resources/qmd/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..f8febec --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,7249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitcode" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ed1b54d8dc333e7be604d00fa9262f4635485ffea923647b6521a5fff045d" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238b90427dfad9da4a9abd60f3ec1cdee6b80454bde49ed37f1781dd8e9dc7f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.115", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.115", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.115", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glam" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898f5a568a84989b6c0f8caa50a93074b97dbdc58fc6d9543157bb4562758933" + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gray_matter" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8666976c40b8633f918783969b6681a3ddb205f29150348617de425d85a3e3bd" +dependencies = [ + "serde", + "serde_json", + "toml 0.5.11", + "yaml-rust2", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "ironcalc_base" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f208abea2365061ed7ce3f6a70e7c1537181ea11780de4974aae3082bf4c6d9" +dependencies = [ + "bitcode", + "chrono", + "chrono-tz", + "csv", + "js-sys", + "rand 0.8.5", + "regex", + "ryu", + "serde", + "statrs", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.13.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.115", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.115", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "sentry" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "255914a8e53822abd946e2ce8baa41d4cded6b8e938913b7f7b9da5b7ab44335" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.28", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00293cd332a859961f24fd69258f7e92af736feaeb91020cff84dac4188a4302" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961990f9caa76476c481de130ada05614cd7f5aa70fb57c2142f0e09ad3fb2aa" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6409d845707d82415c800290a5d63be5e3df3c2e417b0997c60531dfbd35ef" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ab5df4f3b64760508edfe0ba4290feab5acbbda7566a79d72673065888e5cc" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609b1a12340495ce17baeec9e08ff8ed423c337c1a84dffae36a178c783623f3" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f4e86402d5c50239dc7d8fd3f6d5e048221d5fcb4e026d8d50ab57fe4644cb" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3f117b8755dbede8260952de2aeb029e20f432e72634e8969af34324591631" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "http-range", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.115", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.115", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-prevent-default" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2" +dependencies = [ + "bitflags 2.11.0", + "itertools", + "serde", + "strum", + "tauri", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tolaria" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "csv", + "dirs 5.0.1", + "futures-util", + "gray_matter", + "ironcalc_base", + "log", + "notify", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-web-kit", + "regex", + "reqwest 0.12.28", + "sentry", + "serde", + "serde_json", + "serde_yaml", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-log", + "tauri-plugin-opener", + "tauri-plugin-prevent-default", + "tauri-plugin-process", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tempfile", + "tokio", + "uuid", + "walkdir", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "tracing-core", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "native-tls", + "once_cell", + "url", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.115", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.115", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.115", + "winnow 0.7.14", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..4eb8053 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "tolaria" +version = "0.1.0" +description = "Personal knowledge and life management app" +authors = ["Luca Rossi"] +license = "AGPL-3.0-or-later" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "tolaria_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.5.4", features = [] } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +log = "0.4" +notify = "6.1" +tauri = { version = "2.10.0", features = ["macos-private-api", "protocol-asset", "devtools", "image-png"] } +tauri-plugin-log = "2" +gray_matter = "0.2" +walkdir = "2" +chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +futures-util = "0.3" +base64 = "0.22" +regex = "1" +ironcalc_base = "0.7.1" +csv = "1.4" +dirs = "5" +tauri-plugin-dialog = "2" +tauri-plugin-updater = "2.10.0" +tauri-plugin-process = "2.3.1" +tauri-plugin-opener = "2" +tauri-plugin-prevent-default = "4.0.4" +sentry = "0.37" +uuid = { version = "1", features = ["v4"] } +tempfile = "3" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.3" +objc2-app-kit = "0.3.2" +objc2-foundation = "0.3.2" +objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] } + +[dev-dependencies] diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..bdf139d --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSLocalNetworkUsageDescription + Tolaria connects to local model servers you configure for AI chat. + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..e04ee8d --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,11 @@ +fn main() { + // Ensure resource directory exists for the Tauri build. + // Gitignored and populated by bundle-mcp-server.mjs. + // Without a placeholder, `tauri build` / `cargo test` fails if the script hasn't run. + let path = std::path::Path::new("resources/mcp-server"); + if !path.exists() { + std::fs::create_dir_all(path).ok(); + std::fs::write(path.join(".placeholder"), "").ok(); + } + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..8157ff1 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,28 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main", + "ai-workspace", + "note-*" + ], + "platforms": ["linux", "macOS", "windows"], + "permissions": [ + "core:default", + "core:window:allow-create", + "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-set-title", + "core:webview:allow-create-webview-window", + "core:event:default", + "dialog:default", + "deep-link:default", + "updater:default", + "process:default", + "opener:default" + ] +} diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000..c6ae3ab --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,15 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "permissions for iOS/iPadOS", + "windows": [ + "main" + ], + "platforms": ["iOS", "android"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-set-title", + "dialog:default" + ] +} diff --git a/src-tauri/gen/apple/.gitignore b/src-tauri/gen/apple/.gitignore new file mode 100644 index 0000000..6726e2f --- /dev/null +++ b/src-tauri/gen/apple/.gitignore @@ -0,0 +1,3 @@ +xcuserdata/ +build/ +Externals/ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000..a6ac2a8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000..cf265a4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000..29c9746 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e4adcbc Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..704c929 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2a9fbc2 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2cdf184 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4723e4b Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f26fee4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "AppIcon-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppIcon-512@2x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/Assets.xcassets/Contents.json b/src-tauri/gen/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/ExportOptions.plist b/src-tauri/gen/apple/ExportOptions.plist new file mode 100644 index 0000000..0428a17 --- /dev/null +++ b/src-tauri/gen/apple/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + debugging + + diff --git a/src-tauri/gen/apple/LaunchScreen.storyboard b/src-tauri/gen/apple/LaunchScreen.storyboard new file mode 100644 index 0000000..81b5f90 --- /dev/null +++ b/src-tauri/gen/apple/LaunchScreen.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/Podfile b/src-tauri/gen/apple/Podfile new file mode 100644 index 0000000..694980d --- /dev/null +++ b/src-tauri/gen/apple/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project + +target 'laputa_iOS' do +platform :ios, '14.0' + # Pods for laputa_iOS +end + +target 'laputa_macOS' do +platform :osx, '11.0' + # Pods for laputa_macOS +end + +# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' + end + end +end diff --git a/src-tauri/gen/apple/Sources/laputa/bindings/bindings.h b/src-tauri/gen/apple/Sources/laputa/bindings/bindings.h new file mode 100644 index 0000000..5152200 --- /dev/null +++ b/src-tauri/gen/apple/Sources/laputa/bindings/bindings.h @@ -0,0 +1,8 @@ +#pragma once + +namespace ffi { + extern "C" { + void start_app(); + } +} + diff --git a/src-tauri/gen/apple/Sources/laputa/main.mm b/src-tauri/gen/apple/Sources/laputa/main.mm new file mode 100644 index 0000000..7793a9d --- /dev/null +++ b/src-tauri/gen/apple/Sources/laputa/main.mm @@ -0,0 +1,6 @@ +#include "bindings/bindings.h" + +int main(int argc, char * argv[]) { + ffi::start_app(); + return 0; +} diff --git a/src-tauri/gen/apple/assets/mcp-server/index.js b/src-tauri/gen/apple/assets/mcp-server/index.js new file mode 100755 index 0000000..7bbf091 --- /dev/null +++ b/src-tauri/gen/apple/assets/mcp-server/index.js @@ -0,0 +1,28192 @@ +#!/usr/bin/env node +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/code.js +var require_code = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/code.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.regexpCode = exports2.getEsmExportName = exports2.getProperty = exports2.safeStringify = exports2.stringify = exports2.strConcat = exports2.addCodeArg = exports2.str = exports2._ = exports2.nil = exports2._Code = exports2.Name = exports2.IDENTIFIER = exports2._CodeOrName = void 0; + var _CodeOrName = class { + }; + exports2._CodeOrName = _CodeOrName; + exports2.IDENTIFIER = /^[a-z$_][a-z$_0-9]*$/i; + var Name = class extends _CodeOrName { + constructor(s) { + super(); + if (!exports2.IDENTIFIER.test(s)) + throw new Error("CodeGen: name must be a valid identifier"); + this.str = s; + } + toString() { + return this.str; + } + emptyStr() { + return false; + } + get names() { + return { [this.str]: 1 }; + } + }; + exports2.Name = Name; + var _Code = class extends _CodeOrName { + constructor(code) { + super(); + this._items = typeof code === "string" ? [code] : code; + } + toString() { + return this.str; + } + emptyStr() { + if (this._items.length > 1) + return false; + const item = this._items[0]; + return item === "" || item === '""'; + } + get str() { + var _a2; + return (_a2 = this._str) !== null && _a2 !== void 0 ? _a2 : this._str = this._items.reduce((s, c) => `${s}${c}`, ""); + } + get names() { + var _a2; + return (_a2 = this._names) !== null && _a2 !== void 0 ? _a2 : this._names = this._items.reduce((names, c) => { + if (c instanceof Name) + names[c.str] = (names[c.str] || 0) + 1; + return names; + }, {}); + } + }; + exports2._Code = _Code; + exports2.nil = new _Code(""); + function _(strs, ...args) { + const code = [strs[0]]; + let i = 0; + while (i < args.length) { + addCodeArg(code, args[i]); + code.push(strs[++i]); + } + return new _Code(code); + } + exports2._ = _; + var plus = new _Code("+"); + function str2(strs, ...args) { + const expr = [safeStringify(strs[0])]; + let i = 0; + while (i < args.length) { + expr.push(plus); + addCodeArg(expr, args[i]); + expr.push(plus, safeStringify(strs[++i])); + } + optimize(expr); + return new _Code(expr); + } + exports2.str = str2; + function addCodeArg(code, arg) { + if (arg instanceof _Code) + code.push(...arg._items); + else if (arg instanceof Name) + code.push(arg); + else + code.push(interpolate(arg)); + } + exports2.addCodeArg = addCodeArg; + function optimize(expr) { + let i = 1; + while (i < expr.length - 1) { + if (expr[i] === plus) { + const res = mergeExprItems(expr[i - 1], expr[i + 1]); + if (res !== void 0) { + expr.splice(i - 1, 3, res); + continue; + } + expr[i++] = "+"; + } + i++; + } + } + function mergeExprItems(a, b) { + if (b === '""') + return a; + if (a === '""') + return b; + if (typeof a == "string") { + if (b instanceof Name || a[a.length - 1] !== '"') + return; + if (typeof b != "string") + return `${a.slice(0, -1)}${b}"`; + if (b[0] === '"') + return a.slice(0, -1) + b.slice(1); + return; + } + if (typeof b == "string" && b[0] === '"' && !(a instanceof Name)) + return `"${a}${b.slice(1)}`; + return; + } + function strConcat(c1, c2) { + return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str2`${c1}${c2}`; + } + exports2.strConcat = strConcat; + function interpolate(x) { + return typeof x == "number" || typeof x == "boolean" || x === null ? x : safeStringify(Array.isArray(x) ? x.join(",") : x); + } + function stringify(x) { + return new _Code(safeStringify(x)); + } + exports2.stringify = stringify; + function safeStringify(x) { + return JSON.stringify(x).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); + } + exports2.safeStringify = safeStringify; + function getProperty(key) { + return typeof key == "string" && exports2.IDENTIFIER.test(key) ? new _Code(`.${key}`) : _`[${key}]`; + } + exports2.getProperty = getProperty; + function getEsmExportName(key) { + if (typeof key == "string" && exports2.IDENTIFIER.test(key)) { + return new _Code(`${key}`); + } + throw new Error(`CodeGen: invalid export name: ${key}, use explicit $id name mapping`); + } + exports2.getEsmExportName = getEsmExportName; + function regexpCode(rx) { + return new _Code(rx.toString()); + } + exports2.regexpCode = regexpCode; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/scope.js +var require_scope = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/scope.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.ValueScope = exports2.ValueScopeName = exports2.Scope = exports2.varKinds = exports2.UsedValueState = void 0; + var code_1 = require_code(); + var ValueError = class extends Error { + constructor(name) { + super(`CodeGen: "code" for ${name} not defined`); + this.value = name.value; + } + }; + var UsedValueState; + (function(UsedValueState2) { + UsedValueState2[UsedValueState2["Started"] = 0] = "Started"; + UsedValueState2[UsedValueState2["Completed"] = 1] = "Completed"; + })(UsedValueState || (exports2.UsedValueState = UsedValueState = {})); + exports2.varKinds = { + const: new code_1.Name("const"), + let: new code_1.Name("let"), + var: new code_1.Name("var") + }; + var Scope = class { + constructor({ prefixes, parent } = {}) { + this._names = {}; + this._prefixes = prefixes; + this._parent = parent; + } + toName(nameOrPrefix) { + return nameOrPrefix instanceof code_1.Name ? nameOrPrefix : this.name(nameOrPrefix); + } + name(prefix) { + return new code_1.Name(this._newName(prefix)); + } + _newName(prefix) { + const ng = this._names[prefix] || this._nameGroup(prefix); + return `${prefix}${ng.index++}`; + } + _nameGroup(prefix) { + var _a2, _b; + if (((_b = (_a2 = this._parent) === null || _a2 === void 0 ? void 0 : _a2._prefixes) === null || _b === void 0 ? void 0 : _b.has(prefix)) || this._prefixes && !this._prefixes.has(prefix)) { + throw new Error(`CodeGen: prefix "${prefix}" is not allowed in this scope`); + } + return this._names[prefix] = { prefix, index: 0 }; + } + }; + exports2.Scope = Scope; + var ValueScopeName = class extends code_1.Name { + constructor(prefix, nameStr) { + super(nameStr); + this.prefix = prefix; + } + setValue(value, { property, itemIndex }) { + this.value = value; + this.scopePath = (0, code_1._)`.${new code_1.Name(property)}[${itemIndex}]`; + } + }; + exports2.ValueScopeName = ValueScopeName; + var line = (0, code_1._)`\n`; + var ValueScope = class extends Scope { + constructor(opts) { + super(opts); + this._values = {}; + this._scope = opts.scope; + this.opts = { ...opts, _n: opts.lines ? line : code_1.nil }; + } + get() { + return this._scope; + } + name(prefix) { + return new ValueScopeName(prefix, this._newName(prefix)); + } + value(nameOrPrefix, value) { + var _a2; + if (value.ref === void 0) + throw new Error("CodeGen: ref must be passed in value"); + const name = this.toName(nameOrPrefix); + const { prefix } = name; + const valueKey = (_a2 = value.key) !== null && _a2 !== void 0 ? _a2 : value.ref; + let vs = this._values[prefix]; + if (vs) { + const _name = vs.get(valueKey); + if (_name) + return _name; + } else { + vs = this._values[prefix] = /* @__PURE__ */ new Map(); + } + vs.set(valueKey, name); + const s = this._scope[prefix] || (this._scope[prefix] = []); + const itemIndex = s.length; + s[itemIndex] = value.ref; + name.setValue(value, { property: prefix, itemIndex }); + return name; + } + getValue(prefix, keyOrRef) { + const vs = this._values[prefix]; + if (!vs) + return; + return vs.get(keyOrRef); + } + scopeRefs(scopeName, values = this._values) { + return this._reduceValues(values, (name) => { + if (name.scopePath === void 0) + throw new Error(`CodeGen: name "${name}" has no value`); + return (0, code_1._)`${scopeName}${name.scopePath}`; + }); + } + scopeCode(values = this._values, usedValues, getCode) { + return this._reduceValues(values, (name) => { + if (name.value === void 0) + throw new Error(`CodeGen: name "${name}" has no value`); + return name.value.code; + }, usedValues, getCode); + } + _reduceValues(values, valueCode, usedValues = {}, getCode) { + let code = code_1.nil; + for (const prefix in values) { + const vs = values[prefix]; + if (!vs) + continue; + const nameSet = usedValues[prefix] = usedValues[prefix] || /* @__PURE__ */ new Map(); + vs.forEach((name) => { + if (nameSet.has(name)) + return; + nameSet.set(name, UsedValueState.Started); + let c = valueCode(name); + if (c) { + const def = this.opts.es5 ? exports2.varKinds.var : exports2.varKinds.const; + code = (0, code_1._)`${code}${def} ${name} = ${c};${this.opts._n}`; + } else if (c = getCode === null || getCode === void 0 ? void 0 : getCode(name)) { + code = (0, code_1._)`${code}${c}${this.opts._n}`; + } else { + throw new ValueError(name); + } + nameSet.set(name, UsedValueState.Completed); + }); + } + return code; + } + }; + exports2.ValueScope = ValueScope; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/index.js +var require_codegen = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.or = exports2.and = exports2.not = exports2.CodeGen = exports2.operators = exports2.varKinds = exports2.ValueScopeName = exports2.ValueScope = exports2.Scope = exports2.Name = exports2.regexpCode = exports2.stringify = exports2.getProperty = exports2.nil = exports2.strConcat = exports2.str = exports2._ = void 0; + var code_1 = require_code(); + var scope_1 = require_scope(); + var code_2 = require_code(); + Object.defineProperty(exports2, "_", { enumerable: true, get: function() { + return code_2._; + } }); + Object.defineProperty(exports2, "str", { enumerable: true, get: function() { + return code_2.str; + } }); + Object.defineProperty(exports2, "strConcat", { enumerable: true, get: function() { + return code_2.strConcat; + } }); + Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { + return code_2.nil; + } }); + Object.defineProperty(exports2, "getProperty", { enumerable: true, get: function() { + return code_2.getProperty; + } }); + Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { + return code_2.stringify; + } }); + Object.defineProperty(exports2, "regexpCode", { enumerable: true, get: function() { + return code_2.regexpCode; + } }); + Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { + return code_2.Name; + } }); + var scope_2 = require_scope(); + Object.defineProperty(exports2, "Scope", { enumerable: true, get: function() { + return scope_2.Scope; + } }); + Object.defineProperty(exports2, "ValueScope", { enumerable: true, get: function() { + return scope_2.ValueScope; + } }); + Object.defineProperty(exports2, "ValueScopeName", { enumerable: true, get: function() { + return scope_2.ValueScopeName; + } }); + Object.defineProperty(exports2, "varKinds", { enumerable: true, get: function() { + return scope_2.varKinds; + } }); + exports2.operators = { + GT: new code_1._Code(">"), + GTE: new code_1._Code(">="), + LT: new code_1._Code("<"), + LTE: new code_1._Code("<="), + EQ: new code_1._Code("==="), + NEQ: new code_1._Code("!=="), + NOT: new code_1._Code("!"), + OR: new code_1._Code("||"), + AND: new code_1._Code("&&"), + ADD: new code_1._Code("+") + }; + var Node = class { + optimizeNodes() { + return this; + } + optimizeNames(_names, _constants) { + return this; + } + }; + var Def = class extends Node { + constructor(varKind, name, rhs) { + super(); + this.varKind = varKind; + this.name = name; + this.rhs = rhs; + } + render({ es5, _n }) { + const varKind = es5 ? scope_1.varKinds.var : this.varKind; + const rhs = this.rhs === void 0 ? "" : ` = ${this.rhs}`; + return `${varKind} ${this.name}${rhs};` + _n; + } + optimizeNames(names, constants) { + if (!names[this.name.str]) + return; + if (this.rhs) + this.rhs = optimizeExpr(this.rhs, names, constants); + return this; + } + get names() { + return this.rhs instanceof code_1._CodeOrName ? this.rhs.names : {}; + } + }; + var Assign = class extends Node { + constructor(lhs, rhs, sideEffects) { + super(); + this.lhs = lhs; + this.rhs = rhs; + this.sideEffects = sideEffects; + } + render({ _n }) { + return `${this.lhs} = ${this.rhs};` + _n; + } + optimizeNames(names, constants) { + if (this.lhs instanceof code_1.Name && !names[this.lhs.str] && !this.sideEffects) + return; + this.rhs = optimizeExpr(this.rhs, names, constants); + return this; + } + get names() { + const names = this.lhs instanceof code_1.Name ? {} : { ...this.lhs.names }; + return addExprNames(names, this.rhs); + } + }; + var AssignOp = class extends Assign { + constructor(lhs, op, rhs, sideEffects) { + super(lhs, rhs, sideEffects); + this.op = op; + } + render({ _n }) { + return `${this.lhs} ${this.op}= ${this.rhs};` + _n; + } + }; + var Label = class extends Node { + constructor(label) { + super(); + this.label = label; + this.names = {}; + } + render({ _n }) { + return `${this.label}:` + _n; + } + }; + var Break = class extends Node { + constructor(label) { + super(); + this.label = label; + this.names = {}; + } + render({ _n }) { + const label = this.label ? ` ${this.label}` : ""; + return `break${label};` + _n; + } + }; + var Throw = class extends Node { + constructor(error2) { + super(); + this.error = error2; + } + render({ _n }) { + return `throw ${this.error};` + _n; + } + get names() { + return this.error.names; + } + }; + var AnyCode = class extends Node { + constructor(code) { + super(); + this.code = code; + } + render({ _n }) { + return `${this.code};` + _n; + } + optimizeNodes() { + return `${this.code}` ? this : void 0; + } + optimizeNames(names, constants) { + this.code = optimizeExpr(this.code, names, constants); + return this; + } + get names() { + return this.code instanceof code_1._CodeOrName ? this.code.names : {}; + } + }; + var ParentNode = class extends Node { + constructor(nodes = []) { + super(); + this.nodes = nodes; + } + render(opts) { + return this.nodes.reduce((code, n) => code + n.render(opts), ""); + } + optimizeNodes() { + const { nodes } = this; + let i = nodes.length; + while (i--) { + const n = nodes[i].optimizeNodes(); + if (Array.isArray(n)) + nodes.splice(i, 1, ...n); + else if (n) + nodes[i] = n; + else + nodes.splice(i, 1); + } + return nodes.length > 0 ? this : void 0; + } + optimizeNames(names, constants) { + const { nodes } = this; + let i = nodes.length; + while (i--) { + const n = nodes[i]; + if (n.optimizeNames(names, constants)) + continue; + subtractNames(names, n.names); + nodes.splice(i, 1); + } + return nodes.length > 0 ? this : void 0; + } + get names() { + return this.nodes.reduce((names, n) => addNames(names, n.names), {}); + } + }; + var BlockNode = class extends ParentNode { + render(opts) { + return "{" + opts._n + super.render(opts) + "}" + opts._n; + } + }; + var Root = class extends ParentNode { + }; + var Else = class extends BlockNode { + }; + Else.kind = "else"; + var If = class _If extends BlockNode { + constructor(condition, nodes) { + super(nodes); + this.condition = condition; + } + render(opts) { + let code = `if(${this.condition})` + super.render(opts); + if (this.else) + code += "else " + this.else.render(opts); + return code; + } + optimizeNodes() { + super.optimizeNodes(); + const cond = this.condition; + if (cond === true) + return this.nodes; + let e = this.else; + if (e) { + const ns = e.optimizeNodes(); + e = this.else = Array.isArray(ns) ? new Else(ns) : ns; + } + if (e) { + if (cond === false) + return e instanceof _If ? e : e.nodes; + if (this.nodes.length) + return this; + return new _If(not(cond), e instanceof _If ? [e] : e.nodes); + } + if (cond === false || !this.nodes.length) + return void 0; + return this; + } + optimizeNames(names, constants) { + var _a2; + this.else = (_a2 = this.else) === null || _a2 === void 0 ? void 0 : _a2.optimizeNames(names, constants); + if (!(super.optimizeNames(names, constants) || this.else)) + return; + this.condition = optimizeExpr(this.condition, names, constants); + return this; + } + get names() { + const names = super.names; + addExprNames(names, this.condition); + if (this.else) + addNames(names, this.else.names); + return names; + } + }; + If.kind = "if"; + var For = class extends BlockNode { + }; + For.kind = "for"; + var ForLoop = class extends For { + constructor(iteration) { + super(); + this.iteration = iteration; + } + render(opts) { + return `for(${this.iteration})` + super.render(opts); + } + optimizeNames(names, constants) { + if (!super.optimizeNames(names, constants)) + return; + this.iteration = optimizeExpr(this.iteration, names, constants); + return this; + } + get names() { + return addNames(super.names, this.iteration.names); + } + }; + var ForRange = class extends For { + constructor(varKind, name, from, to) { + super(); + this.varKind = varKind; + this.name = name; + this.from = from; + this.to = to; + } + render(opts) { + const varKind = opts.es5 ? scope_1.varKinds.var : this.varKind; + const { name, from, to } = this; + return `for(${varKind} ${name}=${from}; ${name}<${to}; ${name}++)` + super.render(opts); + } + get names() { + const names = addExprNames(super.names, this.from); + return addExprNames(names, this.to); + } + }; + var ForIter = class extends For { + constructor(loop, varKind, name, iterable) { + super(); + this.loop = loop; + this.varKind = varKind; + this.name = name; + this.iterable = iterable; + } + render(opts) { + return `for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})` + super.render(opts); + } + optimizeNames(names, constants) { + if (!super.optimizeNames(names, constants)) + return; + this.iterable = optimizeExpr(this.iterable, names, constants); + return this; + } + get names() { + return addNames(super.names, this.iterable.names); + } + }; + var Func = class extends BlockNode { + constructor(name, args, async) { + super(); + this.name = name; + this.args = args; + this.async = async; + } + render(opts) { + const _async = this.async ? "async " : ""; + return `${_async}function ${this.name}(${this.args})` + super.render(opts); + } + }; + Func.kind = "func"; + var Return = class extends ParentNode { + render(opts) { + return "return " + super.render(opts); + } + }; + Return.kind = "return"; + var Try = class extends BlockNode { + render(opts) { + let code = "try" + super.render(opts); + if (this.catch) + code += this.catch.render(opts); + if (this.finally) + code += this.finally.render(opts); + return code; + } + optimizeNodes() { + var _a2, _b; + super.optimizeNodes(); + (_a2 = this.catch) === null || _a2 === void 0 ? void 0 : _a2.optimizeNodes(); + (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNodes(); + return this; + } + optimizeNames(names, constants) { + var _a2, _b; + super.optimizeNames(names, constants); + (_a2 = this.catch) === null || _a2 === void 0 ? void 0 : _a2.optimizeNames(names, constants); + (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants); + return this; + } + get names() { + const names = super.names; + if (this.catch) + addNames(names, this.catch.names); + if (this.finally) + addNames(names, this.finally.names); + return names; + } + }; + var Catch = class extends BlockNode { + constructor(error2) { + super(); + this.error = error2; + } + render(opts) { + return `catch(${this.error})` + super.render(opts); + } + }; + Catch.kind = "catch"; + var Finally = class extends BlockNode { + render(opts) { + return "finally" + super.render(opts); + } + }; + Finally.kind = "finally"; + var CodeGen = class { + constructor(extScope, opts = {}) { + this._values = {}; + this._blockStarts = []; + this._constants = {}; + this.opts = { ...opts, _n: opts.lines ? "\n" : "" }; + this._extScope = extScope; + this._scope = new scope_1.Scope({ parent: extScope }); + this._nodes = [new Root()]; + } + toString() { + return this._root.render(this.opts); + } + // returns unique name in the internal scope + name(prefix) { + return this._scope.name(prefix); + } + // reserves unique name in the external scope + scopeName(prefix) { + return this._extScope.name(prefix); + } + // reserves unique name in the external scope and assigns value to it + scopeValue(prefixOrName, value) { + const name = this._extScope.value(prefixOrName, value); + const vs = this._values[name.prefix] || (this._values[name.prefix] = /* @__PURE__ */ new Set()); + vs.add(name); + return name; + } + getScopeValue(prefix, keyOrRef) { + return this._extScope.getValue(prefix, keyOrRef); + } + // return code that assigns values in the external scope to the names that are used internally + // (same names that were returned by gen.scopeName or gen.scopeValue) + scopeRefs(scopeName) { + return this._extScope.scopeRefs(scopeName, this._values); + } + scopeCode() { + return this._extScope.scopeCode(this._values); + } + _def(varKind, nameOrPrefix, rhs, constant) { + const name = this._scope.toName(nameOrPrefix); + if (rhs !== void 0 && constant) + this._constants[name.str] = rhs; + this._leafNode(new Def(varKind, name, rhs)); + return name; + } + // `const` declaration (`var` in es5 mode) + const(nameOrPrefix, rhs, _constant) { + return this._def(scope_1.varKinds.const, nameOrPrefix, rhs, _constant); + } + // `let` declaration with optional assignment (`var` in es5 mode) + let(nameOrPrefix, rhs, _constant) { + return this._def(scope_1.varKinds.let, nameOrPrefix, rhs, _constant); + } + // `var` declaration with optional assignment + var(nameOrPrefix, rhs, _constant) { + return this._def(scope_1.varKinds.var, nameOrPrefix, rhs, _constant); + } + // assignment code + assign(lhs, rhs, sideEffects) { + return this._leafNode(new Assign(lhs, rhs, sideEffects)); + } + // `+=` code + add(lhs, rhs) { + return this._leafNode(new AssignOp(lhs, exports2.operators.ADD, rhs)); + } + // appends passed SafeExpr to code or executes Block + code(c) { + if (typeof c == "function") + c(); + else if (c !== code_1.nil) + this._leafNode(new AnyCode(c)); + return this; + } + // returns code for object literal for the passed argument list of key-value pairs + object(...keyValues) { + const code = ["{"]; + for (const [key, value] of keyValues) { + if (code.length > 1) + code.push(","); + code.push(key); + if (key !== value || this.opts.es5) { + code.push(":"); + (0, code_1.addCodeArg)(code, value); + } + } + code.push("}"); + return new code_1._Code(code); + } + // `if` clause (or statement if `thenBody` and, optionally, `elseBody` are passed) + if(condition, thenBody, elseBody) { + this._blockNode(new If(condition)); + if (thenBody && elseBody) { + this.code(thenBody).else().code(elseBody).endIf(); + } else if (thenBody) { + this.code(thenBody).endIf(); + } else if (elseBody) { + throw new Error('CodeGen: "else" body without "then" body'); + } + return this; + } + // `else if` clause - invalid without `if` or after `else` clauses + elseIf(condition) { + return this._elseNode(new If(condition)); + } + // `else` clause - only valid after `if` or `else if` clauses + else() { + return this._elseNode(new Else()); + } + // end `if` statement (needed if gen.if was used only with condition) + endIf() { + return this._endBlockNode(If, Else); + } + _for(node, forBody) { + this._blockNode(node); + if (forBody) + this.code(forBody).endFor(); + return this; + } + // a generic `for` clause (or statement if `forBody` is passed) + for(iteration, forBody) { + return this._for(new ForLoop(iteration), forBody); + } + // `for` statement for a range of values + forRange(nameOrPrefix, from, to, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.let) { + const name = this._scope.toName(nameOrPrefix); + return this._for(new ForRange(varKind, name, from, to), () => forBody(name)); + } + // `for-of` statement (in es5 mode replace with a normal for loop) + forOf(nameOrPrefix, iterable, forBody, varKind = scope_1.varKinds.const) { + const name = this._scope.toName(nameOrPrefix); + if (this.opts.es5) { + const arr = iterable instanceof code_1.Name ? iterable : this.var("_arr", iterable); + return this.forRange("_i", 0, (0, code_1._)`${arr}.length`, (i) => { + this.var(name, (0, code_1._)`${arr}[${i}]`); + forBody(name); + }); + } + return this._for(new ForIter("of", varKind, name, iterable), () => forBody(name)); + } + // `for-in` statement. + // With option `ownProperties` replaced with a `for-of` loop for object keys + forIn(nameOrPrefix, obj, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.const) { + if (this.opts.ownProperties) { + return this.forOf(nameOrPrefix, (0, code_1._)`Object.keys(${obj})`, forBody); + } + const name = this._scope.toName(nameOrPrefix); + return this._for(new ForIter("in", varKind, name, obj), () => forBody(name)); + } + // end `for` loop + endFor() { + return this._endBlockNode(For); + } + // `label` statement + label(label) { + return this._leafNode(new Label(label)); + } + // `break` statement + break(label) { + return this._leafNode(new Break(label)); + } + // `return` statement + return(value) { + const node = new Return(); + this._blockNode(node); + this.code(value); + if (node.nodes.length !== 1) + throw new Error('CodeGen: "return" should have one node'); + return this._endBlockNode(Return); + } + // `try` statement + try(tryBody, catchCode, finallyCode) { + if (!catchCode && !finallyCode) + throw new Error('CodeGen: "try" without "catch" and "finally"'); + const node = new Try(); + this._blockNode(node); + this.code(tryBody); + if (catchCode) { + const error2 = this.name("e"); + this._currNode = node.catch = new Catch(error2); + catchCode(error2); + } + if (finallyCode) { + this._currNode = node.finally = new Finally(); + this.code(finallyCode); + } + return this._endBlockNode(Catch, Finally); + } + // `throw` statement + throw(error2) { + return this._leafNode(new Throw(error2)); + } + // start self-balancing block + block(body, nodeCount) { + this._blockStarts.push(this._nodes.length); + if (body) + this.code(body).endBlock(nodeCount); + return this; + } + // end the current self-balancing block + endBlock(nodeCount) { + const len = this._blockStarts.pop(); + if (len === void 0) + throw new Error("CodeGen: not in self-balancing block"); + const toClose = this._nodes.length - len; + if (toClose < 0 || nodeCount !== void 0 && toClose !== nodeCount) { + throw new Error(`CodeGen: wrong number of nodes: ${toClose} vs ${nodeCount} expected`); + } + this._nodes.length = len; + return this; + } + // `function` heading (or definition if funcBody is passed) + func(name, args = code_1.nil, async, funcBody) { + this._blockNode(new Func(name, args, async)); + if (funcBody) + this.code(funcBody).endFunc(); + return this; + } + // end function definition + endFunc() { + return this._endBlockNode(Func); + } + optimize(n = 1) { + while (n-- > 0) { + this._root.optimizeNodes(); + this._root.optimizeNames(this._root.names, this._constants); + } + } + _leafNode(node) { + this._currNode.nodes.push(node); + return this; + } + _blockNode(node) { + this._currNode.nodes.push(node); + this._nodes.push(node); + } + _endBlockNode(N1, N2) { + const n = this._currNode; + if (n instanceof N1 || N2 && n instanceof N2) { + this._nodes.pop(); + return this; + } + throw new Error(`CodeGen: not in block "${N2 ? `${N1.kind}/${N2.kind}` : N1.kind}"`); + } + _elseNode(node) { + const n = this._currNode; + if (!(n instanceof If)) { + throw new Error('CodeGen: "else" without "if"'); + } + this._currNode = n.else = node; + return this; + } + get _root() { + return this._nodes[0]; + } + get _currNode() { + const ns = this._nodes; + return ns[ns.length - 1]; + } + set _currNode(node) { + const ns = this._nodes; + ns[ns.length - 1] = node; + } + }; + exports2.CodeGen = CodeGen; + function addNames(names, from) { + for (const n in from) + names[n] = (names[n] || 0) + (from[n] || 0); + return names; + } + function addExprNames(names, from) { + return from instanceof code_1._CodeOrName ? addNames(names, from.names) : names; + } + function optimizeExpr(expr, names, constants) { + if (expr instanceof code_1.Name) + return replaceName(expr); + if (!canOptimize(expr)) + return expr; + return new code_1._Code(expr._items.reduce((items, c) => { + if (c instanceof code_1.Name) + c = replaceName(c); + if (c instanceof code_1._Code) + items.push(...c._items); + else + items.push(c); + return items; + }, [])); + function replaceName(n) { + const c = constants[n.str]; + if (c === void 0 || names[n.str] !== 1) + return n; + delete names[n.str]; + return c; + } + function canOptimize(e) { + return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants[c.str] !== void 0); + } + } + function subtractNames(names, from) { + for (const n in from) + names[n] = (names[n] || 0) - (from[n] || 0); + } + function not(x) { + return typeof x == "boolean" || typeof x == "number" || x === null ? !x : (0, code_1._)`!${par(x)}`; + } + exports2.not = not; + var andCode = mappend(exports2.operators.AND); + function and(...args) { + return args.reduce(andCode); + } + exports2.and = and; + var orCode = mappend(exports2.operators.OR); + function or(...args) { + return args.reduce(orCode); + } + exports2.or = or; + function mappend(op) { + return (x, y) => x === code_1.nil ? y : y === code_1.nil ? x : (0, code_1._)`${par(x)} ${op} ${par(y)}`; + } + function par(x) { + return x instanceof code_1.Name ? x : (0, code_1._)`(${x})`; + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/util.js +var require_util = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/util.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.checkStrictMode = exports2.getErrorPath = exports2.Type = exports2.useFunc = exports2.setEvaluated = exports2.evaluatedPropsToName = exports2.mergeEvaluated = exports2.eachItem = exports2.unescapeJsonPointer = exports2.escapeJsonPointer = exports2.escapeFragment = exports2.unescapeFragment = exports2.schemaRefOrVal = exports2.schemaHasRulesButRef = exports2.schemaHasRules = exports2.checkUnknownRules = exports2.alwaysValidSchema = exports2.toHash = void 0; + var codegen_1 = require_codegen(); + var code_1 = require_code(); + function toHash(arr) { + const hash2 = {}; + for (const item of arr) + hash2[item] = true; + return hash2; + } + exports2.toHash = toHash; + function alwaysValidSchema(it, schema) { + if (typeof schema == "boolean") + return schema; + if (Object.keys(schema).length === 0) + return true; + checkUnknownRules(it, schema); + return !schemaHasRules(schema, it.self.RULES.all); + } + exports2.alwaysValidSchema = alwaysValidSchema; + function checkUnknownRules(it, schema = it.schema) { + const { opts, self } = it; + if (!opts.strictSchema) + return; + if (typeof schema === "boolean") + return; + const rules = self.RULES.keywords; + for (const key in schema) { + if (!rules[key]) + checkStrictMode(it, `unknown keyword: "${key}"`); + } + } + exports2.checkUnknownRules = checkUnknownRules; + function schemaHasRules(schema, rules) { + if (typeof schema == "boolean") + return !schema; + for (const key in schema) + if (rules[key]) + return true; + return false; + } + exports2.schemaHasRules = schemaHasRules; + function schemaHasRulesButRef(schema, RULES) { + if (typeof schema == "boolean") + return !schema; + for (const key in schema) + if (key !== "$ref" && RULES.all[key]) + return true; + return false; + } + exports2.schemaHasRulesButRef = schemaHasRulesButRef; + function schemaRefOrVal({ topSchemaRef, schemaPath }, schema, keyword, $data) { + if (!$data) { + if (typeof schema == "number" || typeof schema == "boolean") + return schema; + if (typeof schema == "string") + return (0, codegen_1._)`${schema}`; + } + return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`; + } + exports2.schemaRefOrVal = schemaRefOrVal; + function unescapeFragment(str2) { + return unescapeJsonPointer(decodeURIComponent(str2)); + } + exports2.unescapeFragment = unescapeFragment; + function escapeFragment(str2) { + return encodeURIComponent(escapeJsonPointer(str2)); + } + exports2.escapeFragment = escapeFragment; + function escapeJsonPointer(str2) { + if (typeof str2 == "number") + return `${str2}`; + return str2.replace(/~/g, "~0").replace(/\//g, "~1"); + } + exports2.escapeJsonPointer = escapeJsonPointer; + function unescapeJsonPointer(str2) { + return str2.replace(/~1/g, "/").replace(/~0/g, "~"); + } + exports2.unescapeJsonPointer = unescapeJsonPointer; + function eachItem(xs, f) { + if (Array.isArray(xs)) { + for (const x of xs) + f(x); + } else { + f(xs); + } + } + exports2.eachItem = eachItem; + function makeMergeEvaluated({ mergeNames, mergeToName, mergeValues: mergeValues3, resultToName }) { + return (gen, from, to, toName) => { + const res = to === void 0 ? from : to instanceof codegen_1.Name ? (from instanceof codegen_1.Name ? mergeNames(gen, from, to) : mergeToName(gen, from, to), to) : from instanceof codegen_1.Name ? (mergeToName(gen, to, from), from) : mergeValues3(from, to); + return toName === codegen_1.Name && !(res instanceof codegen_1.Name) ? resultToName(gen, res) : res; + }; + } + exports2.mergeEvaluated = { + props: makeMergeEvaluated({ + mergeNames: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true && ${from} !== undefined`, () => { + gen.if((0, codegen_1._)`${from} === true`, () => gen.assign(to, true), () => gen.assign(to, (0, codegen_1._)`${to} || {}`).code((0, codegen_1._)`Object.assign(${to}, ${from})`)); + }), + mergeToName: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true`, () => { + if (from === true) { + gen.assign(to, true); + } else { + gen.assign(to, (0, codegen_1._)`${to} || {}`); + setEvaluated(gen, to, from); + } + }), + mergeValues: (from, to) => from === true ? true : { ...from, ...to }, + resultToName: evaluatedPropsToName + }), + items: makeMergeEvaluated({ + mergeNames: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true && ${from} !== undefined`, () => gen.assign(to, (0, codegen_1._)`${from} === true ? true : ${to} > ${from} ? ${to} : ${from}`)), + mergeToName: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true`, () => gen.assign(to, from === true ? true : (0, codegen_1._)`${to} > ${from} ? ${to} : ${from}`)), + mergeValues: (from, to) => from === true ? true : Math.max(from, to), + resultToName: (gen, items) => gen.var("items", items) + }) + }; + function evaluatedPropsToName(gen, ps) { + if (ps === true) + return gen.var("props", true); + const props = gen.var("props", (0, codegen_1._)`{}`); + if (ps !== void 0) + setEvaluated(gen, props, ps); + return props; + } + exports2.evaluatedPropsToName = evaluatedPropsToName; + function setEvaluated(gen, props, ps) { + Object.keys(ps).forEach((p) => gen.assign((0, codegen_1._)`${props}${(0, codegen_1.getProperty)(p)}`, true)); + } + exports2.setEvaluated = setEvaluated; + var snippets = {}; + function useFunc(gen, f) { + return gen.scopeValue("func", { + ref: f, + code: snippets[f.code] || (snippets[f.code] = new code_1._Code(f.code)) + }); + } + exports2.useFunc = useFunc; + var Type; + (function(Type2) { + Type2[Type2["Num"] = 0] = "Num"; + Type2[Type2["Str"] = 1] = "Str"; + })(Type || (exports2.Type = Type = {})); + function getErrorPath(dataProp, dataPropType, jsPropertySyntax) { + if (dataProp instanceof codegen_1.Name) { + const isNumber = dataPropType === Type.Num; + return jsPropertySyntax ? isNumber ? (0, codegen_1._)`"[" + ${dataProp} + "]"` : (0, codegen_1._)`"['" + ${dataProp} + "']"` : isNumber ? (0, codegen_1._)`"/" + ${dataProp}` : (0, codegen_1._)`"/" + ${dataProp}.replace(/~/g, "~0").replace(/\\//g, "~1")`; + } + return jsPropertySyntax ? (0, codegen_1.getProperty)(dataProp).toString() : "/" + escapeJsonPointer(dataProp); + } + exports2.getErrorPath = getErrorPath; + function checkStrictMode(it, msg, mode = it.opts.strictSchema) { + if (!mode) + return; + msg = `strict mode: ${msg}`; + if (mode === true) + throw new Error(msg); + it.self.logger.warn(msg); + } + exports2.checkStrictMode = checkStrictMode; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/names.js +var require_names = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/names.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var names = { + // validation function arguments + data: new codegen_1.Name("data"), + // data passed to validation function + // args passed from referencing schema + valCxt: new codegen_1.Name("valCxt"), + // validation/data context - should not be used directly, it is destructured to the names below + instancePath: new codegen_1.Name("instancePath"), + parentData: new codegen_1.Name("parentData"), + parentDataProperty: new codegen_1.Name("parentDataProperty"), + rootData: new codegen_1.Name("rootData"), + // root data - same as the data passed to the first/top validation function + dynamicAnchors: new codegen_1.Name("dynamicAnchors"), + // used to support recursiveRef and dynamicRef + // function scoped variables + vErrors: new codegen_1.Name("vErrors"), + // null or array of validation errors + errors: new codegen_1.Name("errors"), + // counter of validation errors + this: new codegen_1.Name("this"), + // "globals" + self: new codegen_1.Name("self"), + scope: new codegen_1.Name("scope"), + // JTD serialize/parse name for JSON string and position + json: new codegen_1.Name("json"), + jsonPos: new codegen_1.Name("jsonPos"), + jsonLen: new codegen_1.Name("jsonLen"), + jsonPart: new codegen_1.Name("jsonPart") + }; + exports2.default = names; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/errors.js +var require_errors = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/errors.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.extendErrors = exports2.resetErrorsCount = exports2.reportExtraError = exports2.reportError = exports2.keyword$DataError = exports2.keywordError = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var names_1 = require_names(); + exports2.keywordError = { + message: ({ keyword }) => (0, codegen_1.str)`must pass "${keyword}" keyword validation` + }; + exports2.keyword$DataError = { + message: ({ keyword, schemaType }) => schemaType ? (0, codegen_1.str)`"${keyword}" keyword must be ${schemaType} ($data)` : (0, codegen_1.str)`"${keyword}" keyword is invalid ($data)` + }; + function reportError(cxt, error2 = exports2.keywordError, errorPaths, overrideAllErrors) { + const { it } = cxt; + const { gen, compositeRule, allErrors } = it; + const errObj = errorObjectCode(cxt, error2, errorPaths); + if (overrideAllErrors !== null && overrideAllErrors !== void 0 ? overrideAllErrors : compositeRule || allErrors) { + addError(gen, errObj); + } else { + returnErrors(it, (0, codegen_1._)`[${errObj}]`); + } + } + exports2.reportError = reportError; + function reportExtraError(cxt, error2 = exports2.keywordError, errorPaths) { + const { it } = cxt; + const { gen, compositeRule, allErrors } = it; + const errObj = errorObjectCode(cxt, error2, errorPaths); + addError(gen, errObj); + if (!(compositeRule || allErrors)) { + returnErrors(it, names_1.default.vErrors); + } + } + exports2.reportExtraError = reportExtraError; + function resetErrorsCount(gen, errsCount) { + gen.assign(names_1.default.errors, errsCount); + gen.if((0, codegen_1._)`${names_1.default.vErrors} !== null`, () => gen.if(errsCount, () => gen.assign((0, codegen_1._)`${names_1.default.vErrors}.length`, errsCount), () => gen.assign(names_1.default.vErrors, null))); + } + exports2.resetErrorsCount = resetErrorsCount; + function extendErrors({ gen, keyword, schemaValue, data, errsCount, it }) { + if (errsCount === void 0) + throw new Error("ajv implementation error"); + const err = gen.name("err"); + gen.forRange("i", errsCount, names_1.default.errors, (i) => { + gen.const(err, (0, codegen_1._)`${names_1.default.vErrors}[${i}]`); + gen.if((0, codegen_1._)`${err}.instancePath === undefined`, () => gen.assign((0, codegen_1._)`${err}.instancePath`, (0, codegen_1.strConcat)(names_1.default.instancePath, it.errorPath))); + gen.assign((0, codegen_1._)`${err}.schemaPath`, (0, codegen_1.str)`${it.errSchemaPath}/${keyword}`); + if (it.opts.verbose) { + gen.assign((0, codegen_1._)`${err}.schema`, schemaValue); + gen.assign((0, codegen_1._)`${err}.data`, data); + } + }); + } + exports2.extendErrors = extendErrors; + function addError(gen, errObj) { + const err = gen.const("err", errObj); + gen.if((0, codegen_1._)`${names_1.default.vErrors} === null`, () => gen.assign(names_1.default.vErrors, (0, codegen_1._)`[${err}]`), (0, codegen_1._)`${names_1.default.vErrors}.push(${err})`); + gen.code((0, codegen_1._)`${names_1.default.errors}++`); + } + function returnErrors(it, errs) { + const { gen, validateName, schemaEnv } = it; + if (schemaEnv.$async) { + gen.throw((0, codegen_1._)`new ${it.ValidationError}(${errs})`); + } else { + gen.assign((0, codegen_1._)`${validateName}.errors`, errs); + gen.return(false); + } + } + var E = { + keyword: new codegen_1.Name("keyword"), + schemaPath: new codegen_1.Name("schemaPath"), + // also used in JTD errors + params: new codegen_1.Name("params"), + propertyName: new codegen_1.Name("propertyName"), + message: new codegen_1.Name("message"), + schema: new codegen_1.Name("schema"), + parentSchema: new codegen_1.Name("parentSchema") + }; + function errorObjectCode(cxt, error2, errorPaths) { + const { createErrors } = cxt.it; + if (createErrors === false) + return (0, codegen_1._)`{}`; + return errorObject(cxt, error2, errorPaths); + } + function errorObject(cxt, error2, errorPaths = {}) { + const { gen, it } = cxt; + const keyValues = [ + errorInstancePath(it, errorPaths), + errorSchemaPath(cxt, errorPaths) + ]; + extraErrorProps(cxt, error2, keyValues); + return gen.object(...keyValues); + } + function errorInstancePath({ errorPath }, { instancePath }) { + const instPath = instancePath ? (0, codegen_1.str)`${errorPath}${(0, util_1.getErrorPath)(instancePath, util_1.Type.Str)}` : errorPath; + return [names_1.default.instancePath, (0, codegen_1.strConcat)(names_1.default.instancePath, instPath)]; + } + function errorSchemaPath({ keyword, it: { errSchemaPath } }, { schemaPath, parentSchema }) { + let schPath = parentSchema ? errSchemaPath : (0, codegen_1.str)`${errSchemaPath}/${keyword}`; + if (schemaPath) { + schPath = (0, codegen_1.str)`${schPath}${(0, util_1.getErrorPath)(schemaPath, util_1.Type.Str)}`; + } + return [E.schemaPath, schPath]; + } + function extraErrorProps(cxt, { params, message }, keyValues) { + const { keyword, data, schemaValue, it } = cxt; + const { opts, propertyName, topSchemaRef, schemaPath } = it; + keyValues.push([E.keyword, keyword], [E.params, typeof params == "function" ? params(cxt) : params || (0, codegen_1._)`{}`]); + if (opts.messages) { + keyValues.push([E.message, typeof message == "function" ? message(cxt) : message]); + } + if (opts.verbose) { + keyValues.push([E.schema, schemaValue], [E.parentSchema, (0, codegen_1._)`${topSchemaRef}${schemaPath}`], [names_1.default.data, data]); + } + if (propertyName) + keyValues.push([E.propertyName, propertyName]); + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/boolSchema.js +var require_boolSchema = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/boolSchema.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.boolOrEmptySchema = exports2.topBoolOrEmptySchema = void 0; + var errors_1 = require_errors(); + var codegen_1 = require_codegen(); + var names_1 = require_names(); + var boolError = { + message: "boolean schema is false" + }; + function topBoolOrEmptySchema(it) { + const { gen, schema, validateName } = it; + if (schema === false) { + falseSchemaError(it, false); + } else if (typeof schema == "object" && schema.$async === true) { + gen.return(names_1.default.data); + } else { + gen.assign((0, codegen_1._)`${validateName}.errors`, null); + gen.return(true); + } + } + exports2.topBoolOrEmptySchema = topBoolOrEmptySchema; + function boolOrEmptySchema(it, valid) { + const { gen, schema } = it; + if (schema === false) { + gen.var(valid, false); + falseSchemaError(it); + } else { + gen.var(valid, true); + } + } + exports2.boolOrEmptySchema = boolOrEmptySchema; + function falseSchemaError(it, overrideAllErrors) { + const { gen, data } = it; + const cxt = { + gen, + keyword: "false schema", + data, + schema: false, + schemaCode: false, + schemaValue: false, + params: {}, + it + }; + (0, errors_1.reportError)(cxt, boolError, void 0, overrideAllErrors); + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/rules.js +var require_rules = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/rules.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.getRules = exports2.isJSONType = void 0; + var _jsonTypes = ["string", "number", "integer", "boolean", "null", "object", "array"]; + var jsonTypes = new Set(_jsonTypes); + function isJSONType(x) { + return typeof x == "string" && jsonTypes.has(x); + } + exports2.isJSONType = isJSONType; + function getRules() { + const groups = { + number: { type: "number", rules: [] }, + string: { type: "string", rules: [] }, + array: { type: "array", rules: [] }, + object: { type: "object", rules: [] } + }; + return { + types: { ...groups, integer: true, boolean: true, null: true }, + rules: [{ rules: [] }, groups.number, groups.string, groups.array, groups.object], + post: { rules: [] }, + all: {}, + keywords: {} + }; + } + exports2.getRules = getRules; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/applicability.js +var require_applicability = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/applicability.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.shouldUseRule = exports2.shouldUseGroup = exports2.schemaHasRulesForType = void 0; + function schemaHasRulesForType({ schema, self }, type) { + const group = self.RULES.types[type]; + return group && group !== true && shouldUseGroup(schema, group); + } + exports2.schemaHasRulesForType = schemaHasRulesForType; + function shouldUseGroup(schema, group) { + return group.rules.some((rule) => shouldUseRule(schema, rule)); + } + exports2.shouldUseGroup = shouldUseGroup; + function shouldUseRule(schema, rule) { + var _a2; + return schema[rule.keyword] !== void 0 || ((_a2 = rule.definition.implements) === null || _a2 === void 0 ? void 0 : _a2.some((kwd) => schema[kwd] !== void 0)); + } + exports2.shouldUseRule = shouldUseRule; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/dataType.js +var require_dataType = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/dataType.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.reportTypeError = exports2.checkDataTypes = exports2.checkDataType = exports2.coerceAndCheckDataType = exports2.getJSONTypes = exports2.getSchemaTypes = exports2.DataType = void 0; + var rules_1 = require_rules(); + var applicability_1 = require_applicability(); + var errors_1 = require_errors(); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var DataType; + (function(DataType2) { + DataType2[DataType2["Correct"] = 0] = "Correct"; + DataType2[DataType2["Wrong"] = 1] = "Wrong"; + })(DataType || (exports2.DataType = DataType = {})); + function getSchemaTypes(schema) { + const types = getJSONTypes(schema.type); + const hasNull = types.includes("null"); + if (hasNull) { + if (schema.nullable === false) + throw new Error("type: null contradicts nullable: false"); + } else { + if (!types.length && schema.nullable !== void 0) { + throw new Error('"nullable" cannot be used without "type"'); + } + if (schema.nullable === true) + types.push("null"); + } + return types; + } + exports2.getSchemaTypes = getSchemaTypes; + function getJSONTypes(ts) { + const types = Array.isArray(ts) ? ts : ts ? [ts] : []; + if (types.every(rules_1.isJSONType)) + return types; + throw new Error("type must be JSONType or JSONType[]: " + types.join(",")); + } + exports2.getJSONTypes = getJSONTypes; + function coerceAndCheckDataType(it, types) { + const { gen, data, opts } = it; + const coerceTo = coerceToTypes(types, opts.coerceTypes); + const checkTypes = types.length > 0 && !(coerceTo.length === 0 && types.length === 1 && (0, applicability_1.schemaHasRulesForType)(it, types[0])); + if (checkTypes) { + const wrongType = checkDataTypes(types, data, opts.strictNumbers, DataType.Wrong); + gen.if(wrongType, () => { + if (coerceTo.length) + coerceData(it, types, coerceTo); + else + reportTypeError(it); + }); + } + return checkTypes; + } + exports2.coerceAndCheckDataType = coerceAndCheckDataType; + var COERCIBLE = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean", "null"]); + function coerceToTypes(types, coerceTypes) { + return coerceTypes ? types.filter((t) => COERCIBLE.has(t) || coerceTypes === "array" && t === "array") : []; + } + function coerceData(it, types, coerceTo) { + const { gen, data, opts } = it; + const dataType = gen.let("dataType", (0, codegen_1._)`typeof ${data}`); + const coerced = gen.let("coerced", (0, codegen_1._)`undefined`); + if (opts.coerceTypes === "array") { + gen.if((0, codegen_1._)`${dataType} == 'object' && Array.isArray(${data}) && ${data}.length == 1`, () => gen.assign(data, (0, codegen_1._)`${data}[0]`).assign(dataType, (0, codegen_1._)`typeof ${data}`).if(checkDataTypes(types, data, opts.strictNumbers), () => gen.assign(coerced, data))); + } + gen.if((0, codegen_1._)`${coerced} !== undefined`); + for (const t of coerceTo) { + if (COERCIBLE.has(t) || t === "array" && opts.coerceTypes === "array") { + coerceSpecificType(t); + } + } + gen.else(); + reportTypeError(it); + gen.endIf(); + gen.if((0, codegen_1._)`${coerced} !== undefined`, () => { + gen.assign(data, coerced); + assignParentData(it, coerced); + }); + function coerceSpecificType(t) { + switch (t) { + case "string": + gen.elseIf((0, codegen_1._)`${dataType} == "number" || ${dataType} == "boolean"`).assign(coerced, (0, codegen_1._)`"" + ${data}`).elseIf((0, codegen_1._)`${data} === null`).assign(coerced, (0, codegen_1._)`""`); + return; + case "number": + gen.elseIf((0, codegen_1._)`${dataType} == "boolean" || ${data} === null + || (${dataType} == "string" && ${data} && ${data} == +${data})`).assign(coerced, (0, codegen_1._)`+${data}`); + return; + case "integer": + gen.elseIf((0, codegen_1._)`${dataType} === "boolean" || ${data} === null + || (${dataType} === "string" && ${data} && ${data} == +${data} && !(${data} % 1))`).assign(coerced, (0, codegen_1._)`+${data}`); + return; + case "boolean": + gen.elseIf((0, codegen_1._)`${data} === "false" || ${data} === 0 || ${data} === null`).assign(coerced, false).elseIf((0, codegen_1._)`${data} === "true" || ${data} === 1`).assign(coerced, true); + return; + case "null": + gen.elseIf((0, codegen_1._)`${data} === "" || ${data} === 0 || ${data} === false`); + gen.assign(coerced, null); + return; + case "array": + gen.elseIf((0, codegen_1._)`${dataType} === "string" || ${dataType} === "number" + || ${dataType} === "boolean" || ${data} === null`).assign(coerced, (0, codegen_1._)`[${data}]`); + } + } + } + function assignParentData({ gen, parentData, parentDataProperty }, expr) { + gen.if((0, codegen_1._)`${parentData} !== undefined`, () => gen.assign((0, codegen_1._)`${parentData}[${parentDataProperty}]`, expr)); + } + function checkDataType(dataType, data, strictNums, correct = DataType.Correct) { + const EQ = correct === DataType.Correct ? codegen_1.operators.EQ : codegen_1.operators.NEQ; + let cond; + switch (dataType) { + case "null": + return (0, codegen_1._)`${data} ${EQ} null`; + case "array": + cond = (0, codegen_1._)`Array.isArray(${data})`; + break; + case "object": + cond = (0, codegen_1._)`${data} && typeof ${data} == "object" && !Array.isArray(${data})`; + break; + case "integer": + cond = numCond((0, codegen_1._)`!(${data} % 1) && !isNaN(${data})`); + break; + case "number": + cond = numCond(); + break; + default: + return (0, codegen_1._)`typeof ${data} ${EQ} ${dataType}`; + } + return correct === DataType.Correct ? cond : (0, codegen_1.not)(cond); + function numCond(_cond = codegen_1.nil) { + return (0, codegen_1.and)((0, codegen_1._)`typeof ${data} == "number"`, _cond, strictNums ? (0, codegen_1._)`isFinite(${data})` : codegen_1.nil); + } + } + exports2.checkDataType = checkDataType; + function checkDataTypes(dataTypes, data, strictNums, correct) { + if (dataTypes.length === 1) { + return checkDataType(dataTypes[0], data, strictNums, correct); + } + let cond; + const types = (0, util_1.toHash)(dataTypes); + if (types.array && types.object) { + const notObj = (0, codegen_1._)`typeof ${data} != "object"`; + cond = types.null ? notObj : (0, codegen_1._)`!${data} || ${notObj}`; + delete types.null; + delete types.array; + delete types.object; + } else { + cond = codegen_1.nil; + } + if (types.number) + delete types.integer; + for (const t in types) + cond = (0, codegen_1.and)(cond, checkDataType(t, data, strictNums, correct)); + return cond; + } + exports2.checkDataTypes = checkDataTypes; + var typeError = { + message: ({ schema }) => `must be ${schema}`, + params: ({ schema, schemaValue }) => typeof schema == "string" ? (0, codegen_1._)`{type: ${schema}}` : (0, codegen_1._)`{type: ${schemaValue}}` + }; + function reportTypeError(it) { + const cxt = getTypeErrorContext(it); + (0, errors_1.reportError)(cxt, typeError); + } + exports2.reportTypeError = reportTypeError; + function getTypeErrorContext(it) { + const { gen, data, schema } = it; + const schemaCode = (0, util_1.schemaRefOrVal)(it, schema, "type"); + return { + gen, + keyword: "type", + data, + schema: schema.type, + schemaCode, + schemaValue: schemaCode, + parentSchema: schema, + params: {}, + it + }; + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/defaults.js +var require_defaults = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/defaults.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.assignDefaults = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + function assignDefaults(it, ty) { + const { properties, items } = it.schema; + if (ty === "object" && properties) { + for (const key in properties) { + assignDefault(it, key, properties[key].default); + } + } else if (ty === "array" && Array.isArray(items)) { + items.forEach((sch, i) => assignDefault(it, i, sch.default)); + } + } + exports2.assignDefaults = assignDefaults; + function assignDefault(it, prop, defaultValue) { + const { gen, compositeRule, data, opts } = it; + if (defaultValue === void 0) + return; + const childData = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(prop)}`; + if (compositeRule) { + (0, util_1.checkStrictMode)(it, `default is ignored for: ${childData}`); + return; + } + let condition = (0, codegen_1._)`${childData} === undefined`; + if (opts.useDefaults === "empty") { + condition = (0, codegen_1._)`${condition} || ${childData} === null || ${childData} === ""`; + } + gen.if(condition, (0, codegen_1._)`${childData} = ${(0, codegen_1.stringify)(defaultValue)}`); + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/code.js +var require_code2 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/code.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateUnion = exports2.validateArray = exports2.usePattern = exports2.callValidateCode = exports2.schemaProperties = exports2.allSchemaProperties = exports2.noPropertyInData = exports2.propertyInData = exports2.isOwnProperty = exports2.hasPropFunc = exports2.reportMissingProp = exports2.checkMissingProp = exports2.checkReportMissingProp = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var names_1 = require_names(); + var util_2 = require_util(); + function checkReportMissingProp(cxt, prop) { + const { gen, data, it } = cxt; + gen.if(noPropertyInData(gen, data, prop, it.opts.ownProperties), () => { + cxt.setParams({ missingProperty: (0, codegen_1._)`${prop}` }, true); + cxt.error(); + }); + } + exports2.checkReportMissingProp = checkReportMissingProp; + function checkMissingProp({ gen, data, it: { opts } }, properties, missing) { + return (0, codegen_1.or)(...properties.map((prop) => (0, codegen_1.and)(noPropertyInData(gen, data, prop, opts.ownProperties), (0, codegen_1._)`${missing} = ${prop}`))); + } + exports2.checkMissingProp = checkMissingProp; + function reportMissingProp(cxt, missing) { + cxt.setParams({ missingProperty: missing }, true); + cxt.error(); + } + exports2.reportMissingProp = reportMissingProp; + function hasPropFunc(gen) { + return gen.scopeValue("func", { + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: Object.prototype.hasOwnProperty, + code: (0, codegen_1._)`Object.prototype.hasOwnProperty` + }); + } + exports2.hasPropFunc = hasPropFunc; + function isOwnProperty(gen, data, property) { + return (0, codegen_1._)`${hasPropFunc(gen)}.call(${data}, ${property})`; + } + exports2.isOwnProperty = isOwnProperty; + function propertyInData(gen, data, property, ownProperties) { + const cond = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(property)} !== undefined`; + return ownProperties ? (0, codegen_1._)`${cond} && ${isOwnProperty(gen, data, property)}` : cond; + } + exports2.propertyInData = propertyInData; + function noPropertyInData(gen, data, property, ownProperties) { + const cond = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(property)} === undefined`; + return ownProperties ? (0, codegen_1.or)(cond, (0, codegen_1.not)(isOwnProperty(gen, data, property))) : cond; + } + exports2.noPropertyInData = noPropertyInData; + function allSchemaProperties(schemaMap) { + return schemaMap ? Object.keys(schemaMap).filter((p) => p !== "__proto__") : []; + } + exports2.allSchemaProperties = allSchemaProperties; + function schemaProperties(it, schemaMap) { + return allSchemaProperties(schemaMap).filter((p) => !(0, util_1.alwaysValidSchema)(it, schemaMap[p])); + } + exports2.schemaProperties = schemaProperties; + function callValidateCode({ schemaCode, data, it: { gen, topSchemaRef, schemaPath, errorPath }, it }, func, context, passSchema) { + const dataAndSchema = passSchema ? (0, codegen_1._)`${schemaCode}, ${data}, ${topSchemaRef}${schemaPath}` : data; + const valCxt = [ + [names_1.default.instancePath, (0, codegen_1.strConcat)(names_1.default.instancePath, errorPath)], + [names_1.default.parentData, it.parentData], + [names_1.default.parentDataProperty, it.parentDataProperty], + [names_1.default.rootData, names_1.default.rootData] + ]; + if (it.opts.dynamicRef) + valCxt.push([names_1.default.dynamicAnchors, names_1.default.dynamicAnchors]); + const args = (0, codegen_1._)`${dataAndSchema}, ${gen.object(...valCxt)}`; + return context !== codegen_1.nil ? (0, codegen_1._)`${func}.call(${context}, ${args})` : (0, codegen_1._)`${func}(${args})`; + } + exports2.callValidateCode = callValidateCode; + var newRegExp = (0, codegen_1._)`new RegExp`; + function usePattern({ gen, it: { opts } }, pattern) { + const u = opts.unicodeRegExp ? "u" : ""; + const { regExp } = opts.code; + const rx = regExp(pattern, u); + return gen.scopeValue("pattern", { + key: rx.toString(), + ref: rx, + code: (0, codegen_1._)`${regExp.code === "new RegExp" ? newRegExp : (0, util_2.useFunc)(gen, regExp)}(${pattern}, ${u})` + }); + } + exports2.usePattern = usePattern; + function validateArray(cxt) { + const { gen, data, keyword, it } = cxt; + const valid = gen.name("valid"); + if (it.allErrors) { + const validArr = gen.let("valid", true); + validateItems(() => gen.assign(validArr, false)); + return validArr; + } + gen.var(valid, true); + validateItems(() => gen.break()); + return valid; + function validateItems(notValid) { + const len = gen.const("len", (0, codegen_1._)`${data}.length`); + gen.forRange("i", 0, len, (i) => { + cxt.subschema({ + keyword, + dataProp: i, + dataPropType: util_1.Type.Num + }, valid); + gen.if((0, codegen_1.not)(valid), notValid); + }); + } + } + exports2.validateArray = validateArray; + function validateUnion(cxt) { + const { gen, schema, keyword, it } = cxt; + if (!Array.isArray(schema)) + throw new Error("ajv implementation error"); + const alwaysValid = schema.some((sch) => (0, util_1.alwaysValidSchema)(it, sch)); + if (alwaysValid && !it.opts.unevaluated) + return; + const valid = gen.let("valid", false); + const schValid = gen.name("_valid"); + gen.block(() => schema.forEach((_sch, i) => { + const schCxt = cxt.subschema({ + keyword, + schemaProp: i, + compositeRule: true + }, schValid); + gen.assign(valid, (0, codegen_1._)`${valid} || ${schValid}`); + const merged = cxt.mergeValidEvaluated(schCxt, schValid); + if (!merged) + gen.if((0, codegen_1.not)(valid)); + })); + cxt.result(valid, () => cxt.reset(), () => cxt.error(true)); + } + exports2.validateUnion = validateUnion; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/keyword.js +var require_keyword = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/keyword.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateKeywordUsage = exports2.validSchemaType = exports2.funcKeywordCode = exports2.macroKeywordCode = void 0; + var codegen_1 = require_codegen(); + var names_1 = require_names(); + var code_1 = require_code2(); + var errors_1 = require_errors(); + function macroKeywordCode(cxt, def) { + const { gen, keyword, schema, parentSchema, it } = cxt; + const macroSchema = def.macro.call(it.self, schema, parentSchema, it); + const schemaRef = useKeyword(gen, keyword, macroSchema); + if (it.opts.validateSchema !== false) + it.self.validateSchema(macroSchema, true); + const valid = gen.name("valid"); + cxt.subschema({ + schema: macroSchema, + schemaPath: codegen_1.nil, + errSchemaPath: `${it.errSchemaPath}/${keyword}`, + topSchemaRef: schemaRef, + compositeRule: true + }, valid); + cxt.pass(valid, () => cxt.error(true)); + } + exports2.macroKeywordCode = macroKeywordCode; + function funcKeywordCode(cxt, def) { + var _a2; + const { gen, keyword, schema, parentSchema, $data, it } = cxt; + checkAsyncKeyword(it, def); + const validate = !$data && def.compile ? def.compile.call(it.self, schema, parentSchema, it) : def.validate; + const validateRef = useKeyword(gen, keyword, validate); + const valid = gen.let("valid"); + cxt.block$data(valid, validateKeyword); + cxt.ok((_a2 = def.valid) !== null && _a2 !== void 0 ? _a2 : valid); + function validateKeyword() { + if (def.errors === false) { + assignValid(); + if (def.modifying) + modifyData(cxt); + reportErrs(() => cxt.error()); + } else { + const ruleErrs = def.async ? validateAsync() : validateSync(); + if (def.modifying) + modifyData(cxt); + reportErrs(() => addErrs(cxt, ruleErrs)); + } + } + function validateAsync() { + const ruleErrs = gen.let("ruleErrs", null); + gen.try(() => assignValid((0, codegen_1._)`await `), (e) => gen.assign(valid, false).if((0, codegen_1._)`${e} instanceof ${it.ValidationError}`, () => gen.assign(ruleErrs, (0, codegen_1._)`${e}.errors`), () => gen.throw(e))); + return ruleErrs; + } + function validateSync() { + const validateErrs = (0, codegen_1._)`${validateRef}.errors`; + gen.assign(validateErrs, null); + assignValid(codegen_1.nil); + return validateErrs; + } + function assignValid(_await = def.async ? (0, codegen_1._)`await ` : codegen_1.nil) { + const passCxt = it.opts.passContext ? names_1.default.this : names_1.default.self; + const passSchema = !("compile" in def && !$data || def.schema === false); + gen.assign(valid, (0, codegen_1._)`${_await}${(0, code_1.callValidateCode)(cxt, validateRef, passCxt, passSchema)}`, def.modifying); + } + function reportErrs(errors) { + var _a3; + gen.if((0, codegen_1.not)((_a3 = def.valid) !== null && _a3 !== void 0 ? _a3 : valid), errors); + } + } + exports2.funcKeywordCode = funcKeywordCode; + function modifyData(cxt) { + const { gen, data, it } = cxt; + gen.if(it.parentData, () => gen.assign(data, (0, codegen_1._)`${it.parentData}[${it.parentDataProperty}]`)); + } + function addErrs(cxt, errs) { + const { gen } = cxt; + gen.if((0, codegen_1._)`Array.isArray(${errs})`, () => { + gen.assign(names_1.default.vErrors, (0, codegen_1._)`${names_1.default.vErrors} === null ? ${errs} : ${names_1.default.vErrors}.concat(${errs})`).assign(names_1.default.errors, (0, codegen_1._)`${names_1.default.vErrors}.length`); + (0, errors_1.extendErrors)(cxt); + }, () => cxt.error()); + } + function checkAsyncKeyword({ schemaEnv }, def) { + if (def.async && !schemaEnv.$async) + throw new Error("async keyword in sync schema"); + } + function useKeyword(gen, keyword, result) { + if (result === void 0) + throw new Error(`keyword "${keyword}" failed to compile`); + return gen.scopeValue("keyword", typeof result == "function" ? { ref: result } : { ref: result, code: (0, codegen_1.stringify)(result) }); + } + function validSchemaType(schema, schemaType, allowUndefined = false) { + return !schemaType.length || schemaType.some((st) => st === "array" ? Array.isArray(schema) : st === "object" ? schema && typeof schema == "object" && !Array.isArray(schema) : typeof schema == st || allowUndefined && typeof schema == "undefined"); + } + exports2.validSchemaType = validSchemaType; + function validateKeywordUsage({ schema, opts, self, errSchemaPath }, def, keyword) { + if (Array.isArray(def.keyword) ? !def.keyword.includes(keyword) : def.keyword !== keyword) { + throw new Error("ajv implementation error"); + } + const deps = def.dependencies; + if (deps === null || deps === void 0 ? void 0 : deps.some((kwd) => !Object.prototype.hasOwnProperty.call(schema, kwd))) { + throw new Error(`parent schema must have dependencies of ${keyword}: ${deps.join(",")}`); + } + if (def.validateSchema) { + const valid = def.validateSchema(schema[keyword]); + if (!valid) { + const msg = `keyword "${keyword}" value is invalid at path "${errSchemaPath}": ` + self.errorsText(def.validateSchema.errors); + if (opts.validateSchema === "log") + self.logger.error(msg); + else + throw new Error(msg); + } + } + } + exports2.validateKeywordUsage = validateKeywordUsage; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/subschema.js +var require_subschema = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/subschema.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.extendSubschemaMode = exports2.extendSubschemaData = exports2.getSubschema = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + function getSubschema(it, { keyword, schemaProp, schema, schemaPath, errSchemaPath, topSchemaRef }) { + if (keyword !== void 0 && schema !== void 0) { + throw new Error('both "keyword" and "schema" passed, only one allowed'); + } + if (keyword !== void 0) { + const sch = it.schema[keyword]; + return schemaProp === void 0 ? { + schema: sch, + schemaPath: (0, codegen_1._)`${it.schemaPath}${(0, codegen_1.getProperty)(keyword)}`, + errSchemaPath: `${it.errSchemaPath}/${keyword}` + } : { + schema: sch[schemaProp], + schemaPath: (0, codegen_1._)`${it.schemaPath}${(0, codegen_1.getProperty)(keyword)}${(0, codegen_1.getProperty)(schemaProp)}`, + errSchemaPath: `${it.errSchemaPath}/${keyword}/${(0, util_1.escapeFragment)(schemaProp)}` + }; + } + if (schema !== void 0) { + if (schemaPath === void 0 || errSchemaPath === void 0 || topSchemaRef === void 0) { + throw new Error('"schemaPath", "errSchemaPath" and "topSchemaRef" are required with "schema"'); + } + return { + schema, + schemaPath, + topSchemaRef, + errSchemaPath + }; + } + throw new Error('either "keyword" or "schema" must be passed'); + } + exports2.getSubschema = getSubschema; + function extendSubschemaData(subschema, it, { dataProp, dataPropType: dpType, data, dataTypes, propertyName }) { + if (data !== void 0 && dataProp !== void 0) { + throw new Error('both "data" and "dataProp" passed, only one allowed'); + } + const { gen } = it; + if (dataProp !== void 0) { + const { errorPath, dataPathArr, opts } = it; + const nextData = gen.let("data", (0, codegen_1._)`${it.data}${(0, codegen_1.getProperty)(dataProp)}`, true); + dataContextProps(nextData); + subschema.errorPath = (0, codegen_1.str)`${errorPath}${(0, util_1.getErrorPath)(dataProp, dpType, opts.jsPropertySyntax)}`; + subschema.parentDataProperty = (0, codegen_1._)`${dataProp}`; + subschema.dataPathArr = [...dataPathArr, subschema.parentDataProperty]; + } + if (data !== void 0) { + const nextData = data instanceof codegen_1.Name ? data : gen.let("data", data, true); + dataContextProps(nextData); + if (propertyName !== void 0) + subschema.propertyName = propertyName; + } + if (dataTypes) + subschema.dataTypes = dataTypes; + function dataContextProps(_nextData) { + subschema.data = _nextData; + subschema.dataLevel = it.dataLevel + 1; + subschema.dataTypes = []; + it.definedProperties = /* @__PURE__ */ new Set(); + subschema.parentData = it.data; + subschema.dataNames = [...it.dataNames, _nextData]; + } + } + exports2.extendSubschemaData = extendSubschemaData; + function extendSubschemaMode(subschema, { jtdDiscriminator, jtdMetadata, compositeRule, createErrors, allErrors }) { + if (compositeRule !== void 0) + subschema.compositeRule = compositeRule; + if (createErrors !== void 0) + subschema.createErrors = createErrors; + if (allErrors !== void 0) + subschema.allErrors = allErrors; + subschema.jtdDiscriminator = jtdDiscriminator; + subschema.jtdMetadata = jtdMetadata; + } + exports2.extendSubschemaMode = extendSubschemaMode; + } +}); + +// node_modules/.pnpm/fast-deep-equal@3.1.3/node_modules/fast-deep-equal/index.js +var require_fast_deep_equal = __commonJS({ + "node_modules/.pnpm/fast-deep-equal@3.1.3/node_modules/fast-deep-equal/index.js"(exports2, module2) { + "use strict"; + module2.exports = function equal(a, b) { + if (a === b) return true; + if (a && b && typeof a == "object" && typeof b == "object") { + if (a.constructor !== b.constructor) return false; + var length, i, keys; + if (Array.isArray(a)) { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0; ) + if (!equal(a[i], b[i])) return false; + return true; + } + if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; + if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); + if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) return false; + for (i = length; i-- !== 0; ) + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; + for (i = length; i-- !== 0; ) { + var key = keys[i]; + if (!equal(a[key], b[key])) return false; + } + return true; + } + return a !== a && b !== b; + }; + } +}); + +// node_modules/.pnpm/json-schema-traverse@1.0.0/node_modules/json-schema-traverse/index.js +var require_json_schema_traverse = __commonJS({ + "node_modules/.pnpm/json-schema-traverse@1.0.0/node_modules/json-schema-traverse/index.js"(exports2, module2) { + "use strict"; + var traverse = module2.exports = function(schema, opts, cb) { + if (typeof opts == "function") { + cb = opts; + opts = {}; + } + cb = opts.cb || cb; + var pre = typeof cb == "function" ? cb : cb.pre || function() { + }; + var post = cb.post || function() { + }; + _traverse(opts, pre, post, schema, "", schema); + }; + traverse.keywords = { + additionalItems: true, + items: true, + contains: true, + additionalProperties: true, + propertyNames: true, + not: true, + if: true, + then: true, + else: true + }; + traverse.arrayKeywords = { + items: true, + allOf: true, + anyOf: true, + oneOf: true + }; + traverse.propsKeywords = { + $defs: true, + definitions: true, + properties: true, + patternProperties: true, + dependencies: true + }; + traverse.skipKeywords = { + default: true, + enum: true, + const: true, + required: true, + maximum: true, + minimum: true, + exclusiveMaximum: true, + exclusiveMinimum: true, + multipleOf: true, + maxLength: true, + minLength: true, + pattern: true, + format: true, + maxItems: true, + minItems: true, + uniqueItems: true, + maxProperties: true, + minProperties: true + }; + function _traverse(opts, pre, post, schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) { + if (schema && typeof schema == "object" && !Array.isArray(schema)) { + pre(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex); + for (var key in schema) { + var sch = schema[key]; + if (Array.isArray(sch)) { + if (key in traverse.arrayKeywords) { + for (var i = 0; i < sch.length; i++) + _traverse(opts, pre, post, sch[i], jsonPtr + "/" + key + "/" + i, rootSchema, jsonPtr, key, schema, i); + } + } else if (key in traverse.propsKeywords) { + if (sch && typeof sch == "object") { + for (var prop in sch) + _traverse(opts, pre, post, sch[prop], jsonPtr + "/" + key + "/" + escapeJsonPtr(prop), rootSchema, jsonPtr, key, schema, prop); + } + } else if (key in traverse.keywords || opts.allKeys && !(key in traverse.skipKeywords)) { + _traverse(opts, pre, post, sch, jsonPtr + "/" + key, rootSchema, jsonPtr, key, schema); + } + } + post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex); + } + } + function escapeJsonPtr(str2) { + return str2.replace(/~/g, "~0").replace(/\//g, "~1"); + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/resolve.js +var require_resolve = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/resolve.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.getSchemaRefs = exports2.resolveUrl = exports2.normalizeId = exports2._getFullPath = exports2.getFullPath = exports2.inlineRef = void 0; + var util_1 = require_util(); + var equal = require_fast_deep_equal(); + var traverse = require_json_schema_traverse(); + var SIMPLE_INLINED = /* @__PURE__ */ new Set([ + "type", + "format", + "pattern", + "maxLength", + "minLength", + "maxProperties", + "minProperties", + "maxItems", + "minItems", + "maximum", + "minimum", + "uniqueItems", + "multipleOf", + "required", + "enum", + "const" + ]); + function inlineRef(schema, limit = true) { + if (typeof schema == "boolean") + return true; + if (limit === true) + return !hasRef(schema); + if (!limit) + return false; + return countKeys(schema) <= limit; + } + exports2.inlineRef = inlineRef; + var REF_KEYWORDS = /* @__PURE__ */ new Set([ + "$ref", + "$recursiveRef", + "$recursiveAnchor", + "$dynamicRef", + "$dynamicAnchor" + ]); + function hasRef(schema) { + for (const key in schema) { + if (REF_KEYWORDS.has(key)) + return true; + const sch = schema[key]; + if (Array.isArray(sch) && sch.some(hasRef)) + return true; + if (typeof sch == "object" && hasRef(sch)) + return true; + } + return false; + } + function countKeys(schema) { + let count = 0; + for (const key in schema) { + if (key === "$ref") + return Infinity; + count++; + if (SIMPLE_INLINED.has(key)) + continue; + if (typeof schema[key] == "object") { + (0, util_1.eachItem)(schema[key], (sch) => count += countKeys(sch)); + } + if (count === Infinity) + return Infinity; + } + return count; + } + function getFullPath(resolver, id = "", normalize) { + if (normalize !== false) + id = normalizeId(id); + const p = resolver.parse(id); + return _getFullPath(resolver, p); + } + exports2.getFullPath = getFullPath; + function _getFullPath(resolver, p) { + const serialized = resolver.serialize(p); + return serialized.split("#")[0] + "#"; + } + exports2._getFullPath = _getFullPath; + var TRAILING_SLASH_HASH = /#\/?$/; + function normalizeId(id) { + return id ? id.replace(TRAILING_SLASH_HASH, "") : ""; + } + exports2.normalizeId = normalizeId; + function resolveUrl(resolver, baseId, id) { + id = normalizeId(id); + return resolver.resolve(baseId, id); + } + exports2.resolveUrl = resolveUrl; + var ANCHOR = /^[a-z_][-a-z0-9._]*$/i; + function getSchemaRefs(schema, baseId) { + if (typeof schema == "boolean") + return {}; + const { schemaId, uriResolver } = this.opts; + const schId = normalizeId(schema[schemaId] || baseId); + const baseIds = { "": schId }; + const pathPrefix = getFullPath(uriResolver, schId, false); + const localRefs = {}; + const schemaRefs = /* @__PURE__ */ new Set(); + traverse(schema, { allKeys: true }, (sch, jsonPtr, _, parentJsonPtr) => { + if (parentJsonPtr === void 0) + return; + const fullPath = pathPrefix + jsonPtr; + let innerBaseId = baseIds[parentJsonPtr]; + if (typeof sch[schemaId] == "string") + innerBaseId = addRef.call(this, sch[schemaId]); + addAnchor.call(this, sch.$anchor); + addAnchor.call(this, sch.$dynamicAnchor); + baseIds[jsonPtr] = innerBaseId; + function addRef(ref) { + const _resolve = this.opts.uriResolver.resolve; + ref = normalizeId(innerBaseId ? _resolve(innerBaseId, ref) : ref); + if (schemaRefs.has(ref)) + throw ambiguos(ref); + schemaRefs.add(ref); + let schOrRef = this.refs[ref]; + if (typeof schOrRef == "string") + schOrRef = this.refs[schOrRef]; + if (typeof schOrRef == "object") { + checkAmbiguosRef(sch, schOrRef.schema, ref); + } else if (ref !== normalizeId(fullPath)) { + if (ref[0] === "#") { + checkAmbiguosRef(sch, localRefs[ref], ref); + localRefs[ref] = sch; + } else { + this.refs[ref] = fullPath; + } + } + return ref; + } + function addAnchor(anchor) { + if (typeof anchor == "string") { + if (!ANCHOR.test(anchor)) + throw new Error(`invalid anchor "${anchor}"`); + addRef.call(this, `#${anchor}`); + } + } + }); + return localRefs; + function checkAmbiguosRef(sch1, sch2, ref) { + if (sch2 !== void 0 && !equal(sch1, sch2)) + throw ambiguos(ref); + } + function ambiguos(ref) { + return new Error(`reference "${ref}" resolves to more than one schema`); + } + } + exports2.getSchemaRefs = getSchemaRefs; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/index.js +var require_validate = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.getData = exports2.KeywordCxt = exports2.validateFunctionCode = void 0; + var boolSchema_1 = require_boolSchema(); + var dataType_1 = require_dataType(); + var applicability_1 = require_applicability(); + var dataType_2 = require_dataType(); + var defaults_1 = require_defaults(); + var keyword_1 = require_keyword(); + var subschema_1 = require_subschema(); + var codegen_1 = require_codegen(); + var names_1 = require_names(); + var resolve_1 = require_resolve(); + var util_1 = require_util(); + var errors_1 = require_errors(); + function validateFunctionCode(it) { + if (isSchemaObj(it)) { + checkKeywords(it); + if (schemaCxtHasRules(it)) { + topSchemaObjCode(it); + return; + } + } + validateFunction(it, () => (0, boolSchema_1.topBoolOrEmptySchema)(it)); + } + exports2.validateFunctionCode = validateFunctionCode; + function validateFunction({ gen, validateName, schema, schemaEnv, opts }, body) { + if (opts.code.es5) { + gen.func(validateName, (0, codegen_1._)`${names_1.default.data}, ${names_1.default.valCxt}`, schemaEnv.$async, () => { + gen.code((0, codegen_1._)`"use strict"; ${funcSourceUrl(schema, opts)}`); + destructureValCxtES5(gen, opts); + gen.code(body); + }); + } else { + gen.func(validateName, (0, codegen_1._)`${names_1.default.data}, ${destructureValCxt(opts)}`, schemaEnv.$async, () => gen.code(funcSourceUrl(schema, opts)).code(body)); + } + } + function destructureValCxt(opts) { + return (0, codegen_1._)`{${names_1.default.instancePath}="", ${names_1.default.parentData}, ${names_1.default.parentDataProperty}, ${names_1.default.rootData}=${names_1.default.data}${opts.dynamicRef ? (0, codegen_1._)`, ${names_1.default.dynamicAnchors}={}` : codegen_1.nil}}={}`; + } + function destructureValCxtES5(gen, opts) { + gen.if(names_1.default.valCxt, () => { + gen.var(names_1.default.instancePath, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.instancePath}`); + gen.var(names_1.default.parentData, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.parentData}`); + gen.var(names_1.default.parentDataProperty, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.parentDataProperty}`); + gen.var(names_1.default.rootData, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.rootData}`); + if (opts.dynamicRef) + gen.var(names_1.default.dynamicAnchors, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.dynamicAnchors}`); + }, () => { + gen.var(names_1.default.instancePath, (0, codegen_1._)`""`); + gen.var(names_1.default.parentData, (0, codegen_1._)`undefined`); + gen.var(names_1.default.parentDataProperty, (0, codegen_1._)`undefined`); + gen.var(names_1.default.rootData, names_1.default.data); + if (opts.dynamicRef) + gen.var(names_1.default.dynamicAnchors, (0, codegen_1._)`{}`); + }); + } + function topSchemaObjCode(it) { + const { schema, opts, gen } = it; + validateFunction(it, () => { + if (opts.$comment && schema.$comment) + commentKeyword(it); + checkNoDefault(it); + gen.let(names_1.default.vErrors, null); + gen.let(names_1.default.errors, 0); + if (opts.unevaluated) + resetEvaluated(it); + typeAndKeywords(it); + returnResults(it); + }); + return; + } + function resetEvaluated(it) { + const { gen, validateName } = it; + it.evaluated = gen.const("evaluated", (0, codegen_1._)`${validateName}.evaluated`); + gen.if((0, codegen_1._)`${it.evaluated}.dynamicProps`, () => gen.assign((0, codegen_1._)`${it.evaluated}.props`, (0, codegen_1._)`undefined`)); + gen.if((0, codegen_1._)`${it.evaluated}.dynamicItems`, () => gen.assign((0, codegen_1._)`${it.evaluated}.items`, (0, codegen_1._)`undefined`)); + } + function funcSourceUrl(schema, opts) { + const schId = typeof schema == "object" && schema[opts.schemaId]; + return schId && (opts.code.source || opts.code.process) ? (0, codegen_1._)`/*# sourceURL=${schId} */` : codegen_1.nil; + } + function subschemaCode(it, valid) { + if (isSchemaObj(it)) { + checkKeywords(it); + if (schemaCxtHasRules(it)) { + subSchemaObjCode(it, valid); + return; + } + } + (0, boolSchema_1.boolOrEmptySchema)(it, valid); + } + function schemaCxtHasRules({ schema, self }) { + if (typeof schema == "boolean") + return !schema; + for (const key in schema) + if (self.RULES.all[key]) + return true; + return false; + } + function isSchemaObj(it) { + return typeof it.schema != "boolean"; + } + function subSchemaObjCode(it, valid) { + const { schema, gen, opts } = it; + if (opts.$comment && schema.$comment) + commentKeyword(it); + updateContext(it); + checkAsyncSchema(it); + const errsCount = gen.const("_errs", names_1.default.errors); + typeAndKeywords(it, errsCount); + gen.var(valid, (0, codegen_1._)`${errsCount} === ${names_1.default.errors}`); + } + function checkKeywords(it) { + (0, util_1.checkUnknownRules)(it); + checkRefsAndKeywords(it); + } + function typeAndKeywords(it, errsCount) { + if (it.opts.jtd) + return schemaKeywords(it, [], false, errsCount); + const types = (0, dataType_1.getSchemaTypes)(it.schema); + const checkedTypes = (0, dataType_1.coerceAndCheckDataType)(it, types); + schemaKeywords(it, types, !checkedTypes, errsCount); + } + function checkRefsAndKeywords(it) { + const { schema, errSchemaPath, opts, self } = it; + if (schema.$ref && opts.ignoreKeywordsWithRef && (0, util_1.schemaHasRulesButRef)(schema, self.RULES)) { + self.logger.warn(`$ref: keywords ignored in schema at path "${errSchemaPath}"`); + } + } + function checkNoDefault(it) { + const { schema, opts } = it; + if (schema.default !== void 0 && opts.useDefaults && opts.strictSchema) { + (0, util_1.checkStrictMode)(it, "default is ignored in the schema root"); + } + } + function updateContext(it) { + const schId = it.schema[it.opts.schemaId]; + if (schId) + it.baseId = (0, resolve_1.resolveUrl)(it.opts.uriResolver, it.baseId, schId); + } + function checkAsyncSchema(it) { + if (it.schema.$async && !it.schemaEnv.$async) + throw new Error("async schema in sync schema"); + } + function commentKeyword({ gen, schemaEnv, schema, errSchemaPath, opts }) { + const msg = schema.$comment; + if (opts.$comment === true) { + gen.code((0, codegen_1._)`${names_1.default.self}.logger.log(${msg})`); + } else if (typeof opts.$comment == "function") { + const schemaPath = (0, codegen_1.str)`${errSchemaPath}/$comment`; + const rootName = gen.scopeValue("root", { ref: schemaEnv.root }); + gen.code((0, codegen_1._)`${names_1.default.self}.opts.$comment(${msg}, ${schemaPath}, ${rootName}.schema)`); + } + } + function returnResults(it) { + const { gen, schemaEnv, validateName, ValidationError, opts } = it; + if (schemaEnv.$async) { + gen.if((0, codegen_1._)`${names_1.default.errors} === 0`, () => gen.return(names_1.default.data), () => gen.throw((0, codegen_1._)`new ${ValidationError}(${names_1.default.vErrors})`)); + } else { + gen.assign((0, codegen_1._)`${validateName}.errors`, names_1.default.vErrors); + if (opts.unevaluated) + assignEvaluated(it); + gen.return((0, codegen_1._)`${names_1.default.errors} === 0`); + } + } + function assignEvaluated({ gen, evaluated, props, items }) { + if (props instanceof codegen_1.Name) + gen.assign((0, codegen_1._)`${evaluated}.props`, props); + if (items instanceof codegen_1.Name) + gen.assign((0, codegen_1._)`${evaluated}.items`, items); + } + function schemaKeywords(it, types, typeErrors, errsCount) { + const { gen, schema, data, allErrors, opts, self } = it; + const { RULES } = self; + if (schema.$ref && (opts.ignoreKeywordsWithRef || !(0, util_1.schemaHasRulesButRef)(schema, RULES))) { + gen.block(() => keywordCode(it, "$ref", RULES.all.$ref.definition)); + return; + } + if (!opts.jtd) + checkStrictTypes(it, types); + gen.block(() => { + for (const group of RULES.rules) + groupKeywords(group); + groupKeywords(RULES.post); + }); + function groupKeywords(group) { + if (!(0, applicability_1.shouldUseGroup)(schema, group)) + return; + if (group.type) { + gen.if((0, dataType_2.checkDataType)(group.type, data, opts.strictNumbers)); + iterateKeywords(it, group); + if (types.length === 1 && types[0] === group.type && typeErrors) { + gen.else(); + (0, dataType_2.reportTypeError)(it); + } + gen.endIf(); + } else { + iterateKeywords(it, group); + } + if (!allErrors) + gen.if((0, codegen_1._)`${names_1.default.errors} === ${errsCount || 0}`); + } + } + function iterateKeywords(it, group) { + const { gen, schema, opts: { useDefaults } } = it; + if (useDefaults) + (0, defaults_1.assignDefaults)(it, group.type); + gen.block(() => { + for (const rule of group.rules) { + if ((0, applicability_1.shouldUseRule)(schema, rule)) { + keywordCode(it, rule.keyword, rule.definition, group.type); + } + } + }); + } + function checkStrictTypes(it, types) { + if (it.schemaEnv.meta || !it.opts.strictTypes) + return; + checkContextTypes(it, types); + if (!it.opts.allowUnionTypes) + checkMultipleTypes(it, types); + checkKeywordTypes(it, it.dataTypes); + } + function checkContextTypes(it, types) { + if (!types.length) + return; + if (!it.dataTypes.length) { + it.dataTypes = types; + return; + } + types.forEach((t) => { + if (!includesType(it.dataTypes, t)) { + strictTypesError(it, `type "${t}" not allowed by context "${it.dataTypes.join(",")}"`); + } + }); + narrowSchemaTypes(it, types); + } + function checkMultipleTypes(it, ts) { + if (ts.length > 1 && !(ts.length === 2 && ts.includes("null"))) { + strictTypesError(it, "use allowUnionTypes to allow union type keyword"); + } + } + function checkKeywordTypes(it, ts) { + const rules = it.self.RULES.all; + for (const keyword in rules) { + const rule = rules[keyword]; + if (typeof rule == "object" && (0, applicability_1.shouldUseRule)(it.schema, rule)) { + const { type } = rule.definition; + if (type.length && !type.some((t) => hasApplicableType(ts, t))) { + strictTypesError(it, `missing type "${type.join(",")}" for keyword "${keyword}"`); + } + } + } + } + function hasApplicableType(schTs, kwdT) { + return schTs.includes(kwdT) || kwdT === "number" && schTs.includes("integer"); + } + function includesType(ts, t) { + return ts.includes(t) || t === "integer" && ts.includes("number"); + } + function narrowSchemaTypes(it, withTypes) { + const ts = []; + for (const t of it.dataTypes) { + if (includesType(withTypes, t)) + ts.push(t); + else if (withTypes.includes("integer") && t === "number") + ts.push("integer"); + } + it.dataTypes = ts; + } + function strictTypesError(it, msg) { + const schemaPath = it.schemaEnv.baseId + it.errSchemaPath; + msg += ` at "${schemaPath}" (strictTypes)`; + (0, util_1.checkStrictMode)(it, msg, it.opts.strictTypes); + } + var KeywordCxt = class { + constructor(it, def, keyword) { + (0, keyword_1.validateKeywordUsage)(it, def, keyword); + this.gen = it.gen; + this.allErrors = it.allErrors; + this.keyword = keyword; + this.data = it.data; + this.schema = it.schema[keyword]; + this.$data = def.$data && it.opts.$data && this.schema && this.schema.$data; + this.schemaValue = (0, util_1.schemaRefOrVal)(it, this.schema, keyword, this.$data); + this.schemaType = def.schemaType; + this.parentSchema = it.schema; + this.params = {}; + this.it = it; + this.def = def; + if (this.$data) { + this.schemaCode = it.gen.const("vSchema", getData(this.$data, it)); + } else { + this.schemaCode = this.schemaValue; + if (!(0, keyword_1.validSchemaType)(this.schema, def.schemaType, def.allowUndefined)) { + throw new Error(`${keyword} value must be ${JSON.stringify(def.schemaType)}`); + } + } + if ("code" in def ? def.trackErrors : def.errors !== false) { + this.errsCount = it.gen.const("_errs", names_1.default.errors); + } + } + result(condition, successAction, failAction) { + this.failResult((0, codegen_1.not)(condition), successAction, failAction); + } + failResult(condition, successAction, failAction) { + this.gen.if(condition); + if (failAction) + failAction(); + else + this.error(); + if (successAction) { + this.gen.else(); + successAction(); + if (this.allErrors) + this.gen.endIf(); + } else { + if (this.allErrors) + this.gen.endIf(); + else + this.gen.else(); + } + } + pass(condition, failAction) { + this.failResult((0, codegen_1.not)(condition), void 0, failAction); + } + fail(condition) { + if (condition === void 0) { + this.error(); + if (!this.allErrors) + this.gen.if(false); + return; + } + this.gen.if(condition); + this.error(); + if (this.allErrors) + this.gen.endIf(); + else + this.gen.else(); + } + fail$data(condition) { + if (!this.$data) + return this.fail(condition); + const { schemaCode } = this; + this.fail((0, codegen_1._)`${schemaCode} !== undefined && (${(0, codegen_1.or)(this.invalid$data(), condition)})`); + } + error(append, errorParams, errorPaths) { + if (errorParams) { + this.setParams(errorParams); + this._error(append, errorPaths); + this.setParams({}); + return; + } + this._error(append, errorPaths); + } + _error(append, errorPaths) { + ; + (append ? errors_1.reportExtraError : errors_1.reportError)(this, this.def.error, errorPaths); + } + $dataError() { + (0, errors_1.reportError)(this, this.def.$dataError || errors_1.keyword$DataError); + } + reset() { + if (this.errsCount === void 0) + throw new Error('add "trackErrors" to keyword definition'); + (0, errors_1.resetErrorsCount)(this.gen, this.errsCount); + } + ok(cond) { + if (!this.allErrors) + this.gen.if(cond); + } + setParams(obj, assign) { + if (assign) + Object.assign(this.params, obj); + else + this.params = obj; + } + block$data(valid, codeBlock, $dataValid = codegen_1.nil) { + this.gen.block(() => { + this.check$data(valid, $dataValid); + codeBlock(); + }); + } + check$data(valid = codegen_1.nil, $dataValid = codegen_1.nil) { + if (!this.$data) + return; + const { gen, schemaCode, schemaType, def } = this; + gen.if((0, codegen_1.or)((0, codegen_1._)`${schemaCode} === undefined`, $dataValid)); + if (valid !== codegen_1.nil) + gen.assign(valid, true); + if (schemaType.length || def.validateSchema) { + gen.elseIf(this.invalid$data()); + this.$dataError(); + if (valid !== codegen_1.nil) + gen.assign(valid, false); + } + gen.else(); + } + invalid$data() { + const { gen, schemaCode, schemaType, def, it } = this; + return (0, codegen_1.or)(wrong$DataType(), invalid$DataSchema()); + function wrong$DataType() { + if (schemaType.length) { + if (!(schemaCode instanceof codegen_1.Name)) + throw new Error("ajv implementation error"); + const st = Array.isArray(schemaType) ? schemaType : [schemaType]; + return (0, codegen_1._)`${(0, dataType_2.checkDataTypes)(st, schemaCode, it.opts.strictNumbers, dataType_2.DataType.Wrong)}`; + } + return codegen_1.nil; + } + function invalid$DataSchema() { + if (def.validateSchema) { + const validateSchemaRef = gen.scopeValue("validate$data", { ref: def.validateSchema }); + return (0, codegen_1._)`!${validateSchemaRef}(${schemaCode})`; + } + return codegen_1.nil; + } + } + subschema(appl, valid) { + const subschema = (0, subschema_1.getSubschema)(this.it, appl); + (0, subschema_1.extendSubschemaData)(subschema, this.it, appl); + (0, subschema_1.extendSubschemaMode)(subschema, appl); + const nextContext = { ...this.it, ...subschema, items: void 0, props: void 0 }; + subschemaCode(nextContext, valid); + return nextContext; + } + mergeEvaluated(schemaCxt, toName) { + const { it, gen } = this; + if (!it.opts.unevaluated) + return; + if (it.props !== true && schemaCxt.props !== void 0) { + it.props = util_1.mergeEvaluated.props(gen, schemaCxt.props, it.props, toName); + } + if (it.items !== true && schemaCxt.items !== void 0) { + it.items = util_1.mergeEvaluated.items(gen, schemaCxt.items, it.items, toName); + } + } + mergeValidEvaluated(schemaCxt, valid) { + const { it, gen } = this; + if (it.opts.unevaluated && (it.props !== true || it.items !== true)) { + gen.if(valid, () => this.mergeEvaluated(schemaCxt, codegen_1.Name)); + return true; + } + } + }; + exports2.KeywordCxt = KeywordCxt; + function keywordCode(it, keyword, def, ruleType) { + const cxt = new KeywordCxt(it, def, keyword); + if ("code" in def) { + def.code(cxt, ruleType); + } else if (cxt.$data && def.validate) { + (0, keyword_1.funcKeywordCode)(cxt, def); + } else if ("macro" in def) { + (0, keyword_1.macroKeywordCode)(cxt, def); + } else if (def.compile || def.validate) { + (0, keyword_1.funcKeywordCode)(cxt, def); + } + } + var JSON_POINTER = /^\/(?:[^~]|~0|~1)*$/; + var RELATIVE_JSON_POINTER = /^([0-9]+)(#|\/(?:[^~]|~0|~1)*)?$/; + function getData($data, { dataLevel, dataNames, dataPathArr }) { + let jsonPointer; + let data; + if ($data === "") + return names_1.default.rootData; + if ($data[0] === "/") { + if (!JSON_POINTER.test($data)) + throw new Error(`Invalid JSON-pointer: ${$data}`); + jsonPointer = $data; + data = names_1.default.rootData; + } else { + const matches = RELATIVE_JSON_POINTER.exec($data); + if (!matches) + throw new Error(`Invalid JSON-pointer: ${$data}`); + const up = +matches[1]; + jsonPointer = matches[2]; + if (jsonPointer === "#") { + if (up >= dataLevel) + throw new Error(errorMsg("property/index", up)); + return dataPathArr[dataLevel - up]; + } + if (up > dataLevel) + throw new Error(errorMsg("data", up)); + data = dataNames[dataLevel - up]; + if (!jsonPointer) + return data; + } + let expr = data; + const segments = jsonPointer.split("/"); + for (const segment of segments) { + if (segment) { + data = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)((0, util_1.unescapeJsonPointer)(segment))}`; + expr = (0, codegen_1._)`${expr} && ${data}`; + } + } + return expr; + function errorMsg(pointerType, up) { + return `Cannot access ${pointerType} ${up} levels up, current level is ${dataLevel}`; + } + } + exports2.getData = getData; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/validation_error.js +var require_validation_error = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/validation_error.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var ValidationError = class extends Error { + constructor(errors) { + super("validation failed"); + this.errors = errors; + this.ajv = this.validation = true; + } + }; + exports2.default = ValidationError; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/ref_error.js +var require_ref_error = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/ref_error.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var resolve_1 = require_resolve(); + var MissingRefError = class extends Error { + constructor(resolver, baseId, ref, msg) { + super(msg || `can't resolve reference ${ref} from id ${baseId}`); + this.missingRef = (0, resolve_1.resolveUrl)(resolver, baseId, ref); + this.missingSchema = (0, resolve_1.normalizeId)((0, resolve_1.getFullPath)(resolver, this.missingRef)); + } + }; + exports2.default = MissingRefError; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/index.js +var require_compile = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.resolveSchema = exports2.getCompilingSchema = exports2.resolveRef = exports2.compileSchema = exports2.SchemaEnv = void 0; + var codegen_1 = require_codegen(); + var validation_error_1 = require_validation_error(); + var names_1 = require_names(); + var resolve_1 = require_resolve(); + var util_1 = require_util(); + var validate_1 = require_validate(); + var SchemaEnv = class { + constructor(env) { + var _a2; + this.refs = {}; + this.dynamicAnchors = {}; + let schema; + if (typeof env.schema == "object") + schema = env.schema; + this.schema = env.schema; + this.schemaId = env.schemaId; + this.root = env.root || this; + this.baseId = (_a2 = env.baseId) !== null && _a2 !== void 0 ? _a2 : (0, resolve_1.normalizeId)(schema === null || schema === void 0 ? void 0 : schema[env.schemaId || "$id"]); + this.schemaPath = env.schemaPath; + this.localRefs = env.localRefs; + this.meta = env.meta; + this.$async = schema === null || schema === void 0 ? void 0 : schema.$async; + this.refs = {}; + } + }; + exports2.SchemaEnv = SchemaEnv; + function compileSchema(sch) { + const _sch = getCompilingSchema.call(this, sch); + if (_sch) + return _sch; + const rootId = (0, resolve_1.getFullPath)(this.opts.uriResolver, sch.root.baseId); + const { es5, lines } = this.opts.code; + const { ownProperties } = this.opts; + const gen = new codegen_1.CodeGen(this.scope, { es5, lines, ownProperties }); + let _ValidationError; + if (sch.$async) { + _ValidationError = gen.scopeValue("Error", { + ref: validation_error_1.default, + code: (0, codegen_1._)`require("ajv/dist/runtime/validation_error").default` + }); + } + const validateName = gen.scopeName("validate"); + sch.validateName = validateName; + const schemaCxt = { + gen, + allErrors: this.opts.allErrors, + data: names_1.default.data, + parentData: names_1.default.parentData, + parentDataProperty: names_1.default.parentDataProperty, + dataNames: [names_1.default.data], + dataPathArr: [codegen_1.nil], + // TODO can its length be used as dataLevel if nil is removed? + dataLevel: 0, + dataTypes: [], + definedProperties: /* @__PURE__ */ new Set(), + topSchemaRef: gen.scopeValue("schema", this.opts.code.source === true ? { ref: sch.schema, code: (0, codegen_1.stringify)(sch.schema) } : { ref: sch.schema }), + validateName, + ValidationError: _ValidationError, + schema: sch.schema, + schemaEnv: sch, + rootId, + baseId: sch.baseId || rootId, + schemaPath: codegen_1.nil, + errSchemaPath: sch.schemaPath || (this.opts.jtd ? "" : "#"), + errorPath: (0, codegen_1._)`""`, + opts: this.opts, + self: this + }; + let sourceCode; + try { + this._compilations.add(sch); + (0, validate_1.validateFunctionCode)(schemaCxt); + gen.optimize(this.opts.code.optimize); + const validateCode = gen.toString(); + sourceCode = `${gen.scopeRefs(names_1.default.scope)}return ${validateCode}`; + if (this.opts.code.process) + sourceCode = this.opts.code.process(sourceCode, sch); + const makeValidate = new Function(`${names_1.default.self}`, `${names_1.default.scope}`, sourceCode); + const validate = makeValidate(this, this.scope.get()); + this.scope.value(validateName, { ref: validate }); + validate.errors = null; + validate.schema = sch.schema; + validate.schemaEnv = sch; + if (sch.$async) + validate.$async = true; + if (this.opts.code.source === true) { + validate.source = { validateName, validateCode, scopeValues: gen._values }; + } + if (this.opts.unevaluated) { + const { props, items } = schemaCxt; + validate.evaluated = { + props: props instanceof codegen_1.Name ? void 0 : props, + items: items instanceof codegen_1.Name ? void 0 : items, + dynamicProps: props instanceof codegen_1.Name, + dynamicItems: items instanceof codegen_1.Name + }; + if (validate.source) + validate.source.evaluated = (0, codegen_1.stringify)(validate.evaluated); + } + sch.validate = validate; + return sch; + } catch (e) { + delete sch.validate; + delete sch.validateName; + if (sourceCode) + this.logger.error("Error compiling schema, function code:", sourceCode); + throw e; + } finally { + this._compilations.delete(sch); + } + } + exports2.compileSchema = compileSchema; + function resolveRef(root, baseId, ref) { + var _a2; + ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref); + const schOrFunc = root.refs[ref]; + if (schOrFunc) + return schOrFunc; + let _sch = resolve.call(this, root, ref); + if (_sch === void 0) { + const schema = (_a2 = root.localRefs) === null || _a2 === void 0 ? void 0 : _a2[ref]; + const { schemaId } = this.opts; + if (schema) + _sch = new SchemaEnv({ schema, schemaId, root, baseId }); + } + if (_sch === void 0) + return; + return root.refs[ref] = inlineOrCompile.call(this, _sch); + } + exports2.resolveRef = resolveRef; + function inlineOrCompile(sch) { + if ((0, resolve_1.inlineRef)(sch.schema, this.opts.inlineRefs)) + return sch.schema; + return sch.validate ? sch : compileSchema.call(this, sch); + } + function getCompilingSchema(schEnv) { + for (const sch of this._compilations) { + if (sameSchemaEnv(sch, schEnv)) + return sch; + } + } + exports2.getCompilingSchema = getCompilingSchema; + function sameSchemaEnv(s1, s2) { + return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; + } + function resolve(root, ref) { + let sch; + while (typeof (sch = this.refs[ref]) == "string") + ref = sch; + return sch || this.schemas[ref] || resolveSchema.call(this, root, ref); + } + function resolveSchema(root, ref) { + const p = this.opts.uriResolver.parse(ref); + const refPath = (0, resolve_1._getFullPath)(this.opts.uriResolver, p); + let baseId = (0, resolve_1.getFullPath)(this.opts.uriResolver, root.baseId, void 0); + if (Object.keys(root.schema).length > 0 && refPath === baseId) { + return getJsonPointer.call(this, p, root); + } + const id = (0, resolve_1.normalizeId)(refPath); + const schOrRef = this.refs[id] || this.schemas[id]; + if (typeof schOrRef == "string") { + const sch = resolveSchema.call(this, root, schOrRef); + if (typeof (sch === null || sch === void 0 ? void 0 : sch.schema) !== "object") + return; + return getJsonPointer.call(this, p, sch); + } + if (typeof (schOrRef === null || schOrRef === void 0 ? void 0 : schOrRef.schema) !== "object") + return; + if (!schOrRef.validate) + compileSchema.call(this, schOrRef); + if (id === (0, resolve_1.normalizeId)(ref)) { + const { schema } = schOrRef; + const { schemaId } = this.opts; + const schId = schema[schemaId]; + if (schId) + baseId = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schId); + return new SchemaEnv({ schema, schemaId, root, baseId }); + } + return getJsonPointer.call(this, p, schOrRef); + } + exports2.resolveSchema = resolveSchema; + var PREVENT_SCOPE_CHANGE = /* @__PURE__ */ new Set([ + "properties", + "patternProperties", + "enum", + "dependencies", + "definitions" + ]); + function getJsonPointer(parsedRef, { baseId, schema, root }) { + var _a2; + if (((_a2 = parsedRef.fragment) === null || _a2 === void 0 ? void 0 : _a2[0]) !== "/") + return; + for (const part of parsedRef.fragment.slice(1).split("/")) { + if (typeof schema === "boolean") + return; + const partSchema = schema[(0, util_1.unescapeFragment)(part)]; + if (partSchema === void 0) + return; + schema = partSchema; + const schId = typeof schema === "object" && schema[this.opts.schemaId]; + if (!PREVENT_SCOPE_CHANGE.has(part) && schId) { + baseId = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schId); + } + } + let env; + if (typeof schema != "boolean" && schema.$ref && !(0, util_1.schemaHasRulesButRef)(schema, this.RULES)) { + const $ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schema.$ref); + env = resolveSchema.call(this, root, $ref); + } + const { schemaId } = this.opts; + env = env || new SchemaEnv({ schema, schemaId, root, baseId }); + if (env.schema !== env.root.schema) + return env; + return void 0; + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/data.json +var require_data = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/data.json"(exports2, module2) { + module2.exports = { + $id: "https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#", + description: "Meta-schema for $data reference (JSON AnySchema extension proposal)", + type: "object", + required: ["$data"], + properties: { + $data: { + type: "string", + anyOf: [{ format: "relative-json-pointer" }, { format: "json-pointer" }] + } + }, + additionalProperties: false + }; + } +}); + +// node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/utils.js +var require_utils = __commonJS({ + "node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/utils.js"(exports2, module2) { + "use strict"; + var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); + var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); + function stringArrayToHexStripped(input) { + let acc = ""; + let code = 0; + let i = 0; + for (i = 0; i < input.length; i++) { + code = input[i].charCodeAt(0); + if (code === 48) { + continue; + } + if (!(code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102)) { + return ""; + } + acc += input[i]; + break; + } + for (i += 1; i < input.length; i++) { + code = input[i].charCodeAt(0); + if (!(code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102)) { + return ""; + } + acc += input[i]; + } + return acc; + } + var nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u); + function consumeIsZone(buffer) { + buffer.length = 0; + return true; + } + function consumeHextets(buffer, address, output) { + if (buffer.length) { + const hex3 = stringArrayToHexStripped(buffer); + if (hex3 !== "") { + address.push(hex3); + } else { + output.error = true; + return false; + } + buffer.length = 0; + } + return true; + } + function getIPV6(input) { + let tokenCount = 0; + const output = { error: false, address: "", zone: "" }; + const address = []; + const buffer = []; + let endipv6Encountered = false; + let endIpv6 = false; + let consume = consumeHextets; + for (let i = 0; i < input.length; i++) { + const cursor = input[i]; + if (cursor === "[" || cursor === "]") { + continue; + } + if (cursor === ":") { + if (endipv6Encountered === true) { + endIpv6 = true; + } + if (!consume(buffer, address, output)) { + break; + } + if (++tokenCount > 7) { + output.error = true; + break; + } + if (i > 0 && input[i - 1] === ":") { + endipv6Encountered = true; + } + address.push(":"); + continue; + } else if (cursor === "%") { + if (!consume(buffer, address, output)) { + break; + } + consume = consumeIsZone; + } else { + buffer.push(cursor); + continue; + } + } + if (buffer.length) { + if (consume === consumeIsZone) { + output.zone = buffer.join(""); + } else if (endIpv6) { + address.push(buffer.join("")); + } else { + address.push(stringArrayToHexStripped(buffer)); + } + } + output.address = address.join(""); + return output; + } + function normalizeIPv6(host) { + if (findToken(host, ":") < 2) { + return { host, isIPV6: false }; + } + const ipv63 = getIPV6(host); + if (!ipv63.error) { + let newHost = ipv63.address; + let escapedHost = ipv63.address; + if (ipv63.zone) { + newHost += "%" + ipv63.zone; + escapedHost += "%25" + ipv63.zone; + } + return { host: newHost, isIPV6: true, escapedHost }; + } else { + return { host, isIPV6: false }; + } + } + function findToken(str2, token) { + let ind = 0; + for (let i = 0; i < str2.length; i++) { + if (str2[i] === token) ind++; + } + return ind; + } + function removeDotSegments(path2) { + let input = path2; + const output = []; + let nextSlash = -1; + let len = 0; + while (len = input.length) { + if (len === 1) { + if (input === ".") { + break; + } else if (input === "/") { + output.push("/"); + break; + } else { + output.push(input); + break; + } + } else if (len === 2) { + if (input[0] === ".") { + if (input[1] === ".") { + break; + } else if (input[1] === "/") { + input = input.slice(2); + continue; + } + } else if (input[0] === "/") { + if (input[1] === "." || input[1] === "/") { + output.push("/"); + break; + } + } + } else if (len === 3) { + if (input === "/..") { + if (output.length !== 0) { + output.pop(); + } + output.push("/"); + break; + } + } + if (input[0] === ".") { + if (input[1] === ".") { + if (input[2] === "/") { + input = input.slice(3); + continue; + } + } else if (input[1] === "/") { + input = input.slice(2); + continue; + } + } else if (input[0] === "/") { + if (input[1] === ".") { + if (input[2] === "/") { + input = input.slice(2); + continue; + } else if (input[2] === ".") { + if (input[3] === "/") { + input = input.slice(3); + if (output.length !== 0) { + output.pop(); + } + continue; + } + } + } + } + if ((nextSlash = input.indexOf("/", 1)) === -1) { + output.push(input); + break; + } else { + output.push(input.slice(0, nextSlash)); + input = input.slice(nextSlash); + } + } + return output.join(""); + } + function normalizeComponentEncoding(component, esc2) { + const func = esc2 !== true ? escape : unescape; + if (component.scheme !== void 0) { + component.scheme = func(component.scheme); + } + if (component.userinfo !== void 0) { + component.userinfo = func(component.userinfo); + } + if (component.host !== void 0) { + component.host = func(component.host); + } + if (component.path !== void 0) { + component.path = func(component.path); + } + if (component.query !== void 0) { + component.query = func(component.query); + } + if (component.fragment !== void 0) { + component.fragment = func(component.fragment); + } + return component; + } + function recomposeAuthority(component) { + const uriTokens = []; + if (component.userinfo !== void 0) { + uriTokens.push(component.userinfo); + uriTokens.push("@"); + } + if (component.host !== void 0) { + let host = unescape(component.host); + if (!isIPv4(host)) { + const ipV6res = normalizeIPv6(host); + if (ipV6res.isIPV6 === true) { + host = `[${ipV6res.escapedHost}]`; + } else { + host = component.host; + } + } + uriTokens.push(host); + } + if (typeof component.port === "number" || typeof component.port === "string") { + uriTokens.push(":"); + uriTokens.push(String(component.port)); + } + return uriTokens.length ? uriTokens.join("") : void 0; + } + module2.exports = { + nonSimpleDomain, + recomposeAuthority, + normalizeComponentEncoding, + removeDotSegments, + isIPv4, + isUUID, + normalizeIPv6, + stringArrayToHexStripped + }; + } +}); + +// node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/schemes.js +var require_schemes = __commonJS({ + "node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/schemes.js"(exports2, module2) { + "use strict"; + var { isUUID } = require_utils(); + var URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu; + var supportedSchemeNames = ( + /** @type {const} */ + [ + "http", + "https", + "ws", + "wss", + "urn", + "urn:uuid" + ] + ); + function isValidSchemeName(name) { + return supportedSchemeNames.indexOf( + /** @type {*} */ + name + ) !== -1; + } + function wsIsSecure(wsComponent) { + if (wsComponent.secure === true) { + return true; + } else if (wsComponent.secure === false) { + return false; + } else if (wsComponent.scheme) { + return wsComponent.scheme.length === 3 && (wsComponent.scheme[0] === "w" || wsComponent.scheme[0] === "W") && (wsComponent.scheme[1] === "s" || wsComponent.scheme[1] === "S") && (wsComponent.scheme[2] === "s" || wsComponent.scheme[2] === "S"); + } else { + return false; + } + } + function httpParse(component) { + if (!component.host) { + component.error = component.error || "HTTP URIs must have a host."; + } + return component; + } + function httpSerialize(component) { + const secure = String(component.scheme).toLowerCase() === "https"; + if (component.port === (secure ? 443 : 80) || component.port === "") { + component.port = void 0; + } + if (!component.path) { + component.path = "/"; + } + return component; + } + function wsParse(wsComponent) { + wsComponent.secure = wsIsSecure(wsComponent); + wsComponent.resourceName = (wsComponent.path || "/") + (wsComponent.query ? "?" + wsComponent.query : ""); + wsComponent.path = void 0; + wsComponent.query = void 0; + return wsComponent; + } + function wsSerialize(wsComponent) { + if (wsComponent.port === (wsIsSecure(wsComponent) ? 443 : 80) || wsComponent.port === "") { + wsComponent.port = void 0; + } + if (typeof wsComponent.secure === "boolean") { + wsComponent.scheme = wsComponent.secure ? "wss" : "ws"; + wsComponent.secure = void 0; + } + if (wsComponent.resourceName) { + const [path2, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path2 && path2 !== "/" ? path2 : void 0; + wsComponent.query = query; + wsComponent.resourceName = void 0; + } + wsComponent.fragment = void 0; + return wsComponent; + } + function urnParse(urnComponent, options2) { + if (!urnComponent.path) { + urnComponent.error = "URN can not be parsed"; + return urnComponent; + } + const matches = urnComponent.path.match(URN_REG); + if (matches) { + const scheme = options2.scheme || urnComponent.scheme || "urn"; + urnComponent.nid = matches[1].toLowerCase(); + urnComponent.nss = matches[2]; + const urnScheme = `${scheme}:${options2.nid || urnComponent.nid}`; + const schemeHandler = getSchemeHandler(urnScheme); + urnComponent.path = void 0; + if (schemeHandler) { + urnComponent = schemeHandler.parse(urnComponent, options2); + } + } else { + urnComponent.error = urnComponent.error || "URN can not be parsed."; + } + return urnComponent; + } + function urnSerialize(urnComponent, options2) { + if (urnComponent.nid === void 0) { + throw new Error("URN without nid cannot be serialized"); + } + const scheme = options2.scheme || urnComponent.scheme || "urn"; + const nid = urnComponent.nid.toLowerCase(); + const urnScheme = `${scheme}:${options2.nid || nid}`; + const schemeHandler = getSchemeHandler(urnScheme); + if (schemeHandler) { + urnComponent = schemeHandler.serialize(urnComponent, options2); + } + const uriComponent = urnComponent; + const nss = urnComponent.nss; + uriComponent.path = `${nid || options2.nid}:${nss}`; + options2.skipEscape = true; + return uriComponent; + } + function urnuuidParse(urnComponent, options2) { + const uuidComponent = urnComponent; + uuidComponent.uuid = uuidComponent.nss; + uuidComponent.nss = void 0; + if (!options2.tolerant && (!uuidComponent.uuid || !isUUID(uuidComponent.uuid))) { + uuidComponent.error = uuidComponent.error || "UUID is not valid."; + } + return uuidComponent; + } + function urnuuidSerialize(uuidComponent) { + const urnComponent = uuidComponent; + urnComponent.nss = (uuidComponent.uuid || "").toLowerCase(); + return urnComponent; + } + var http = ( + /** @type {SchemeHandler} */ + { + scheme: "http", + domainHost: true, + parse: httpParse, + serialize: httpSerialize + } + ); + var https = ( + /** @type {SchemeHandler} */ + { + scheme: "https", + domainHost: http.domainHost, + parse: httpParse, + serialize: httpSerialize + } + ); + var ws = ( + /** @type {SchemeHandler} */ + { + scheme: "ws", + domainHost: true, + parse: wsParse, + serialize: wsSerialize + } + ); + var wss = ( + /** @type {SchemeHandler} */ + { + scheme: "wss", + domainHost: ws.domainHost, + parse: ws.parse, + serialize: ws.serialize + } + ); + var urn = ( + /** @type {SchemeHandler} */ + { + scheme: "urn", + parse: urnParse, + serialize: urnSerialize, + skipNormalize: true + } + ); + var urnuuid = ( + /** @type {SchemeHandler} */ + { + scheme: "urn:uuid", + parse: urnuuidParse, + serialize: urnuuidSerialize, + skipNormalize: true + } + ); + var SCHEMES = ( + /** @type {Record} */ + { + http, + https, + ws, + wss, + urn, + "urn:uuid": urnuuid + } + ); + Object.setPrototypeOf(SCHEMES, null); + function getSchemeHandler(scheme) { + return scheme && (SCHEMES[ + /** @type {SchemeName} */ + scheme + ] || SCHEMES[ + /** @type {SchemeName} */ + scheme.toLowerCase() + ]) || void 0; + } + module2.exports = { + wsIsSecure, + SCHEMES, + isValidSchemeName, + getSchemeHandler + }; + } +}); + +// node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/index.js +var require_fast_uri = __commonJS({ + "node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/index.js"(exports2, module2) { + "use strict"; + var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils(); + var { SCHEMES, getSchemeHandler } = require_schemes(); + function normalize(uri, options2) { + if (typeof uri === "string") { + uri = /** @type {T} */ + serialize(parse4(uri, options2), options2); + } else if (typeof uri === "object") { + uri = /** @type {T} */ + parse4(serialize(uri, options2), options2); + } + return uri; + } + function resolve(baseURI, relativeURI, options2) { + const schemelessOptions = options2 ? Object.assign({ scheme: "null" }, options2) : { scheme: "null" }; + const resolved = resolveComponent(parse4(baseURI, schemelessOptions), parse4(relativeURI, schemelessOptions), schemelessOptions, true); + schemelessOptions.skipEscape = true; + return serialize(resolved, schemelessOptions); + } + function resolveComponent(base, relative, options2, skipNormalization) { + const target = {}; + if (!skipNormalization) { + base = parse4(serialize(base, options2), options2); + relative = parse4(serialize(relative, options2), options2); + } + options2 = options2 || {}; + if (!options2.tolerant && relative.scheme) { + target.scheme = relative.scheme; + target.userinfo = relative.userinfo; + target.host = relative.host; + target.port = relative.port; + target.path = removeDotSegments(relative.path || ""); + target.query = relative.query; + } else { + if (relative.userinfo !== void 0 || relative.host !== void 0 || relative.port !== void 0) { + target.userinfo = relative.userinfo; + target.host = relative.host; + target.port = relative.port; + target.path = removeDotSegments(relative.path || ""); + target.query = relative.query; + } else { + if (!relative.path) { + target.path = base.path; + if (relative.query !== void 0) { + target.query = relative.query; + } else { + target.query = base.query; + } + } else { + if (relative.path[0] === "/") { + target.path = removeDotSegments(relative.path); + } else { + if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) { + target.path = "/" + relative.path; + } else if (!base.path) { + target.path = relative.path; + } else { + target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative.path; + } + target.path = removeDotSegments(target.path); + } + target.query = relative.query; + } + target.userinfo = base.userinfo; + target.host = base.host; + target.port = base.port; + } + target.scheme = base.scheme; + } + target.fragment = relative.fragment; + return target; + } + function equal(uriA, uriB, options2) { + if (typeof uriA === "string") { + uriA = unescape(uriA); + uriA = serialize(normalizeComponentEncoding(parse4(uriA, options2), true), { ...options2, skipEscape: true }); + } else if (typeof uriA === "object") { + uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options2, skipEscape: true }); + } + if (typeof uriB === "string") { + uriB = unescape(uriB); + uriB = serialize(normalizeComponentEncoding(parse4(uriB, options2), true), { ...options2, skipEscape: true }); + } else if (typeof uriB === "object") { + uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options2, skipEscape: true }); + } + return uriA.toLowerCase() === uriB.toLowerCase(); + } + function serialize(cmpts, opts) { + const component = { + host: cmpts.host, + scheme: cmpts.scheme, + userinfo: cmpts.userinfo, + port: cmpts.port, + path: cmpts.path, + query: cmpts.query, + nid: cmpts.nid, + nss: cmpts.nss, + uuid: cmpts.uuid, + fragment: cmpts.fragment, + reference: cmpts.reference, + resourceName: cmpts.resourceName, + secure: cmpts.secure, + error: "" + }; + const options2 = Object.assign({}, opts); + const uriTokens = []; + const schemeHandler = getSchemeHandler(options2.scheme || component.scheme); + if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options2); + if (component.path !== void 0) { + if (!options2.skipEscape) { + component.path = escape(component.path); + if (component.scheme !== void 0) { + component.path = component.path.split("%3A").join(":"); + } + } else { + component.path = unescape(component.path); + } + } + if (options2.reference !== "suffix" && component.scheme) { + uriTokens.push(component.scheme, ":"); + } + const authority = recomposeAuthority(component); + if (authority !== void 0) { + if (options2.reference !== "suffix") { + uriTokens.push("//"); + } + uriTokens.push(authority); + if (component.path && component.path[0] !== "/") { + uriTokens.push("/"); + } + } + if (component.path !== void 0) { + let s = component.path; + if (!options2.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) { + s = removeDotSegments(s); + } + if (authority === void 0 && s[0] === "/" && s[1] === "/") { + s = "/%2F" + s.slice(2); + } + uriTokens.push(s); + } + if (component.query !== void 0) { + uriTokens.push("?", component.query); + } + if (component.fragment !== void 0) { + uriTokens.push("#", component.fragment); + } + return uriTokens.join(""); + } + var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u; + function parse4(uri, opts) { + const options2 = Object.assign({}, opts); + const parsed = { + scheme: void 0, + userinfo: void 0, + host: "", + port: void 0, + path: "", + query: void 0, + fragment: void 0 + }; + let isIP = false; + if (options2.reference === "suffix") { + if (options2.scheme) { + uri = options2.scheme + ":" + uri; + } else { + uri = "//" + uri; + } + } + const matches = uri.match(URI_PARSE); + if (matches) { + parsed.scheme = matches[1]; + parsed.userinfo = matches[3]; + parsed.host = matches[4]; + parsed.port = parseInt(matches[5], 10); + parsed.path = matches[6] || ""; + parsed.query = matches[7]; + parsed.fragment = matches[8]; + if (isNaN(parsed.port)) { + parsed.port = matches[5]; + } + if (parsed.host) { + const ipv4result = isIPv4(parsed.host); + if (ipv4result === false) { + const ipv6result = normalizeIPv6(parsed.host); + parsed.host = ipv6result.host.toLowerCase(); + isIP = ipv6result.isIPV6; + } else { + isIP = true; + } + } + if (parsed.scheme === void 0 && parsed.userinfo === void 0 && parsed.host === void 0 && parsed.port === void 0 && parsed.query === void 0 && !parsed.path) { + parsed.reference = "same-document"; + } else if (parsed.scheme === void 0) { + parsed.reference = "relative"; + } else if (parsed.fragment === void 0) { + parsed.reference = "absolute"; + } else { + parsed.reference = "uri"; + } + if (options2.reference && options2.reference !== "suffix" && options2.reference !== parsed.reference) { + parsed.error = parsed.error || "URI is not a " + options2.reference + " reference."; + } + const schemeHandler = getSchemeHandler(options2.scheme || parsed.scheme); + if (!options2.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) { + if (parsed.host && (options2.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { + try { + parsed.host = URL.domainToASCII(parsed.host.toLowerCase()); + } catch (e) { + parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e; + } + } + } + if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { + if (uri.indexOf("%") !== -1) { + if (parsed.scheme !== void 0) { + parsed.scheme = unescape(parsed.scheme); + } + if (parsed.host !== void 0) { + parsed.host = unescape(parsed.host); + } + } + if (parsed.path) { + parsed.path = escape(unescape(parsed.path)); + } + if (parsed.fragment) { + parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment)); + } + } + if (schemeHandler && schemeHandler.parse) { + schemeHandler.parse(parsed, options2); + } + } else { + parsed.error = parsed.error || "URI can not be parsed."; + } + return parsed; + } + var fastUri = { + SCHEMES, + normalize, + resolve, + resolveComponent, + equal, + serialize, + parse: parse4 + }; + module2.exports = fastUri; + module2.exports.default = fastUri; + module2.exports.fastUri = fastUri; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/uri.js +var require_uri = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/uri.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var uri = require_fast_uri(); + uri.code = 'require("ajv/dist/runtime/uri").default'; + exports2.default = uri; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/core.js +var require_core = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/core.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.CodeGen = exports2.Name = exports2.nil = exports2.stringify = exports2.str = exports2._ = exports2.KeywordCxt = void 0; + var validate_1 = require_validate(); + Object.defineProperty(exports2, "KeywordCxt", { enumerable: true, get: function() { + return validate_1.KeywordCxt; + } }); + var codegen_1 = require_codegen(); + Object.defineProperty(exports2, "_", { enumerable: true, get: function() { + return codegen_1._; + } }); + Object.defineProperty(exports2, "str", { enumerable: true, get: function() { + return codegen_1.str; + } }); + Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { + return codegen_1.stringify; + } }); + Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { + return codegen_1.nil; + } }); + Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { + return codegen_1.Name; + } }); + Object.defineProperty(exports2, "CodeGen", { enumerable: true, get: function() { + return codegen_1.CodeGen; + } }); + var validation_error_1 = require_validation_error(); + var ref_error_1 = require_ref_error(); + var rules_1 = require_rules(); + var compile_1 = require_compile(); + var codegen_2 = require_codegen(); + var resolve_1 = require_resolve(); + var dataType_1 = require_dataType(); + var util_1 = require_util(); + var $dataRefSchema = require_data(); + var uri_1 = require_uri(); + var defaultRegExp = (str2, flags) => new RegExp(str2, flags); + defaultRegExp.code = "new RegExp"; + var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"]; + var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([ + "validate", + "serialize", + "parse", + "wrapper", + "root", + "schema", + "keyword", + "pattern", + "formats", + "validate$data", + "func", + "obj", + "Error" + ]); + var removedOptions = { + errorDataPath: "", + format: "`validateFormats: false` can be used instead.", + nullable: '"nullable" keyword is supported by default.', + jsonPointers: "Deprecated jsPropertySyntax can be used instead.", + extendRefs: "Deprecated ignoreKeywordsWithRef can be used instead.", + missingRefs: "Pass empty schema with $id that should be ignored to ajv.addSchema.", + processCode: "Use option `code: {process: (code, schemaEnv: object) => string}`", + sourceCode: "Use option `code: {source: true}`", + strictDefaults: "It is default now, see option `strict`.", + strictKeywords: "It is default now, see option `strict`.", + uniqueItems: '"uniqueItems" keyword is always validated.', + unknownFormats: "Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).", + cache: "Map is used as cache, schema object as key.", + serialize: "Map is used as cache, schema object as key.", + ajvErrors: "It is default now." + }; + var deprecatedOptions = { + ignoreKeywordsWithRef: "", + jsPropertySyntax: "", + unicode: '"minLength"/"maxLength" account for unicode characters by default.' + }; + var MAX_EXPRESSION = 200; + function requiredOptions(o) { + var _a2, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0; + const s = o.strict; + const _optz = (_a2 = o.code) === null || _a2 === void 0 ? void 0 : _a2.optimize; + const optimize = _optz === true || _optz === void 0 ? 1 : _optz || 0; + const regExp = (_c = (_b = o.code) === null || _b === void 0 ? void 0 : _b.regExp) !== null && _c !== void 0 ? _c : defaultRegExp; + const uriResolver = (_d = o.uriResolver) !== null && _d !== void 0 ? _d : uri_1.default; + return { + strictSchema: (_f = (_e = o.strictSchema) !== null && _e !== void 0 ? _e : s) !== null && _f !== void 0 ? _f : true, + strictNumbers: (_h = (_g = o.strictNumbers) !== null && _g !== void 0 ? _g : s) !== null && _h !== void 0 ? _h : true, + strictTypes: (_k = (_j = o.strictTypes) !== null && _j !== void 0 ? _j : s) !== null && _k !== void 0 ? _k : "log", + strictTuples: (_m = (_l = o.strictTuples) !== null && _l !== void 0 ? _l : s) !== null && _m !== void 0 ? _m : "log", + strictRequired: (_p = (_o = o.strictRequired) !== null && _o !== void 0 ? _o : s) !== null && _p !== void 0 ? _p : false, + code: o.code ? { ...o.code, optimize, regExp } : { optimize, regExp }, + loopRequired: (_q = o.loopRequired) !== null && _q !== void 0 ? _q : MAX_EXPRESSION, + loopEnum: (_r = o.loopEnum) !== null && _r !== void 0 ? _r : MAX_EXPRESSION, + meta: (_s = o.meta) !== null && _s !== void 0 ? _s : true, + messages: (_t = o.messages) !== null && _t !== void 0 ? _t : true, + inlineRefs: (_u = o.inlineRefs) !== null && _u !== void 0 ? _u : true, + schemaId: (_v = o.schemaId) !== null && _v !== void 0 ? _v : "$id", + addUsedSchema: (_w = o.addUsedSchema) !== null && _w !== void 0 ? _w : true, + validateSchema: (_x = o.validateSchema) !== null && _x !== void 0 ? _x : true, + validateFormats: (_y = o.validateFormats) !== null && _y !== void 0 ? _y : true, + unicodeRegExp: (_z = o.unicodeRegExp) !== null && _z !== void 0 ? _z : true, + int32range: (_0 = o.int32range) !== null && _0 !== void 0 ? _0 : true, + uriResolver + }; + } + var Ajv2 = class { + constructor(opts = {}) { + this.schemas = {}; + this.refs = {}; + this.formats = {}; + this._compilations = /* @__PURE__ */ new Set(); + this._loading = {}; + this._cache = /* @__PURE__ */ new Map(); + opts = this.opts = { ...opts, ...requiredOptions(opts) }; + const { es5, lines } = this.opts.code; + this.scope = new codegen_2.ValueScope({ scope: {}, prefixes: EXT_SCOPE_NAMES, es5, lines }); + this.logger = getLogger(opts.logger); + const formatOpt = opts.validateFormats; + opts.validateFormats = false; + this.RULES = (0, rules_1.getRules)(); + checkOptions.call(this, removedOptions, opts, "NOT SUPPORTED"); + checkOptions.call(this, deprecatedOptions, opts, "DEPRECATED", "warn"); + this._metaOpts = getMetaSchemaOptions.call(this); + if (opts.formats) + addInitialFormats.call(this); + this._addVocabularies(); + this._addDefaultMetaSchema(); + if (opts.keywords) + addInitialKeywords.call(this, opts.keywords); + if (typeof opts.meta == "object") + this.addMetaSchema(opts.meta); + addInitialSchemas.call(this); + opts.validateFormats = formatOpt; + } + _addVocabularies() { + this.addKeyword("$async"); + } + _addDefaultMetaSchema() { + const { $data, meta: meta3, schemaId } = this.opts; + let _dataRefSchema = $dataRefSchema; + if (schemaId === "id") { + _dataRefSchema = { ...$dataRefSchema }; + _dataRefSchema.id = _dataRefSchema.$id; + delete _dataRefSchema.$id; + } + if (meta3 && $data) + this.addMetaSchema(_dataRefSchema, _dataRefSchema[schemaId], false); + } + defaultMeta() { + const { meta: meta3, schemaId } = this.opts; + return this.opts.defaultMeta = typeof meta3 == "object" ? meta3[schemaId] || meta3 : void 0; + } + validate(schemaKeyRef, data) { + let v; + if (typeof schemaKeyRef == "string") { + v = this.getSchema(schemaKeyRef); + if (!v) + throw new Error(`no schema with key or ref "${schemaKeyRef}"`); + } else { + v = this.compile(schemaKeyRef); + } + const valid = v(data); + if (!("$async" in v)) + this.errors = v.errors; + return valid; + } + compile(schema, _meta) { + const sch = this._addSchema(schema, _meta); + return sch.validate || this._compileSchemaEnv(sch); + } + compileAsync(schema, meta3) { + if (typeof this.opts.loadSchema != "function") { + throw new Error("options.loadSchema should be a function"); + } + const { loadSchema } = this.opts; + return runCompileAsync.call(this, schema, meta3); + async function runCompileAsync(_schema, _meta) { + await loadMetaSchema.call(this, _schema.$schema); + const sch = this._addSchema(_schema, _meta); + return sch.validate || _compileAsync.call(this, sch); + } + async function loadMetaSchema($ref) { + if ($ref && !this.getSchema($ref)) { + await runCompileAsync.call(this, { $ref }, true); + } + } + async function _compileAsync(sch) { + try { + return this._compileSchemaEnv(sch); + } catch (e) { + if (!(e instanceof ref_error_1.default)) + throw e; + checkLoaded.call(this, e); + await loadMissingSchema.call(this, e.missingSchema); + return _compileAsync.call(this, sch); + } + } + function checkLoaded({ missingSchema: ref, missingRef }) { + if (this.refs[ref]) { + throw new Error(`AnySchema ${ref} is loaded but ${missingRef} cannot be resolved`); + } + } + async function loadMissingSchema(ref) { + const _schema = await _loadSchema.call(this, ref); + if (!this.refs[ref]) + await loadMetaSchema.call(this, _schema.$schema); + if (!this.refs[ref]) + this.addSchema(_schema, ref, meta3); + } + async function _loadSchema(ref) { + const p = this._loading[ref]; + if (p) + return p; + try { + return await (this._loading[ref] = loadSchema(ref)); + } finally { + delete this._loading[ref]; + } + } + } + // Adds schema to the instance + addSchema(schema, key, _meta, _validateSchema = this.opts.validateSchema) { + if (Array.isArray(schema)) { + for (const sch of schema) + this.addSchema(sch, void 0, _meta, _validateSchema); + return this; + } + let id; + if (typeof schema === "object") { + const { schemaId } = this.opts; + id = schema[schemaId]; + if (id !== void 0 && typeof id != "string") { + throw new Error(`schema ${schemaId} must be string`); + } + } + key = (0, resolve_1.normalizeId)(key || id); + this._checkUnique(key); + this.schemas[key] = this._addSchema(schema, _meta, key, _validateSchema, true); + return this; + } + // Add schema that will be used to validate other schemas + // options in META_IGNORE_OPTIONS are alway set to false + addMetaSchema(schema, key, _validateSchema = this.opts.validateSchema) { + this.addSchema(schema, key, true, _validateSchema); + return this; + } + // Validate schema against its meta-schema + validateSchema(schema, throwOrLogError) { + if (typeof schema == "boolean") + return true; + let $schema; + $schema = schema.$schema; + if ($schema !== void 0 && typeof $schema != "string") { + throw new Error("$schema must be a string"); + } + $schema = $schema || this.opts.defaultMeta || this.defaultMeta(); + if (!$schema) { + this.logger.warn("meta-schema not available"); + this.errors = null; + return true; + } + const valid = this.validate($schema, schema); + if (!valid && throwOrLogError) { + const message = "schema is invalid: " + this.errorsText(); + if (this.opts.validateSchema === "log") + this.logger.error(message); + else + throw new Error(message); + } + return valid; + } + // Get compiled schema by `key` or `ref`. + // (`key` that was passed to `addSchema` or full schema reference - `schema.$id` or resolved id) + getSchema(keyRef) { + let sch; + while (typeof (sch = getSchEnv.call(this, keyRef)) == "string") + keyRef = sch; + if (sch === void 0) { + const { schemaId } = this.opts; + const root = new compile_1.SchemaEnv({ schema: {}, schemaId }); + sch = compile_1.resolveSchema.call(this, root, keyRef); + if (!sch) + return; + this.refs[keyRef] = sch; + } + return sch.validate || this._compileSchemaEnv(sch); + } + // Remove cached schema(s). + // If no parameter is passed all schemas but meta-schemas are removed. + // If RegExp is passed all schemas with key/id matching pattern but meta-schemas are removed. + // Even if schema is referenced by other schemas it still can be removed as other schemas have local references. + removeSchema(schemaKeyRef) { + if (schemaKeyRef instanceof RegExp) { + this._removeAllSchemas(this.schemas, schemaKeyRef); + this._removeAllSchemas(this.refs, schemaKeyRef); + return this; + } + switch (typeof schemaKeyRef) { + case "undefined": + this._removeAllSchemas(this.schemas); + this._removeAllSchemas(this.refs); + this._cache.clear(); + return this; + case "string": { + const sch = getSchEnv.call(this, schemaKeyRef); + if (typeof sch == "object") + this._cache.delete(sch.schema); + delete this.schemas[schemaKeyRef]; + delete this.refs[schemaKeyRef]; + return this; + } + case "object": { + const cacheKey = schemaKeyRef; + this._cache.delete(cacheKey); + let id = schemaKeyRef[this.opts.schemaId]; + if (id) { + id = (0, resolve_1.normalizeId)(id); + delete this.schemas[id]; + delete this.refs[id]; + } + return this; + } + default: + throw new Error("ajv.removeSchema: invalid parameter"); + } + } + // add "vocabulary" - a collection of keywords + addVocabulary(definitions) { + for (const def of definitions) + this.addKeyword(def); + return this; + } + addKeyword(kwdOrDef, def) { + let keyword; + if (typeof kwdOrDef == "string") { + keyword = kwdOrDef; + if (typeof def == "object") { + this.logger.warn("these parameters are deprecated, see docs for addKeyword"); + def.keyword = keyword; + } + } else if (typeof kwdOrDef == "object" && def === void 0) { + def = kwdOrDef; + keyword = def.keyword; + if (Array.isArray(keyword) && !keyword.length) { + throw new Error("addKeywords: keyword must be string or non-empty array"); + } + } else { + throw new Error("invalid addKeywords parameters"); + } + checkKeyword.call(this, keyword, def); + if (!def) { + (0, util_1.eachItem)(keyword, (kwd) => addRule.call(this, kwd)); + return this; + } + keywordMetaschema.call(this, def); + const definition = { + ...def, + type: (0, dataType_1.getJSONTypes)(def.type), + schemaType: (0, dataType_1.getJSONTypes)(def.schemaType) + }; + (0, util_1.eachItem)(keyword, definition.type.length === 0 ? (k) => addRule.call(this, k, definition) : (k) => definition.type.forEach((t) => addRule.call(this, k, definition, t))); + return this; + } + getKeyword(keyword) { + const rule = this.RULES.all[keyword]; + return typeof rule == "object" ? rule.definition : !!rule; + } + // Remove keyword + removeKeyword(keyword) { + const { RULES } = this; + delete RULES.keywords[keyword]; + delete RULES.all[keyword]; + for (const group of RULES.rules) { + const i = group.rules.findIndex((rule) => rule.keyword === keyword); + if (i >= 0) + group.rules.splice(i, 1); + } + return this; + } + // Add format + addFormat(name, format) { + if (typeof format == "string") + format = new RegExp(format); + this.formats[name] = format; + return this; + } + errorsText(errors = this.errors, { separator = ", ", dataVar = "data" } = {}) { + if (!errors || errors.length === 0) + return "No errors"; + return errors.map((e) => `${dataVar}${e.instancePath} ${e.message}`).reduce((text, msg) => text + separator + msg); + } + $dataMetaSchema(metaSchema, keywordsJsonPointers) { + const rules = this.RULES.all; + metaSchema = JSON.parse(JSON.stringify(metaSchema)); + for (const jsonPointer of keywordsJsonPointers) { + const segments = jsonPointer.split("/").slice(1); + let keywords = metaSchema; + for (const seg of segments) + keywords = keywords[seg]; + for (const key in rules) { + const rule = rules[key]; + if (typeof rule != "object") + continue; + const { $data } = rule.definition; + const schema = keywords[key]; + if ($data && schema) + keywords[key] = schemaOrData(schema); + } + } + return metaSchema; + } + _removeAllSchemas(schemas, regex) { + for (const keyRef in schemas) { + const sch = schemas[keyRef]; + if (!regex || regex.test(keyRef)) { + if (typeof sch == "string") { + delete schemas[keyRef]; + } else if (sch && !sch.meta) { + this._cache.delete(sch.schema); + delete schemas[keyRef]; + } + } + } + } + _addSchema(schema, meta3, baseId, validateSchema = this.opts.validateSchema, addSchema = this.opts.addUsedSchema) { + let id; + const { schemaId } = this.opts; + if (typeof schema == "object") { + id = schema[schemaId]; + } else { + if (this.opts.jtd) + throw new Error("schema must be object"); + else if (typeof schema != "boolean") + throw new Error("schema must be object or boolean"); + } + let sch = this._cache.get(schema); + if (sch !== void 0) + return sch; + baseId = (0, resolve_1.normalizeId)(id || baseId); + const localRefs = resolve_1.getSchemaRefs.call(this, schema, baseId); + sch = new compile_1.SchemaEnv({ schema, schemaId, meta: meta3, baseId, localRefs }); + this._cache.set(sch.schema, sch); + if (addSchema && !baseId.startsWith("#")) { + if (baseId) + this._checkUnique(baseId); + this.refs[baseId] = sch; + } + if (validateSchema) + this.validateSchema(schema, true); + return sch; + } + _checkUnique(id) { + if (this.schemas[id] || this.refs[id]) { + throw new Error(`schema with key or id "${id}" already exists`); + } + } + _compileSchemaEnv(sch) { + if (sch.meta) + this._compileMetaSchema(sch); + else + compile_1.compileSchema.call(this, sch); + if (!sch.validate) + throw new Error("ajv implementation error"); + return sch.validate; + } + _compileMetaSchema(sch) { + const currentOpts = this.opts; + this.opts = this._metaOpts; + try { + compile_1.compileSchema.call(this, sch); + } finally { + this.opts = currentOpts; + } + } + }; + Ajv2.ValidationError = validation_error_1.default; + Ajv2.MissingRefError = ref_error_1.default; + exports2.default = Ajv2; + function checkOptions(checkOpts, options2, msg, log = "error") { + for (const key in checkOpts) { + const opt = key; + if (opt in options2) + this.logger[log](`${msg}: option ${key}. ${checkOpts[opt]}`); + } + } + function getSchEnv(keyRef) { + keyRef = (0, resolve_1.normalizeId)(keyRef); + return this.schemas[keyRef] || this.refs[keyRef]; + } + function addInitialSchemas() { + const optsSchemas = this.opts.schemas; + if (!optsSchemas) + return; + if (Array.isArray(optsSchemas)) + this.addSchema(optsSchemas); + else + for (const key in optsSchemas) + this.addSchema(optsSchemas[key], key); + } + function addInitialFormats() { + for (const name in this.opts.formats) { + const format = this.opts.formats[name]; + if (format) + this.addFormat(name, format); + } + } + function addInitialKeywords(defs) { + if (Array.isArray(defs)) { + this.addVocabulary(defs); + return; + } + this.logger.warn("keywords option as map is deprecated, pass array"); + for (const keyword in defs) { + const def = defs[keyword]; + if (!def.keyword) + def.keyword = keyword; + this.addKeyword(def); + } + } + function getMetaSchemaOptions() { + const metaOpts = { ...this.opts }; + for (const opt of META_IGNORE_OPTIONS) + delete metaOpts[opt]; + return metaOpts; + } + var noLogs = { log() { + }, warn() { + }, error() { + } }; + function getLogger(logger) { + if (logger === false) + return noLogs; + if (logger === void 0) + return console; + if (logger.log && logger.warn && logger.error) + return logger; + throw new Error("logger must implement log, warn and error methods"); + } + var KEYWORD_NAME = /^[a-z_$][a-z0-9_$:-]*$/i; + function checkKeyword(keyword, def) { + const { RULES } = this; + (0, util_1.eachItem)(keyword, (kwd) => { + if (RULES.keywords[kwd]) + throw new Error(`Keyword ${kwd} is already defined`); + if (!KEYWORD_NAME.test(kwd)) + throw new Error(`Keyword ${kwd} has invalid name`); + }); + if (!def) + return; + if (def.$data && !("code" in def || "validate" in def)) { + throw new Error('$data keyword must have "code" or "validate" function'); + } + } + function addRule(keyword, definition, dataType) { + var _a2; + const post = definition === null || definition === void 0 ? void 0 : definition.post; + if (dataType && post) + throw new Error('keyword with "post" flag cannot have "type"'); + const { RULES } = this; + let ruleGroup = post ? RULES.post : RULES.rules.find(({ type: t }) => t === dataType); + if (!ruleGroup) { + ruleGroup = { type: dataType, rules: [] }; + RULES.rules.push(ruleGroup); + } + RULES.keywords[keyword] = true; + if (!definition) + return; + const rule = { + keyword, + definition: { + ...definition, + type: (0, dataType_1.getJSONTypes)(definition.type), + schemaType: (0, dataType_1.getJSONTypes)(definition.schemaType) + } + }; + if (definition.before) + addBeforeRule.call(this, ruleGroup, rule, definition.before); + else + ruleGroup.rules.push(rule); + RULES.all[keyword] = rule; + (_a2 = definition.implements) === null || _a2 === void 0 ? void 0 : _a2.forEach((kwd) => this.addKeyword(kwd)); + } + function addBeforeRule(ruleGroup, rule, before) { + const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before); + if (i >= 0) { + ruleGroup.rules.splice(i, 0, rule); + } else { + ruleGroup.rules.push(rule); + this.logger.warn(`rule ${before} is not defined`); + } + } + function keywordMetaschema(def) { + let { metaSchema } = def; + if (metaSchema === void 0) + return; + if (def.$data && this.opts.$data) + metaSchema = schemaOrData(metaSchema); + def.validateSchema = this.compile(metaSchema, true); + } + var $dataRef = { + $ref: "https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#" + }; + function schemaOrData(schema) { + return { anyOf: [schema, $dataRef] }; + } + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/id.js +var require_id = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/id.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var def = { + keyword: "id", + code() { + throw new Error('NOT SUPPORTED: keyword "id", use "$id" for schema ID'); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/ref.js +var require_ref = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/ref.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.callRef = exports2.getValidate = void 0; + var ref_error_1 = require_ref_error(); + var code_1 = require_code2(); + var codegen_1 = require_codegen(); + var names_1 = require_names(); + var compile_1 = require_compile(); + var util_1 = require_util(); + var def = { + keyword: "$ref", + schemaType: "string", + code(cxt) { + const { gen, schema: $ref, it } = cxt; + const { baseId, schemaEnv: env, validateName, opts, self } = it; + const { root } = env; + if (($ref === "#" || $ref === "#/") && baseId === root.baseId) + return callRootRef(); + const schOrEnv = compile_1.resolveRef.call(self, root, baseId, $ref); + if (schOrEnv === void 0) + throw new ref_error_1.default(it.opts.uriResolver, baseId, $ref); + if (schOrEnv instanceof compile_1.SchemaEnv) + return callValidate(schOrEnv); + return inlineRefSchema(schOrEnv); + function callRootRef() { + if (env === root) + return callRef(cxt, validateName, env, env.$async); + const rootName = gen.scopeValue("root", { ref: root }); + return callRef(cxt, (0, codegen_1._)`${rootName}.validate`, root, root.$async); + } + function callValidate(sch) { + const v = getValidate(cxt, sch); + callRef(cxt, v, sch, sch.$async); + } + function inlineRefSchema(sch) { + const schName = gen.scopeValue("schema", opts.code.source === true ? { ref: sch, code: (0, codegen_1.stringify)(sch) } : { ref: sch }); + const valid = gen.name("valid"); + const schCxt = cxt.subschema({ + schema: sch, + dataTypes: [], + schemaPath: codegen_1.nil, + topSchemaRef: schName, + errSchemaPath: $ref + }, valid); + cxt.mergeEvaluated(schCxt); + cxt.ok(valid); + } + } + }; + function getValidate(cxt, sch) { + const { gen } = cxt; + return sch.validate ? gen.scopeValue("validate", { ref: sch.validate }) : (0, codegen_1._)`${gen.scopeValue("wrapper", { ref: sch })}.validate`; + } + exports2.getValidate = getValidate; + function callRef(cxt, v, sch, $async) { + const { gen, it } = cxt; + const { allErrors, schemaEnv: env, opts } = it; + const passCxt = opts.passContext ? names_1.default.this : codegen_1.nil; + if ($async) + callAsyncRef(); + else + callSyncRef(); + function callAsyncRef() { + if (!env.$async) + throw new Error("async schema referenced by sync schema"); + const valid = gen.let("valid"); + gen.try(() => { + gen.code((0, codegen_1._)`await ${(0, code_1.callValidateCode)(cxt, v, passCxt)}`); + addEvaluatedFrom(v); + if (!allErrors) + gen.assign(valid, true); + }, (e) => { + gen.if((0, codegen_1._)`!(${e} instanceof ${it.ValidationError})`, () => gen.throw(e)); + addErrorsFrom(e); + if (!allErrors) + gen.assign(valid, false); + }); + cxt.ok(valid); + } + function callSyncRef() { + cxt.result((0, code_1.callValidateCode)(cxt, v, passCxt), () => addEvaluatedFrom(v), () => addErrorsFrom(v)); + } + function addErrorsFrom(source) { + const errs = (0, codegen_1._)`${source}.errors`; + gen.assign(names_1.default.vErrors, (0, codegen_1._)`${names_1.default.vErrors} === null ? ${errs} : ${names_1.default.vErrors}.concat(${errs})`); + gen.assign(names_1.default.errors, (0, codegen_1._)`${names_1.default.vErrors}.length`); + } + function addEvaluatedFrom(source) { + var _a2; + if (!it.opts.unevaluated) + return; + const schEvaluated = (_a2 = sch === null || sch === void 0 ? void 0 : sch.validate) === null || _a2 === void 0 ? void 0 : _a2.evaluated; + if (it.props !== true) { + if (schEvaluated && !schEvaluated.dynamicProps) { + if (schEvaluated.props !== void 0) { + it.props = util_1.mergeEvaluated.props(gen, schEvaluated.props, it.props); + } + } else { + const props = gen.var("props", (0, codegen_1._)`${source}.evaluated.props`); + it.props = util_1.mergeEvaluated.props(gen, props, it.props, codegen_1.Name); + } + } + if (it.items !== true) { + if (schEvaluated && !schEvaluated.dynamicItems) { + if (schEvaluated.items !== void 0) { + it.items = util_1.mergeEvaluated.items(gen, schEvaluated.items, it.items); + } + } else { + const items = gen.var("items", (0, codegen_1._)`${source}.evaluated.items`); + it.items = util_1.mergeEvaluated.items(gen, items, it.items, codegen_1.Name); + } + } + } + } + exports2.callRef = callRef; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/index.js +var require_core2 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var id_1 = require_id(); + var ref_1 = require_ref(); + var core = [ + "$schema", + "$id", + "$defs", + "$vocabulary", + { keyword: "$comment" }, + "definitions", + id_1.default, + ref_1.default + ]; + exports2.default = core; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitNumber.js +var require_limitNumber = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitNumber.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var ops = codegen_1.operators; + var KWDs = { + maximum: { okStr: "<=", ok: ops.LTE, fail: ops.GT }, + minimum: { okStr: ">=", ok: ops.GTE, fail: ops.LT }, + exclusiveMaximum: { okStr: "<", ok: ops.LT, fail: ops.GTE }, + exclusiveMinimum: { okStr: ">", ok: ops.GT, fail: ops.LTE } + }; + var error2 = { + message: ({ keyword, schemaCode }) => (0, codegen_1.str)`must be ${KWDs[keyword].okStr} ${schemaCode}`, + params: ({ keyword, schemaCode }) => (0, codegen_1._)`{comparison: ${KWDs[keyword].okStr}, limit: ${schemaCode}}` + }; + var def = { + keyword: Object.keys(KWDs), + type: "number", + schemaType: "number", + $data: true, + error: error2, + code(cxt) { + const { keyword, data, schemaCode } = cxt; + cxt.fail$data((0, codegen_1._)`${data} ${KWDs[keyword].fail} ${schemaCode} || isNaN(${data})`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/multipleOf.js +var require_multipleOf = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/multipleOf.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var error2 = { + message: ({ schemaCode }) => (0, codegen_1.str)`must be multiple of ${schemaCode}`, + params: ({ schemaCode }) => (0, codegen_1._)`{multipleOf: ${schemaCode}}` + }; + var def = { + keyword: "multipleOf", + type: "number", + schemaType: "number", + $data: true, + error: error2, + code(cxt) { + const { gen, data, schemaCode, it } = cxt; + const prec = it.opts.multipleOfPrecision; + const res = gen.let("res"); + const invalid = prec ? (0, codegen_1._)`Math.abs(Math.round(${res}) - ${res}) > 1e-${prec}` : (0, codegen_1._)`${res} !== parseInt(${res})`; + cxt.fail$data((0, codegen_1._)`(${schemaCode} === 0 || (${res} = ${data}/${schemaCode}, ${invalid}))`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/ucs2length.js +var require_ucs2length = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/ucs2length.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + function ucs2length(str2) { + const len = str2.length; + let length = 0; + let pos = 0; + let value; + while (pos < len) { + length++; + value = str2.charCodeAt(pos++); + if (value >= 55296 && value <= 56319 && pos < len) { + value = str2.charCodeAt(pos); + if ((value & 64512) === 56320) + pos++; + } + } + return length; + } + exports2.default = ucs2length; + ucs2length.code = 'require("ajv/dist/runtime/ucs2length").default'; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitLength.js +var require_limitLength = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitLength.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var ucs2length_1 = require_ucs2length(); + var error2 = { + message({ keyword, schemaCode }) { + const comp = keyword === "maxLength" ? "more" : "fewer"; + return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} characters`; + }, + params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` + }; + var def = { + keyword: ["maxLength", "minLength"], + type: "string", + schemaType: "number", + $data: true, + error: error2, + code(cxt) { + const { keyword, data, schemaCode, it } = cxt; + const op = keyword === "maxLength" ? codegen_1.operators.GT : codegen_1.operators.LT; + const len = it.opts.unicode === false ? (0, codegen_1._)`${data}.length` : (0, codegen_1._)`${(0, util_1.useFunc)(cxt.gen, ucs2length_1.default)}(${data})`; + cxt.fail$data((0, codegen_1._)`${len} ${op} ${schemaCode}`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/pattern.js +var require_pattern = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/pattern.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var code_1 = require_code2(); + var util_1 = require_util(); + var codegen_1 = require_codegen(); + var error2 = { + message: ({ schemaCode }) => (0, codegen_1.str)`must match pattern "${schemaCode}"`, + params: ({ schemaCode }) => (0, codegen_1._)`{pattern: ${schemaCode}}` + }; + var def = { + keyword: "pattern", + type: "string", + schemaType: "string", + $data: true, + error: error2, + code(cxt) { + const { gen, data, $data, schema, schemaCode, it } = cxt; + const u = it.opts.unicodeRegExp ? "u" : ""; + if ($data) { + const { regExp } = it.opts.code; + const regExpCode = regExp.code === "new RegExp" ? (0, codegen_1._)`new RegExp` : (0, util_1.useFunc)(gen, regExp); + const valid = gen.let("valid"); + gen.try(() => gen.assign(valid, (0, codegen_1._)`${regExpCode}(${schemaCode}, ${u}).test(${data})`), () => gen.assign(valid, false)); + cxt.fail$data((0, codegen_1._)`!${valid}`); + } else { + const regExp = (0, code_1.usePattern)(cxt, schema); + cxt.fail$data((0, codegen_1._)`!${regExp}.test(${data})`); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitProperties.js +var require_limitProperties = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitProperties.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var error2 = { + message({ keyword, schemaCode }) { + const comp = keyword === "maxProperties" ? "more" : "fewer"; + return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} properties`; + }, + params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` + }; + var def = { + keyword: ["maxProperties", "minProperties"], + type: "object", + schemaType: "number", + $data: true, + error: error2, + code(cxt) { + const { keyword, data, schemaCode } = cxt; + const op = keyword === "maxProperties" ? codegen_1.operators.GT : codegen_1.operators.LT; + cxt.fail$data((0, codegen_1._)`Object.keys(${data}).length ${op} ${schemaCode}`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/required.js +var require_required = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/required.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var code_1 = require_code2(); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: ({ params: { missingProperty } }) => (0, codegen_1.str)`must have required property '${missingProperty}'`, + params: ({ params: { missingProperty } }) => (0, codegen_1._)`{missingProperty: ${missingProperty}}` + }; + var def = { + keyword: "required", + type: "object", + schemaType: "array", + $data: true, + error: error2, + code(cxt) { + const { gen, schema, schemaCode, data, $data, it } = cxt; + const { opts } = it; + if (!$data && schema.length === 0) + return; + const useLoop = schema.length >= opts.loopRequired; + if (it.allErrors) + allErrorsMode(); + else + exitOnErrorMode(); + if (opts.strictRequired) { + const props = cxt.parentSchema.properties; + const { definedProperties } = cxt.it; + for (const requiredKey of schema) { + if ((props === null || props === void 0 ? void 0 : props[requiredKey]) === void 0 && !definedProperties.has(requiredKey)) { + const schemaPath = it.schemaEnv.baseId + it.errSchemaPath; + const msg = `required property "${requiredKey}" is not defined at "${schemaPath}" (strictRequired)`; + (0, util_1.checkStrictMode)(it, msg, it.opts.strictRequired); + } + } + } + function allErrorsMode() { + if (useLoop || $data) { + cxt.block$data(codegen_1.nil, loopAllRequired); + } else { + for (const prop of schema) { + (0, code_1.checkReportMissingProp)(cxt, prop); + } + } + } + function exitOnErrorMode() { + const missing = gen.let("missing"); + if (useLoop || $data) { + const valid = gen.let("valid", true); + cxt.block$data(valid, () => loopUntilMissing(missing, valid)); + cxt.ok(valid); + } else { + gen.if((0, code_1.checkMissingProp)(cxt, schema, missing)); + (0, code_1.reportMissingProp)(cxt, missing); + gen.else(); + } + } + function loopAllRequired() { + gen.forOf("prop", schemaCode, (prop) => { + cxt.setParams({ missingProperty: prop }); + gen.if((0, code_1.noPropertyInData)(gen, data, prop, opts.ownProperties), () => cxt.error()); + }); + } + function loopUntilMissing(missing, valid) { + cxt.setParams({ missingProperty: missing }); + gen.forOf(missing, schemaCode, () => { + gen.assign(valid, (0, code_1.propertyInData)(gen, data, missing, opts.ownProperties)); + gen.if((0, codegen_1.not)(valid), () => { + cxt.error(); + gen.break(); + }); + }, codegen_1.nil); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitItems.js +var require_limitItems = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitItems.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var error2 = { + message({ keyword, schemaCode }) { + const comp = keyword === "maxItems" ? "more" : "fewer"; + return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} items`; + }, + params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` + }; + var def = { + keyword: ["maxItems", "minItems"], + type: "array", + schemaType: "number", + $data: true, + error: error2, + code(cxt) { + const { keyword, data, schemaCode } = cxt; + const op = keyword === "maxItems" ? codegen_1.operators.GT : codegen_1.operators.LT; + cxt.fail$data((0, codegen_1._)`${data}.length ${op} ${schemaCode}`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/equal.js +var require_equal = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/equal.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var equal = require_fast_deep_equal(); + equal.code = 'require("ajv/dist/runtime/equal").default'; + exports2.default = equal; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/uniqueItems.js +var require_uniqueItems = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/uniqueItems.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var dataType_1 = require_dataType(); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var equal_1 = require_equal(); + var error2 = { + message: ({ params: { i, j } }) => (0, codegen_1.str)`must NOT have duplicate items (items ## ${j} and ${i} are identical)`, + params: ({ params: { i, j } }) => (0, codegen_1._)`{i: ${i}, j: ${j}}` + }; + var def = { + keyword: "uniqueItems", + type: "array", + schemaType: "boolean", + $data: true, + error: error2, + code(cxt) { + const { gen, data, $data, schema, parentSchema, schemaCode, it } = cxt; + if (!$data && !schema) + return; + const valid = gen.let("valid"); + const itemTypes = parentSchema.items ? (0, dataType_1.getSchemaTypes)(parentSchema.items) : []; + cxt.block$data(valid, validateUniqueItems, (0, codegen_1._)`${schemaCode} === false`); + cxt.ok(valid); + function validateUniqueItems() { + const i = gen.let("i", (0, codegen_1._)`${data}.length`); + const j = gen.let("j"); + cxt.setParams({ i, j }); + gen.assign(valid, true); + gen.if((0, codegen_1._)`${i} > 1`, () => (canOptimize() ? loopN : loopN2)(i, j)); + } + function canOptimize() { + return itemTypes.length > 0 && !itemTypes.some((t) => t === "object" || t === "array"); + } + function loopN(i, j) { + const item = gen.name("item"); + const wrongType = (0, dataType_1.checkDataTypes)(itemTypes, item, it.opts.strictNumbers, dataType_1.DataType.Wrong); + const indices = gen.const("indices", (0, codegen_1._)`{}`); + gen.for((0, codegen_1._)`;${i}--;`, () => { + gen.let(item, (0, codegen_1._)`${data}[${i}]`); + gen.if(wrongType, (0, codegen_1._)`continue`); + if (itemTypes.length > 1) + gen.if((0, codegen_1._)`typeof ${item} == "string"`, (0, codegen_1._)`${item} += "_"`); + gen.if((0, codegen_1._)`typeof ${indices}[${item}] == "number"`, () => { + gen.assign(j, (0, codegen_1._)`${indices}[${item}]`); + cxt.error(); + gen.assign(valid, false).break(); + }).code((0, codegen_1._)`${indices}[${item}] = ${i}`); + }); + } + function loopN2(i, j) { + const eql = (0, util_1.useFunc)(gen, equal_1.default); + const outer = gen.name("outer"); + gen.label(outer).for((0, codegen_1._)`;${i}--;`, () => gen.for((0, codegen_1._)`${j} = ${i}; ${j}--;`, () => gen.if((0, codegen_1._)`${eql}(${data}[${i}], ${data}[${j}])`, () => { + cxt.error(); + gen.assign(valid, false).break(outer); + }))); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/const.js +var require_const = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/const.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var equal_1 = require_equal(); + var error2 = { + message: "must be equal to constant", + params: ({ schemaCode }) => (0, codegen_1._)`{allowedValue: ${schemaCode}}` + }; + var def = { + keyword: "const", + $data: true, + error: error2, + code(cxt) { + const { gen, data, $data, schemaCode, schema } = cxt; + if ($data || schema && typeof schema == "object") { + cxt.fail$data((0, codegen_1._)`!${(0, util_1.useFunc)(gen, equal_1.default)}(${data}, ${schemaCode})`); + } else { + cxt.fail((0, codegen_1._)`${schema} !== ${data}`); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/enum.js +var require_enum = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/enum.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var equal_1 = require_equal(); + var error2 = { + message: "must be equal to one of the allowed values", + params: ({ schemaCode }) => (0, codegen_1._)`{allowedValues: ${schemaCode}}` + }; + var def = { + keyword: "enum", + schemaType: "array", + $data: true, + error: error2, + code(cxt) { + const { gen, data, $data, schema, schemaCode, it } = cxt; + if (!$data && schema.length === 0) + throw new Error("enum must have non-empty array"); + const useLoop = schema.length >= it.opts.loopEnum; + let eql; + const getEql = () => eql !== null && eql !== void 0 ? eql : eql = (0, util_1.useFunc)(gen, equal_1.default); + let valid; + if (useLoop || $data) { + valid = gen.let("valid"); + cxt.block$data(valid, loopEnum); + } else { + if (!Array.isArray(schema)) + throw new Error("ajv implementation error"); + const vSchema = gen.const("vSchema", schemaCode); + valid = (0, codegen_1.or)(...schema.map((_x, i) => equalCode(vSchema, i))); + } + cxt.pass(valid); + function loopEnum() { + gen.assign(valid, false); + gen.forOf("v", schemaCode, (v) => gen.if((0, codegen_1._)`${getEql()}(${data}, ${v})`, () => gen.assign(valid, true).break())); + } + function equalCode(vSchema, i) { + const sch = schema[i]; + return typeof sch === "object" && sch !== null ? (0, codegen_1._)`${getEql()}(${data}, ${vSchema}[${i}])` : (0, codegen_1._)`${data} === ${sch}`; + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/index.js +var require_validation = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var limitNumber_1 = require_limitNumber(); + var multipleOf_1 = require_multipleOf(); + var limitLength_1 = require_limitLength(); + var pattern_1 = require_pattern(); + var limitProperties_1 = require_limitProperties(); + var required_1 = require_required(); + var limitItems_1 = require_limitItems(); + var uniqueItems_1 = require_uniqueItems(); + var const_1 = require_const(); + var enum_1 = require_enum(); + var validation = [ + // number + limitNumber_1.default, + multipleOf_1.default, + // string + limitLength_1.default, + pattern_1.default, + // object + limitProperties_1.default, + required_1.default, + // array + limitItems_1.default, + uniqueItems_1.default, + // any + { keyword: "type", schemaType: ["string", "array"] }, + { keyword: "nullable", schemaType: "boolean" }, + const_1.default, + enum_1.default + ]; + exports2.default = validation; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalItems.js +var require_additionalItems = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalItems.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateAdditionalItems = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: ({ params: { len } }) => (0, codegen_1.str)`must NOT have more than ${len} items`, + params: ({ params: { len } }) => (0, codegen_1._)`{limit: ${len}}` + }; + var def = { + keyword: "additionalItems", + type: "array", + schemaType: ["boolean", "object"], + before: "uniqueItems", + error: error2, + code(cxt) { + const { parentSchema, it } = cxt; + const { items } = parentSchema; + if (!Array.isArray(items)) { + (0, util_1.checkStrictMode)(it, '"additionalItems" is ignored when "items" is not an array of schemas'); + return; + } + validateAdditionalItems(cxt, items); + } + }; + function validateAdditionalItems(cxt, items) { + const { gen, schema, data, keyword, it } = cxt; + it.items = true; + const len = gen.const("len", (0, codegen_1._)`${data}.length`); + if (schema === false) { + cxt.setParams({ len: items.length }); + cxt.pass((0, codegen_1._)`${len} <= ${items.length}`); + } else if (typeof schema == "object" && !(0, util_1.alwaysValidSchema)(it, schema)) { + const valid = gen.var("valid", (0, codegen_1._)`${len} <= ${items.length}`); + gen.if((0, codegen_1.not)(valid), () => validateItems(valid)); + cxt.ok(valid); + } + function validateItems(valid) { + gen.forRange("i", items.length, len, (i) => { + cxt.subschema({ keyword, dataProp: i, dataPropType: util_1.Type.Num }, valid); + if (!it.allErrors) + gen.if((0, codegen_1.not)(valid), () => gen.break()); + }); + } + } + exports2.validateAdditionalItems = validateAdditionalItems; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items.js +var require_items = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateTuple = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var code_1 = require_code2(); + var def = { + keyword: "items", + type: "array", + schemaType: ["object", "array", "boolean"], + before: "uniqueItems", + code(cxt) { + const { schema, it } = cxt; + if (Array.isArray(schema)) + return validateTuple(cxt, "additionalItems", schema); + it.items = true; + if ((0, util_1.alwaysValidSchema)(it, schema)) + return; + cxt.ok((0, code_1.validateArray)(cxt)); + } + }; + function validateTuple(cxt, extraItems, schArr = cxt.schema) { + const { gen, parentSchema, data, keyword, it } = cxt; + checkStrictTuple(parentSchema); + if (it.opts.unevaluated && schArr.length && it.items !== true) { + it.items = util_1.mergeEvaluated.items(gen, schArr.length, it.items); + } + const valid = gen.name("valid"); + const len = gen.const("len", (0, codegen_1._)`${data}.length`); + schArr.forEach((sch, i) => { + if ((0, util_1.alwaysValidSchema)(it, sch)) + return; + gen.if((0, codegen_1._)`${len} > ${i}`, () => cxt.subschema({ + keyword, + schemaProp: i, + dataProp: i + }, valid)); + cxt.ok(valid); + }); + function checkStrictTuple(sch) { + const { opts, errSchemaPath } = it; + const l = schArr.length; + const fullTuple = l === sch.minItems && (l === sch.maxItems || sch[extraItems] === false); + if (opts.strictTuples && !fullTuple) { + const msg = `"${keyword}" is ${l}-tuple, but minItems or maxItems/${extraItems} are not specified or different at path "${errSchemaPath}"`; + (0, util_1.checkStrictMode)(it, msg, opts.strictTuples); + } + } + } + exports2.validateTuple = validateTuple; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/prefixItems.js +var require_prefixItems = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/prefixItems.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var items_1 = require_items(); + var def = { + keyword: "prefixItems", + type: "array", + schemaType: ["array"], + before: "uniqueItems", + code: (cxt) => (0, items_1.validateTuple)(cxt, "items") + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items2020.js +var require_items2020 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items2020.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var code_1 = require_code2(); + var additionalItems_1 = require_additionalItems(); + var error2 = { + message: ({ params: { len } }) => (0, codegen_1.str)`must NOT have more than ${len} items`, + params: ({ params: { len } }) => (0, codegen_1._)`{limit: ${len}}` + }; + var def = { + keyword: "items", + type: "array", + schemaType: ["object", "boolean"], + before: "uniqueItems", + error: error2, + code(cxt) { + const { schema, parentSchema, it } = cxt; + const { prefixItems } = parentSchema; + it.items = true; + if ((0, util_1.alwaysValidSchema)(it, schema)) + return; + if (prefixItems) + (0, additionalItems_1.validateAdditionalItems)(cxt, prefixItems); + else + cxt.ok((0, code_1.validateArray)(cxt)); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/contains.js +var require_contains = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/contains.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: ({ params: { min, max } }) => max === void 0 ? (0, codegen_1.str)`must contain at least ${min} valid item(s)` : (0, codegen_1.str)`must contain at least ${min} and no more than ${max} valid item(s)`, + params: ({ params: { min, max } }) => max === void 0 ? (0, codegen_1._)`{minContains: ${min}}` : (0, codegen_1._)`{minContains: ${min}, maxContains: ${max}}` + }; + var def = { + keyword: "contains", + type: "array", + schemaType: ["object", "boolean"], + before: "uniqueItems", + trackErrors: true, + error: error2, + code(cxt) { + const { gen, schema, parentSchema, data, it } = cxt; + let min; + let max; + const { minContains, maxContains } = parentSchema; + if (it.opts.next) { + min = minContains === void 0 ? 1 : minContains; + max = maxContains; + } else { + min = 1; + } + const len = gen.const("len", (0, codegen_1._)`${data}.length`); + cxt.setParams({ min, max }); + if (max === void 0 && min === 0) { + (0, util_1.checkStrictMode)(it, `"minContains" == 0 without "maxContains": "contains" keyword ignored`); + return; + } + if (max !== void 0 && min > max) { + (0, util_1.checkStrictMode)(it, `"minContains" > "maxContains" is always invalid`); + cxt.fail(); + return; + } + if ((0, util_1.alwaysValidSchema)(it, schema)) { + let cond = (0, codegen_1._)`${len} >= ${min}`; + if (max !== void 0) + cond = (0, codegen_1._)`${cond} && ${len} <= ${max}`; + cxt.pass(cond); + return; + } + it.items = true; + const valid = gen.name("valid"); + if (max === void 0 && min === 1) { + validateItems(valid, () => gen.if(valid, () => gen.break())); + } else if (min === 0) { + gen.let(valid, true); + if (max !== void 0) + gen.if((0, codegen_1._)`${data}.length > 0`, validateItemsWithCount); + } else { + gen.let(valid, false); + validateItemsWithCount(); + } + cxt.result(valid, () => cxt.reset()); + function validateItemsWithCount() { + const schValid = gen.name("_valid"); + const count = gen.let("count", 0); + validateItems(schValid, () => gen.if(schValid, () => checkLimits(count))); + } + function validateItems(_valid, block) { + gen.forRange("i", 0, len, (i) => { + cxt.subschema({ + keyword: "contains", + dataProp: i, + dataPropType: util_1.Type.Num, + compositeRule: true + }, _valid); + block(); + }); + } + function checkLimits(count) { + gen.code((0, codegen_1._)`${count}++`); + if (max === void 0) { + gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true).break()); + } else { + gen.if((0, codegen_1._)`${count} > ${max}`, () => gen.assign(valid, false).break()); + if (min === 1) + gen.assign(valid, true); + else + gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true)); + } + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/dependencies.js +var require_dependencies = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/dependencies.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateSchemaDeps = exports2.validatePropertyDeps = exports2.error = void 0; + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var code_1 = require_code2(); + exports2.error = { + message: ({ params: { property, depsCount, deps } }) => { + const property_ies = depsCount === 1 ? "property" : "properties"; + return (0, codegen_1.str)`must have ${property_ies} ${deps} when property ${property} is present`; + }, + params: ({ params: { property, depsCount, deps, missingProperty } }) => (0, codegen_1._)`{property: ${property}, + missingProperty: ${missingProperty}, + depsCount: ${depsCount}, + deps: ${deps}}` + // TODO change to reference + }; + var def = { + keyword: "dependencies", + type: "object", + schemaType: "object", + error: exports2.error, + code(cxt) { + const [propDeps, schDeps] = splitDependencies(cxt); + validatePropertyDeps(cxt, propDeps); + validateSchemaDeps(cxt, schDeps); + } + }; + function splitDependencies({ schema }) { + const propertyDeps = {}; + const schemaDeps = {}; + for (const key in schema) { + if (key === "__proto__") + continue; + const deps = Array.isArray(schema[key]) ? propertyDeps : schemaDeps; + deps[key] = schema[key]; + } + return [propertyDeps, schemaDeps]; + } + function validatePropertyDeps(cxt, propertyDeps = cxt.schema) { + const { gen, data, it } = cxt; + if (Object.keys(propertyDeps).length === 0) + return; + const missing = gen.let("missing"); + for (const prop in propertyDeps) { + const deps = propertyDeps[prop]; + if (deps.length === 0) + continue; + const hasProperty = (0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties); + cxt.setParams({ + property: prop, + depsCount: deps.length, + deps: deps.join(", ") + }); + if (it.allErrors) { + gen.if(hasProperty, () => { + for (const depProp of deps) { + (0, code_1.checkReportMissingProp)(cxt, depProp); + } + }); + } else { + gen.if((0, codegen_1._)`${hasProperty} && (${(0, code_1.checkMissingProp)(cxt, deps, missing)})`); + (0, code_1.reportMissingProp)(cxt, missing); + gen.else(); + } + } + } + exports2.validatePropertyDeps = validatePropertyDeps; + function validateSchemaDeps(cxt, schemaDeps = cxt.schema) { + const { gen, data, keyword, it } = cxt; + const valid = gen.name("valid"); + for (const prop in schemaDeps) { + if ((0, util_1.alwaysValidSchema)(it, schemaDeps[prop])) + continue; + gen.if( + (0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties), + () => { + const schCxt = cxt.subschema({ keyword, schemaProp: prop }, valid); + cxt.mergeValidEvaluated(schCxt, valid); + }, + () => gen.var(valid, true) + // TODO var + ); + cxt.ok(valid); + } + } + exports2.validateSchemaDeps = validateSchemaDeps; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/propertyNames.js +var require_propertyNames = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/propertyNames.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: "property name must be valid", + params: ({ params }) => (0, codegen_1._)`{propertyName: ${params.propertyName}}` + }; + var def = { + keyword: "propertyNames", + type: "object", + schemaType: ["object", "boolean"], + error: error2, + code(cxt) { + const { gen, schema, data, it } = cxt; + if ((0, util_1.alwaysValidSchema)(it, schema)) + return; + const valid = gen.name("valid"); + gen.forIn("key", data, (key) => { + cxt.setParams({ propertyName: key }); + cxt.subschema({ + keyword: "propertyNames", + data: key, + dataTypes: ["string"], + propertyName: key, + compositeRule: true + }, valid); + gen.if((0, codegen_1.not)(valid), () => { + cxt.error(true); + if (!it.allErrors) + gen.break(); + }); + }); + cxt.ok(valid); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js +var require_additionalProperties = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var code_1 = require_code2(); + var codegen_1 = require_codegen(); + var names_1 = require_names(); + var util_1 = require_util(); + var error2 = { + message: "must NOT have additional properties", + params: ({ params }) => (0, codegen_1._)`{additionalProperty: ${params.additionalProperty}}` + }; + var def = { + keyword: "additionalProperties", + type: ["object"], + schemaType: ["boolean", "object"], + allowUndefined: true, + trackErrors: true, + error: error2, + code(cxt) { + const { gen, schema, parentSchema, data, errsCount, it } = cxt; + if (!errsCount) + throw new Error("ajv implementation error"); + const { allErrors, opts } = it; + it.props = true; + if (opts.removeAdditional !== "all" && (0, util_1.alwaysValidSchema)(it, schema)) + return; + const props = (0, code_1.allSchemaProperties)(parentSchema.properties); + const patProps = (0, code_1.allSchemaProperties)(parentSchema.patternProperties); + checkAdditionalProperties(); + cxt.ok((0, codegen_1._)`${errsCount} === ${names_1.default.errors}`); + function checkAdditionalProperties() { + gen.forIn("key", data, (key) => { + if (!props.length && !patProps.length) + additionalPropertyCode(key); + else + gen.if(isAdditional(key), () => additionalPropertyCode(key)); + }); + } + function isAdditional(key) { + let definedProp; + if (props.length > 8) { + const propsSchema = (0, util_1.schemaRefOrVal)(it, parentSchema.properties, "properties"); + definedProp = (0, code_1.isOwnProperty)(gen, propsSchema, key); + } else if (props.length) { + definedProp = (0, codegen_1.or)(...props.map((p) => (0, codegen_1._)`${key} === ${p}`)); + } else { + definedProp = codegen_1.nil; + } + if (patProps.length) { + definedProp = (0, codegen_1.or)(definedProp, ...patProps.map((p) => (0, codegen_1._)`${(0, code_1.usePattern)(cxt, p)}.test(${key})`)); + } + return (0, codegen_1.not)(definedProp); + } + function deleteAdditional(key) { + gen.code((0, codegen_1._)`delete ${data}[${key}]`); + } + function additionalPropertyCode(key) { + if (opts.removeAdditional === "all" || opts.removeAdditional && schema === false) { + deleteAdditional(key); + return; + } + if (schema === false) { + cxt.setParams({ additionalProperty: key }); + cxt.error(); + if (!allErrors) + gen.break(); + return; + } + if (typeof schema == "object" && !(0, util_1.alwaysValidSchema)(it, schema)) { + const valid = gen.name("valid"); + if (opts.removeAdditional === "failing") { + applyAdditionalSchema(key, valid, false); + gen.if((0, codegen_1.not)(valid), () => { + cxt.reset(); + deleteAdditional(key); + }); + } else { + applyAdditionalSchema(key, valid); + if (!allErrors) + gen.if((0, codegen_1.not)(valid), () => gen.break()); + } + } + } + function applyAdditionalSchema(key, valid, errors) { + const subschema = { + keyword: "additionalProperties", + dataProp: key, + dataPropType: util_1.Type.Str + }; + if (errors === false) { + Object.assign(subschema, { + compositeRule: true, + createErrors: false, + allErrors: false + }); + } + cxt.subschema(subschema, valid); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/properties.js +var require_properties = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/properties.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var validate_1 = require_validate(); + var code_1 = require_code2(); + var util_1 = require_util(); + var additionalProperties_1 = require_additionalProperties(); + var def = { + keyword: "properties", + type: "object", + schemaType: "object", + code(cxt) { + const { gen, schema, parentSchema, data, it } = cxt; + if (it.opts.removeAdditional === "all" && parentSchema.additionalProperties === void 0) { + additionalProperties_1.default.code(new validate_1.KeywordCxt(it, additionalProperties_1.default, "additionalProperties")); + } + const allProps = (0, code_1.allSchemaProperties)(schema); + for (const prop of allProps) { + it.definedProperties.add(prop); + } + if (it.opts.unevaluated && allProps.length && it.props !== true) { + it.props = util_1.mergeEvaluated.props(gen, (0, util_1.toHash)(allProps), it.props); + } + const properties = allProps.filter((p) => !(0, util_1.alwaysValidSchema)(it, schema[p])); + if (properties.length === 0) + return; + const valid = gen.name("valid"); + for (const prop of properties) { + if (hasDefault(prop)) { + applyPropertySchema(prop); + } else { + gen.if((0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties)); + applyPropertySchema(prop); + if (!it.allErrors) + gen.else().var(valid, true); + gen.endIf(); + } + cxt.it.definedProperties.add(prop); + cxt.ok(valid); + } + function hasDefault(prop) { + return it.opts.useDefaults && !it.compositeRule && schema[prop].default !== void 0; + } + function applyPropertySchema(prop) { + cxt.subschema({ + keyword: "properties", + schemaProp: prop, + dataProp: prop + }, valid); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/patternProperties.js +var require_patternProperties = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/patternProperties.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var code_1 = require_code2(); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var util_2 = require_util(); + var def = { + keyword: "patternProperties", + type: "object", + schemaType: "object", + code(cxt) { + const { gen, schema, data, parentSchema, it } = cxt; + const { opts } = it; + const patterns = (0, code_1.allSchemaProperties)(schema); + const alwaysValidPatterns = patterns.filter((p) => (0, util_1.alwaysValidSchema)(it, schema[p])); + if (patterns.length === 0 || alwaysValidPatterns.length === patterns.length && (!it.opts.unevaluated || it.props === true)) { + return; + } + const checkProperties = opts.strictSchema && !opts.allowMatchingProperties && parentSchema.properties; + const valid = gen.name("valid"); + if (it.props !== true && !(it.props instanceof codegen_1.Name)) { + it.props = (0, util_2.evaluatedPropsToName)(gen, it.props); + } + const { props } = it; + validatePatternProperties(); + function validatePatternProperties() { + for (const pat of patterns) { + if (checkProperties) + checkMatchingProperties(pat); + if (it.allErrors) { + validateProperties(pat); + } else { + gen.var(valid, true); + validateProperties(pat); + gen.if(valid); + } + } + } + function checkMatchingProperties(pat) { + for (const prop in checkProperties) { + if (new RegExp(pat).test(prop)) { + (0, util_1.checkStrictMode)(it, `property ${prop} matches pattern ${pat} (use allowMatchingProperties)`); + } + } + } + function validateProperties(pat) { + gen.forIn("key", data, (key) => { + gen.if((0, codegen_1._)`${(0, code_1.usePattern)(cxt, pat)}.test(${key})`, () => { + const alwaysValid = alwaysValidPatterns.includes(pat); + if (!alwaysValid) { + cxt.subschema({ + keyword: "patternProperties", + schemaProp: pat, + dataProp: key, + dataPropType: util_2.Type.Str + }, valid); + } + if (it.opts.unevaluated && props !== true) { + gen.assign((0, codegen_1._)`${props}[${key}]`, true); + } else if (!alwaysValid && !it.allErrors) { + gen.if((0, codegen_1.not)(valid), () => gen.break()); + } + }); + }); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/not.js +var require_not = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/not.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var util_1 = require_util(); + var def = { + keyword: "not", + schemaType: ["object", "boolean"], + trackErrors: true, + code(cxt) { + const { gen, schema, it } = cxt; + if ((0, util_1.alwaysValidSchema)(it, schema)) { + cxt.fail(); + return; + } + const valid = gen.name("valid"); + cxt.subschema({ + keyword: "not", + compositeRule: true, + createErrors: false, + allErrors: false + }, valid); + cxt.failResult(valid, () => cxt.reset(), () => cxt.error()); + }, + error: { message: "must NOT be valid" } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/anyOf.js +var require_anyOf = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/anyOf.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var code_1 = require_code2(); + var def = { + keyword: "anyOf", + schemaType: "array", + trackErrors: true, + code: code_1.validateUnion, + error: { message: "must match a schema in anyOf" } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/oneOf.js +var require_oneOf = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/oneOf.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: "must match exactly one schema in oneOf", + params: ({ params }) => (0, codegen_1._)`{passingSchemas: ${params.passing}}` + }; + var def = { + keyword: "oneOf", + schemaType: "array", + trackErrors: true, + error: error2, + code(cxt) { + const { gen, schema, parentSchema, it } = cxt; + if (!Array.isArray(schema)) + throw new Error("ajv implementation error"); + if (it.opts.discriminator && parentSchema.discriminator) + return; + const schArr = schema; + const valid = gen.let("valid", false); + const passing = gen.let("passing", null); + const schValid = gen.name("_valid"); + cxt.setParams({ passing }); + gen.block(validateOneOf); + cxt.result(valid, () => cxt.reset(), () => cxt.error(true)); + function validateOneOf() { + schArr.forEach((sch, i) => { + let schCxt; + if ((0, util_1.alwaysValidSchema)(it, sch)) { + gen.var(schValid, true); + } else { + schCxt = cxt.subschema({ + keyword: "oneOf", + schemaProp: i, + compositeRule: true + }, schValid); + } + if (i > 0) { + gen.if((0, codegen_1._)`${schValid} && ${valid}`).assign(valid, false).assign(passing, (0, codegen_1._)`[${passing}, ${i}]`).else(); + } + gen.if(schValid, () => { + gen.assign(valid, true); + gen.assign(passing, i); + if (schCxt) + cxt.mergeEvaluated(schCxt, codegen_1.Name); + }); + }); + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/allOf.js +var require_allOf = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/allOf.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var util_1 = require_util(); + var def = { + keyword: "allOf", + schemaType: "array", + code(cxt) { + const { gen, schema, it } = cxt; + if (!Array.isArray(schema)) + throw new Error("ajv implementation error"); + const valid = gen.name("valid"); + schema.forEach((sch, i) => { + if ((0, util_1.alwaysValidSchema)(it, sch)) + return; + const schCxt = cxt.subschema({ keyword: "allOf", schemaProp: i }, valid); + cxt.ok(valid); + cxt.mergeEvaluated(schCxt); + }); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/if.js +var require_if = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/if.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var util_1 = require_util(); + var error2 = { + message: ({ params }) => (0, codegen_1.str)`must match "${params.ifClause}" schema`, + params: ({ params }) => (0, codegen_1._)`{failingKeyword: ${params.ifClause}}` + }; + var def = { + keyword: "if", + schemaType: ["object", "boolean"], + trackErrors: true, + error: error2, + code(cxt) { + const { gen, parentSchema, it } = cxt; + if (parentSchema.then === void 0 && parentSchema.else === void 0) { + (0, util_1.checkStrictMode)(it, '"if" without "then" and "else" is ignored'); + } + const hasThen = hasSchema(it, "then"); + const hasElse = hasSchema(it, "else"); + if (!hasThen && !hasElse) + return; + const valid = gen.let("valid", true); + const schValid = gen.name("_valid"); + validateIf(); + cxt.reset(); + if (hasThen && hasElse) { + const ifClause = gen.let("ifClause"); + cxt.setParams({ ifClause }); + gen.if(schValid, validateClause("then", ifClause), validateClause("else", ifClause)); + } else if (hasThen) { + gen.if(schValid, validateClause("then")); + } else { + gen.if((0, codegen_1.not)(schValid), validateClause("else")); + } + cxt.pass(valid, () => cxt.error(true)); + function validateIf() { + const schCxt = cxt.subschema({ + keyword: "if", + compositeRule: true, + createErrors: false, + allErrors: false + }, schValid); + cxt.mergeEvaluated(schCxt); + } + function validateClause(keyword, ifClause) { + return () => { + const schCxt = cxt.subschema({ keyword }, schValid); + gen.assign(valid, schValid); + cxt.mergeValidEvaluated(schCxt, valid); + if (ifClause) + gen.assign(ifClause, (0, codegen_1._)`${keyword}`); + else + cxt.setParams({ ifClause: keyword }); + }; + } + } + }; + function hasSchema(it, keyword) { + const schema = it.schema[keyword]; + return schema !== void 0 && !(0, util_1.alwaysValidSchema)(it, schema); + } + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/thenElse.js +var require_thenElse = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/thenElse.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var util_1 = require_util(); + var def = { + keyword: ["then", "else"], + schemaType: ["object", "boolean"], + code({ keyword, parentSchema, it }) { + if (parentSchema.if === void 0) + (0, util_1.checkStrictMode)(it, `"${keyword}" without "if" is ignored`); + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/index.js +var require_applicator = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var additionalItems_1 = require_additionalItems(); + var prefixItems_1 = require_prefixItems(); + var items_1 = require_items(); + var items2020_1 = require_items2020(); + var contains_1 = require_contains(); + var dependencies_1 = require_dependencies(); + var propertyNames_1 = require_propertyNames(); + var additionalProperties_1 = require_additionalProperties(); + var properties_1 = require_properties(); + var patternProperties_1 = require_patternProperties(); + var not_1 = require_not(); + var anyOf_1 = require_anyOf(); + var oneOf_1 = require_oneOf(); + var allOf_1 = require_allOf(); + var if_1 = require_if(); + var thenElse_1 = require_thenElse(); + function getApplicator(draft2020 = false) { + const applicator = [ + // any + not_1.default, + anyOf_1.default, + oneOf_1.default, + allOf_1.default, + if_1.default, + thenElse_1.default, + // object + propertyNames_1.default, + additionalProperties_1.default, + dependencies_1.default, + properties_1.default, + patternProperties_1.default + ]; + if (draft2020) + applicator.push(prefixItems_1.default, items2020_1.default); + else + applicator.push(additionalItems_1.default, items_1.default); + applicator.push(contains_1.default); + return applicator; + } + exports2.default = getApplicator; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/format.js +var require_format = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/format.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var error2 = { + message: ({ schemaCode }) => (0, codegen_1.str)`must match format "${schemaCode}"`, + params: ({ schemaCode }) => (0, codegen_1._)`{format: ${schemaCode}}` + }; + var def = { + keyword: "format", + type: ["number", "string"], + schemaType: "string", + $data: true, + error: error2, + code(cxt, ruleType) { + const { gen, data, $data, schema, schemaCode, it } = cxt; + const { opts, errSchemaPath, schemaEnv, self } = it; + if (!opts.validateFormats) + return; + if ($data) + validate$DataFormat(); + else + validateFormat(); + function validate$DataFormat() { + const fmts = gen.scopeValue("formats", { + ref: self.formats, + code: opts.code.formats + }); + const fDef = gen.const("fDef", (0, codegen_1._)`${fmts}[${schemaCode}]`); + const fType = gen.let("fType"); + const format = gen.let("format"); + gen.if((0, codegen_1._)`typeof ${fDef} == "object" && !(${fDef} instanceof RegExp)`, () => gen.assign(fType, (0, codegen_1._)`${fDef}.type || "string"`).assign(format, (0, codegen_1._)`${fDef}.validate`), () => gen.assign(fType, (0, codegen_1._)`"string"`).assign(format, fDef)); + cxt.fail$data((0, codegen_1.or)(unknownFmt(), invalidFmt())); + function unknownFmt() { + if (opts.strictSchema === false) + return codegen_1.nil; + return (0, codegen_1._)`${schemaCode} && !${format}`; + } + function invalidFmt() { + const callFormat = schemaEnv.$async ? (0, codegen_1._)`(${fDef}.async ? await ${format}(${data}) : ${format}(${data}))` : (0, codegen_1._)`${format}(${data})`; + const validData = (0, codegen_1._)`(typeof ${format} == "function" ? ${callFormat} : ${format}.test(${data}))`; + return (0, codegen_1._)`${format} && ${format} !== true && ${fType} === ${ruleType} && !${validData}`; + } + } + function validateFormat() { + const formatDef = self.formats[schema]; + if (!formatDef) { + unknownFormat(); + return; + } + if (formatDef === true) + return; + const [fmtType, format, fmtRef] = getFormat(formatDef); + if (fmtType === ruleType) + cxt.pass(validCondition()); + function unknownFormat() { + if (opts.strictSchema === false) { + self.logger.warn(unknownMsg()); + return; + } + throw new Error(unknownMsg()); + function unknownMsg() { + return `unknown format "${schema}" ignored in schema at path "${errSchemaPath}"`; + } + } + function getFormat(fmtDef) { + const code = fmtDef instanceof RegExp ? (0, codegen_1.regexpCode)(fmtDef) : opts.code.formats ? (0, codegen_1._)`${opts.code.formats}${(0, codegen_1.getProperty)(schema)}` : void 0; + const fmt = gen.scopeValue("formats", { key: schema, ref: fmtDef, code }); + if (typeof fmtDef == "object" && !(fmtDef instanceof RegExp)) { + return [fmtDef.type || "string", fmtDef.validate, (0, codegen_1._)`${fmt}.validate`]; + } + return ["string", fmtDef, fmt]; + } + function validCondition() { + if (typeof formatDef == "object" && !(formatDef instanceof RegExp) && formatDef.async) { + if (!schemaEnv.$async) + throw new Error("async format in sync schema"); + return (0, codegen_1._)`await ${fmtRef}(${data})`; + } + return typeof format == "function" ? (0, codegen_1._)`${fmtRef}(${data})` : (0, codegen_1._)`${fmtRef}.test(${data})`; + } + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/index.js +var require_format2 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var format_1 = require_format(); + var format = [format_1.default]; + exports2.default = format; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/metadata.js +var require_metadata = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/metadata.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.contentVocabulary = exports2.metadataVocabulary = void 0; + exports2.metadataVocabulary = [ + "title", + "description", + "default", + "deprecated", + "readOnly", + "writeOnly", + "examples" + ]; + exports2.contentVocabulary = [ + "contentMediaType", + "contentEncoding", + "contentSchema" + ]; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/draft7.js +var require_draft7 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/draft7.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var core_1 = require_core2(); + var validation_1 = require_validation(); + var applicator_1 = require_applicator(); + var format_1 = require_format2(); + var metadata_1 = require_metadata(); + var draft7Vocabularies = [ + core_1.default, + validation_1.default, + (0, applicator_1.default)(), + format_1.default, + metadata_1.metadataVocabulary, + metadata_1.contentVocabulary + ]; + exports2.default = draft7Vocabularies; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/types.js +var require_types = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/types.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.DiscrError = void 0; + var DiscrError; + (function(DiscrError2) { + DiscrError2["Tag"] = "tag"; + DiscrError2["Mapping"] = "mapping"; + })(DiscrError || (exports2.DiscrError = DiscrError = {})); + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/index.js +var require_discriminator = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var codegen_1 = require_codegen(); + var types_1 = require_types(); + var compile_1 = require_compile(); + var ref_error_1 = require_ref_error(); + var util_1 = require_util(); + var error2 = { + message: ({ params: { discrError, tagName } }) => discrError === types_1.DiscrError.Tag ? `tag "${tagName}" must be string` : `value of tag "${tagName}" must be in oneOf`, + params: ({ params: { discrError, tag, tagName } }) => (0, codegen_1._)`{error: ${discrError}, tag: ${tagName}, tagValue: ${tag}}` + }; + var def = { + keyword: "discriminator", + type: "object", + schemaType: "object", + error: error2, + code(cxt) { + const { gen, data, schema, parentSchema, it } = cxt; + const { oneOf } = parentSchema; + if (!it.opts.discriminator) { + throw new Error("discriminator: requires discriminator option"); + } + const tagName = schema.propertyName; + if (typeof tagName != "string") + throw new Error("discriminator: requires propertyName"); + if (schema.mapping) + throw new Error("discriminator: mapping is not supported"); + if (!oneOf) + throw new Error("discriminator: requires oneOf keyword"); + const valid = gen.let("valid", false); + const tag = gen.const("tag", (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(tagName)}`); + gen.if((0, codegen_1._)`typeof ${tag} == "string"`, () => validateMapping(), () => cxt.error(false, { discrError: types_1.DiscrError.Tag, tag, tagName })); + cxt.ok(valid); + function validateMapping() { + const mapping = getMapping(); + gen.if(false); + for (const tagValue in mapping) { + gen.elseIf((0, codegen_1._)`${tag} === ${tagValue}`); + gen.assign(valid, applyTagSchema(mapping[tagValue])); + } + gen.else(); + cxt.error(false, { discrError: types_1.DiscrError.Mapping, tag, tagName }); + gen.endIf(); + } + function applyTagSchema(schemaProp) { + const _valid = gen.name("valid"); + const schCxt = cxt.subschema({ keyword: "oneOf", schemaProp }, _valid); + cxt.mergeEvaluated(schCxt, codegen_1.Name); + return _valid; + } + function getMapping() { + var _a2; + const oneOfMapping = {}; + const topRequired = hasRequired(parentSchema); + let tagRequired = true; + for (let i = 0; i < oneOf.length; i++) { + let sch = oneOf[i]; + if ((sch === null || sch === void 0 ? void 0 : sch.$ref) && !(0, util_1.schemaHasRulesButRef)(sch, it.self.RULES)) { + const ref = sch.$ref; + sch = compile_1.resolveRef.call(it.self, it.schemaEnv.root, it.baseId, ref); + if (sch instanceof compile_1.SchemaEnv) + sch = sch.schema; + if (sch === void 0) + throw new ref_error_1.default(it.opts.uriResolver, it.baseId, ref); + } + const propSch = (_a2 = sch === null || sch === void 0 ? void 0 : sch.properties) === null || _a2 === void 0 ? void 0 : _a2[tagName]; + if (typeof propSch != "object") { + throw new Error(`discriminator: oneOf subschemas (or referenced schemas) must have "properties/${tagName}"`); + } + tagRequired = tagRequired && (topRequired || hasRequired(sch)); + addMappings(propSch, i); + } + if (!tagRequired) + throw new Error(`discriminator: "${tagName}" must be required`); + return oneOfMapping; + function hasRequired({ required: required2 }) { + return Array.isArray(required2) && required2.includes(tagName); + } + function addMappings(sch, i) { + if (sch.const) { + addMapping(sch.const, i); + } else if (sch.enum) { + for (const tagValue of sch.enum) { + addMapping(tagValue, i); + } + } else { + throw new Error(`discriminator: "properties/${tagName}" must have "const" or "enum"`); + } + } + function addMapping(tagValue, i) { + if (typeof tagValue != "string" || tagValue in oneOfMapping) { + throw new Error(`discriminator: "${tagName}" values must be unique strings`); + } + oneOfMapping[tagValue] = i; + } + } + } + }; + exports2.default = def; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/json-schema-draft-07.json +var require_json_schema_draft_07 = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/json-schema-draft-07.json"(exports2, module2) { + module2.exports = { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "http://json-schema.org/draft-07/schema#", + title: "Core schema meta-schema", + definitions: { + schemaArray: { + type: "array", + minItems: 1, + items: { $ref: "#" } + }, + nonNegativeInteger: { + type: "integer", + minimum: 0 + }, + nonNegativeIntegerDefault0: { + allOf: [{ $ref: "#/definitions/nonNegativeInteger" }, { default: 0 }] + }, + simpleTypes: { + enum: ["array", "boolean", "integer", "null", "number", "object", "string"] + }, + stringArray: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + default: [] + } + }, + type: ["object", "boolean"], + properties: { + $id: { + type: "string", + format: "uri-reference" + }, + $schema: { + type: "string", + format: "uri" + }, + $ref: { + type: "string", + format: "uri-reference" + }, + $comment: { + type: "string" + }, + title: { + type: "string" + }, + description: { + type: "string" + }, + default: true, + readOnly: { + type: "boolean", + default: false + }, + examples: { + type: "array", + items: true + }, + multipleOf: { + type: "number", + exclusiveMinimum: 0 + }, + maximum: { + type: "number" + }, + exclusiveMaximum: { + type: "number" + }, + minimum: { + type: "number" + }, + exclusiveMinimum: { + type: "number" + }, + maxLength: { $ref: "#/definitions/nonNegativeInteger" }, + minLength: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, + pattern: { + type: "string", + format: "regex" + }, + additionalItems: { $ref: "#" }, + items: { + anyOf: [{ $ref: "#" }, { $ref: "#/definitions/schemaArray" }], + default: true + }, + maxItems: { $ref: "#/definitions/nonNegativeInteger" }, + minItems: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, + uniqueItems: { + type: "boolean", + default: false + }, + contains: { $ref: "#" }, + maxProperties: { $ref: "#/definitions/nonNegativeInteger" }, + minProperties: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, + required: { $ref: "#/definitions/stringArray" }, + additionalProperties: { $ref: "#" }, + definitions: { + type: "object", + additionalProperties: { $ref: "#" }, + default: {} + }, + properties: { + type: "object", + additionalProperties: { $ref: "#" }, + default: {} + }, + patternProperties: { + type: "object", + additionalProperties: { $ref: "#" }, + propertyNames: { format: "regex" }, + default: {} + }, + dependencies: { + type: "object", + additionalProperties: { + anyOf: [{ $ref: "#" }, { $ref: "#/definitions/stringArray" }] + } + }, + propertyNames: { $ref: "#" }, + const: true, + enum: { + type: "array", + items: true, + minItems: 1, + uniqueItems: true + }, + type: { + anyOf: [ + { $ref: "#/definitions/simpleTypes" }, + { + type: "array", + items: { $ref: "#/definitions/simpleTypes" }, + minItems: 1, + uniqueItems: true + } + ] + }, + format: { type: "string" }, + contentMediaType: { type: "string" }, + contentEncoding: { type: "string" }, + if: { $ref: "#" }, + then: { $ref: "#" }, + else: { $ref: "#" }, + allOf: { $ref: "#/definitions/schemaArray" }, + anyOf: { $ref: "#/definitions/schemaArray" }, + oneOf: { $ref: "#/definitions/schemaArray" }, + not: { $ref: "#" } + }, + default: true + }; + } +}); + +// node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/ajv.js +var require_ajv = __commonJS({ + "node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/ajv.js"(exports2, module2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.MissingRefError = exports2.ValidationError = exports2.CodeGen = exports2.Name = exports2.nil = exports2.stringify = exports2.str = exports2._ = exports2.KeywordCxt = exports2.Ajv = void 0; + var core_1 = require_core(); + var draft7_1 = require_draft7(); + var discriminator_1 = require_discriminator(); + var draft7MetaSchema = require_json_schema_draft_07(); + var META_SUPPORT_DATA = ["/properties"]; + var META_SCHEMA_ID = "http://json-schema.org/draft-07/schema"; + var Ajv2 = class extends core_1.default { + _addVocabularies() { + super._addVocabularies(); + draft7_1.default.forEach((v) => this.addVocabulary(v)); + if (this.opts.discriminator) + this.addKeyword(discriminator_1.default); + } + _addDefaultMetaSchema() { + super._addDefaultMetaSchema(); + if (!this.opts.meta) + return; + const metaSchema = this.opts.$data ? this.$dataMetaSchema(draft7MetaSchema, META_SUPPORT_DATA) : draft7MetaSchema; + this.addMetaSchema(metaSchema, META_SCHEMA_ID, false); + this.refs["http://json-schema.org/schema"] = META_SCHEMA_ID; + } + defaultMeta() { + return this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : void 0); + } + }; + exports2.Ajv = Ajv2; + module2.exports = exports2 = Ajv2; + module2.exports.Ajv = Ajv2; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.default = Ajv2; + var validate_1 = require_validate(); + Object.defineProperty(exports2, "KeywordCxt", { enumerable: true, get: function() { + return validate_1.KeywordCxt; + } }); + var codegen_1 = require_codegen(); + Object.defineProperty(exports2, "_", { enumerable: true, get: function() { + return codegen_1._; + } }); + Object.defineProperty(exports2, "str", { enumerable: true, get: function() { + return codegen_1.str; + } }); + Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { + return codegen_1.stringify; + } }); + Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { + return codegen_1.nil; + } }); + Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { + return codegen_1.Name; + } }); + Object.defineProperty(exports2, "CodeGen", { enumerable: true, get: function() { + return codegen_1.CodeGen; + } }); + var validation_error_1 = require_validation_error(); + Object.defineProperty(exports2, "ValidationError", { enumerable: true, get: function() { + return validation_error_1.default; + } }); + var ref_error_1 = require_ref_error(); + Object.defineProperty(exports2, "MissingRefError", { enumerable: true, get: function() { + return ref_error_1.default; + } }); + } +}); + +// node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/formats.js +var require_formats = __commonJS({ + "node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/formats.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.formatNames = exports2.fastFormats = exports2.fullFormats = void 0; + function fmtDef(validate, compare) { + return { validate, compare }; + } + exports2.fullFormats = { + // date: http://tools.ietf.org/html/rfc3339#section-5.6 + date: fmtDef(date4, compareDate), + // date-time: http://tools.ietf.org/html/rfc3339#section-5.6 + time: fmtDef(getTime(true), compareTime), + "date-time": fmtDef(getDateTime(true), compareDateTime), + "iso-time": fmtDef(getTime(), compareIsoTime), + "iso-date-time": fmtDef(getDateTime(), compareIsoDateTime), + // duration: https://tools.ietf.org/html/rfc3339#appendix-A + duration: /^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$/, + uri, + "uri-reference": /^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i, + // uri-template: https://tools.ietf.org/html/rfc6570 + "uri-template": /^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i, + // For the source: https://gist.github.com/dperini/729294 + // For test cases: https://mathiasbynens.be/demo/url-regex + url: /^(?:https?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)(?:\.(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu, + email: /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, + hostname: /^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i, + // optimized https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html + ipv4: /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/, + ipv6: /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i, + regex, + // uuid: http://tools.ietf.org/html/rfc4122 + uuid: /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i, + // JSON-pointer: https://tools.ietf.org/html/rfc6901 + // uri fragment: https://tools.ietf.org/html/rfc3986#appendix-A + "json-pointer": /^(?:\/(?:[^~/]|~0|~1)*)*$/, + "json-pointer-uri-fragment": /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i, + // relative JSON-pointer: http://tools.ietf.org/html/draft-luff-relative-json-pointer-00 + "relative-json-pointer": /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/, + // the following formats are used by the openapi specification: https://spec.openapis.org/oas/v3.0.0#data-types + // byte: https://github.com/miguelmota/is-base64 + byte, + // signed 32 bit integer + int32: { type: "number", validate: validateInt32 }, + // signed 64 bit integer + int64: { type: "number", validate: validateInt64 }, + // C-type float + float: { type: "number", validate: validateNumber }, + // C-type double + double: { type: "number", validate: validateNumber }, + // hint to the UI to hide input strings + password: true, + // unchecked string payload + binary: true + }; + exports2.fastFormats = { + ...exports2.fullFormats, + date: fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\d$/, compareDate), + time: fmtDef(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, compareTime), + "date-time": fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\dt(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, compareDateTime), + "iso-time": fmtDef(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, compareIsoTime), + "iso-date-time": fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, compareIsoDateTime), + // uri: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js + uri: /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$/i, + "uri-reference": /^(?:(?:[a-z][a-z0-9+\-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i, + // email (sources from jsen validator): + // http://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address#answer-8829363 + // http://www.w3.org/TR/html5/forms.html#valid-e-mail-address (search for 'wilful violation') + email: /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i + }; + exports2.formatNames = Object.keys(exports2.fullFormats); + function isLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + } + var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; + var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + function date4(str2) { + const matches = DATE.exec(str2); + if (!matches) + return false; + const year = +matches[1]; + const month = +matches[2]; + const day = +matches[3]; + return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]); + } + function compareDate(d1, d2) { + if (!(d1 && d2)) + return void 0; + if (d1 > d2) + return 1; + if (d1 < d2) + return -1; + return 0; + } + var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i; + function getTime(strictTimeZone) { + return function time3(str2) { + const matches = TIME.exec(str2); + if (!matches) + return false; + const hr = +matches[1]; + const min = +matches[2]; + const sec = +matches[3]; + const tz = matches[4]; + const tzSign = matches[5] === "-" ? -1 : 1; + const tzH = +(matches[6] || 0); + const tzM = +(matches[7] || 0); + if (tzH > 23 || tzM > 59 || strictTimeZone && !tz) + return false; + if (hr <= 23 && min <= 59 && sec < 60) + return true; + const utcMin = min - tzM * tzSign; + const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0); + return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61; + }; + } + function compareTime(s1, s2) { + if (!(s1 && s2)) + return void 0; + const t1 = (/* @__PURE__ */ new Date("2020-01-01T" + s1)).valueOf(); + const t2 = (/* @__PURE__ */ new Date("2020-01-01T" + s2)).valueOf(); + if (!(t1 && t2)) + return void 0; + return t1 - t2; + } + function compareIsoTime(t1, t2) { + if (!(t1 && t2)) + return void 0; + const a1 = TIME.exec(t1); + const a2 = TIME.exec(t2); + if (!(a1 && a2)) + return void 0; + t1 = a1[1] + a1[2] + a1[3]; + t2 = a2[1] + a2[2] + a2[3]; + if (t1 > t2) + return 1; + if (t1 < t2) + return -1; + return 0; + } + var DATE_TIME_SEPARATOR = /t|\s/i; + function getDateTime(strictTimeZone) { + const time3 = getTime(strictTimeZone); + return function date_time(str2) { + const dateTime = str2.split(DATE_TIME_SEPARATOR); + return dateTime.length === 2 && date4(dateTime[0]) && time3(dateTime[1]); + }; + } + function compareDateTime(dt1, dt2) { + if (!(dt1 && dt2)) + return void 0; + const d1 = new Date(dt1).valueOf(); + const d2 = new Date(dt2).valueOf(); + if (!(d1 && d2)) + return void 0; + return d1 - d2; + } + function compareIsoDateTime(dt1, dt2) { + if (!(dt1 && dt2)) + return void 0; + const [d1, t1] = dt1.split(DATE_TIME_SEPARATOR); + const [d2, t2] = dt2.split(DATE_TIME_SEPARATOR); + const res = compareDate(d1, d2); + if (res === void 0) + return void 0; + return res || compareTime(t1, t2); + } + var NOT_URI_FRAGMENT = /\/|:/; + var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; + function uri(str2) { + return NOT_URI_FRAGMENT.test(str2) && URI.test(str2); + } + var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm; + function byte(str2) { + BYTE.lastIndex = 0; + return BYTE.test(str2); + } + var MIN_INT32 = -(2 ** 31); + var MAX_INT32 = 2 ** 31 - 1; + function validateInt32(value) { + return Number.isInteger(value) && value <= MAX_INT32 && value >= MIN_INT32; + } + function validateInt64(value) { + return Number.isInteger(value); + } + function validateNumber() { + return true; + } + var Z_ANCHOR = /[^\\]\\Z/; + function regex(str2) { + if (Z_ANCHOR.test(str2)) + return false; + try { + new RegExp(str2); + return true; + } catch (e) { + return false; + } + } + } +}); + +// node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/limit.js +var require_limit = __commonJS({ + "node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/limit.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.formatLimitDefinition = void 0; + var ajv_1 = require_ajv(); + var codegen_1 = require_codegen(); + var ops = codegen_1.operators; + var KWDs = { + formatMaximum: { okStr: "<=", ok: ops.LTE, fail: ops.GT }, + formatMinimum: { okStr: ">=", ok: ops.GTE, fail: ops.LT }, + formatExclusiveMaximum: { okStr: "<", ok: ops.LT, fail: ops.GTE }, + formatExclusiveMinimum: { okStr: ">", ok: ops.GT, fail: ops.LTE } + }; + var error2 = { + message: ({ keyword, schemaCode }) => (0, codegen_1.str)`should be ${KWDs[keyword].okStr} ${schemaCode}`, + params: ({ keyword, schemaCode }) => (0, codegen_1._)`{comparison: ${KWDs[keyword].okStr}, limit: ${schemaCode}}` + }; + exports2.formatLimitDefinition = { + keyword: Object.keys(KWDs), + type: "string", + schemaType: "string", + $data: true, + error: error2, + code(cxt) { + const { gen, data, schemaCode, keyword, it } = cxt; + const { opts, self } = it; + if (!opts.validateFormats) + return; + const fCxt = new ajv_1.KeywordCxt(it, self.RULES.all.format.definition, "format"); + if (fCxt.$data) + validate$DataFormat(); + else + validateFormat(); + function validate$DataFormat() { + const fmts = gen.scopeValue("formats", { + ref: self.formats, + code: opts.code.formats + }); + const fmt = gen.const("fmt", (0, codegen_1._)`${fmts}[${fCxt.schemaCode}]`); + cxt.fail$data((0, codegen_1.or)((0, codegen_1._)`typeof ${fmt} != "object"`, (0, codegen_1._)`${fmt} instanceof RegExp`, (0, codegen_1._)`typeof ${fmt}.compare != "function"`, compareCode(fmt))); + } + function validateFormat() { + const format = fCxt.schema; + const fmtDef = self.formats[format]; + if (!fmtDef || fmtDef === true) + return; + if (typeof fmtDef != "object" || fmtDef instanceof RegExp || typeof fmtDef.compare != "function") { + throw new Error(`"${keyword}": format "${format}" does not define "compare" function`); + } + const fmt = gen.scopeValue("formats", { + key: format, + ref: fmtDef, + code: opts.code.formats ? (0, codegen_1._)`${opts.code.formats}${(0, codegen_1.getProperty)(format)}` : void 0 + }); + cxt.fail$data(compareCode(fmt)); + } + function compareCode(fmt) { + return (0, codegen_1._)`${fmt}.compare(${data}, ${schemaCode}) ${KWDs[keyword].fail} 0`; + } + }, + dependencies: ["format"] + }; + var formatLimitPlugin = (ajv) => { + ajv.addKeyword(exports2.formatLimitDefinition); + return ajv; + }; + exports2.default = formatLimitPlugin; + } +}); + +// node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/index.js +var require_dist = __commonJS({ + "node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/index.js"(exports2, module2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + var formats_1 = require_formats(); + var limit_1 = require_limit(); + var codegen_1 = require_codegen(); + var fullName = new codegen_1.Name("fullFormats"); + var fastName = new codegen_1.Name("fastFormats"); + var formatsPlugin = (ajv, opts = { keywords: true }) => { + if (Array.isArray(opts)) { + addFormats(ajv, opts, formats_1.fullFormats, fullName); + return ajv; + } + const [formats, exportName] = opts.mode === "fast" ? [formats_1.fastFormats, fastName] : [formats_1.fullFormats, fullName]; + const list = opts.formats || formats_1.formatNames; + addFormats(ajv, list, formats, exportName); + if (opts.keywords) + (0, limit_1.default)(ajv); + return ajv; + }; + formatsPlugin.get = (name, mode = "full") => { + const formats = mode === "fast" ? formats_1.fastFormats : formats_1.fullFormats; + const f = formats[name]; + if (!f) + throw new Error(`Unknown format "${name}"`); + return f; + }; + function addFormats(ajv, list, fs2, exportName) { + var _a2; + var _b; + (_a2 = (_b = ajv.opts.code).formats) !== null && _a2 !== void 0 ? _a2 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; + for (const f of list) + ajv.addFormat(f, fs2[f]); + } + module2.exports = exports2 = formatsPlugin; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.default = formatsPlugin; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js +var require_constants = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js"(exports2, module2) { + "use strict"; + var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; + var hasBlob = typeof Blob !== "undefined"; + if (hasBlob) BINARY_TYPES.push("blob"); + module2.exports = { + BINARY_TYPES, + CLOSE_TIMEOUT: 3e4, + EMPTY_BUFFER: Buffer.alloc(0), + GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", + hasBlob, + kForOnEventAttribute: /* @__PURE__ */ Symbol("kIsForOnEventAttribute"), + kListener: /* @__PURE__ */ Symbol("kListener"), + kStatusCode: /* @__PURE__ */ Symbol("status-code"), + kWebSocket: /* @__PURE__ */ Symbol("websocket"), + NOOP: () => { + } + }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/buffer-util.js +var require_buffer_util = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/buffer-util.js"(exports2, module2) { + "use strict"; + var { EMPTY_BUFFER } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } + return target; + } + function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } + } + function _unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } + } + function toArrayBuffer(buf) { + if (buf.length === buf.buffer.byteLength) { + return buf.buffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); + } + function toBuffer(data) { + toBuffer.readOnly = true; + if (Buffer.isBuffer(data)) return data; + let buf; + if (data instanceof ArrayBuffer) { + buf = new FastBuffer(data); + } else if (ArrayBuffer.isView(data)) { + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + return buf; + } + module2.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask + }; + if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require("bufferutil"); + module2.exports.mask = function(source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + module2.exports.unmask = function(buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/limiter.js +var require_limiter = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/limiter.js"(exports2, module2) { + "use strict"; + var kDone = /* @__PURE__ */ Symbol("kDone"); + var kRun = /* @__PURE__ */ Symbol("kRun"); + var Limiter = class { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + if (this.jobs.length) { + const job = this.jobs.shift(); + this.pending++; + job(this[kDone]); + } + } + }; + module2.exports = Limiter; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/permessage-deflate.js +var require_permessage_deflate = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/permessage-deflate.js"(exports2, module2) { + "use strict"; + var zlib = require("zlib"); + var bufferUtil = require_buffer_util(); + var Limiter = require_limiter(); + var { kStatusCode } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + var TRAILER = Buffer.from([0, 0, 255, 255]); + var kPerMessageDeflate = /* @__PURE__ */ Symbol("permessage-deflate"); + var kTotalLength = /* @__PURE__ */ Symbol("total-length"); + var kCallback = /* @__PURE__ */ Symbol("callback"); + var kBuffers = /* @__PURE__ */ Symbol("buffers"); + var kError = /* @__PURE__ */ Symbol("error"); + var zlibLimiter; + var PerMessageDeflate = class { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length + */ + constructor(options2, isServer, maxPayload) { + this._maxPayload = maxPayload | 0; + this._options = options2 || {}; + this._threshold = this._options.threshold !== void 0 ? this._options.threshold : 1024; + this._isServer = !!isServer; + this._deflate = null; + this._inflate = null; + this.params = null; + if (!zlibLimiter) { + const concurrency = this._options.concurrencyLimit !== void 0 ? this._options.concurrencyLimit : 10; + zlibLimiter = new Limiter(concurrency); + } + } + /** + * @type {String} + */ + static get extensionName() { + return "permessage-deflate"; + } + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + return params; + } + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + this.params = this._isServer ? this.acceptAsServer(configurations) : this.acceptAsClient(configurations); + return this.params; + } + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + if (this._deflate) { + const callback = this._deflate[kCallback]; + this._deflate.close(); + this._deflate = null; + if (callback) { + callback( + new Error( + "The deflate stream was closed while data was being processed" + ) + ); + } + } + } + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { + return false; + } + return true; + }); + if (!accepted) { + throw new Error("None of the extension offers can be accepted"); + } + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === "number") { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === "number") { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if (accepted.client_max_window_bits === true || opts.clientMaxWindowBits === false) { + delete accepted.client_max_window_bits; + } + return accepted; + } + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + if (this._options.clientNoContextTakeover === false && params.client_no_context_takeover) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === "number") { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if (this._options.clientMaxWindowBits === false || typeof this._options.clientMaxWindowBits === "number" && params.client_max_window_bits > this._options.clientMaxWindowBits) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + return params; + } + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + value = value[0]; + if (key === "client_max_window_bits") { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === "server_max_window_bits") { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + params[key] = value; + }); + }); + return configurations; + } + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? "client" : "server"; + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on("error", inflateOnError); + this._inflate.on("data", inflateOnData); + } + this._inflate[kCallback] = callback; + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + this._inflate.flush(() => { + const err = this._inflate[kError]; + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + const data2 = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + callback(null, data2); + }); + } + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? "server" : "client"; + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + this._deflate.on("data", deflateOnData); + } + this._deflate[kCallback] = callback; + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + return; + } + let data2 = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + if (fin) { + data2 = new FastBuffer(data2.buffer, data2.byteOffset, data2.length - 4); + } + this._deflate[kCallback] = null; + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + callback(null, data2); + }); + } + }; + module2.exports = PerMessageDeflate; + function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; + } + function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + if (this[kPerMessageDeflate]._maxPayload < 1 || this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload) { + this[kBuffers].push(chunk); + return; + } + this[kError] = new RangeError("Max payload size exceeded"); + this[kError].code = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"; + this[kError][kStatusCode] = 1009; + this.removeListener("data", inflateOnData); + this.reset(); + } + function inflateOnError(err) { + this[kPerMessageDeflate]._inflate = null; + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + err[kStatusCode] = 1007; + this[kCallback](err); + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/validation.js +var require_validation2 = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/validation.js"(exports2, module2) { + "use strict"; + var { isUtf8 } = require("buffer"); + var { hasBlob } = require_constants(); + var tokenChars = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 0 - 15 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 16 - 31 + 0, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 0, + // 32 - 47 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + // 48 - 63 + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 64 - 79 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + // 80 - 95 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 96 - 111 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 0 + // 112 - 127 + ]; + function isValidStatusCode(code) { + return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999; + } + function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + while (i < len) { + if ((buf[i] & 128) === 0) { + i++; + } else if ((buf[i] & 224) === 192) { + if (i + 1 === len || (buf[i + 1] & 192) !== 128 || (buf[i] & 254) === 192) { + return false; + } + i += 2; + } else if ((buf[i] & 240) === 224) { + if (i + 2 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || buf[i] === 224 && (buf[i + 1] & 224) === 128 || // Overlong + buf[i] === 237 && (buf[i + 1] & 224) === 160) { + return false; + } + i += 3; + } else if ((buf[i] & 248) === 240) { + if (i + 3 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || (buf[i + 3] & 192) !== 128 || buf[i] === 240 && (buf[i + 1] & 240) === 128 || // Overlong + buf[i] === 244 && buf[i + 1] > 143 || buf[i] > 244) { + return false; + } + i += 4; + } else { + return false; + } + } + return true; + } + function isBlob(value) { + return hasBlob && typeof value === "object" && typeof value.arrayBuffer === "function" && typeof value.type === "string" && typeof value.stream === "function" && (value[Symbol.toStringTag] === "Blob" || value[Symbol.toStringTag] === "File"); + } + module2.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars + }; + if (isUtf8) { + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); + }; + } else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require("utf-8-validate"); + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/receiver.js +var require_receiver = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/receiver.js"(exports2, module2) { + "use strict"; + var { Writable } = require("stream"); + var PerMessageDeflate = require_permessage_deflate(); + var { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket + } = require_constants(); + var { concat, toArrayBuffer, unmask } = require_buffer_util(); + var { isValidStatusCode, isValidUTF8 } = require_validation2(); + var FastBuffer = Buffer[Symbol.species]; + var GET_INFO = 0; + var GET_PAYLOAD_LENGTH_16 = 1; + var GET_PAYLOAD_LENGTH_64 = 2; + var GET_MASK = 3; + var GET_DATA = 4; + var INFLATING = 5; + var DEFER_EVENT = 6; + var Receiver2 = class extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options2 = {}) { + super(); + this._allowSynchronousEvents = options2.allowSynchronousEvents !== void 0 ? options2.allowSynchronousEvents : true; + this._binaryType = options2.binaryType || BINARY_TYPES[0]; + this._extensions = options2.extensions || {}; + this._isServer = !!options2.isServer; + this._maxPayload = options2.maxPayload | 0; + this._skipUTF8Validation = !!options2.skipUTF8Validation; + this[kWebSocket] = void 0; + this._bufferedBytes = 0; + this._buffers = []; + this._compressed = false; + this._payloadLength = 0; + this._mask = void 0; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + this._errored = false; + this._loop = false; + this._state = GET_INFO; + } + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 8 && this._state == GET_INFO) return cb(); + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + if (n === this._buffers[0].length) return this._buffers.shift(); + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + return new FastBuffer(buf.buffer, buf.byteOffset, n); + } + const dst = Buffer.allocUnsafe(n); + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + } + n -= buf.length; + } while (n > 0); + return dst; + } + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + this._loop = true; + do { + switch (this._state) { + case GET_INFO: + this.getInfo(cb); + break; + case GET_PAYLOAD_LENGTH_16: + this.getPayloadLength16(cb); + break; + case GET_PAYLOAD_LENGTH_64: + this.getPayloadLength64(cb); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + this.getData(cb); + break; + case INFLATING: + case DEFER_EVENT: + this._loop = false; + return; + } + } while (this._loop); + if (!this._errored) cb(); + } + /** + * Reads the first two bytes of a frame. + * + * @param {Function} cb Callback + * @private + */ + getInfo(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + const buf = this.consume(2); + if ((buf[0] & 48) !== 0) { + const error2 = this.createError( + RangeError, + "RSV2 and RSV3 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_2_3" + ); + cb(error2); + return; + } + const compressed = (buf[0] & 64) === 64; + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + const error2 = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error2); + return; + } + this._fin = (buf[0] & 128) === 128; + this._opcode = buf[0] & 15; + this._payloadLength = buf[1] & 127; + if (this._opcode === 0) { + if (compressed) { + const error2 = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error2); + return; + } + if (!this._fragmented) { + const error2 = this.createError( + RangeError, + "invalid opcode 0", + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error2); + return; + } + this._opcode = this._fragmented; + } else if (this._opcode === 1 || this._opcode === 2) { + if (this._fragmented) { + const error2 = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error2); + return; + } + this._compressed = compressed; + } else if (this._opcode > 7 && this._opcode < 11) { + if (!this._fin) { + const error2 = this.createError( + RangeError, + "FIN must be set", + true, + 1002, + "WS_ERR_EXPECTED_FIN" + ); + cb(error2); + return; + } + if (compressed) { + const error2 = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error2); + return; + } + if (this._payloadLength > 125 || this._opcode === 8 && this._payloadLength === 1) { + const error2 = this.createError( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + "WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH" + ); + cb(error2); + return; + } + } else { + const error2 = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error2); + return; + } + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 128) === 128; + if (this._isServer) { + if (!this._masked) { + const error2 = this.createError( + RangeError, + "MASK must be set", + true, + 1002, + "WS_ERR_EXPECTED_MASK" + ); + cb(error2); + return; + } + } else if (this._masked) { + const error2 = this.createError( + RangeError, + "MASK must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_MASK" + ); + cb(error2); + return; + } + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else this.haveLength(cb); + } + /** + * Gets extended payload length (7+16). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength16(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + this._payloadLength = this.consume(2).readUInt16BE(0); + this.haveLength(cb); + } + /** + * Gets extended payload length (7+64). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength64(cb) { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + if (num > Math.pow(2, 53 - 32) - 1) { + const error2 = this.createError( + RangeError, + "Unsupported WebSocket frame: payload length > 2^53 - 1", + false, + 1009, + "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH" + ); + cb(error2); + return; + } + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + this.haveLength(cb); + } + /** + * Payload length has been read. + * + * @param {Function} cb Callback + * @private + */ + haveLength(cb) { + if (this._payloadLength && this._opcode < 8) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + const error2 = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error2); + return; + } + } + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + this._mask = this.consume(4); + this._state = GET_DATA; + } + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + data = this.consume(this._payloadLength); + if (this._masked && (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0) { + unmask(data, this._mask); + } + } + if (this._opcode > 7) { + this.controlMessage(data, cb); + return; + } + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + if (data.length) { + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + this.dataMessage(cb); + } + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + const error2 = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error2); + return; + } + this._fragments.push(buf); + } + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); + }); + } + /** + * Handles a data message. + * + * @param {Function} cb Callback + * @private + */ + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + const messageLength = this._messageLength; + const fragments = this._fragments; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + if (this._opcode === 2) { + let data; + if (this._binaryType === "nodebuffer") { + data = concat(fragments, messageLength); + } else if (this._binaryType === "arraybuffer") { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === "blob") { + data = new Blob(fragments); + } else { + data = fragments; + } + if (this._allowSynchronousEvents) { + this.emit("message", data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error2 = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error2); + return; + } + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit("message", buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + } + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data, cb) { + if (this._opcode === 8) { + if (data.length === 0) { + this._loop = false; + this.emit("conclude", 1005, EMPTY_BUFFER); + this.end(); + } else { + const code = data.readUInt16BE(0); + if (!isValidStatusCode(code)) { + const error2 = this.createError( + RangeError, + `invalid status code ${code}`, + true, + 1002, + "WS_ERR_INVALID_CLOSE_CODE" + ); + cb(error2); + return; + } + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error2 = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error2); + return; + } + this._loop = false; + this.emit("conclude", code, buf); + this.end(); + } + this._state = GET_INFO; + return; + } + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; + } + }; + module2.exports = Receiver2; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/sender.js +var require_sender = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/sender.js"(exports2, module2) { + "use strict"; + var { Duplex } = require("stream"); + var { randomFillSync } = require("crypto"); + var PerMessageDeflate = require_permessage_deflate(); + var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants(); + var { isBlob, isValidStatusCode } = require_validation2(); + var { mask: applyMask, toBuffer } = require_buffer_util(); + var kByteLength = /* @__PURE__ */ Symbol("kByteLength"); + var maskBuffer = Buffer.alloc(4); + var RANDOM_POOL_SIZE = 8 * 1024; + var randomPool; + var randomPoolPointer = RANDOM_POOL_SIZE; + var DEFAULT = 0; + var DEFLATING = 1; + var GET_BLOB_DATA = 2; + var Sender2 = class _Sender { + /** + * Creates a Sender instance. + * + * @param {Duplex} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + this._socket = socket; + this._firstFragment = true; + this._compress = false; + this._bufferedBytes = 0; + this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = void 0; + } + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options2) { + let mask; + let merge2 = false; + let offset = 2; + let skipMasking = false; + if (options2.mask) { + mask = options2.maskBuffer || maskBuffer; + if (options2.generateMask) { + options2.generateMask(mask); + } else { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + if (randomPool === void 0) { + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + let dataLength; + if (typeof data === "string") { + if ((!options2.mask || skipMasking) && options2[kByteLength] !== void 0) { + dataLength = options2[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge2 = options2.mask && options2.readOnly && !skipMasking; + } + let payloadLength = dataLength; + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + const target = Buffer.allocUnsafe(merge2 ? dataLength + offset : offset); + target[0] = options2.fin ? options2.opcode | 128 : options2.opcode; + if (options2.rsv1) target[0] |= 64; + target[1] = payloadLength; + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + if (!options2.mask) return [target, data]; + target[1] |= 128; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + if (skipMasking) return [target, data]; + if (merge2) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + if (code === void 0) { + buf = EMPTY_BUFFER; + } else if (typeof code !== "number" || !isValidStatusCode(code)) { + throw new TypeError("First argument must be a valid error code number"); + } else if (data === void 0 || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + if (length > 123) { + throw new RangeError("The message must not be greater than 123 bytes"); + } + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + if (typeof data === "string") { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + const options2 = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 8, + readOnly: false, + rsv1: false + }; + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(buf, options2), cb); + } + } + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options2 = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 9, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options2, cb]); + } else { + this.getBlobData(data, false, options2, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(data, options2), cb); + } + } + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options2 = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 10, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options2, cb]); + } else { + this.getBlobData(data, false, options2, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(data, options2), cb); + } + } + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options2, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options2.binary ? 2 : 1; + let rsv1 = options2.compress; + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { + this._firstFragment = false; + if (rsv1 && perMessageDeflate && perMessageDeflate.params[perMessageDeflate._isServer ? "server_no_context_takeover" : "client_no_context_takeover"]) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + if (options2.fin) this._firstFragment = true; + const opts = { + [kByteLength]: byteLength, + fin: options2.fin, + generateMask: this._generateMask, + mask: options2.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); + } else { + this.getBlobData(data, this._compress, opts, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } + /** + * Gets the contents of a blob as binary data. + * + * @param {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * the data + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + getBlobData(blob, compress, options2, cb) { + this._bufferedBytes += options2[kByteLength]; + this._state = GET_BLOB_DATA; + blob.arrayBuffer().then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while the blob was being read" + ); + process.nextTick(callCallbacks, this, err, cb); + return; + } + this._bufferedBytes -= options2[kByteLength]; + const data = toBuffer(arrayBuffer); + if (!compress) { + this._state = DEFAULT; + this.sendFrame(_Sender.frame(data, options2), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options2, cb); + } + }).catch((err) => { + process.nextTick(onError, this, err, cb); + }); + } + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options2, cb) { + if (!compress) { + this.sendFrame(_Sender.frame(data, options2), cb); + return; + } + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + this._bufferedBytes += options2[kByteLength]; + this._state = DEFLATING; + perMessageDeflate.compress(data, options2.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while data was being compressed" + ); + callCallbacks(this, err, cb); + return; + } + this._bufferedBytes -= options2[kByteLength]; + this._state = DEFAULT; + options2.readOnly = false; + this.sendFrame(_Sender.frame(buf, options2), cb); + this.dequeue(); + }); + } + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (this._state === DEFAULT && this._queue.length) { + const params = this._queue.shift(); + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + /** + * Sends a frame. + * + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } + }; + module2.exports = Sender2; + function callCallbacks(sender, err, cb) { + if (typeof cb === "function") cb(err); + for (let i = 0; i < sender._queue.length; i++) { + const params = sender._queue[i]; + const callback = params[params.length - 1]; + if (typeof callback === "function") callback(err); + } + } + function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/event-target.js +var require_event_target = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/event-target.js"(exports2, module2) { + "use strict"; + var { kForOnEventAttribute, kListener } = require_constants(); + var kCode = /* @__PURE__ */ Symbol("kCode"); + var kData = /* @__PURE__ */ Symbol("kData"); + var kError = /* @__PURE__ */ Symbol("kError"); + var kMessage = /* @__PURE__ */ Symbol("kMessage"); + var kReason = /* @__PURE__ */ Symbol("kReason"); + var kTarget = /* @__PURE__ */ Symbol("kTarget"); + var kType = /* @__PURE__ */ Symbol("kType"); + var kWasClean = /* @__PURE__ */ Symbol("kWasClean"); + var Event = class { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + /** + * @type {String} + */ + get type() { + return this[kType]; + } + }; + Object.defineProperty(Event.prototype, "target", { enumerable: true }); + Object.defineProperty(Event.prototype, "type", { enumerable: true }); + var CloseEvent = class extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options2 = {}) { + super(type); + this[kCode] = options2.code === void 0 ? 0 : options2.code; + this[kReason] = options2.reason === void 0 ? "" : options2.reason; + this[kWasClean] = options2.wasClean === void 0 ? false : options2.wasClean; + } + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } + }; + Object.defineProperty(CloseEvent.prototype, "code", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "reason", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "wasClean", { enumerable: true }); + var ErrorEvent = class extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options2 = {}) { + super(type); + this[kError] = options2.error === void 0 ? null : options2.error; + this[kMessage] = options2.message === void 0 ? "" : options2.message; + } + /** + * @type {*} + */ + get error() { + return this[kError]; + } + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } + }; + Object.defineProperty(ErrorEvent.prototype, "error", { enumerable: true }); + Object.defineProperty(ErrorEvent.prototype, "message", { enumerable: true }); + var MessageEvent = class extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options2 = {}) { + super(type); + this[kData] = options2.data === void 0 ? null : options2.data; + } + /** + * @type {*} + */ + get data() { + return this[kData]; + } + }; + Object.defineProperty(MessageEvent.prototype, "data", { enumerable: true }); + var EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, handler, options2 = {}) { + for (const listener of this.listeners(type)) { + if (!options2[kForOnEventAttribute] && listener[kListener] === handler && !listener[kForOnEventAttribute]) { + return; + } + } + let wrapper; + if (type === "message") { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent("message", { + data: isBinary ? data : data.toString() + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "close") { + wrapper = function onClose(code, message) { + const event = new CloseEvent("close", { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "error") { + wrapper = function onError(error2) { + const event = new ErrorEvent("error", { + error: error2, + message: error2.message + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "open") { + wrapper = function onOpen() { + const event = new Event("open"); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else { + return; + } + wrapper[kForOnEventAttribute] = !!options2[kForOnEventAttribute]; + wrapper[kListener] = handler; + if (options2.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {(Function|Object)} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } + }; + module2.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent + }; + function callListener(listener, thisArg, event) { + if (typeof listener === "object" && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/extension.js +var require_extension = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/extension.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation2(); + function push(dest, name, elem) { + if (dest[name] === void 0) dest[name] = [elem]; + else dest[name].push(elem); + } + function parse4(header) { + const offers = /* @__PURE__ */ Object.create(null); + let params = /* @__PURE__ */ Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + for (; i < header.length; i++) { + code = header.charCodeAt(i); + if (extensionName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 44) { + push(offers, name, params); + params = /* @__PURE__ */ Object.create(null); + } else { + extensionName = name; + } + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 32 || code === 9) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + start = end = -1; + } else if (code === 61 && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 34 && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 92) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 34 && header.charCodeAt(i - 1) === 61) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 32 || code === 9)) { + if (end === -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ""); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + paramName = void 0; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + if (start === -1 || inQuotes || code === 32 || code === 9) { + throw new SyntaxError("Unexpected end of input"); + } + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === void 0) { + push(offers, token, params); + } else { + if (paramName === void 0) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, "")); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + return offers; + } + function format(extensions) { + return Object.keys(extensions).map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations.map((params) => { + return [extension].concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values.map((v) => v === true ? k : `${k}=${v}`).join("; "); + }) + ).join("; "); + }).join(", "); + }).join(", "); + } + module2.exports = { format, parse: parse4 }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js +var require_websocket = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var https = require("https"); + var http = require("http"); + var net = require("net"); + var tls = require("tls"); + var { randomBytes, createHash } = require("crypto"); + var { Duplex, Readable } = require("stream"); + var { URL: URL2 } = require("url"); + var PerMessageDeflate = require_permessage_deflate(); + var Receiver2 = require_receiver(); + var Sender2 = require_sender(); + var { isBlob } = require_validation2(); + var { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP + } = require_constants(); + var { + EventTarget: { addEventListener, removeEventListener } + } = require_event_target(); + var { format, parse: parse4 } = require_extension(); + var { toBuffer } = require_buffer_util(); + var kAborted = /* @__PURE__ */ Symbol("kAborted"); + var protocolVersions = [8, 13]; + var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; + var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + var WebSocket2 = class _WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options2) { + super(); + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ""; + this._readyState = _WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + if (protocols === void 0) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === "object" && protocols !== null) { + options2 = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + initAsClient(this, address, protocols, options2); + } else { + this._autoPong = options2.autoPong; + this._closeTimeout = options2.closeTimeout; + this._isServer = true; + } + } + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + this._binaryType = type; + if (this._receiver) this._receiver._binaryType = type; + } + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + return this._socket._writableState.length + this._sender._bufferedBytes; + } + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + /** + * @type {String} + */ + get url() { + return this._url; + } + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options2) { + const receiver = new Receiver2({ + allowSynchronousEvents: options2.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options2.maxPayload, + skipUTF8Validation: options2.skipUTF8Validation + }); + const sender = new Sender2(socket, this._extensions, options2.generateMask); + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + receiver.on("conclude", receiverOnConclude); + receiver.on("drain", receiverOnDrain); + receiver.on("error", receiverOnError); + receiver.on("message", receiverOnMessage); + receiver.on("ping", receiverOnPing); + receiver.on("pong", receiverOnPong); + sender.onerror = senderOnError; + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + if (head.length > 0) socket.unshift(head); + socket.on("close", socketOnClose); + socket.on("data", socketOnData); + socket.on("end", socketOnEnd); + socket.on("error", socketOnError); + this._readyState = _WebSocket.OPEN; + this.emit("open"); + } + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + return; + } + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + this._receiver.removeAllListeners(); + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + } + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this.readyState === _WebSocket.CLOSING) { + if (this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted)) { + this._socket.end(); + } + return; + } + this._readyState = _WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + if (err) return; + this._closeFrameSent = true; + if (this._closeFrameReceived || this._receiver._writableState.errorEmitted) { + this._socket.end(); + } + }); + setCloseTimer(this); + } + /** + * Pause the socket. + * + * @public + */ + pause() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = true; + this._socket.pause(); + } + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + /** + * Resume the socket. + * + * @public + */ + resume() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options2, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof options2 === "function") { + cb = options2; + options2 = {}; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + const opts = { + binary: typeof data !== "string", + mask: !this._isServer, + compress: true, + fin: true, + ...options2 + }; + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this._socket) { + this._readyState = _WebSocket.CLOSING; + this._socket.destroy(); + } + } + }; + Object.defineProperty(WebSocket2, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket2.prototype, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket2, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket2.prototype, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket2, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket2.prototype, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket2, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + Object.defineProperty(WebSocket2.prototype, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + [ + "binaryType", + "bufferedAmount", + "extensions", + "isPaused", + "protocol", + "readyState", + "url" + ].forEach((property) => { + Object.defineProperty(WebSocket2.prototype, property, { enumerable: true }); + }); + ["open", "error", "close", "message"].forEach((method) => { + Object.defineProperty(WebSocket2.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + if (typeof handler !== "function") return; + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); + }); + WebSocket2.prototype.addEventListener = addEventListener; + WebSocket2.prototype.removeEventListener = removeEventListener; + module2.exports = WebSocket2; + function initAsClient(websocket, address, protocols, options2) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options2, + socketPath: void 0, + hostname: void 0, + protocol: void 0, + timeout: void 0, + method: "GET", + host: void 0, + path: void 0, + port: void 0 + }; + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} (supported versions: ${protocolVersions.join(", ")})` + ); + } + let parsedUrl; + if (address instanceof URL2) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL2(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + if (parsedUrl.protocol === "http:") { + parsedUrl.protocol = "ws:"; + } else if (parsedUrl.protocol === "https:") { + parsedUrl.protocol = "wss:"; + } + websocket._url = parsedUrl.href; + const isSecure = parsedUrl.protocol === "wss:"; + const isIpcUrl = parsedUrl.protocol === "ws+unix:"; + let invalidUrlMessage; + if (parsedUrl.protocol !== "ws:" && !isSecure && !isIpcUrl) { + invalidUrlMessage = `The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = "The URL contains a fragment identifier"; + } + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString("base64"); + const request = isSecure ? https.request : http.request; + const protocolSet = /* @__PURE__ */ new Set(); + let perMessageDeflate; + opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + "Sec-WebSocket-Version": opts.protocolVersion, + "Sec-WebSocket-Key": key, + Connection: "Upgrade", + Upgrade: "websocket" + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers["Sec-WebSocket-Extensions"] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if (typeof protocol !== "string" || !subprotocolRegex.test(protocol) || protocolSet.has(protocol)) { + throw new SyntaxError( + "An invalid or duplicated subprotocol was specified" + ); + } + protocolSet.add(protocol); + } + opts.headers["Sec-WebSocket-Protocol"] = protocols.join(","); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers["Sec-WebSocket-Origin"] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + if (isIpcUrl) { + const parts = opts.path.split(":"); + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + let req; + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; + const headers = options2 && options2.headers; + options2 = { ...options2, headers: {} }; + if (headers) { + for (const [key2, value] of Object.entries(headers)) { + options2.headers[key2.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount("redirect") === 0) { + const isSameHost = isIpcUrl ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; + if (!isSameHost || websocket._originalSecure && !isSecure) { + delete opts.headers.authorization; + delete opts.headers.cookie; + if (!isSameHost) delete opts.headers.host; + opts.auth = void 0; + } + } + if (opts.auth && !options2.headers.authorization) { + options2.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64"); + } + req = websocket._req = request(opts); + if (websocket._redirects) { + websocket.emit("redirect", websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + if (opts.timeout) { + req.on("timeout", () => { + abortHandshake(websocket, req, "Opening handshake has timed out"); + }); + } + req.on("error", (err) => { + if (req === null || req[kAborted]) return; + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + req.on("response", (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, "Maximum redirects exceeded"); + return; + } + req.abort(); + let addr; + try { + addr = new URL2(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + initAsClient(websocket, addr, protocols, options2); + } else if (!websocket.emit("unexpected-response", req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + req.on("upgrade", (res, socket, head) => { + websocket.emit("upgrade", res); + if (websocket.readyState !== WebSocket2.CONNECTING) return; + req = websocket._req = null; + const upgrade = res.headers.upgrade; + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + abortHandshake(websocket, socket, "Invalid Upgrade header"); + return; + } + const digest = createHash("sha1").update(key + GUID).digest("base64"); + if (res.headers["sec-websocket-accept"] !== digest) { + abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); + return; + } + const serverProt = res.headers["sec-websocket-protocol"]; + let protError; + if (serverProt !== void 0) { + if (!protocolSet.size) { + protError = "Server sent a subprotocol but none was requested"; + } else if (!protocolSet.has(serverProt)) { + protError = "Server sent an invalid subprotocol"; + } + } else if (protocolSet.size) { + protError = "Server sent no subprotocol"; + } + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + if (serverProt) websocket._protocol = serverProt; + const secWebSocketExtensions = res.headers["sec-websocket-extensions"]; + if (secWebSocketExtensions !== void 0) { + if (!perMessageDeflate) { + const message = "Server sent a Sec-WebSocket-Extensions header but no extension was requested"; + abortHandshake(websocket, socket, message); + return; + } + let extensions; + try { + extensions = parse4(secWebSocketExtensions); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + const extensionNames = Object.keys(extensions); + if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) { + const message = "Server indicated an extension that was not requested"; + abortHandshake(websocket, socket, message); + return; + } + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } + } + function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket2.CLOSING; + websocket._errorEmitted = true; + websocket.emit("error", err); + websocket.emitClose(); + } + function netConnect(options2) { + options2.path = options2.socketPath; + return net.connect(options2); + } + function tlsConnect(options2) { + options2.path = void 0; + if (!options2.servername && options2.servername !== "") { + options2.servername = net.isIP(options2.host) ? "" : options2.host; + } + return tls.connect(options2); + } + function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket2.CLOSING; + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + if (stream.socket && !stream.socket.destroyed) { + stream.socket.destroy(); + } + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once("error", websocket.emit.bind(websocket, "error")); + stream.once("close", websocket.emitClose.bind(websocket)); + } + } + function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} (${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } + } + function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + if (websocket._socket[kWebSocket] === void 0) return; + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + if (code === 1005) websocket.close(); + else websocket.close(code, reason); + } + function receiverOnDrain() { + const websocket = this[kWebSocket]; + if (!websocket.isPaused) websocket._socket.resume(); + } + function receiverOnError(err) { + const websocket = this[kWebSocket]; + if (websocket._socket[kWebSocket] !== void 0) { + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + websocket.close(err[kStatusCode]); + } + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function receiverOnFinish() { + this[kWebSocket].emitClose(); + } + function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit("message", data, isBinary); + } + function receiverOnPing(data) { + const websocket = this[kWebSocket]; + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit("ping", data); + } + function receiverOnPong(data) { + this[kWebSocket].emit("pong", data); + } + function resume(stream) { + stream.resume(); + } + function senderOnError(err) { + const websocket = this[kWebSocket]; + if (websocket.readyState === WebSocket2.CLOSED) return; + if (websocket.readyState === WebSocket2.OPEN) { + websocket._readyState = WebSocket2.CLOSING; + setCloseTimer(websocket); + } + this._socket.end(); + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); + } + function socketOnClose() { + const websocket = this[kWebSocket]; + this.removeListener("close", socketOnClose); + this.removeListener("data", socketOnData); + this.removeListener("end", socketOnEnd); + websocket._readyState = WebSocket2.CLOSING; + if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && this._readableState.length !== 0) { + const chunk = this.read(this._readableState.length); + websocket._receiver.write(chunk); + } + websocket._receiver.end(); + this[kWebSocket] = void 0; + clearTimeout(websocket._closeTimer); + if (websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted) { + websocket.emitClose(); + } else { + websocket._receiver.on("error", receiverOnFinish); + websocket._receiver.on("finish", receiverOnFinish); + } + } + function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } + } + function socketOnEnd() { + const websocket = this[kWebSocket]; + websocket._readyState = WebSocket2.CLOSING; + websocket._receiver.end(); + this.end(); + } + function socketOnError() { + const websocket = this[kWebSocket]; + this.removeListener("error", socketOnError); + this.on("error", NOOP); + if (websocket) { + websocket._readyState = WebSocket2.CLOSING; + this.destroy(); + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/stream.js +var require_stream = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/stream.js"(exports2, module2) { + "use strict"; + var WebSocket2 = require_websocket(); + var { Duplex } = require("stream"); + function emitClose(stream) { + stream.emit("close"); + } + function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } + } + function duplexOnError(err) { + this.removeListener("error", duplexOnError); + this.destroy(); + if (this.listenerCount("error") === 0) { + this.emit("error", err); + } + } + function createWebSocketStream2(ws, options2) { + let terminateOnDestroy = true; + const duplex = new Duplex({ + ...options2, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + ws.on("message", function message(msg, isBinary) { + const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + if (!duplex.push(data)) ws.pause(); + }); + ws.once("error", function error2(err) { + if (duplex.destroyed) return; + terminateOnDestroy = false; + duplex.destroy(err); + }); + ws.once("close", function close() { + if (duplex.destroyed) return; + duplex.push(null); + }); + duplex._destroy = function(err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + let called = false; + ws.once("error", function error2(err2) { + called = true; + callback(err2); + }); + ws.once("close", function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + if (terminateOnDestroy) ws.terminate(); + }; + duplex._final = function(callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._final(callback); + }); + return; + } + if (ws._socket === null) return; + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once("finish", function finish() { + callback(); + }); + ws.close(); + } + }; + duplex._read = function() { + if (ws.isPaused) ws.resume(); + }; + duplex._write = function(chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + ws.send(chunk, callback); + }; + duplex.on("end", duplexOnEnd); + duplex.on("error", duplexOnError); + return duplex; + } + module2.exports = createWebSocketStream2; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js +var require_subprotocol = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation2(); + function parse4(header) { + const protocols = /* @__PURE__ */ new Set(); + let start = -1; + let end = -1; + let i = 0; + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const protocol2 = header.slice(start, end); + if (protocols.has(protocol2)) { + throw new SyntaxError(`The "${protocol2}" subprotocol is duplicated`); + } + protocols.add(protocol2); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + if (start === -1 || end !== -1) { + throw new SyntaxError("Unexpected end of input"); + } + const protocol = header.slice(start, i); + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + protocols.add(protocol); + return protocols; + } + module2.exports = { parse: parse4 }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket-server.js +var require_websocket_server = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket-server.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var http = require("http"); + var { Duplex } = require("stream"); + var { createHash } = require("crypto"); + var extension = require_extension(); + var PerMessageDeflate = require_permessage_deflate(); + var subprotocol = require_subprotocol(); + var WebSocket2 = require_websocket(); + var { CLOSE_TIMEOUT, GUID, kWebSocket } = require_constants(); + var keyRegex = /^[+/0-9A-Za-z]{22}==$/; + var RUNNING = 0; + var CLOSING = 1; + var CLOSED = 2; + var WebSocketServer2 = class extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to + * wait for the closing handshake to finish after `websocket.close()` is + * called + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options2, callback) { + super(); + options2 = { + allowSynchronousEvents: true, + autoPong: true, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + closeTimeout: CLOSE_TIMEOUT, + verifyClient: null, + noServer: false, + backlog: null, + // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket: WebSocket2, + ...options2 + }; + if (options2.port == null && !options2.server && !options2.noServer || options2.port != null && (options2.server || options2.noServer) || options2.server && options2.noServer) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options must be specified' + ); + } + if (options2.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + res.writeHead(426, { + "Content-Length": body.length, + "Content-Type": "text/plain" + }); + res.end(body); + }); + this._server.listen( + options2.port, + options2.host, + options2.backlog, + callback + ); + } else if (options2.server) { + this._server = options2.server; + } + if (this._server) { + const emitConnection = this.emit.bind(this, "connection"); + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, "listening"), + error: this.emit.bind(this, "error"), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + if (options2.perMessageDeflate === true) options2.perMessageDeflate = {}; + if (options2.clientTracking) { + this.clients = /* @__PURE__ */ new Set(); + this._shouldEmitClose = false; + } + this.options = options2; + this._state = RUNNING; + } + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + if (!this._server) return null; + return this._server.address(); + } + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once("close", () => { + cb(new Error("The server is not running")); + }); + } + process.nextTick(emitClose, this); + return; + } + if (cb) this.once("close", cb); + if (this._state === CLOSING) return; + this._state = CLOSING; + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server2 = this._server; + this._removeListeners(); + this._removeListeners = this._server = null; + server2.close(() => { + emitClose(this); + }); + } + } + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf("?"); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + if (pathname !== this.options.path) return false; + } + return true; + } + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on("error", socketOnError); + const key = req.headers["sec-websocket-key"]; + const upgrade = req.headers.upgrade; + const version2 = +req.headers["sec-websocket-version"]; + if (req.method !== "GET") { + const message = "Invalid HTTP method"; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + const message = "Invalid Upgrade header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (key === void 0 || !keyRegex.test(key)) { + const message = "Missing or invalid Sec-WebSocket-Key header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (version2 !== 13 && version2 !== 8) { + const message = "Missing or invalid Sec-WebSocket-Version header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + "Sec-WebSocket-Version": "13, 8" + }); + return; + } + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; + let protocols = /* @__PURE__ */ new Set(); + if (secWebSocketProtocol !== void 0) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = "Invalid Sec-WebSocket-Protocol header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; + const extensions = {}; + if (this.options.perMessageDeflate && secWebSocketExtensions !== void 0) { + const perMessageDeflate = new PerMessageDeflate( + this.options.perMessageDeflate, + true, + this.options.maxPayload + ); + try { + const offers = extension.parse(secWebSocketExtensions); + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + if (this.options.verifyClient) { + const info = { + origin: req.headers[`${version2 === 8 ? "sec-websocket-origin" : "origin"}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + if (!socket.readable || !socket.writable) return socket.destroy(); + if (socket[kWebSocket]) { + throw new Error( + "server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration" + ); + } + if (this._state > RUNNING) return abortHandshake(socket, 503); + const digest = createHash("sha1").update(key + GUID).digest("base64"); + const headers = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${digest}` + ]; + const ws = new this.options.WebSocket(null, void 0, this.options); + if (protocols.size) { + const protocol = this.options.handleProtocols ? this.options.handleProtocols(protocols, req) : protocols.values().next().value; + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + this.emit("headers", headers, req); + socket.write(headers.concat("\r\n").join("\r\n")); + socket.removeListener("error", socketOnError); + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + if (this.clients) { + this.clients.add(ws); + ws.on("close", () => { + this.clients.delete(ws); + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + cb(ws, req); + } + }; + module2.exports = WebSocketServer2; + function addListeners(server2, map2) { + for (const event of Object.keys(map2)) server2.on(event, map2[event]); + return function removeListeners() { + for (const event of Object.keys(map2)) { + server2.removeListener(event, map2[event]); + } + }; + } + function emitClose(server2) { + server2._state = CLOSED; + server2.emit("close"); + } + function socketOnError() { + this.destroy(); + } + function abortHandshake(socket, code, message, headers) { + message = message || http.STATUS_CODES[code]; + headers = { + Connection: "close", + "Content-Type": "text/html", + "Content-Length": Buffer.byteLength(message), + ...headers + }; + socket.once("finish", socket.destroy); + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r +` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message + ); + } + function abortHandshakeOrEmitwsClientError(server2, req, socket, code, message, headers) { + if (server2.listenerCount("wsClientError")) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + server2.emit("wsClientError", err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } + } + } +}); + +// node_modules/.pnpm/kind-of@6.0.3/node_modules/kind-of/index.js +var require_kind_of = __commonJS({ + "node_modules/.pnpm/kind-of@6.0.3/node_modules/kind-of/index.js"(exports2, module2) { + var toString = Object.prototype.toString; + module2.exports = function kindOf(val) { + if (val === void 0) return "undefined"; + if (val === null) return "null"; + var type = typeof val; + if (type === "boolean") return "boolean"; + if (type === "string") return "string"; + if (type === "number") return "number"; + if (type === "symbol") return "symbol"; + if (type === "function") { + return isGeneratorFn(val) ? "generatorfunction" : "function"; + } + if (isArray(val)) return "array"; + if (isBuffer(val)) return "buffer"; + if (isArguments(val)) return "arguments"; + if (isDate(val)) return "date"; + if (isError(val)) return "error"; + if (isRegexp(val)) return "regexp"; + switch (ctorName(val)) { + case "Symbol": + return "symbol"; + case "Promise": + return "promise"; + // Set, Map, WeakSet, WeakMap + case "WeakMap": + return "weakmap"; + case "WeakSet": + return "weakset"; + case "Map": + return "map"; + case "Set": + return "set"; + // 8-bit typed arrays + case "Int8Array": + return "int8array"; + case "Uint8Array": + return "uint8array"; + case "Uint8ClampedArray": + return "uint8clampedarray"; + // 16-bit typed arrays + case "Int16Array": + return "int16array"; + case "Uint16Array": + return "uint16array"; + // 32-bit typed arrays + case "Int32Array": + return "int32array"; + case "Uint32Array": + return "uint32array"; + case "Float32Array": + return "float32array"; + case "Float64Array": + return "float64array"; + } + if (isGeneratorObj(val)) { + return "generator"; + } + type = toString.call(val); + switch (type) { + case "[object Object]": + return "object"; + // iterators + case "[object Map Iterator]": + return "mapiterator"; + case "[object Set Iterator]": + return "setiterator"; + case "[object String Iterator]": + return "stringiterator"; + case "[object Array Iterator]": + return "arrayiterator"; + } + return type.slice(8, -1).toLowerCase().replace(/\s/g, ""); + }; + function ctorName(val) { + return typeof val.constructor === "function" ? val.constructor.name : null; + } + function isArray(val) { + if (Array.isArray) return Array.isArray(val); + return val instanceof Array; + } + function isError(val) { + return val instanceof Error || typeof val.message === "string" && val.constructor && typeof val.constructor.stackTraceLimit === "number"; + } + function isDate(val) { + if (val instanceof Date) return true; + return typeof val.toDateString === "function" && typeof val.getDate === "function" && typeof val.setDate === "function"; + } + function isRegexp(val) { + if (val instanceof RegExp) return true; + return typeof val.flags === "string" && typeof val.ignoreCase === "boolean" && typeof val.multiline === "boolean" && typeof val.global === "boolean"; + } + function isGeneratorFn(name, val) { + return ctorName(name) === "GeneratorFunction"; + } + function isGeneratorObj(val) { + return typeof val.throw === "function" && typeof val.return === "function" && typeof val.next === "function"; + } + function isArguments(val) { + try { + if (typeof val.length === "number" && typeof val.callee === "function") { + return true; + } + } catch (err) { + if (err.message.indexOf("callee") !== -1) { + return true; + } + } + return false; + } + function isBuffer(val) { + if (val.constructor && typeof val.constructor.isBuffer === "function") { + return val.constructor.isBuffer(val); + } + return false; + } + } +}); + +// node_modules/.pnpm/is-extendable@0.1.1/node_modules/is-extendable/index.js +var require_is_extendable = __commonJS({ + "node_modules/.pnpm/is-extendable@0.1.1/node_modules/is-extendable/index.js"(exports2, module2) { + "use strict"; + module2.exports = function isExtendable(val) { + return typeof val !== "undefined" && val !== null && (typeof val === "object" || typeof val === "function"); + }; + } +}); + +// node_modules/.pnpm/extend-shallow@2.0.1/node_modules/extend-shallow/index.js +var require_extend_shallow = __commonJS({ + "node_modules/.pnpm/extend-shallow@2.0.1/node_modules/extend-shallow/index.js"(exports2, module2) { + "use strict"; + var isObject2 = require_is_extendable(); + module2.exports = function extend2(o) { + if (!isObject2(o)) { + o = {}; + } + var len = arguments.length; + for (var i = 1; i < len; i++) { + var obj = arguments[i]; + if (isObject2(obj)) { + assign(o, obj); + } + } + return o; + }; + function assign(a, b) { + for (var key in b) { + if (hasOwn(b, key)) { + a[key] = b[key]; + } + } + } + function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); + } + } +}); + +// node_modules/.pnpm/section-matter@1.0.0/node_modules/section-matter/index.js +var require_section_matter = __commonJS({ + "node_modules/.pnpm/section-matter@1.0.0/node_modules/section-matter/index.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var extend2 = require_extend_shallow(); + module2.exports = function(input, options2) { + if (typeof options2 === "function") { + options2 = { parse: options2 }; + } + var file2 = toObject(input); + var defaults = { section_delimiter: "---", parse: identity }; + var opts = extend2({}, defaults, options2); + var delim = opts.section_delimiter; + var lines = file2.content.split(/\r?\n/); + var sections = null; + var section = createSection(); + var content = []; + var stack = []; + function initSections(val) { + file2.content = val; + sections = []; + content = []; + } + function closeSection(val) { + if (stack.length) { + section.key = getKey(stack[0], delim); + section.content = val; + opts.parse(section, sections); + sections.push(section); + section = createSection(); + content = []; + stack = []; + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + var len = stack.length; + var ln = line.trim(); + if (isDelimiter(ln, delim)) { + if (ln.length === 3 && i !== 0) { + if (len === 0 || len === 2) { + content.push(line); + continue; + } + stack.push(ln); + section.data = content.join("\n"); + content = []; + continue; + } + if (sections === null) { + initSections(content.join("\n")); + } + if (len === 2) { + closeSection(content.join("\n")); + } + stack.push(ln); + continue; + } + content.push(line); + } + if (sections === null) { + initSections(content.join("\n")); + } else { + closeSection(content.join("\n")); + } + file2.sections = sections; + return file2; + }; + function isDelimiter(line, delim) { + if (line.slice(0, delim.length) !== delim) { + return false; + } + if (line.charAt(delim.length + 1) === delim.slice(-1)) { + return false; + } + return true; + } + function toObject(input) { + if (typeOf(input) !== "object") { + input = { content: input }; + } + if (typeof input.content !== "string" && !isBuffer(input.content)) { + throw new TypeError("expected a buffer or string"); + } + input.content = input.content.toString(); + input.sections = []; + return input; + } + function getKey(val, delim) { + return val ? val.slice(delim.length).trim() : ""; + } + function createSection() { + return { key: "", data: "", content: "" }; + } + function identity(val) { + return val; + } + function isBuffer(val) { + if (val && val.constructor && typeof val.constructor.isBuffer === "function") { + return val.constructor.isBuffer(val); + } + return false; + } + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/common.js +var require_common = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/common.js"(exports2, module2) { + "use strict"; + function isNothing(subject) { + return typeof subject === "undefined" || subject === null; + } + function isObject2(subject) { + return typeof subject === "object" && subject !== null; + } + function toArray(sequence) { + if (Array.isArray(sequence)) return sequence; + else if (isNothing(sequence)) return []; + return [sequence]; + } + function extend2(target, source) { + var index, length, key, sourceKeys; + if (source) { + sourceKeys = Object.keys(source); + for (index = 0, length = sourceKeys.length; index < length; index += 1) { + key = sourceKeys[index]; + target[key] = source[key]; + } + } + return target; + } + function repeat(string3, count) { + var result = "", cycle; + for (cycle = 0; cycle < count; cycle += 1) { + result += string3; + } + return result; + } + function isNegativeZero(number3) { + return number3 === 0 && Number.NEGATIVE_INFINITY === 1 / number3; + } + module2.exports.isNothing = isNothing; + module2.exports.isObject = isObject2; + module2.exports.toArray = toArray; + module2.exports.repeat = repeat; + module2.exports.isNegativeZero = isNegativeZero; + module2.exports.extend = extend2; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/exception.js +var require_exception = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/exception.js"(exports2, module2) { + "use strict"; + function YAMLException(reason, mark) { + Error.call(this); + this.name = "YAMLException"; + this.reason = reason; + this.mark = mark; + this.message = (this.reason || "(unknown reason)") + (this.mark ? " " + this.mark.toString() : ""); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = new Error().stack || ""; + } + } + YAMLException.prototype = Object.create(Error.prototype); + YAMLException.prototype.constructor = YAMLException; + YAMLException.prototype.toString = function toString(compact) { + var result = this.name + ": "; + result += this.reason || "(unknown reason)"; + if (!compact && this.mark) { + result += " " + this.mark.toString(); + } + return result; + }; + module2.exports = YAMLException; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/mark.js +var require_mark = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/mark.js"(exports2, module2) { + "use strict"; + var common = require_common(); + function Mark(name, buffer, position, line, column) { + this.name = name; + this.buffer = buffer; + this.position = position; + this.line = line; + this.column = column; + } + Mark.prototype.getSnippet = function getSnippet(indent, maxLength) { + var head, start, tail, end, snippet; + if (!this.buffer) return null; + indent = indent || 4; + maxLength = maxLength || 75; + head = ""; + start = this.position; + while (start > 0 && "\0\r\n\x85\u2028\u2029".indexOf(this.buffer.charAt(start - 1)) === -1) { + start -= 1; + if (this.position - start > maxLength / 2 - 1) { + head = " ... "; + start += 5; + break; + } + } + tail = ""; + end = this.position; + while (end < this.buffer.length && "\0\r\n\x85\u2028\u2029".indexOf(this.buffer.charAt(end)) === -1) { + end += 1; + if (end - this.position > maxLength / 2 - 1) { + tail = " ... "; + end -= 5; + break; + } + } + snippet = this.buffer.slice(start, end); + return common.repeat(" ", indent) + head + snippet + tail + "\n" + common.repeat(" ", indent + this.position - start + head.length) + "^"; + }; + Mark.prototype.toString = function toString(compact) { + var snippet, where = ""; + if (this.name) { + where += 'in "' + this.name + '" '; + } + where += "at line " + (this.line + 1) + ", column " + (this.column + 1); + if (!compact) { + snippet = this.getSnippet(); + if (snippet) { + where += ":\n" + snippet; + } + } + return where; + }; + module2.exports = Mark; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type.js +var require_type = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type.js"(exports2, module2) { + "use strict"; + var YAMLException = require_exception(); + var TYPE_CONSTRUCTOR_OPTIONS = [ + "kind", + "resolve", + "construct", + "instanceOf", + "predicate", + "represent", + "defaultStyle", + "styleAliases" + ]; + var YAML_NODE_KINDS = [ + "scalar", + "sequence", + "mapping" + ]; + function compileStyleAliases(map2) { + var result = {}; + if (map2 !== null) { + Object.keys(map2).forEach(function(style) { + map2[style].forEach(function(alias) { + result[String(alias)] = style; + }); + }); + } + return result; + } + function Type(tag, options2) { + options2 = options2 || {}; + Object.keys(options2).forEach(function(name) { + if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) { + throw new YAMLException('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.'); + } + }); + this.tag = tag; + this.kind = options2["kind"] || null; + this.resolve = options2["resolve"] || function() { + return true; + }; + this.construct = options2["construct"] || function(data) { + return data; + }; + this.instanceOf = options2["instanceOf"] || null; + this.predicate = options2["predicate"] || null; + this.represent = options2["represent"] || null; + this.defaultStyle = options2["defaultStyle"] || null; + this.styleAliases = compileStyleAliases(options2["styleAliases"] || null); + if (YAML_NODE_KINDS.indexOf(this.kind) === -1) { + throw new YAMLException('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.'); + } + } + module2.exports = Type; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema.js +var require_schema = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var Type = require_type(); + function compileList(schema, name, result) { + var exclude = []; + schema.include.forEach(function(includedSchema) { + result = compileList(includedSchema, name, result); + }); + schema[name].forEach(function(currentType) { + result.forEach(function(previousType, previousIndex) { + if (previousType.tag === currentType.tag && previousType.kind === currentType.kind) { + exclude.push(previousIndex); + } + }); + result.push(currentType); + }); + return result.filter(function(type, index) { + return exclude.indexOf(index) === -1; + }); + } + function compileMap() { + var result = { + scalar: {}, + sequence: {}, + mapping: {}, + fallback: {} + }, index, length; + function collectType(type) { + result[type.kind][type.tag] = result["fallback"][type.tag] = type; + } + for (index = 0, length = arguments.length; index < length; index += 1) { + arguments[index].forEach(collectType); + } + return result; + } + function Schema(definition) { + this.include = definition.include || []; + this.implicit = definition.implicit || []; + this.explicit = definition.explicit || []; + this.implicit.forEach(function(type) { + if (type.loadKind && type.loadKind !== "scalar") { + throw new YAMLException("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported."); + } + }); + this.compiledImplicit = compileList(this, "implicit", []); + this.compiledExplicit = compileList(this, "explicit", []); + this.compiledTypeMap = compileMap(this.compiledImplicit, this.compiledExplicit); + } + Schema.DEFAULT = null; + Schema.create = function createSchema() { + var schemas, types; + switch (arguments.length) { + case 1: + schemas = Schema.DEFAULT; + types = arguments[0]; + break; + case 2: + schemas = arguments[0]; + types = arguments[1]; + break; + default: + throw new YAMLException("Wrong number of arguments for Schema.create function"); + } + schemas = common.toArray(schemas); + types = common.toArray(types); + if (!schemas.every(function(schema) { + return schema instanceof Schema; + })) { + throw new YAMLException("Specified list of super schemas (or a single Schema object) contains a non-Schema object."); + } + if (!types.every(function(type) { + return type instanceof Type; + })) { + throw new YAMLException("Specified list of YAML types (or a single Type object) contains a non-Type object."); + } + return new Schema({ + include: schemas, + explicit: types + }); + }; + module2.exports = Schema; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/str.js +var require_str = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/str.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:str", { + kind: "scalar", + construct: function(data) { + return data !== null ? data : ""; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/seq.js +var require_seq = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/seq.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:seq", { + kind: "sequence", + construct: function(data) { + return data !== null ? data : []; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/map.js +var require_map = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/map.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:map", { + kind: "mapping", + construct: function(data) { + return data !== null ? data : {}; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/failsafe.js +var require_failsafe = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/failsafe.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + explicit: [ + require_str(), + require_seq(), + require_map() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/null.js +var require_null = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/null.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlNull(data) { + if (data === null) return true; + var max = data.length; + return max === 1 && data === "~" || max === 4 && (data === "null" || data === "Null" || data === "NULL"); + } + function constructYamlNull() { + return null; + } + function isNull(object3) { + return object3 === null; + } + module2.exports = new Type("tag:yaml.org,2002:null", { + kind: "scalar", + resolve: resolveYamlNull, + construct: constructYamlNull, + predicate: isNull, + represent: { + canonical: function() { + return "~"; + }, + lowercase: function() { + return "null"; + }, + uppercase: function() { + return "NULL"; + }, + camelcase: function() { + return "Null"; + } + }, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/bool.js +var require_bool = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/bool.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlBoolean(data) { + if (data === null) return false; + var max = data.length; + return max === 4 && (data === "true" || data === "True" || data === "TRUE") || max === 5 && (data === "false" || data === "False" || data === "FALSE"); + } + function constructYamlBoolean(data) { + return data === "true" || data === "True" || data === "TRUE"; + } + function isBoolean(object3) { + return Object.prototype.toString.call(object3) === "[object Boolean]"; + } + module2.exports = new Type("tag:yaml.org,2002:bool", { + kind: "scalar", + resolve: resolveYamlBoolean, + construct: constructYamlBoolean, + predicate: isBoolean, + represent: { + lowercase: function(object3) { + return object3 ? "true" : "false"; + }, + uppercase: function(object3) { + return object3 ? "TRUE" : "FALSE"; + }, + camelcase: function(object3) { + return object3 ? "True" : "False"; + } + }, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/int.js +var require_int = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/int.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var Type = require_type(); + function isHexCode(c) { + return 48 <= c && c <= 57 || 65 <= c && c <= 70 || 97 <= c && c <= 102; + } + function isOctCode(c) { + return 48 <= c && c <= 55; + } + function isDecCode(c) { + return 48 <= c && c <= 57; + } + function resolveYamlInteger(data) { + if (data === null) return false; + var max = data.length, index = 0, hasDigits = false, ch; + if (!max) return false; + ch = data[index]; + if (ch === "-" || ch === "+") { + ch = data[++index]; + } + if (ch === "0") { + if (index + 1 === max) return true; + ch = data[++index]; + if (ch === "b") { + index++; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (ch !== "0" && ch !== "1") return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + if (ch === "x") { + index++; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (!isHexCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (!isOctCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + if (ch === "_") return false; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (ch === ":") break; + if (!isDecCode(data.charCodeAt(index))) { + return false; + } + hasDigits = true; + } + if (!hasDigits || ch === "_") return false; + if (ch !== ":") return true; + return /^(:[0-5]?[0-9])+$/.test(data.slice(index)); + } + function constructYamlInteger(data) { + var value = data, sign = 1, ch, base, digits = []; + if (value.indexOf("_") !== -1) { + value = value.replace(/_/g, ""); + } + ch = value[0]; + if (ch === "-" || ch === "+") { + if (ch === "-") sign = -1; + value = value.slice(1); + ch = value[0]; + } + if (value === "0") return 0; + if (ch === "0") { + if (value[1] === "b") return sign * parseInt(value.slice(2), 2); + if (value[1] === "x") return sign * parseInt(value, 16); + return sign * parseInt(value, 8); + } + if (value.indexOf(":") !== -1) { + value.split(":").forEach(function(v) { + digits.unshift(parseInt(v, 10)); + }); + value = 0; + base = 1; + digits.forEach(function(d) { + value += d * base; + base *= 60; + }); + return sign * value; + } + return sign * parseInt(value, 10); + } + function isInteger(object3) { + return Object.prototype.toString.call(object3) === "[object Number]" && (object3 % 1 === 0 && !common.isNegativeZero(object3)); + } + module2.exports = new Type("tag:yaml.org,2002:int", { + kind: "scalar", + resolve: resolveYamlInteger, + construct: constructYamlInteger, + predicate: isInteger, + represent: { + binary: function(obj) { + return obj >= 0 ? "0b" + obj.toString(2) : "-0b" + obj.toString(2).slice(1); + }, + octal: function(obj) { + return obj >= 0 ? "0" + obj.toString(8) : "-0" + obj.toString(8).slice(1); + }, + decimal: function(obj) { + return obj.toString(10); + }, + /* eslint-disable max-len */ + hexadecimal: function(obj) { + return obj >= 0 ? "0x" + obj.toString(16).toUpperCase() : "-0x" + obj.toString(16).toUpperCase().slice(1); + } + }, + defaultStyle: "decimal", + styleAliases: { + binary: [2, "bin"], + octal: [8, "oct"], + decimal: [10, "dec"], + hexadecimal: [16, "hex"] + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/float.js +var require_float = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/float.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var Type = require_type(); + var YAML_FLOAT_PATTERN = new RegExp( + // 2.5e4, 2.5 and integers + "^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$" + ); + function resolveYamlFloat(data) { + if (data === null) return false; + if (!YAML_FLOAT_PATTERN.test(data) || // Quick hack to not allow integers end with `_` + // Probably should update regexp & check speed + data[data.length - 1] === "_") { + return false; + } + return true; + } + function constructYamlFloat(data) { + var value, sign, base, digits; + value = data.replace(/_/g, "").toLowerCase(); + sign = value[0] === "-" ? -1 : 1; + digits = []; + if ("+-".indexOf(value[0]) >= 0) { + value = value.slice(1); + } + if (value === ".inf") { + return sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } else if (value === ".nan") { + return NaN; + } else if (value.indexOf(":") >= 0) { + value.split(":").forEach(function(v) { + digits.unshift(parseFloat(v, 10)); + }); + value = 0; + base = 1; + digits.forEach(function(d) { + value += d * base; + base *= 60; + }); + return sign * value; + } + return sign * parseFloat(value, 10); + } + var SCIENTIFIC_WITHOUT_DOT = /^[-+]?[0-9]+e/; + function representYamlFloat(object3, style) { + var res; + if (isNaN(object3)) { + switch (style) { + case "lowercase": + return ".nan"; + case "uppercase": + return ".NAN"; + case "camelcase": + return ".NaN"; + } + } else if (Number.POSITIVE_INFINITY === object3) { + switch (style) { + case "lowercase": + return ".inf"; + case "uppercase": + return ".INF"; + case "camelcase": + return ".Inf"; + } + } else if (Number.NEGATIVE_INFINITY === object3) { + switch (style) { + case "lowercase": + return "-.inf"; + case "uppercase": + return "-.INF"; + case "camelcase": + return "-.Inf"; + } + } else if (common.isNegativeZero(object3)) { + return "-0.0"; + } + res = object3.toString(10); + return SCIENTIFIC_WITHOUT_DOT.test(res) ? res.replace("e", ".e") : res; + } + function isFloat(object3) { + return Object.prototype.toString.call(object3) === "[object Number]" && (object3 % 1 !== 0 || common.isNegativeZero(object3)); + } + module2.exports = new Type("tag:yaml.org,2002:float", { + kind: "scalar", + resolve: resolveYamlFloat, + construct: constructYamlFloat, + predicate: isFloat, + represent: representYamlFloat, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/json.js +var require_json = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/json.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_failsafe() + ], + implicit: [ + require_null(), + require_bool(), + require_int(), + require_float() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/core.js +var require_core3 = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/core.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_json() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/timestamp.js +var require_timestamp = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/timestamp.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var YAML_DATE_REGEXP = new RegExp( + "^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$" + ); + var YAML_TIMESTAMP_REGEXP = new RegExp( + "^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$" + ); + function resolveYamlTimestamp(data) { + if (data === null) return false; + if (YAML_DATE_REGEXP.exec(data) !== null) return true; + if (YAML_TIMESTAMP_REGEXP.exec(data) !== null) return true; + return false; + } + function constructYamlTimestamp(data) { + var match, year, month, day, hour, minute, second, fraction = 0, delta = null, tz_hour, tz_minute, date4; + match = YAML_DATE_REGEXP.exec(data); + if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(data); + if (match === null) throw new Error("Date resolve error"); + year = +match[1]; + month = +match[2] - 1; + day = +match[3]; + if (!match[4]) { + return new Date(Date.UTC(year, month, day)); + } + hour = +match[4]; + minute = +match[5]; + second = +match[6]; + if (match[7]) { + fraction = match[7].slice(0, 3); + while (fraction.length < 3) { + fraction += "0"; + } + fraction = +fraction; + } + if (match[9]) { + tz_hour = +match[10]; + tz_minute = +(match[11] || 0); + delta = (tz_hour * 60 + tz_minute) * 6e4; + if (match[9] === "-") delta = -delta; + } + date4 = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)); + if (delta) date4.setTime(date4.getTime() - delta); + return date4; + } + function representYamlTimestamp(object3) { + return object3.toISOString(); + } + module2.exports = new Type("tag:yaml.org,2002:timestamp", { + kind: "scalar", + resolve: resolveYamlTimestamp, + construct: constructYamlTimestamp, + instanceOf: Date, + represent: representYamlTimestamp + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/merge.js +var require_merge = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/merge.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlMerge(data) { + return data === "<<" || data === null; + } + module2.exports = new Type("tag:yaml.org,2002:merge", { + kind: "scalar", + resolve: resolveYamlMerge + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/binary.js +var require_binary = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/binary.js"(exports2, module2) { + "use strict"; + var NodeBuffer; + try { + _require = require; + NodeBuffer = _require("buffer").Buffer; + } catch (__) { + } + var _require; + var Type = require_type(); + var BASE64_MAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r"; + function resolveYamlBinary(data) { + if (data === null) return false; + var code, idx, bitlen = 0, max = data.length, map2 = BASE64_MAP; + for (idx = 0; idx < max; idx++) { + code = map2.indexOf(data.charAt(idx)); + if (code > 64) continue; + if (code < 0) return false; + bitlen += 6; + } + return bitlen % 8 === 0; + } + function constructYamlBinary(data) { + var idx, tailbits, input = data.replace(/[\r\n=]/g, ""), max = input.length, map2 = BASE64_MAP, bits = 0, result = []; + for (idx = 0; idx < max; idx++) { + if (idx % 4 === 0 && idx) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } + bits = bits << 6 | map2.indexOf(input.charAt(idx)); + } + tailbits = max % 4 * 6; + if (tailbits === 0) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } else if (tailbits === 18) { + result.push(bits >> 10 & 255); + result.push(bits >> 2 & 255); + } else if (tailbits === 12) { + result.push(bits >> 4 & 255); + } + if (NodeBuffer) { + return NodeBuffer.from ? NodeBuffer.from(result) : new NodeBuffer(result); + } + return result; + } + function representYamlBinary(object3) { + var result = "", bits = 0, idx, tail, max = object3.length, map2 = BASE64_MAP; + for (idx = 0; idx < max; idx++) { + if (idx % 3 === 0 && idx) { + result += map2[bits >> 18 & 63]; + result += map2[bits >> 12 & 63]; + result += map2[bits >> 6 & 63]; + result += map2[bits & 63]; + } + bits = (bits << 8) + object3[idx]; + } + tail = max % 3; + if (tail === 0) { + result += map2[bits >> 18 & 63]; + result += map2[bits >> 12 & 63]; + result += map2[bits >> 6 & 63]; + result += map2[bits & 63]; + } else if (tail === 2) { + result += map2[bits >> 10 & 63]; + result += map2[bits >> 4 & 63]; + result += map2[bits << 2 & 63]; + result += map2[64]; + } else if (tail === 1) { + result += map2[bits >> 2 & 63]; + result += map2[bits << 4 & 63]; + result += map2[64]; + result += map2[64]; + } + return result; + } + function isBinary(object3) { + return NodeBuffer && NodeBuffer.isBuffer(object3); + } + module2.exports = new Type("tag:yaml.org,2002:binary", { + kind: "scalar", + resolve: resolveYamlBinary, + construct: constructYamlBinary, + predicate: isBinary, + represent: representYamlBinary + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/omap.js +var require_omap = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/omap.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var _toString = Object.prototype.toString; + function resolveYamlOmap(data) { + if (data === null) return true; + var objectKeys = [], index, length, pair, pairKey, pairHasKey, object3 = data; + for (index = 0, length = object3.length; index < length; index += 1) { + pair = object3[index]; + pairHasKey = false; + if (_toString.call(pair) !== "[object Object]") return false; + for (pairKey in pair) { + if (_hasOwnProperty.call(pair, pairKey)) { + if (!pairHasKey) pairHasKey = true; + else return false; + } + } + if (!pairHasKey) return false; + if (objectKeys.indexOf(pairKey) === -1) objectKeys.push(pairKey); + else return false; + } + return true; + } + function constructYamlOmap(data) { + return data !== null ? data : []; + } + module2.exports = new Type("tag:yaml.org,2002:omap", { + kind: "sequence", + resolve: resolveYamlOmap, + construct: constructYamlOmap + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/pairs.js +var require_pairs = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/pairs.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _toString = Object.prototype.toString; + function resolveYamlPairs(data) { + if (data === null) return true; + var index, length, pair, keys, result, object3 = data; + result = new Array(object3.length); + for (index = 0, length = object3.length; index < length; index += 1) { + pair = object3[index]; + if (_toString.call(pair) !== "[object Object]") return false; + keys = Object.keys(pair); + if (keys.length !== 1) return false; + result[index] = [keys[0], pair[keys[0]]]; + } + return true; + } + function constructYamlPairs(data) { + if (data === null) return []; + var index, length, pair, keys, result, object3 = data; + result = new Array(object3.length); + for (index = 0, length = object3.length; index < length; index += 1) { + pair = object3[index]; + keys = Object.keys(pair); + result[index] = [keys[0], pair[keys[0]]]; + } + return result; + } + module2.exports = new Type("tag:yaml.org,2002:pairs", { + kind: "sequence", + resolve: resolveYamlPairs, + construct: constructYamlPairs + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/set.js +var require_set = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/set.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + function resolveYamlSet(data) { + if (data === null) return true; + var key, object3 = data; + for (key in object3) { + if (_hasOwnProperty.call(object3, key)) { + if (object3[key] !== null) return false; + } + } + return true; + } + function constructYamlSet(data) { + return data !== null ? data : {}; + } + module2.exports = new Type("tag:yaml.org,2002:set", { + kind: "mapping", + resolve: resolveYamlSet, + construct: constructYamlSet + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_safe.js +var require_default_safe = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_safe.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_core3() + ], + implicit: [ + require_timestamp(), + require_merge() + ], + explicit: [ + require_binary(), + require_omap(), + require_pairs(), + require_set() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/undefined.js +var require_undefined = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/undefined.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveJavascriptUndefined() { + return true; + } + function constructJavascriptUndefined() { + return void 0; + } + function representJavascriptUndefined() { + return ""; + } + function isUndefined(object3) { + return typeof object3 === "undefined"; + } + module2.exports = new Type("tag:yaml.org,2002:js/undefined", { + kind: "scalar", + resolve: resolveJavascriptUndefined, + construct: constructJavascriptUndefined, + predicate: isUndefined, + represent: representJavascriptUndefined + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/regexp.js +var require_regexp = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/regexp.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveJavascriptRegExp(data) { + if (data === null) return false; + if (data.length === 0) return false; + var regexp = data, tail = /\/([gim]*)$/.exec(data), modifiers = ""; + if (regexp[0] === "/") { + if (tail) modifiers = tail[1]; + if (modifiers.length > 3) return false; + if (regexp[regexp.length - modifiers.length - 1] !== "/") return false; + } + return true; + } + function constructJavascriptRegExp(data) { + var regexp = data, tail = /\/([gim]*)$/.exec(data), modifiers = ""; + if (regexp[0] === "/") { + if (tail) modifiers = tail[1]; + regexp = regexp.slice(1, regexp.length - modifiers.length - 1); + } + return new RegExp(regexp, modifiers); + } + function representJavascriptRegExp(object3) { + var result = "/" + object3.source + "/"; + if (object3.global) result += "g"; + if (object3.multiline) result += "m"; + if (object3.ignoreCase) result += "i"; + return result; + } + function isRegExp(object3) { + return Object.prototype.toString.call(object3) === "[object RegExp]"; + } + module2.exports = new Type("tag:yaml.org,2002:js/regexp", { + kind: "scalar", + resolve: resolveJavascriptRegExp, + construct: constructJavascriptRegExp, + predicate: isRegExp, + represent: representJavascriptRegExp + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/function.js +var require_function = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/function.js"(exports2, module2) { + "use strict"; + var esprima; + try { + _require = require; + esprima = _require("esprima"); + } catch (_) { + if (typeof window !== "undefined") esprima = window.esprima; + } + var _require; + var Type = require_type(); + function resolveJavascriptFunction(data) { + if (data === null) return false; + try { + var source = "(" + data + ")", ast = esprima.parse(source, { range: true }); + if (ast.type !== "Program" || ast.body.length !== 1 || ast.body[0].type !== "ExpressionStatement" || ast.body[0].expression.type !== "ArrowFunctionExpression" && ast.body[0].expression.type !== "FunctionExpression") { + return false; + } + return true; + } catch (err) { + return false; + } + } + function constructJavascriptFunction(data) { + var source = "(" + data + ")", ast = esprima.parse(source, { range: true }), params = [], body; + if (ast.type !== "Program" || ast.body.length !== 1 || ast.body[0].type !== "ExpressionStatement" || ast.body[0].expression.type !== "ArrowFunctionExpression" && ast.body[0].expression.type !== "FunctionExpression") { + throw new Error("Failed to resolve function"); + } + ast.body[0].expression.params.forEach(function(param) { + params.push(param.name); + }); + body = ast.body[0].expression.body.range; + if (ast.body[0].expression.body.type === "BlockStatement") { + return new Function(params, source.slice(body[0] + 1, body[1] - 1)); + } + return new Function(params, "return " + source.slice(body[0], body[1])); + } + function representJavascriptFunction(object3) { + return object3.toString(); + } + function isFunction(object3) { + return Object.prototype.toString.call(object3) === "[object Function]"; + } + module2.exports = new Type("tag:yaml.org,2002:js/function", { + kind: "scalar", + resolve: resolveJavascriptFunction, + construct: constructJavascriptFunction, + predicate: isFunction, + represent: representJavascriptFunction + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_full.js +var require_default_full = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_full.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = Schema.DEFAULT = new Schema({ + include: [ + require_default_safe() + ], + explicit: [ + require_undefined(), + require_regexp(), + require_function() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/loader.js +var require_loader = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/loader.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var Mark = require_mark(); + var DEFAULT_SAFE_SCHEMA = require_default_safe(); + var DEFAULT_FULL_SCHEMA = require_default_full(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CONTEXT_FLOW_IN = 1; + var CONTEXT_FLOW_OUT = 2; + var CONTEXT_BLOCK_IN = 3; + var CONTEXT_BLOCK_OUT = 4; + var CHOMPING_CLIP = 1; + var CHOMPING_STRIP = 2; + var CHOMPING_KEEP = 3; + var PATTERN_NON_PRINTABLE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/; + var PATTERN_NON_ASCII_LINE_BREAKS = /[\x85\u2028\u2029]/; + var PATTERN_FLOW_INDICATORS = /[,\[\]\{\}]/; + var PATTERN_TAG_HANDLE = /^(?:!|!!|![a-z\-]+!)$/i; + var PATTERN_TAG_URI = /^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i; + function _class(obj) { + return Object.prototype.toString.call(obj); + } + function is_EOL(c) { + return c === 10 || c === 13; + } + function is_WHITE_SPACE(c) { + return c === 9 || c === 32; + } + function is_WS_OR_EOL(c) { + return c === 9 || c === 32 || c === 10 || c === 13; + } + function is_FLOW_INDICATOR(c) { + return c === 44 || c === 91 || c === 93 || c === 123 || c === 125; + } + function fromHexCode(c) { + var lc; + if (48 <= c && c <= 57) { + return c - 48; + } + lc = c | 32; + if (97 <= lc && lc <= 102) { + return lc - 97 + 10; + } + return -1; + } + function escapedHexLen(c) { + if (c === 120) { + return 2; + } + if (c === 117) { + return 4; + } + if (c === 85) { + return 8; + } + return 0; + } + function fromDecimalCode(c) { + if (48 <= c && c <= 57) { + return c - 48; + } + return -1; + } + function simpleEscapeSequence(c) { + return c === 48 ? "\0" : c === 97 ? "\x07" : c === 98 ? "\b" : c === 116 ? " " : c === 9 ? " " : c === 110 ? "\n" : c === 118 ? "\v" : c === 102 ? "\f" : c === 114 ? "\r" : c === 101 ? "\x1B" : c === 32 ? " " : c === 34 ? '"' : c === 47 ? "/" : c === 92 ? "\\" : c === 78 ? "\x85" : c === 95 ? "\xA0" : c === 76 ? "\u2028" : c === 80 ? "\u2029" : ""; + } + function charFromCodepoint(c) { + if (c <= 65535) { + return String.fromCharCode(c); + } + return String.fromCharCode( + (c - 65536 >> 10) + 55296, + (c - 65536 & 1023) + 56320 + ); + } + function setProperty(object3, key, value) { + if (key === "__proto__") { + Object.defineProperty(object3, key, { + configurable: true, + enumerable: true, + writable: true, + value + }); + } else { + object3[key] = value; + } + } + var simpleEscapeCheck = new Array(256); + var simpleEscapeMap = new Array(256); + for (i = 0; i < 256; i++) { + simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0; + simpleEscapeMap[i] = simpleEscapeSequence(i); + } + var i; + function State(input, options2) { + this.input = input; + this.filename = options2["filename"] || null; + this.schema = options2["schema"] || DEFAULT_FULL_SCHEMA; + this.onWarning = options2["onWarning"] || null; + this.legacy = options2["legacy"] || false; + this.json = options2["json"] || false; + this.listener = options2["listener"] || null; + this.implicitTypes = this.schema.compiledImplicit; + this.typeMap = this.schema.compiledTypeMap; + this.length = input.length; + this.position = 0; + this.line = 0; + this.lineStart = 0; + this.lineIndent = 0; + this.documents = []; + } + function generateError(state, message) { + return new YAMLException( + message, + new Mark(state.filename, state.input, state.position, state.line, state.position - state.lineStart) + ); + } + function throwError(state, message) { + throw generateError(state, message); + } + function throwWarning(state, message) { + if (state.onWarning) { + state.onWarning.call(null, generateError(state, message)); + } + } + var directiveHandlers = { + YAML: function handleYamlDirective(state, name, args) { + var match, major, minor; + if (state.version !== null) { + throwError(state, "duplication of %YAML directive"); + } + if (args.length !== 1) { + throwError(state, "YAML directive accepts exactly one argument"); + } + match = /^([0-9]+)\.([0-9]+)$/.exec(args[0]); + if (match === null) { + throwError(state, "ill-formed argument of the YAML directive"); + } + major = parseInt(match[1], 10); + minor = parseInt(match[2], 10); + if (major !== 1) { + throwError(state, "unacceptable YAML version of the document"); + } + state.version = args[0]; + state.checkLineBreaks = minor < 2; + if (minor !== 1 && minor !== 2) { + throwWarning(state, "unsupported YAML version of the document"); + } + }, + TAG: function handleTagDirective(state, name, args) { + var handle, prefix; + if (args.length !== 2) { + throwError(state, "TAG directive accepts exactly two arguments"); + } + handle = args[0]; + prefix = args[1]; + if (!PATTERN_TAG_HANDLE.test(handle)) { + throwError(state, "ill-formed tag handle (first argument) of the TAG directive"); + } + if (_hasOwnProperty.call(state.tagMap, handle)) { + throwError(state, 'there is a previously declared suffix for "' + handle + '" tag handle'); + } + if (!PATTERN_TAG_URI.test(prefix)) { + throwError(state, "ill-formed tag prefix (second argument) of the TAG directive"); + } + state.tagMap[handle] = prefix; + } + }; + function captureSegment(state, start, end, checkJson) { + var _position, _length2, _character, _result; + if (start < end) { + _result = state.input.slice(start, end); + if (checkJson) { + for (_position = 0, _length2 = _result.length; _position < _length2; _position += 1) { + _character = _result.charCodeAt(_position); + if (!(_character === 9 || 32 <= _character && _character <= 1114111)) { + throwError(state, "expected valid JSON character"); + } + } + } else if (PATTERN_NON_PRINTABLE.test(_result)) { + throwError(state, "the stream contains non-printable characters"); + } + state.result += _result; + } + } + function mergeMappings(state, destination, source, overridableKeys) { + var sourceKeys, key, index, quantity; + if (!common.isObject(source)) { + throwError(state, "cannot merge mappings; the provided source object is unacceptable"); + } + sourceKeys = Object.keys(source); + for (index = 0, quantity = sourceKeys.length; index < quantity; index += 1) { + key = sourceKeys[index]; + if (!_hasOwnProperty.call(destination, key)) { + setProperty(destination, key, source[key]); + overridableKeys[key] = true; + } + } + } + function storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, startLine, startPos) { + var index, quantity; + if (Array.isArray(keyNode)) { + keyNode = Array.prototype.slice.call(keyNode); + for (index = 0, quantity = keyNode.length; index < quantity; index += 1) { + if (Array.isArray(keyNode[index])) { + throwError(state, "nested arrays are not supported inside keys"); + } + if (typeof keyNode === "object" && _class(keyNode[index]) === "[object Object]") { + keyNode[index] = "[object Object]"; + } + } + } + if (typeof keyNode === "object" && _class(keyNode) === "[object Object]") { + keyNode = "[object Object]"; + } + keyNode = String(keyNode); + if (_result === null) { + _result = {}; + } + if (keyTag === "tag:yaml.org,2002:merge") { + if (Array.isArray(valueNode)) { + for (index = 0, quantity = valueNode.length; index < quantity; index += 1) { + mergeMappings(state, _result, valueNode[index], overridableKeys); + } + } else { + mergeMappings(state, _result, valueNode, overridableKeys); + } + } else { + if (!state.json && !_hasOwnProperty.call(overridableKeys, keyNode) && _hasOwnProperty.call(_result, keyNode)) { + state.line = startLine || state.line; + state.position = startPos || state.position; + throwError(state, "duplicated mapping key"); + } + setProperty(_result, keyNode, valueNode); + delete overridableKeys[keyNode]; + } + return _result; + } + function readLineBreak(state) { + var ch; + ch = state.input.charCodeAt(state.position); + if (ch === 10) { + state.position++; + } else if (ch === 13) { + state.position++; + if (state.input.charCodeAt(state.position) === 10) { + state.position++; + } + } else { + throwError(state, "a line break is expected"); + } + state.line += 1; + state.lineStart = state.position; + } + function skipSeparationSpace(state, allowComments, checkIndent) { + var lineBreaks = 0, ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (allowComments && ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 10 && ch !== 13 && ch !== 0); + } + if (is_EOL(ch)) { + readLineBreak(state); + ch = state.input.charCodeAt(state.position); + lineBreaks++; + state.lineIndent = 0; + while (ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + } else { + break; + } + } + if (checkIndent !== -1 && lineBreaks !== 0 && state.lineIndent < checkIndent) { + throwWarning(state, "deficient indentation"); + } + return lineBreaks; + } + function testDocumentSeparator(state) { + var _position = state.position, ch; + ch = state.input.charCodeAt(_position); + if ((ch === 45 || ch === 46) && ch === state.input.charCodeAt(_position + 1) && ch === state.input.charCodeAt(_position + 2)) { + _position += 3; + ch = state.input.charCodeAt(_position); + if (ch === 0 || is_WS_OR_EOL(ch)) { + return true; + } + } + return false; + } + function writeFoldedLines(state, count) { + if (count === 1) { + state.result += " "; + } else if (count > 1) { + state.result += common.repeat("\n", count - 1); + } + } + function readPlainScalar(state, nodeIndent, withinFlowCollection) { + var preceding, following, captureStart, captureEnd, hasPendingContent, _line, _lineStart, _lineIndent, _kind = state.kind, _result = state.result, ch; + ch = state.input.charCodeAt(state.position); + if (is_WS_OR_EOL(ch) || is_FLOW_INDICATOR(ch) || ch === 35 || ch === 38 || ch === 42 || ch === 33 || ch === 124 || ch === 62 || ch === 39 || ch === 34 || ch === 37 || ch === 64 || ch === 96) { + return false; + } + if (ch === 63 || ch === 45) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following) || withinFlowCollection && is_FLOW_INDICATOR(following)) { + return false; + } + } + state.kind = "scalar"; + state.result = ""; + captureStart = captureEnd = state.position; + hasPendingContent = false; + while (ch !== 0) { + if (ch === 58) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following) || withinFlowCollection && is_FLOW_INDICATOR(following)) { + break; + } + } else if (ch === 35) { + preceding = state.input.charCodeAt(state.position - 1); + if (is_WS_OR_EOL(preceding)) { + break; + } + } else if (state.position === state.lineStart && testDocumentSeparator(state) || withinFlowCollection && is_FLOW_INDICATOR(ch)) { + break; + } else if (is_EOL(ch)) { + _line = state.line; + _lineStart = state.lineStart; + _lineIndent = state.lineIndent; + skipSeparationSpace(state, false, -1); + if (state.lineIndent >= nodeIndent) { + hasPendingContent = true; + ch = state.input.charCodeAt(state.position); + continue; + } else { + state.position = captureEnd; + state.line = _line; + state.lineStart = _lineStart; + state.lineIndent = _lineIndent; + break; + } + } + if (hasPendingContent) { + captureSegment(state, captureStart, captureEnd, false); + writeFoldedLines(state, state.line - _line); + captureStart = captureEnd = state.position; + hasPendingContent = false; + } + if (!is_WHITE_SPACE(ch)) { + captureEnd = state.position + 1; + } + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, captureEnd, false); + if (state.result) { + return true; + } + state.kind = _kind; + state.result = _result; + return false; + } + function readSingleQuotedScalar(state, nodeIndent) { + var ch, captureStart, captureEnd; + ch = state.input.charCodeAt(state.position); + if (ch !== 39) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 39) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (ch === 39) { + captureStart = state.position; + state.position++; + captureEnd = state.position; + } else { + return true; + } + } else if (is_EOL(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a single quoted scalar"); + } else { + state.position++; + captureEnd = state.position; + } + } + throwError(state, "unexpected end of the stream within a single quoted scalar"); + } + function readDoubleQuotedScalar(state, nodeIndent) { + var captureStart, captureEnd, hexLength, hexResult, tmp, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 34) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 34) { + captureSegment(state, captureStart, state.position, true); + state.position++; + return true; + } else if (ch === 92) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (is_EOL(ch)) { + skipSeparationSpace(state, false, nodeIndent); + } else if (ch < 256 && simpleEscapeCheck[ch]) { + state.result += simpleEscapeMap[ch]; + state.position++; + } else if ((tmp = escapedHexLen(ch)) > 0) { + hexLength = tmp; + hexResult = 0; + for (; hexLength > 0; hexLength--) { + ch = state.input.charCodeAt(++state.position); + if ((tmp = fromHexCode(ch)) >= 0) { + hexResult = (hexResult << 4) + tmp; + } else { + throwError(state, "expected hexadecimal character"); + } + } + state.result += charFromCodepoint(hexResult); + state.position++; + } else { + throwError(state, "unknown escape sequence"); + } + captureStart = captureEnd = state.position; + } else if (is_EOL(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a double quoted scalar"); + } else { + state.position++; + captureEnd = state.position; + } + } + throwError(state, "unexpected end of the stream within a double quoted scalar"); + } + function readFlowCollection(state, nodeIndent) { + var readNext = true, _line, _tag = state.tag, _result, _anchor = state.anchor, following, terminator, isPair, isExplicitPair, isMapping, overridableKeys = {}, keyNode, keyTag, valueNode, ch; + ch = state.input.charCodeAt(state.position); + if (ch === 91) { + terminator = 93; + isMapping = false; + _result = []; + } else if (ch === 123) { + terminator = 125; + isMapping = true; + _result = {}; + } else { + return false; + } + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(++state.position); + while (ch !== 0) { + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === terminator) { + state.position++; + state.tag = _tag; + state.anchor = _anchor; + state.kind = isMapping ? "mapping" : "sequence"; + state.result = _result; + return true; + } else if (!readNext) { + throwError(state, "missed comma between flow collection entries"); + } + keyTag = keyNode = valueNode = null; + isPair = isExplicitPair = false; + if (ch === 63) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following)) { + isPair = isExplicitPair = true; + state.position++; + skipSeparationSpace(state, true, nodeIndent); + } + } + _line = state.line; + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + keyTag = state.tag; + keyNode = state.result; + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if ((isExplicitPair || state.line === _line) && ch === 58) { + isPair = true; + ch = state.input.charCodeAt(++state.position); + skipSeparationSpace(state, true, nodeIndent); + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + valueNode = state.result; + } + if (isMapping) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode); + } else if (isPair) { + _result.push(storeMappingPair(state, null, overridableKeys, keyTag, keyNode, valueNode)); + } else { + _result.push(keyNode); + } + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === 44) { + readNext = true; + ch = state.input.charCodeAt(++state.position); + } else { + readNext = false; + } + } + throwError(state, "unexpected end of the stream within a flow collection"); + } + function readBlockScalar(state, nodeIndent) { + var captureStart, folding, chomping = CHOMPING_CLIP, didReadContent = false, detectedIndent = false, textIndent = nodeIndent, emptyLines = 0, atMoreIndented = false, tmp, ch; + ch = state.input.charCodeAt(state.position); + if (ch === 124) { + folding = false; + } else if (ch === 62) { + folding = true; + } else { + return false; + } + state.kind = "scalar"; + state.result = ""; + while (ch !== 0) { + ch = state.input.charCodeAt(++state.position); + if (ch === 43 || ch === 45) { + if (CHOMPING_CLIP === chomping) { + chomping = ch === 43 ? CHOMPING_KEEP : CHOMPING_STRIP; + } else { + throwError(state, "repeat of a chomping mode identifier"); + } + } else if ((tmp = fromDecimalCode(ch)) >= 0) { + if (tmp === 0) { + throwError(state, "bad explicit indentation width of a block scalar; it cannot be less than one"); + } else if (!detectedIndent) { + textIndent = nodeIndent + tmp - 1; + detectedIndent = true; + } else { + throwError(state, "repeat of an indentation width identifier"); + } + } else { + break; + } + } + if (is_WHITE_SPACE(ch)) { + do { + ch = state.input.charCodeAt(++state.position); + } while (is_WHITE_SPACE(ch)); + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (!is_EOL(ch) && ch !== 0); + } + } + while (ch !== 0) { + readLineBreak(state); + state.lineIndent = 0; + ch = state.input.charCodeAt(state.position); + while ((!detectedIndent || state.lineIndent < textIndent) && ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + if (!detectedIndent && state.lineIndent > textIndent) { + textIndent = state.lineIndent; + } + if (is_EOL(ch)) { + emptyLines++; + continue; + } + if (state.lineIndent < textIndent) { + if (chomping === CHOMPING_KEEP) { + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (chomping === CHOMPING_CLIP) { + if (didReadContent) { + state.result += "\n"; + } + } + break; + } + if (folding) { + if (is_WHITE_SPACE(ch)) { + atMoreIndented = true; + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (atMoreIndented) { + atMoreIndented = false; + state.result += common.repeat("\n", emptyLines + 1); + } else if (emptyLines === 0) { + if (didReadContent) { + state.result += " "; + } + } else { + state.result += common.repeat("\n", emptyLines); + } + } else { + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } + didReadContent = true; + detectedIndent = true; + emptyLines = 0; + captureStart = state.position; + while (!is_EOL(ch) && ch !== 0) { + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, state.position, false); + } + return true; + } + function readBlockSequence(state, nodeIndent) { + var _line, _tag = state.tag, _anchor = state.anchor, _result = [], following, detected = false, ch; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + if (ch !== 45) { + break; + } + following = state.input.charCodeAt(state.position + 1); + if (!is_WS_OR_EOL(following)) { + break; + } + detected = true; + state.position++; + if (skipSeparationSpace(state, true, -1)) { + if (state.lineIndent <= nodeIndent) { + _result.push(null); + ch = state.input.charCodeAt(state.position); + continue; + } + } + _line = state.line; + composeNode(state, nodeIndent, CONTEXT_BLOCK_IN, false, true); + _result.push(state.result); + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if ((state.line === _line || state.lineIndent > nodeIndent) && ch !== 0) { + throwError(state, "bad indentation of a sequence entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "sequence"; + state.result = _result; + return true; + } + return false; + } + function readBlockMapping(state, nodeIndent, flowIndent) { + var following, allowCompact, _line, _pos, _tag = state.tag, _anchor = state.anchor, _result = {}, overridableKeys = {}, keyTag = null, keyNode = null, valueNode = null, atExplicitKey = false, detected = false, ch; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + following = state.input.charCodeAt(state.position + 1); + _line = state.line; + _pos = state.position; + if ((ch === 63 || ch === 58) && is_WS_OR_EOL(following)) { + if (ch === 63) { + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = true; + allowCompact = true; + } else if (atExplicitKey) { + atExplicitKey = false; + allowCompact = true; + } else { + throwError(state, "incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"); + } + state.position += 1; + ch = following; + } else if (composeNode(state, flowIndent, CONTEXT_FLOW_OUT, false, true)) { + if (state.line === _line) { + ch = state.input.charCodeAt(state.position); + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 58) { + ch = state.input.charCodeAt(++state.position); + if (!is_WS_OR_EOL(ch)) { + throwError(state, "a whitespace character is expected after the key-value separator within a block mapping"); + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = false; + allowCompact = false; + keyTag = state.tag; + keyNode = state.result; + } else if (detected) { + throwError(state, "can not read an implicit mapping pair; a colon is missed"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } else if (detected) { + throwError(state, "can not read a block mapping entry; a multiline key may not be an implicit key"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } else { + break; + } + if (state.line === _line || state.lineIndent > nodeIndent) { + if (composeNode(state, nodeIndent, CONTEXT_BLOCK_OUT, true, allowCompact)) { + if (atExplicitKey) { + keyNode = state.result; + } else { + valueNode = state.result; + } + } + if (!atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, _line, _pos); + keyTag = keyNode = valueNode = null; + } + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + } + if (state.lineIndent > nodeIndent && ch !== 0) { + throwError(state, "bad indentation of a mapping entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "mapping"; + state.result = _result; + } + return detected; + } + function readTagProperty(state) { + var _position, isVerbatim = false, isNamed = false, tagHandle, tagName, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 33) return false; + if (state.tag !== null) { + throwError(state, "duplication of a tag property"); + } + ch = state.input.charCodeAt(++state.position); + if (ch === 60) { + isVerbatim = true; + ch = state.input.charCodeAt(++state.position); + } else if (ch === 33) { + isNamed = true; + tagHandle = "!!"; + ch = state.input.charCodeAt(++state.position); + } else { + tagHandle = "!"; + } + _position = state.position; + if (isVerbatim) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && ch !== 62); + if (state.position < state.length) { + tagName = state.input.slice(_position, state.position); + ch = state.input.charCodeAt(++state.position); + } else { + throwError(state, "unexpected end of the stream within a verbatim tag"); + } + } else { + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + if (ch === 33) { + if (!isNamed) { + tagHandle = state.input.slice(_position - 1, state.position + 1); + if (!PATTERN_TAG_HANDLE.test(tagHandle)) { + throwError(state, "named tag handle cannot contain such characters"); + } + isNamed = true; + _position = state.position + 1; + } else { + throwError(state, "tag suffix cannot contain exclamation marks"); + } + } + ch = state.input.charCodeAt(++state.position); + } + tagName = state.input.slice(_position, state.position); + if (PATTERN_FLOW_INDICATORS.test(tagName)) { + throwError(state, "tag suffix cannot contain flow indicator characters"); + } + } + if (tagName && !PATTERN_TAG_URI.test(tagName)) { + throwError(state, "tag name cannot contain such characters: " + tagName); + } + if (isVerbatim) { + state.tag = tagName; + } else if (_hasOwnProperty.call(state.tagMap, tagHandle)) { + state.tag = state.tagMap[tagHandle] + tagName; + } else if (tagHandle === "!") { + state.tag = "!" + tagName; + } else if (tagHandle === "!!") { + state.tag = "tag:yaml.org,2002:" + tagName; + } else { + throwError(state, 'undeclared tag handle "' + tagHandle + '"'); + } + return true; + } + function readAnchorProperty(state) { + var _position, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 38) return false; + if (state.anchor !== null) { + throwError(state, "duplication of an anchor property"); + } + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch) && !is_FLOW_INDICATOR(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an anchor node must contain at least one character"); + } + state.anchor = state.input.slice(_position, state.position); + return true; + } + function readAlias(state) { + var _position, alias, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 42) return false; + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch) && !is_FLOW_INDICATOR(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an alias node must contain at least one character"); + } + alias = state.input.slice(_position, state.position); + if (!_hasOwnProperty.call(state.anchorMap, alias)) { + throwError(state, 'unidentified alias "' + alias + '"'); + } + state.result = state.anchorMap[alias]; + skipSeparationSpace(state, true, -1); + return true; + } + function composeNode(state, parentIndent, nodeContext, allowToSeek, allowCompact) { + var allowBlockStyles, allowBlockScalars, allowBlockCollections, indentStatus = 1, atNewLine = false, hasContent = false, typeIndex, typeQuantity, type, flowIndent, blockIndent; + if (state.listener !== null) { + state.listener("open", state); + } + state.tag = null; + state.anchor = null; + state.kind = null; + state.result = null; + allowBlockStyles = allowBlockScalars = allowBlockCollections = CONTEXT_BLOCK_OUT === nodeContext || CONTEXT_BLOCK_IN === nodeContext; + if (allowToSeek) { + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } + } + if (indentStatus === 1) { + while (readTagProperty(state) || readAnchorProperty(state)) { + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + allowBlockCollections = allowBlockStyles; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } else { + allowBlockCollections = false; + } + } + } + if (allowBlockCollections) { + allowBlockCollections = atNewLine || allowCompact; + } + if (indentStatus === 1 || CONTEXT_BLOCK_OUT === nodeContext) { + if (CONTEXT_FLOW_IN === nodeContext || CONTEXT_FLOW_OUT === nodeContext) { + flowIndent = parentIndent; + } else { + flowIndent = parentIndent + 1; + } + blockIndent = state.position - state.lineStart; + if (indentStatus === 1) { + if (allowBlockCollections && (readBlockSequence(state, blockIndent) || readBlockMapping(state, blockIndent, flowIndent)) || readFlowCollection(state, flowIndent)) { + hasContent = true; + } else { + if (allowBlockScalars && readBlockScalar(state, flowIndent) || readSingleQuotedScalar(state, flowIndent) || readDoubleQuotedScalar(state, flowIndent)) { + hasContent = true; + } else if (readAlias(state)) { + hasContent = true; + if (state.tag !== null || state.anchor !== null) { + throwError(state, "alias node should not have any properties"); + } + } else if (readPlainScalar(state, flowIndent, CONTEXT_FLOW_IN === nodeContext)) { + hasContent = true; + if (state.tag === null) { + state.tag = "?"; + } + } + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + } + } else if (indentStatus === 0) { + hasContent = allowBlockCollections && readBlockSequence(state, blockIndent); + } + } + if (state.tag !== null && state.tag !== "!") { + if (state.tag === "?") { + if (state.result !== null && state.kind !== "scalar") { + throwError(state, 'unacceptable node kind for ! tag; it should be "scalar", not "' + state.kind + '"'); + } + for (typeIndex = 0, typeQuantity = state.implicitTypes.length; typeIndex < typeQuantity; typeIndex += 1) { + type = state.implicitTypes[typeIndex]; + if (type.resolve(state.result)) { + state.result = type.construct(state.result); + state.tag = type.tag; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + break; + } + } + } else if (_hasOwnProperty.call(state.typeMap[state.kind || "fallback"], state.tag)) { + type = state.typeMap[state.kind || "fallback"][state.tag]; + if (state.result !== null && type.kind !== state.kind) { + throwError(state, "unacceptable node kind for !<" + state.tag + '> tag; it should be "' + type.kind + '", not "' + state.kind + '"'); + } + if (!type.resolve(state.result)) { + throwError(state, "cannot resolve a node with !<" + state.tag + "> explicit tag"); + } else { + state.result = type.construct(state.result); + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + } + } else { + throwError(state, "unknown tag !<" + state.tag + ">"); + } + } + if (state.listener !== null) { + state.listener("close", state); + } + return state.tag !== null || state.anchor !== null || hasContent; + } + function readDocument(state) { + var documentStart = state.position, _position, directiveName, directiveArgs, hasDirectives = false, ch; + state.version = null; + state.checkLineBreaks = state.legacy; + state.tagMap = {}; + state.anchorMap = {}; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if (state.lineIndent > 0 || ch !== 37) { + break; + } + hasDirectives = true; + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + ch = state.input.charCodeAt(++state.position); + } + directiveName = state.input.slice(_position, state.position); + directiveArgs = []; + if (directiveName.length < 1) { + throwError(state, "directive name must not be less than one character in length"); + } + while (ch !== 0) { + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && !is_EOL(ch)); + break; + } + if (is_EOL(ch)) break; + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + ch = state.input.charCodeAt(++state.position); + } + directiveArgs.push(state.input.slice(_position, state.position)); + } + if (ch !== 0) readLineBreak(state); + if (_hasOwnProperty.call(directiveHandlers, directiveName)) { + directiveHandlers[directiveName](state, directiveName, directiveArgs); + } else { + throwWarning(state, 'unknown document directive "' + directiveName + '"'); + } + } + skipSeparationSpace(state, true, -1); + if (state.lineIndent === 0 && state.input.charCodeAt(state.position) === 45 && state.input.charCodeAt(state.position + 1) === 45 && state.input.charCodeAt(state.position + 2) === 45) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } else if (hasDirectives) { + throwError(state, "directives end mark is expected"); + } + composeNode(state, state.lineIndent - 1, CONTEXT_BLOCK_OUT, false, true); + skipSeparationSpace(state, true, -1); + if (state.checkLineBreaks && PATTERN_NON_ASCII_LINE_BREAKS.test(state.input.slice(documentStart, state.position))) { + throwWarning(state, "non-ASCII line breaks are interpreted as content"); + } + state.documents.push(state.result); + if (state.position === state.lineStart && testDocumentSeparator(state)) { + if (state.input.charCodeAt(state.position) === 46) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } + return; + } + if (state.position < state.length - 1) { + throwError(state, "end of the stream or a document separator is expected"); + } else { + return; + } + } + function loadDocuments(input, options2) { + input = String(input); + options2 = options2 || {}; + if (input.length !== 0) { + if (input.charCodeAt(input.length - 1) !== 10 && input.charCodeAt(input.length - 1) !== 13) { + input += "\n"; + } + if (input.charCodeAt(0) === 65279) { + input = input.slice(1); + } + } + var state = new State(input, options2); + var nullpos = input.indexOf("\0"); + if (nullpos !== -1) { + state.position = nullpos; + throwError(state, "null byte is not allowed in input"); + } + state.input += "\0"; + while (state.input.charCodeAt(state.position) === 32) { + state.lineIndent += 1; + state.position += 1; + } + while (state.position < state.length - 1) { + readDocument(state); + } + return state.documents; + } + function loadAll(input, iterator, options2) { + if (iterator !== null && typeof iterator === "object" && typeof options2 === "undefined") { + options2 = iterator; + iterator = null; + } + var documents = loadDocuments(input, options2); + if (typeof iterator !== "function") { + return documents; + } + for (var index = 0, length = documents.length; index < length; index += 1) { + iterator(documents[index]); + } + } + function load(input, options2) { + var documents = loadDocuments(input, options2); + if (documents.length === 0) { + return void 0; + } else if (documents.length === 1) { + return documents[0]; + } + throw new YAMLException("expected a single document in the stream, but found more"); + } + function safeLoadAll(input, iterator, options2) { + if (typeof iterator === "object" && iterator !== null && typeof options2 === "undefined") { + options2 = iterator; + iterator = null; + } + return loadAll(input, iterator, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + function safeLoad(input, options2) { + return load(input, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + module2.exports.loadAll = loadAll; + module2.exports.load = load; + module2.exports.safeLoadAll = safeLoadAll; + module2.exports.safeLoad = safeLoad; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/dumper.js +var require_dumper = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/dumper.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var DEFAULT_FULL_SCHEMA = require_default_full(); + var DEFAULT_SAFE_SCHEMA = require_default_safe(); + var _toString = Object.prototype.toString; + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CHAR_TAB = 9; + var CHAR_LINE_FEED = 10; + var CHAR_CARRIAGE_RETURN = 13; + var CHAR_SPACE = 32; + var CHAR_EXCLAMATION = 33; + var CHAR_DOUBLE_QUOTE = 34; + var CHAR_SHARP = 35; + var CHAR_PERCENT = 37; + var CHAR_AMPERSAND = 38; + var CHAR_SINGLE_QUOTE = 39; + var CHAR_ASTERISK = 42; + var CHAR_COMMA = 44; + var CHAR_MINUS = 45; + var CHAR_COLON = 58; + var CHAR_EQUALS = 61; + var CHAR_GREATER_THAN = 62; + var CHAR_QUESTION = 63; + var CHAR_COMMERCIAL_AT = 64; + var CHAR_LEFT_SQUARE_BRACKET = 91; + var CHAR_RIGHT_SQUARE_BRACKET = 93; + var CHAR_GRAVE_ACCENT = 96; + var CHAR_LEFT_CURLY_BRACKET = 123; + var CHAR_VERTICAL_LINE = 124; + var CHAR_RIGHT_CURLY_BRACKET = 125; + var ESCAPE_SEQUENCES = {}; + ESCAPE_SEQUENCES[0] = "\\0"; + ESCAPE_SEQUENCES[7] = "\\a"; + ESCAPE_SEQUENCES[8] = "\\b"; + ESCAPE_SEQUENCES[9] = "\\t"; + ESCAPE_SEQUENCES[10] = "\\n"; + ESCAPE_SEQUENCES[11] = "\\v"; + ESCAPE_SEQUENCES[12] = "\\f"; + ESCAPE_SEQUENCES[13] = "\\r"; + ESCAPE_SEQUENCES[27] = "\\e"; + ESCAPE_SEQUENCES[34] = '\\"'; + ESCAPE_SEQUENCES[92] = "\\\\"; + ESCAPE_SEQUENCES[133] = "\\N"; + ESCAPE_SEQUENCES[160] = "\\_"; + ESCAPE_SEQUENCES[8232] = "\\L"; + ESCAPE_SEQUENCES[8233] = "\\P"; + var DEPRECATED_BOOLEANS_SYNTAX = [ + "y", + "Y", + "yes", + "Yes", + "YES", + "on", + "On", + "ON", + "n", + "N", + "no", + "No", + "NO", + "off", + "Off", + "OFF" + ]; + function compileStyleMap(schema, map2) { + var result, keys, index, length, tag, style, type; + if (map2 === null) return {}; + result = {}; + keys = Object.keys(map2); + for (index = 0, length = keys.length; index < length; index += 1) { + tag = keys[index]; + style = String(map2[tag]); + if (tag.slice(0, 2) === "!!") { + tag = "tag:yaml.org,2002:" + tag.slice(2); + } + type = schema.compiledTypeMap["fallback"][tag]; + if (type && _hasOwnProperty.call(type.styleAliases, style)) { + style = type.styleAliases[style]; + } + result[tag] = style; + } + return result; + } + function encodeHex(character) { + var string3, handle, length; + string3 = character.toString(16).toUpperCase(); + if (character <= 255) { + handle = "x"; + length = 2; + } else if (character <= 65535) { + handle = "u"; + length = 4; + } else if (character <= 4294967295) { + handle = "U"; + length = 8; + } else { + throw new YAMLException("code point within a string may not be greater than 0xFFFFFFFF"); + } + return "\\" + handle + common.repeat("0", length - string3.length) + string3; + } + function State(options2) { + this.schema = options2["schema"] || DEFAULT_FULL_SCHEMA; + this.indent = Math.max(1, options2["indent"] || 2); + this.noArrayIndent = options2["noArrayIndent"] || false; + this.skipInvalid = options2["skipInvalid"] || false; + this.flowLevel = common.isNothing(options2["flowLevel"]) ? -1 : options2["flowLevel"]; + this.styleMap = compileStyleMap(this.schema, options2["styles"] || null); + this.sortKeys = options2["sortKeys"] || false; + this.lineWidth = options2["lineWidth"] || 80; + this.noRefs = options2["noRefs"] || false; + this.noCompatMode = options2["noCompatMode"] || false; + this.condenseFlow = options2["condenseFlow"] || false; + this.implicitTypes = this.schema.compiledImplicit; + this.explicitTypes = this.schema.compiledExplicit; + this.tag = null; + this.result = ""; + this.duplicates = []; + this.usedDuplicates = null; + } + function indentString(string3, spaces) { + var ind = common.repeat(" ", spaces), position = 0, next = -1, result = "", line, length = string3.length; + while (position < length) { + next = string3.indexOf("\n", position); + if (next === -1) { + line = string3.slice(position); + position = length; + } else { + line = string3.slice(position, next + 1); + position = next + 1; + } + if (line.length && line !== "\n") result += ind; + result += line; + } + return result; + } + function generateNextLine(state, level) { + return "\n" + common.repeat(" ", state.indent * level); + } + function testImplicitResolving(state, str2) { + var index, length, type; + for (index = 0, length = state.implicitTypes.length; index < length; index += 1) { + type = state.implicitTypes[index]; + if (type.resolve(str2)) { + return true; + } + } + return false; + } + function isWhitespace(c) { + return c === CHAR_SPACE || c === CHAR_TAB; + } + function isPrintable(c) { + return 32 <= c && c <= 126 || 161 <= c && c <= 55295 && c !== 8232 && c !== 8233 || 57344 <= c && c <= 65533 && c !== 65279 || 65536 <= c && c <= 1114111; + } + function isNsChar(c) { + return isPrintable(c) && !isWhitespace(c) && c !== 65279 && c !== CHAR_CARRIAGE_RETURN && c !== CHAR_LINE_FEED; + } + function isPlainSafe(c, prev) { + return isPrintable(c) && c !== 65279 && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET && c !== CHAR_COLON && (c !== CHAR_SHARP || prev && isNsChar(prev)); + } + function isPlainSafeFirst(c) { + return isPrintable(c) && c !== 65279 && !isWhitespace(c) && c !== CHAR_MINUS && c !== CHAR_QUESTION && c !== CHAR_COLON && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET && c !== CHAR_SHARP && c !== CHAR_AMPERSAND && c !== CHAR_ASTERISK && c !== CHAR_EXCLAMATION && c !== CHAR_VERTICAL_LINE && c !== CHAR_EQUALS && c !== CHAR_GREATER_THAN && c !== CHAR_SINGLE_QUOTE && c !== CHAR_DOUBLE_QUOTE && c !== CHAR_PERCENT && c !== CHAR_COMMERCIAL_AT && c !== CHAR_GRAVE_ACCENT; + } + function needIndentIndicator(string3) { + var leadingSpaceRe = /^\n* /; + return leadingSpaceRe.test(string3); + } + var STYLE_PLAIN = 1; + var STYLE_SINGLE = 2; + var STYLE_LITERAL = 3; + var STYLE_FOLDED = 4; + var STYLE_DOUBLE = 5; + function chooseScalarStyle(string3, singleLineOnly, indentPerLevel, lineWidth, testAmbiguousType) { + var i; + var char, prev_char; + var hasLineBreak = false; + var hasFoldableLine = false; + var shouldTrackWidth = lineWidth !== -1; + var previousLineBreak = -1; + var plain = isPlainSafeFirst(string3.charCodeAt(0)) && !isWhitespace(string3.charCodeAt(string3.length - 1)); + if (singleLineOnly) { + for (i = 0; i < string3.length; i++) { + char = string3.charCodeAt(i); + if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + prev_char = i > 0 ? string3.charCodeAt(i - 1) : null; + plain = plain && isPlainSafe(char, prev_char); + } + } else { + for (i = 0; i < string3.length; i++) { + char = string3.charCodeAt(i); + if (char === CHAR_LINE_FEED) { + hasLineBreak = true; + if (shouldTrackWidth) { + hasFoldableLine = hasFoldableLine || // Foldable line = too long, and not more-indented. + i - previousLineBreak - 1 > lineWidth && string3[previousLineBreak + 1] !== " "; + previousLineBreak = i; + } + } else if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + prev_char = i > 0 ? string3.charCodeAt(i - 1) : null; + plain = plain && isPlainSafe(char, prev_char); + } + hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i - previousLineBreak - 1 > lineWidth && string3[previousLineBreak + 1] !== " "); + } + if (!hasLineBreak && !hasFoldableLine) { + return plain && !testAmbiguousType(string3) ? STYLE_PLAIN : STYLE_SINGLE; + } + if (indentPerLevel > 9 && needIndentIndicator(string3)) { + return STYLE_DOUBLE; + } + return hasFoldableLine ? STYLE_FOLDED : STYLE_LITERAL; + } + function writeScalar(state, string3, level, iskey) { + state.dump = (function() { + if (string3.length === 0) { + return "''"; + } + if (!state.noCompatMode && DEPRECATED_BOOLEANS_SYNTAX.indexOf(string3) !== -1) { + return "'" + string3 + "'"; + } + var indent = state.indent * Math.max(1, level); + var lineWidth = state.lineWidth === -1 ? -1 : Math.max(Math.min(state.lineWidth, 40), state.lineWidth - indent); + var singleLineOnly = iskey || state.flowLevel > -1 && level >= state.flowLevel; + function testAmbiguity(string4) { + return testImplicitResolving(state, string4); + } + switch (chooseScalarStyle(string3, singleLineOnly, state.indent, lineWidth, testAmbiguity)) { + case STYLE_PLAIN: + return string3; + case STYLE_SINGLE: + return "'" + string3.replace(/'/g, "''") + "'"; + case STYLE_LITERAL: + return "|" + blockHeader(string3, state.indent) + dropEndingNewline(indentString(string3, indent)); + case STYLE_FOLDED: + return ">" + blockHeader(string3, state.indent) + dropEndingNewline(indentString(foldString(string3, lineWidth), indent)); + case STYLE_DOUBLE: + return '"' + escapeString(string3, lineWidth) + '"'; + default: + throw new YAMLException("impossible error: invalid scalar style"); + } + })(); + } + function blockHeader(string3, indentPerLevel) { + var indentIndicator = needIndentIndicator(string3) ? String(indentPerLevel) : ""; + var clip = string3[string3.length - 1] === "\n"; + var keep = clip && (string3[string3.length - 2] === "\n" || string3 === "\n"); + var chomp = keep ? "+" : clip ? "" : "-"; + return indentIndicator + chomp + "\n"; + } + function dropEndingNewline(string3) { + return string3[string3.length - 1] === "\n" ? string3.slice(0, -1) : string3; + } + function foldString(string3, width) { + var lineRe = /(\n+)([^\n]*)/g; + var result = (function() { + var nextLF = string3.indexOf("\n"); + nextLF = nextLF !== -1 ? nextLF : string3.length; + lineRe.lastIndex = nextLF; + return foldLine(string3.slice(0, nextLF), width); + })(); + var prevMoreIndented = string3[0] === "\n" || string3[0] === " "; + var moreIndented; + var match; + while (match = lineRe.exec(string3)) { + var prefix = match[1], line = match[2]; + moreIndented = line[0] === " "; + result += prefix + (!prevMoreIndented && !moreIndented && line !== "" ? "\n" : "") + foldLine(line, width); + prevMoreIndented = moreIndented; + } + return result; + } + function foldLine(line, width) { + if (line === "" || line[0] === " ") return line; + var breakRe = / [^ ]/g; + var match; + var start = 0, end, curr = 0, next = 0; + var result = ""; + while (match = breakRe.exec(line)) { + next = match.index; + if (next - start > width) { + end = curr > start ? curr : next; + result += "\n" + line.slice(start, end); + start = end + 1; + } + curr = next; + } + result += "\n"; + if (line.length - start > width && curr > start) { + result += line.slice(start, curr) + "\n" + line.slice(curr + 1); + } else { + result += line.slice(start); + } + return result.slice(1); + } + function escapeString(string3) { + var result = ""; + var char, nextChar; + var escapeSeq; + for (var i = 0; i < string3.length; i++) { + char = string3.charCodeAt(i); + if (char >= 55296 && char <= 56319) { + nextChar = string3.charCodeAt(i + 1); + if (nextChar >= 56320 && nextChar <= 57343) { + result += encodeHex((char - 55296) * 1024 + nextChar - 56320 + 65536); + i++; + continue; + } + } + escapeSeq = ESCAPE_SEQUENCES[char]; + result += !escapeSeq && isPrintable(char) ? string3[i] : escapeSeq || encodeHex(char); + } + return result; + } + function writeFlowSequence(state, level, object3) { + var _result = "", _tag = state.tag, index, length; + for (index = 0, length = object3.length; index < length; index += 1) { + if (writeNode(state, level, object3[index], false, false)) { + if (index !== 0) _result += "," + (!state.condenseFlow ? " " : ""); + _result += state.dump; + } + } + state.tag = _tag; + state.dump = "[" + _result + "]"; + } + function writeBlockSequence(state, level, object3, compact) { + var _result = "", _tag = state.tag, index, length; + for (index = 0, length = object3.length; index < length; index += 1) { + if (writeNode(state, level + 1, object3[index], true, true)) { + if (!compact || index !== 0) { + _result += generateNextLine(state, level); + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + _result += "-"; + } else { + _result += "- "; + } + _result += state.dump; + } + } + state.tag = _tag; + state.dump = _result || "[]"; + } + function writeFlowMapping(state, level, object3) { + var _result = "", _tag = state.tag, objectKeyList = Object.keys(object3), index, length, objectKey, objectValue, pairBuffer; + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + pairBuffer = ""; + if (index !== 0) pairBuffer += ", "; + if (state.condenseFlow) pairBuffer += '"'; + objectKey = objectKeyList[index]; + objectValue = object3[objectKey]; + if (!writeNode(state, level, objectKey, false, false)) { + continue; + } + if (state.dump.length > 1024) pairBuffer += "? "; + pairBuffer += state.dump + (state.condenseFlow ? '"' : "") + ":" + (state.condenseFlow ? "" : " "); + if (!writeNode(state, level, objectValue, false, false)) { + continue; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = "{" + _result + "}"; + } + function writeBlockMapping(state, level, object3, compact) { + var _result = "", _tag = state.tag, objectKeyList = Object.keys(object3), index, length, objectKey, objectValue, explicitPair, pairBuffer; + if (state.sortKeys === true) { + objectKeyList.sort(); + } else if (typeof state.sortKeys === "function") { + objectKeyList.sort(state.sortKeys); + } else if (state.sortKeys) { + throw new YAMLException("sortKeys must be a boolean or a function"); + } + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + pairBuffer = ""; + if (!compact || index !== 0) { + pairBuffer += generateNextLine(state, level); + } + objectKey = objectKeyList[index]; + objectValue = object3[objectKey]; + if (!writeNode(state, level + 1, objectKey, true, true, true)) { + continue; + } + explicitPair = state.tag !== null && state.tag !== "?" || state.dump && state.dump.length > 1024; + if (explicitPair) { + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += "?"; + } else { + pairBuffer += "? "; + } + } + pairBuffer += state.dump; + if (explicitPair) { + pairBuffer += generateNextLine(state, level); + } + if (!writeNode(state, level + 1, objectValue, true, explicitPair)) { + continue; + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += ":"; + } else { + pairBuffer += ": "; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = _result || "{}"; + } + function detectType(state, object3, explicit) { + var _result, typeList, index, length, type, style; + typeList = explicit ? state.explicitTypes : state.implicitTypes; + for (index = 0, length = typeList.length; index < length; index += 1) { + type = typeList[index]; + if ((type.instanceOf || type.predicate) && (!type.instanceOf || typeof object3 === "object" && object3 instanceof type.instanceOf) && (!type.predicate || type.predicate(object3))) { + state.tag = explicit ? type.tag : "?"; + if (type.represent) { + style = state.styleMap[type.tag] || type.defaultStyle; + if (_toString.call(type.represent) === "[object Function]") { + _result = type.represent(object3, style); + } else if (_hasOwnProperty.call(type.represent, style)) { + _result = type.represent[style](object3, style); + } else { + throw new YAMLException("!<" + type.tag + '> tag resolver accepts not "' + style + '" style'); + } + state.dump = _result; + } + return true; + } + } + return false; + } + function writeNode(state, level, object3, block, compact, iskey) { + state.tag = null; + state.dump = object3; + if (!detectType(state, object3, false)) { + detectType(state, object3, true); + } + var type = _toString.call(state.dump); + if (block) { + block = state.flowLevel < 0 || state.flowLevel > level; + } + var objectOrArray = type === "[object Object]" || type === "[object Array]", duplicateIndex, duplicate; + if (objectOrArray) { + duplicateIndex = state.duplicates.indexOf(object3); + duplicate = duplicateIndex !== -1; + } + if (state.tag !== null && state.tag !== "?" || duplicate || state.indent !== 2 && level > 0) { + compact = false; + } + if (duplicate && state.usedDuplicates[duplicateIndex]) { + state.dump = "*ref_" + duplicateIndex; + } else { + if (objectOrArray && duplicate && !state.usedDuplicates[duplicateIndex]) { + state.usedDuplicates[duplicateIndex] = true; + } + if (type === "[object Object]") { + if (block && Object.keys(state.dump).length !== 0) { + writeBlockMapping(state, level, state.dump, compact); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowMapping(state, level, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type === "[object Array]") { + var arrayLevel = state.noArrayIndent && level > 0 ? level - 1 : level; + if (block && state.dump.length !== 0) { + writeBlockSequence(state, arrayLevel, state.dump, compact); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowSequence(state, arrayLevel, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type === "[object String]") { + if (state.tag !== "?") { + writeScalar(state, state.dump, level, iskey); + } + } else { + if (state.skipInvalid) return false; + throw new YAMLException("unacceptable kind of an object to dump " + type); + } + if (state.tag !== null && state.tag !== "?") { + state.dump = "!<" + state.tag + "> " + state.dump; + } + } + return true; + } + function getDuplicateReferences(object3, state) { + var objects = [], duplicatesIndexes = [], index, length; + inspectNode(object3, objects, duplicatesIndexes); + for (index = 0, length = duplicatesIndexes.length; index < length; index += 1) { + state.duplicates.push(objects[duplicatesIndexes[index]]); + } + state.usedDuplicates = new Array(length); + } + function inspectNode(object3, objects, duplicatesIndexes) { + var objectKeyList, index, length; + if (object3 !== null && typeof object3 === "object") { + index = objects.indexOf(object3); + if (index !== -1) { + if (duplicatesIndexes.indexOf(index) === -1) { + duplicatesIndexes.push(index); + } + } else { + objects.push(object3); + if (Array.isArray(object3)) { + for (index = 0, length = object3.length; index < length; index += 1) { + inspectNode(object3[index], objects, duplicatesIndexes); + } + } else { + objectKeyList = Object.keys(object3); + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + inspectNode(object3[objectKeyList[index]], objects, duplicatesIndexes); + } + } + } + } + } + function dump(input, options2) { + options2 = options2 || {}; + var state = new State(options2); + if (!state.noRefs) getDuplicateReferences(input, state); + if (writeNode(state, 0, input, true, true)) return state.dump + "\n"; + return ""; + } + function safeDump(input, options2) { + return dump(input, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + module2.exports.dump = dump; + module2.exports.safeDump = safeDump; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml.js +var require_js_yaml = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml.js"(exports2, module2) { + "use strict"; + var loader = require_loader(); + var dumper = require_dumper(); + function deprecated(name) { + return function() { + throw new Error("Function " + name + " is deprecated and cannot be used."); + }; + } + module2.exports.Type = require_type(); + module2.exports.Schema = require_schema(); + module2.exports.FAILSAFE_SCHEMA = require_failsafe(); + module2.exports.JSON_SCHEMA = require_json(); + module2.exports.CORE_SCHEMA = require_core3(); + module2.exports.DEFAULT_SAFE_SCHEMA = require_default_safe(); + module2.exports.DEFAULT_FULL_SCHEMA = require_default_full(); + module2.exports.load = loader.load; + module2.exports.loadAll = loader.loadAll; + module2.exports.safeLoad = loader.safeLoad; + module2.exports.safeLoadAll = loader.safeLoadAll; + module2.exports.dump = dumper.dump; + module2.exports.safeDump = dumper.safeDump; + module2.exports.YAMLException = require_exception(); + module2.exports.MINIMAL_SCHEMA = require_failsafe(); + module2.exports.SAFE_SCHEMA = require_default_safe(); + module2.exports.DEFAULT_SCHEMA = require_default_full(); + module2.exports.scan = deprecated("scan"); + module2.exports.parse = deprecated("parse"); + module2.exports.compose = deprecated("compose"); + module2.exports.addConstructor = deprecated("addConstructor"); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/index.js +var require_js_yaml2 = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/index.js"(exports2, module2) { + "use strict"; + var yaml2 = require_js_yaml(); + module2.exports = yaml2; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engines.js +var require_engines = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engines.js"(exports, module) { + "use strict"; + var yaml = require_js_yaml2(); + var engines = exports = module.exports; + engines.yaml = { + parse: yaml.safeLoad.bind(yaml), + stringify: yaml.safeDump.bind(yaml) + }; + engines.json = { + parse: JSON.parse.bind(JSON), + stringify: function(obj, options2) { + const opts = Object.assign({ replacer: null, space: 2 }, options2); + return JSON.stringify(obj, opts.replacer, opts.space); + } + }; + engines.javascript = { + parse: function parse(str, options, wrap) { + try { + if (wrap !== false) { + str = "(function() {\nreturn " + str.trim() + ";\n}());"; + } + return eval(str) || {}; + } catch (err) { + if (wrap !== false && /(unexpected|identifier)/i.test(err.message)) { + return parse(str, options, false); + } + throw new SyntaxError(err); + } + }, + stringify: function() { + throw new Error("stringifying JavaScript is not supported"); + } + }; + } +}); + +// node_modules/.pnpm/strip-bom-string@1.0.0/node_modules/strip-bom-string/index.js +var require_strip_bom_string = __commonJS({ + "node_modules/.pnpm/strip-bom-string@1.0.0/node_modules/strip-bom-string/index.js"(exports2, module2) { + "use strict"; + module2.exports = function(str2) { + if (typeof str2 === "string" && str2.charAt(0) === "\uFEFF") { + return str2.slice(1); + } + return str2; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/utils.js +var require_utils2 = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/utils.js"(exports2) { + "use strict"; + var stripBom = require_strip_bom_string(); + var typeOf = require_kind_of(); + exports2.define = function(obj, key, val) { + Reflect.defineProperty(obj, key, { + enumerable: false, + configurable: true, + writable: true, + value: val + }); + }; + exports2.isBuffer = function(val) { + return typeOf(val) === "buffer"; + }; + exports2.isObject = function(val) { + return typeOf(val) === "object"; + }; + exports2.toBuffer = function(input) { + return typeof input === "string" ? Buffer.from(input) : input; + }; + exports2.toString = function(input) { + if (exports2.isBuffer(input)) return stripBom(String(input)); + if (typeof input !== "string") { + throw new TypeError("expected input to be a string or buffer"); + } + return stripBom(input); + }; + exports2.arrayify = function(val) { + return val ? Array.isArray(val) ? val : [val] : []; + }; + exports2.startsWith = function(str2, substr, len) { + if (typeof len !== "number") len = substr.length; + return str2.slice(0, len) === substr; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/defaults.js +var require_defaults2 = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/defaults.js"(exports2, module2) { + "use strict"; + var engines2 = require_engines(); + var utils = require_utils2(); + module2.exports = function(options2) { + const opts = Object.assign({}, options2); + opts.delimiters = utils.arrayify(opts.delims || opts.delimiters || "---"); + if (opts.delimiters.length === 1) { + opts.delimiters.push(opts.delimiters[0]); + } + opts.language = (opts.language || opts.lang || "yaml").toLowerCase(); + opts.engines = Object.assign({}, engines2, opts.parsers, opts.engines); + return opts; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engine.js +var require_engine = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engine.js"(exports2, module2) { + "use strict"; + module2.exports = function(name, options2) { + let engine = options2.engines[name] || options2.engines[aliase(name)]; + if (typeof engine === "undefined") { + throw new Error('gray-matter engine "' + name + '" is not registered'); + } + if (typeof engine === "function") { + engine = { parse: engine }; + } + return engine; + }; + function aliase(name) { + switch (name.toLowerCase()) { + case "js": + case "javascript": + return "javascript"; + case "coffee": + case "coffeescript": + case "cson": + return "coffee"; + case "yaml": + case "yml": + return "yaml"; + default: { + return name; + } + } + } + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/stringify.js +var require_stringify = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/stringify.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var getEngine = require_engine(); + var defaults = require_defaults2(); + module2.exports = function(file2, data, options2) { + if (data == null && options2 == null) { + switch (typeOf(file2)) { + case "object": + data = file2.data; + options2 = {}; + break; + case "string": + return file2; + default: { + throw new TypeError("expected file to be a string or object"); + } + } + } + const str2 = file2.content; + const opts = defaults(options2); + if (data == null) { + if (!opts.data) return file2; + data = opts.data; + } + const language = file2.language || opts.language; + const engine = getEngine(language, opts); + if (typeof engine.stringify !== "function") { + throw new TypeError('expected "' + language + '.stringify" to be a function'); + } + data = Object.assign({}, file2.data, data); + const open = opts.delimiters[0]; + const close = opts.delimiters[1]; + const matter2 = engine.stringify(data, options2).trim(); + let buf = ""; + if (matter2 !== "{}") { + buf = newline(open) + newline(matter2) + newline(close); + } + if (typeof file2.excerpt === "string" && file2.excerpt !== "") { + if (str2.indexOf(file2.excerpt.trim()) === -1) { + buf += newline(file2.excerpt) + newline(close); + } + } + return buf + newline(str2); + }; + function newline(str2) { + return str2.slice(-1) !== "\n" ? str2 + "\n" : str2; + } + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/excerpt.js +var require_excerpt = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/excerpt.js"(exports2, module2) { + "use strict"; + var defaults = require_defaults2(); + module2.exports = function(file2, options2) { + const opts = defaults(options2); + if (file2.data == null) { + file2.data = {}; + } + if (typeof opts.excerpt === "function") { + return opts.excerpt(file2, opts); + } + const sep = file2.data.excerpt_separator || opts.excerpt_separator; + if (sep == null && (opts.excerpt === false || opts.excerpt == null)) { + return file2; + } + const delimiter = typeof opts.excerpt === "string" ? opts.excerpt : sep || opts.delimiters[0]; + const idx = file2.content.indexOf(delimiter); + if (idx !== -1) { + file2.excerpt = file2.content.slice(0, idx); + } + return file2; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/to-file.js +var require_to_file = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/to-file.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var stringify = require_stringify(); + var utils = require_utils2(); + module2.exports = function(file2) { + if (typeOf(file2) !== "object") { + file2 = { content: file2 }; + } + if (typeOf(file2.data) !== "object") { + file2.data = {}; + } + if (file2.contents && file2.content == null) { + file2.content = file2.contents; + } + utils.define(file2, "orig", utils.toBuffer(file2.content)); + utils.define(file2, "language", file2.language || ""); + utils.define(file2, "matter", file2.matter || ""); + utils.define(file2, "stringify", function(data, options2) { + if (options2 && options2.language) { + file2.language = options2.language; + } + return stringify(file2, data, options2); + }); + file2.content = utils.toString(file2.content); + file2.isEmpty = false; + file2.excerpt = ""; + return file2; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/parse.js +var require_parse = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/parse.js"(exports2, module2) { + "use strict"; + var getEngine = require_engine(); + var defaults = require_defaults2(); + module2.exports = function(language, str2, options2) { + const opts = defaults(options2); + const engine = getEngine(language, opts); + if (typeof engine.parse !== "function") { + throw new TypeError('expected "' + language + '.parse" to be a function'); + } + return engine.parse(str2, opts); + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js +var require_gray_matter = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js"(exports2, module2) { + "use strict"; + var fs2 = require("fs"); + var sections = require_section_matter(); + var defaults = require_defaults2(); + var stringify = require_stringify(); + var excerpt = require_excerpt(); + var engines2 = require_engines(); + var toFile = require_to_file(); + var parse4 = require_parse(); + var utils = require_utils2(); + function matter2(input, options2) { + if (input === "") { + return { data: {}, content: input, excerpt: "", orig: input }; + } + let file2 = toFile(input); + const cached2 = matter2.cache[file2.content]; + if (!options2) { + if (cached2) { + file2 = Object.assign({}, cached2); + file2.orig = cached2.orig; + return file2; + } + matter2.cache[file2.content] = file2; + } + return parseMatter(file2, options2); + } + function parseMatter(file2, options2) { + const opts = defaults(options2); + const open = opts.delimiters[0]; + const close = "\n" + opts.delimiters[1]; + let str2 = file2.content; + if (opts.language) { + file2.language = opts.language; + } + const openLen = open.length; + if (!utils.startsWith(str2, open, openLen)) { + excerpt(file2, opts); + return file2; + } + if (str2.charAt(openLen) === open.slice(-1)) { + return file2; + } + str2 = str2.slice(openLen); + const len = str2.length; + const language = matter2.language(str2, opts); + if (language.name) { + file2.language = language.name; + str2 = str2.slice(language.raw.length); + } + let closeIndex = str2.indexOf(close); + if (closeIndex === -1) { + closeIndex = len; + } + file2.matter = str2.slice(0, closeIndex); + const block = file2.matter.replace(/^\s*#[^\n]+/gm, "").trim(); + if (block === "") { + file2.isEmpty = true; + file2.empty = file2.content; + file2.data = {}; + } else { + file2.data = parse4(file2.language, file2.matter, opts); + } + if (closeIndex === len) { + file2.content = ""; + } else { + file2.content = str2.slice(closeIndex + close.length); + if (file2.content[0] === "\r") { + file2.content = file2.content.slice(1); + } + if (file2.content[0] === "\n") { + file2.content = file2.content.slice(1); + } + } + excerpt(file2, opts); + if (opts.sections === true || typeof opts.section === "function") { + sections(file2, opts.section); + } + return file2; + } + matter2.engines = engines2; + matter2.stringify = function(file2, data, options2) { + if (typeof file2 === "string") file2 = matter2(file2, options2); + return stringify(file2, data, options2); + }; + matter2.read = function(filepath, options2) { + const str2 = fs2.readFileSync(filepath, "utf8"); + const file2 = matter2(str2, options2); + file2.path = filepath; + return file2; + }; + matter2.test = function(str2, options2) { + return utils.startsWith(str2, defaults(options2).delimiters[0]); + }; + matter2.language = function(str2, options2) { + const opts = defaults(options2); + const open = opts.delimiters[0]; + if (matter2.test(str2)) { + str2 = str2.slice(open.length); + } + const language = str2.slice(0, str2.search(/\r?\n/)); + return { + raw: language, + name: language ? language.trim() : "" + }; + }; + matter2.cache = {}; + matter2.clearCache = function() { + matter2.cache = {}; + }; + module2.exports = matter2; + } +}); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/util.js +var util; +(function(util2) { + util2.assertEqual = (_) => { + }; + function assertIs2(_arg) { + } + util2.assertIs = assertIs2; + function assertNever2(_x) { + throw new Error(); + } + util2.assertNever = assertNever2; + util2.arrayToEnum = (items) => { + const obj = {}; + for (const item of items) { + obj[item] = item; + } + return obj; + }; + util2.getValidEnumValues = (obj) => { + const validKeys = util2.objectKeys(obj).filter((k) => typeof obj[obj[k]] !== "number"); + const filtered = {}; + for (const k of validKeys) { + filtered[k] = obj[k]; + } + return util2.objectValues(filtered); + }; + util2.objectValues = (obj) => { + return util2.objectKeys(obj).map(function(e) { + return obj[e]; + }); + }; + util2.objectKeys = typeof Object.keys === "function" ? (obj) => Object.keys(obj) : (object3) => { + const keys = []; + for (const key in object3) { + if (Object.prototype.hasOwnProperty.call(object3, key)) { + keys.push(key); + } + } + return keys; + }; + util2.find = (arr, checker) => { + for (const item of arr) { + if (checker(item)) + return item; + } + return void 0; + }; + util2.isInteger = typeof Number.isInteger === "function" ? (val) => Number.isInteger(val) : (val) => typeof val === "number" && Number.isFinite(val) && Math.floor(val) === val; + function joinValues2(array2, separator = " | ") { + return array2.map((val) => typeof val === "string" ? `'${val}'` : val).join(separator); + } + util2.joinValues = joinValues2; + util2.jsonStringifyReplacer = (_, value) => { + if (typeof value === "bigint") { + return value.toString(); + } + return value; + }; +})(util || (util = {})); +var objectUtil; +(function(objectUtil2) { + objectUtil2.mergeShapes = (first, second) => { + return { + ...first, + ...second + // second overwrites first + }; + }; +})(objectUtil || (objectUtil = {})); +var ZodParsedType = util.arrayToEnum([ + "string", + "nan", + "number", + "integer", + "float", + "boolean", + "date", + "bigint", + "symbol", + "function", + "undefined", + "null", + "array", + "object", + "unknown", + "promise", + "void", + "never", + "map", + "set" +]); +var getParsedType = (data) => { + const t = typeof data; + switch (t) { + case "undefined": + return ZodParsedType.undefined; + case "string": + return ZodParsedType.string; + case "number": + return Number.isNaN(data) ? ZodParsedType.nan : ZodParsedType.number; + case "boolean": + return ZodParsedType.boolean; + case "function": + return ZodParsedType.function; + case "bigint": + return ZodParsedType.bigint; + case "symbol": + return ZodParsedType.symbol; + case "object": + if (Array.isArray(data)) { + return ZodParsedType.array; + } + if (data === null) { + return ZodParsedType.null; + } + if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { + return ZodParsedType.promise; + } + if (typeof Map !== "undefined" && data instanceof Map) { + return ZodParsedType.map; + } + if (typeof Set !== "undefined" && data instanceof Set) { + return ZodParsedType.set; + } + if (typeof Date !== "undefined" && data instanceof Date) { + return ZodParsedType.date; + } + return ZodParsedType.object; + default: + return ZodParsedType.unknown; + } +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/ZodError.js +var ZodIssueCode = util.arrayToEnum([ + "invalid_type", + "invalid_literal", + "custom", + "invalid_union", + "invalid_union_discriminator", + "invalid_enum_value", + "unrecognized_keys", + "invalid_arguments", + "invalid_return_type", + "invalid_date", + "invalid_string", + "too_small", + "too_big", + "invalid_intersection_types", + "not_multiple_of", + "not_finite" +]); +var ZodError = class _ZodError extends Error { + get errors() { + return this.issues; + } + constructor(issues) { + super(); + this.issues = []; + this.addIssue = (sub) => { + this.issues = [...this.issues, sub]; + }; + this.addIssues = (subs = []) => { + this.issues = [...this.issues, ...subs]; + }; + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } else { + this.__proto__ = actualProto; + } + this.name = "ZodError"; + this.issues = issues; + } + format(_mapper) { + const mapper = _mapper || function(issue2) { + return issue2.message; + }; + const fieldErrors = { _errors: [] }; + const processError = (error2) => { + for (const issue2 of error2.issues) { + if (issue2.code === "invalid_union") { + issue2.unionErrors.map(processError); + } else if (issue2.code === "invalid_return_type") { + processError(issue2.returnTypeError); + } else if (issue2.code === "invalid_arguments") { + processError(issue2.argumentsError); + } else if (issue2.path.length === 0) { + fieldErrors._errors.push(mapper(issue2)); + } else { + let curr = fieldErrors; + let i = 0; + while (i < issue2.path.length) { + const el = issue2.path[i]; + const terminal = i === issue2.path.length - 1; + if (!terminal) { + curr[el] = curr[el] || { _errors: [] }; + } else { + curr[el] = curr[el] || { _errors: [] }; + curr[el]._errors.push(mapper(issue2)); + } + curr = curr[el]; + i++; + } + } + } + }; + processError(this); + return fieldErrors; + } + static assert(value) { + if (!(value instanceof _ZodError)) { + throw new Error(`Not a ZodError: ${value}`); + } + } + toString() { + return this.message; + } + get message() { + return JSON.stringify(this.issues, util.jsonStringifyReplacer, 2); + } + get isEmpty() { + return this.issues.length === 0; + } + flatten(mapper = (issue2) => issue2.message) { + const fieldErrors = /* @__PURE__ */ Object.create(null); + const formErrors = []; + for (const sub of this.issues) { + if (sub.path.length > 0) { + const firstEl = sub.path[0]; + fieldErrors[firstEl] = fieldErrors[firstEl] || []; + fieldErrors[firstEl].push(mapper(sub)); + } else { + formErrors.push(mapper(sub)); + } + } + return { formErrors, fieldErrors }; + } + get formErrors() { + return this.flatten(); + } +}; +ZodError.create = (issues) => { + const error2 = new ZodError(issues); + return error2; +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/locales/en.js +var errorMap = (issue2, _ctx) => { + let message; + switch (issue2.code) { + case ZodIssueCode.invalid_type: + if (issue2.received === ZodParsedType.undefined) { + message = "Required"; + } else { + message = `Expected ${issue2.expected}, received ${issue2.received}`; + } + break; + case ZodIssueCode.invalid_literal: + message = `Invalid literal value, expected ${JSON.stringify(issue2.expected, util.jsonStringifyReplacer)}`; + break; + case ZodIssueCode.unrecognized_keys: + message = `Unrecognized key(s) in object: ${util.joinValues(issue2.keys, ", ")}`; + break; + case ZodIssueCode.invalid_union: + message = `Invalid input`; + break; + case ZodIssueCode.invalid_union_discriminator: + message = `Invalid discriminator value. Expected ${util.joinValues(issue2.options)}`; + break; + case ZodIssueCode.invalid_enum_value: + message = `Invalid enum value. Expected ${util.joinValues(issue2.options)}, received '${issue2.received}'`; + break; + case ZodIssueCode.invalid_arguments: + message = `Invalid function arguments`; + break; + case ZodIssueCode.invalid_return_type: + message = `Invalid function return type`; + break; + case ZodIssueCode.invalid_date: + message = `Invalid date`; + break; + case ZodIssueCode.invalid_string: + if (typeof issue2.validation === "object") { + if ("includes" in issue2.validation) { + message = `Invalid input: must include "${issue2.validation.includes}"`; + if (typeof issue2.validation.position === "number") { + message = `${message} at one or more positions greater than or equal to ${issue2.validation.position}`; + } + } else if ("startsWith" in issue2.validation) { + message = `Invalid input: must start with "${issue2.validation.startsWith}"`; + } else if ("endsWith" in issue2.validation) { + message = `Invalid input: must end with "${issue2.validation.endsWith}"`; + } else { + util.assertNever(issue2.validation); + } + } else if (issue2.validation !== "regex") { + message = `Invalid ${issue2.validation}`; + } else { + message = "Invalid"; + } + break; + case ZodIssueCode.too_small: + if (issue2.type === "array") + message = `Array must contain ${issue2.exact ? "exactly" : issue2.inclusive ? `at least` : `more than`} ${issue2.minimum} element(s)`; + else if (issue2.type === "string") + message = `String must contain ${issue2.exact ? "exactly" : issue2.inclusive ? `at least` : `over`} ${issue2.minimum} character(s)`; + else if (issue2.type === "number") + message = `Number must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${issue2.minimum}`; + else if (issue2.type === "bigint") + message = `Number must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${issue2.minimum}`; + else if (issue2.type === "date") + message = `Date must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${new Date(Number(issue2.minimum))}`; + else + message = "Invalid input"; + break; + case ZodIssueCode.too_big: + if (issue2.type === "array") + message = `Array must contain ${issue2.exact ? `exactly` : issue2.inclusive ? `at most` : `less than`} ${issue2.maximum} element(s)`; + else if (issue2.type === "string") + message = `String must contain ${issue2.exact ? `exactly` : issue2.inclusive ? `at most` : `under`} ${issue2.maximum} character(s)`; + else if (issue2.type === "number") + message = `Number must be ${issue2.exact ? `exactly` : issue2.inclusive ? `less than or equal to` : `less than`} ${issue2.maximum}`; + else if (issue2.type === "bigint") + message = `BigInt must be ${issue2.exact ? `exactly` : issue2.inclusive ? `less than or equal to` : `less than`} ${issue2.maximum}`; + else if (issue2.type === "date") + message = `Date must be ${issue2.exact ? `exactly` : issue2.inclusive ? `smaller than or equal to` : `smaller than`} ${new Date(Number(issue2.maximum))}`; + else + message = "Invalid input"; + break; + case ZodIssueCode.custom: + message = `Invalid input`; + break; + case ZodIssueCode.invalid_intersection_types: + message = `Intersection results could not be merged`; + break; + case ZodIssueCode.not_multiple_of: + message = `Number must be a multiple of ${issue2.multipleOf}`; + break; + case ZodIssueCode.not_finite: + message = "Number must be finite"; + break; + default: + message = _ctx.defaultError; + util.assertNever(issue2); + } + return { message }; +}; +var en_default = errorMap; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/errors.js +var overrideErrorMap = en_default; +function getErrorMap() { + return overrideErrorMap; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/parseUtil.js +var makeIssue = (params) => { + const { data, path: path2, errorMaps, issueData } = params; + const fullPath = [...path2, ...issueData.path || []]; + const fullIssue = { + ...issueData, + path: fullPath + }; + if (issueData.message !== void 0) { + return { + ...issueData, + path: fullPath, + message: issueData.message + }; + } + let errorMessage = ""; + const maps = errorMaps.filter((m) => !!m).slice().reverse(); + for (const map2 of maps) { + errorMessage = map2(fullIssue, { data, defaultError: errorMessage }).message; + } + return { + ...issueData, + path: fullPath, + message: errorMessage + }; +}; +function addIssueToContext(ctx, issueData) { + const overrideMap = getErrorMap(); + const issue2 = makeIssue({ + issueData, + data: ctx.data, + path: ctx.path, + errorMaps: [ + ctx.common.contextualErrorMap, + // contextual error map is first priority + ctx.schemaErrorMap, + // then schema-bound map if available + overrideMap, + // then global override map + overrideMap === en_default ? void 0 : en_default + // then global default map + ].filter((x) => !!x) + }); + ctx.common.issues.push(issue2); +} +var ParseStatus = class _ParseStatus { + constructor() { + this.value = "valid"; + } + dirty() { + if (this.value === "valid") + this.value = "dirty"; + } + abort() { + if (this.value !== "aborted") + this.value = "aborted"; + } + static mergeArray(status, results) { + const arrayValue = []; + for (const s of results) { + if (s.status === "aborted") + return INVALID; + if (s.status === "dirty") + status.dirty(); + arrayValue.push(s.value); + } + return { status: status.value, value: arrayValue }; + } + static async mergeObjectAsync(status, pairs) { + const syncPairs = []; + for (const pair of pairs) { + const key = await pair.key; + const value = await pair.value; + syncPairs.push({ + key, + value + }); + } + return _ParseStatus.mergeObjectSync(status, syncPairs); + } + static mergeObjectSync(status, pairs) { + const finalObject = {}; + for (const pair of pairs) { + const { key, value } = pair; + if (key.status === "aborted") + return INVALID; + if (value.status === "aborted") + return INVALID; + if (key.status === "dirty") + status.dirty(); + if (value.status === "dirty") + status.dirty(); + if (key.value !== "__proto__" && (typeof value.value !== "undefined" || pair.alwaysSet)) { + finalObject[key.value] = value.value; + } + } + return { status: status.value, value: finalObject }; + } +}; +var INVALID = Object.freeze({ + status: "aborted" +}); +var DIRTY = (value) => ({ status: "dirty", value }); +var OK = (value) => ({ status: "valid", value }); +var isAborted = (x) => x.status === "aborted"; +var isDirty = (x) => x.status === "dirty"; +var isValid = (x) => x.status === "valid"; +var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/errorUtil.js +var errorUtil; +(function(errorUtil2) { + errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {}; + errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message; +})(errorUtil || (errorUtil = {})); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/types.js +var ParseInputLazyPath = class { + constructor(parent, value, path2, key) { + this._cachedPath = []; + this.parent = parent; + this.data = value; + this._path = path2; + this._key = key; + } + get path() { + if (!this._cachedPath.length) { + if (Array.isArray(this._key)) { + this._cachedPath.push(...this._path, ...this._key); + } else { + this._cachedPath.push(...this._path, this._key); + } + } + return this._cachedPath; + } +}; +var handleResult = (ctx, result) => { + if (isValid(result)) { + return { success: true, data: result.value }; + } else { + if (!ctx.common.issues.length) { + throw new Error("Validation failed but no issues detected."); + } + return { + success: false, + get error() { + if (this._error) + return this._error; + const error2 = new ZodError(ctx.common.issues); + this._error = error2; + return this._error; + } + }; + } +}; +function processCreateParams(params) { + if (!params) + return {}; + const { errorMap: errorMap2, invalid_type_error, required_error, description } = params; + if (errorMap2 && (invalid_type_error || required_error)) { + throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); + } + if (errorMap2) + return { errorMap: errorMap2, description }; + const customMap = (iss, ctx) => { + const { message } = params; + if (iss.code === "invalid_enum_value") { + return { message: message ?? ctx.defaultError }; + } + if (typeof ctx.data === "undefined") { + return { message: message ?? required_error ?? ctx.defaultError }; + } + if (iss.code !== "invalid_type") + return { message: ctx.defaultError }; + return { message: message ?? invalid_type_error ?? ctx.defaultError }; + }; + return { errorMap: customMap, description }; +} +var ZodType = class { + get description() { + return this._def.description; + } + _getType(input) { + return getParsedType(input.data); + } + _getOrReturnCtx(input, ctx) { + return ctx || { + common: input.parent.common, + data: input.data, + parsedType: getParsedType(input.data), + schemaErrorMap: this._def.errorMap, + path: input.path, + parent: input.parent + }; + } + _processInputParams(input) { + return { + status: new ParseStatus(), + ctx: { + common: input.parent.common, + data: input.data, + parsedType: getParsedType(input.data), + schemaErrorMap: this._def.errorMap, + path: input.path, + parent: input.parent + } + }; + } + _parseSync(input) { + const result = this._parse(input); + if (isAsync(result)) { + throw new Error("Synchronous parse encountered promise."); + } + return result; + } + _parseAsync(input) { + const result = this._parse(input); + return Promise.resolve(result); + } + parse(data, params) { + const result = this.safeParse(data, params); + if (result.success) + return result.data; + throw result.error; + } + safeParse(data, params) { + const ctx = { + common: { + issues: [], + async: params?.async ?? false, + contextualErrorMap: params?.errorMap + }, + path: params?.path || [], + schemaErrorMap: this._def.errorMap, + parent: null, + data, + parsedType: getParsedType(data) + }; + const result = this._parseSync({ data, path: ctx.path, parent: ctx }); + return handleResult(ctx, result); + } + "~validate"(data) { + const ctx = { + common: { + issues: [], + async: !!this["~standard"].async + }, + path: [], + schemaErrorMap: this._def.errorMap, + parent: null, + data, + parsedType: getParsedType(data) + }; + if (!this["~standard"].async) { + try { + const result = this._parseSync({ data, path: [], parent: ctx }); + return isValid(result) ? { + value: result.value + } : { + issues: ctx.common.issues + }; + } catch (err) { + if (err?.message?.toLowerCase()?.includes("encountered")) { + this["~standard"].async = true; + } + ctx.common = { + issues: [], + async: true + }; + } + } + return this._parseAsync({ data, path: [], parent: ctx }).then((result) => isValid(result) ? { + value: result.value + } : { + issues: ctx.common.issues + }); + } + async parseAsync(data, params) { + const result = await this.safeParseAsync(data, params); + if (result.success) + return result.data; + throw result.error; + } + async safeParseAsync(data, params) { + const ctx = { + common: { + issues: [], + contextualErrorMap: params?.errorMap, + async: true + }, + path: params?.path || [], + schemaErrorMap: this._def.errorMap, + parent: null, + data, + parsedType: getParsedType(data) + }; + const maybeAsyncResult = this._parse({ data, path: ctx.path, parent: ctx }); + const result = await (isAsync(maybeAsyncResult) ? maybeAsyncResult : Promise.resolve(maybeAsyncResult)); + return handleResult(ctx, result); + } + refine(check2, message) { + const getIssueProperties = (val) => { + if (typeof message === "string" || typeof message === "undefined") { + return { message }; + } else if (typeof message === "function") { + return message(val); + } else { + return message; + } + }; + return this._refinement((val, ctx) => { + const result = check2(val); + const setError = () => ctx.addIssue({ + code: ZodIssueCode.custom, + ...getIssueProperties(val) + }); + if (typeof Promise !== "undefined" && result instanceof Promise) { + return result.then((data) => { + if (!data) { + setError(); + return false; + } else { + return true; + } + }); + } + if (!result) { + setError(); + return false; + } else { + return true; + } + }); + } + refinement(check2, refinementData) { + return this._refinement((val, ctx) => { + if (!check2(val)) { + ctx.addIssue(typeof refinementData === "function" ? refinementData(val, ctx) : refinementData); + return false; + } else { + return true; + } + }); + } + _refinement(refinement) { + return new ZodEffects({ + schema: this, + typeName: ZodFirstPartyTypeKind.ZodEffects, + effect: { type: "refinement", refinement } + }); + } + superRefine(refinement) { + return this._refinement(refinement); + } + constructor(def) { + this.spa = this.safeParseAsync; + this._def = def; + this.parse = this.parse.bind(this); + this.safeParse = this.safeParse.bind(this); + this.parseAsync = this.parseAsync.bind(this); + this.safeParseAsync = this.safeParseAsync.bind(this); + this.spa = this.spa.bind(this); + this.refine = this.refine.bind(this); + this.refinement = this.refinement.bind(this); + this.superRefine = this.superRefine.bind(this); + this.optional = this.optional.bind(this); + this.nullable = this.nullable.bind(this); + this.nullish = this.nullish.bind(this); + this.array = this.array.bind(this); + this.promise = this.promise.bind(this); + this.or = this.or.bind(this); + this.and = this.and.bind(this); + this.transform = this.transform.bind(this); + this.brand = this.brand.bind(this); + this.default = this.default.bind(this); + this.catch = this.catch.bind(this); + this.describe = this.describe.bind(this); + this.pipe = this.pipe.bind(this); + this.readonly = this.readonly.bind(this); + this.isNullable = this.isNullable.bind(this); + this.isOptional = this.isOptional.bind(this); + this["~standard"] = { + version: 1, + vendor: "zod", + validate: (data) => this["~validate"](data) + }; + } + optional() { + return ZodOptional.create(this, this._def); + } + nullable() { + return ZodNullable.create(this, this._def); + } + nullish() { + return this.nullable().optional(); + } + array() { + return ZodArray.create(this); + } + promise() { + return ZodPromise.create(this, this._def); + } + or(option) { + return ZodUnion.create([this, option], this._def); + } + and(incoming) { + return ZodIntersection.create(this, incoming, this._def); + } + transform(transform2) { + return new ZodEffects({ + ...processCreateParams(this._def), + schema: this, + typeName: ZodFirstPartyTypeKind.ZodEffects, + effect: { type: "transform", transform: transform2 } + }); + } + default(def) { + const defaultValueFunc = typeof def === "function" ? def : () => def; + return new ZodDefault({ + ...processCreateParams(this._def), + innerType: this, + defaultValue: defaultValueFunc, + typeName: ZodFirstPartyTypeKind.ZodDefault + }); + } + brand() { + return new ZodBranded({ + typeName: ZodFirstPartyTypeKind.ZodBranded, + type: this, + ...processCreateParams(this._def) + }); + } + catch(def) { + const catchValueFunc = typeof def === "function" ? def : () => def; + return new ZodCatch({ + ...processCreateParams(this._def), + innerType: this, + catchValue: catchValueFunc, + typeName: ZodFirstPartyTypeKind.ZodCatch + }); + } + describe(description) { + const This = this.constructor; + return new This({ + ...this._def, + description + }); + } + pipe(target) { + return ZodPipeline.create(this, target); + } + readonly() { + return ZodReadonly.create(this); + } + isOptional() { + return this.safeParse(void 0).success; + } + isNullable() { + return this.safeParse(null).success; + } +}; +var cuidRegex = /^c[^\s-]{8,}$/i; +var cuid2Regex = /^[0-9a-z]+$/; +var ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; +var uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; +var nanoidRegex = /^[a-z0-9_-]{21}$/i; +var jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; +var durationRegex = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; +var emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +var _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; +var emojiRegex; +var ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +var ipv4CidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; +var ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; +var ipv6CidrRegex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; +var base64Regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; +var base64urlRegex = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; +var dateRegexSource = `((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))`; +var dateRegex = new RegExp(`^${dateRegexSource}$`); +function timeRegexSource(args) { + let secondsRegexSource = `[0-5]\\d`; + if (args.precision) { + secondsRegexSource = `${secondsRegexSource}\\.\\d{${args.precision}}`; + } else if (args.precision == null) { + secondsRegexSource = `${secondsRegexSource}(\\.\\d+)?`; + } + const secondsQuantifier = args.precision ? "+" : "?"; + return `([01]\\d|2[0-3]):[0-5]\\d(:${secondsRegexSource})${secondsQuantifier}`; +} +function timeRegex(args) { + return new RegExp(`^${timeRegexSource(args)}$`); +} +function datetimeRegex(args) { + let regex = `${dateRegexSource}T${timeRegexSource(args)}`; + const opts = []; + opts.push(args.local ? `Z?` : `Z`); + if (args.offset) + opts.push(`([+-]\\d{2}:?\\d{2})`); + regex = `${regex}(${opts.join("|")})`; + return new RegExp(`^${regex}$`); +} +function isValidIP(ip, version2) { + if ((version2 === "v4" || !version2) && ipv4Regex.test(ip)) { + return true; + } + if ((version2 === "v6" || !version2) && ipv6Regex.test(ip)) { + return true; + } + return false; +} +function isValidJWT(jwt2, alg) { + if (!jwtRegex.test(jwt2)) + return false; + try { + const [header] = jwt2.split("."); + if (!header) + return false; + const base643 = header.replace(/-/g, "+").replace(/_/g, "/").padEnd(header.length + (4 - header.length % 4) % 4, "="); + const decoded = JSON.parse(atob(base643)); + if (typeof decoded !== "object" || decoded === null) + return false; + if ("typ" in decoded && decoded?.typ !== "JWT") + return false; + if (!decoded.alg) + return false; + if (alg && decoded.alg !== alg) + return false; + return true; + } catch { + return false; + } +} +function isValidCidr(ip, version2) { + if ((version2 === "v4" || !version2) && ipv4CidrRegex.test(ip)) { + return true; + } + if ((version2 === "v6" || !version2) && ipv6CidrRegex.test(ip)) { + return true; + } + return false; +} +var ZodString = class _ZodString2 extends ZodType { + _parse(input) { + if (this._def.coerce) { + input.data = String(input.data); + } + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.string) { + const ctx2 = this._getOrReturnCtx(input); + addIssueToContext(ctx2, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.string, + received: ctx2.parsedType + }); + return INVALID; + } + const status = new ParseStatus(); + let ctx = void 0; + for (const check2 of this._def.checks) { + if (check2.kind === "min") { + if (input.data.length < check2.value) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check2.value, + type: "string", + inclusive: true, + exact: false, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "max") { + if (input.data.length > check2.value) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check2.value, + type: "string", + inclusive: true, + exact: false, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "length") { + const tooBig = input.data.length > check2.value; + const tooSmall = input.data.length < check2.value; + if (tooBig || tooSmall) { + ctx = this._getOrReturnCtx(input, ctx); + if (tooBig) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check2.value, + type: "string", + inclusive: true, + exact: true, + message: check2.message + }); + } else if (tooSmall) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check2.value, + type: "string", + inclusive: true, + exact: true, + message: check2.message + }); + } + status.dirty(); + } + } else if (check2.kind === "email") { + if (!emailRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "email", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "emoji") { + if (!emojiRegex) { + emojiRegex = new RegExp(_emojiRegex, "u"); + } + if (!emojiRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "emoji", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "uuid") { + if (!uuidRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "uuid", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "nanoid") { + if (!nanoidRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "nanoid", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "cuid") { + if (!cuidRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "cuid", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "cuid2") { + if (!cuid2Regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "cuid2", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "ulid") { + if (!ulidRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ulid", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "url") { + try { + new URL(input.data); + } catch { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "url", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "regex") { + check2.regex.lastIndex = 0; + const testResult = check2.regex.test(input.data); + if (!testResult) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "regex", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "trim") { + input.data = input.data.trim(); + } else if (check2.kind === "includes") { + if (!input.data.includes(check2.value, check2.position)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { includes: check2.value, position: check2.position }, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "toLowerCase") { + input.data = input.data.toLowerCase(); + } else if (check2.kind === "toUpperCase") { + input.data = input.data.toUpperCase(); + } else if (check2.kind === "startsWith") { + if (!input.data.startsWith(check2.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { startsWith: check2.value }, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "endsWith") { + if (!input.data.endsWith(check2.value)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: { endsWith: check2.value }, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "datetime") { + const regex = datetimeRegex(check2); + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "datetime", + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "date") { + const regex = dateRegex; + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "date", + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "time") { + const regex = timeRegex(check2); + if (!regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_string, + validation: "time", + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "duration") { + if (!durationRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "duration", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "ip") { + if (!isValidIP(input.data, check2.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ip", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "jwt") { + if (!isValidJWT(input.data, check2.alg)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "cidr") { + if (!isValidCidr(input.data, check2.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "cidr", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "base64") { + if (!base64Regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "base64", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "base64url") { + if (!base64urlRegex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "base64url", + code: ZodIssueCode.invalid_string, + message: check2.message + }); + status.dirty(); + } + } else { + util.assertNever(check2); + } + } + return { status: status.value, value: input.data }; + } + _regex(regex, validation, message) { + return this.refinement((data) => regex.test(data), { + validation, + code: ZodIssueCode.invalid_string, + ...errorUtil.errToObj(message) + }); + } + _addCheck(check2) { + return new _ZodString2({ + ...this._def, + checks: [...this._def.checks, check2] + }); + } + email(message) { + return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); + } + url(message) { + return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); + } + emoji(message) { + return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); + } + uuid(message) { + return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); + } + nanoid(message) { + return this._addCheck({ kind: "nanoid", ...errorUtil.errToObj(message) }); + } + cuid(message) { + return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); + } + cuid2(message) { + return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); + } + ulid(message) { + return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); + } + base64(message) { + return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); + } + base64url(message) { + return this._addCheck({ + kind: "base64url", + ...errorUtil.errToObj(message) + }); + } + jwt(options2) { + return this._addCheck({ kind: "jwt", ...errorUtil.errToObj(options2) }); + } + ip(options2) { + return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options2) }); + } + cidr(options2) { + return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options2) }); + } + datetime(options2) { + if (typeof options2 === "string") { + return this._addCheck({ + kind: "datetime", + precision: null, + offset: false, + local: false, + message: options2 + }); + } + return this._addCheck({ + kind: "datetime", + precision: typeof options2?.precision === "undefined" ? null : options2?.precision, + offset: options2?.offset ?? false, + local: options2?.local ?? false, + ...errorUtil.errToObj(options2?.message) + }); + } + date(message) { + return this._addCheck({ kind: "date", message }); + } + time(options2) { + if (typeof options2 === "string") { + return this._addCheck({ + kind: "time", + precision: null, + message: options2 + }); + } + return this._addCheck({ + kind: "time", + precision: typeof options2?.precision === "undefined" ? null : options2?.precision, + ...errorUtil.errToObj(options2?.message) + }); + } + duration(message) { + return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); + } + regex(regex, message) { + return this._addCheck({ + kind: "regex", + regex, + ...errorUtil.errToObj(message) + }); + } + includes(value, options2) { + return this._addCheck({ + kind: "includes", + value, + position: options2?.position, + ...errorUtil.errToObj(options2?.message) + }); + } + startsWith(value, message) { + return this._addCheck({ + kind: "startsWith", + value, + ...errorUtil.errToObj(message) + }); + } + endsWith(value, message) { + return this._addCheck({ + kind: "endsWith", + value, + ...errorUtil.errToObj(message) + }); + } + min(minLength, message) { + return this._addCheck({ + kind: "min", + value: minLength, + ...errorUtil.errToObj(message) + }); + } + max(maxLength, message) { + return this._addCheck({ + kind: "max", + value: maxLength, + ...errorUtil.errToObj(message) + }); + } + length(len, message) { + return this._addCheck({ + kind: "length", + value: len, + ...errorUtil.errToObj(message) + }); + } + /** + * Equivalent to `.min(1)` + */ + nonempty(message) { + return this.min(1, errorUtil.errToObj(message)); + } + trim() { + return new _ZodString2({ + ...this._def, + checks: [...this._def.checks, { kind: "trim" }] + }); + } + toLowerCase() { + return new _ZodString2({ + ...this._def, + checks: [...this._def.checks, { kind: "toLowerCase" }] + }); + } + toUpperCase() { + return new _ZodString2({ + ...this._def, + checks: [...this._def.checks, { kind: "toUpperCase" }] + }); + } + get isDatetime() { + return !!this._def.checks.find((ch) => ch.kind === "datetime"); + } + get isDate() { + return !!this._def.checks.find((ch) => ch.kind === "date"); + } + get isTime() { + return !!this._def.checks.find((ch) => ch.kind === "time"); + } + get isDuration() { + return !!this._def.checks.find((ch) => ch.kind === "duration"); + } + get isEmail() { + return !!this._def.checks.find((ch) => ch.kind === "email"); + } + get isURL() { + return !!this._def.checks.find((ch) => ch.kind === "url"); + } + get isEmoji() { + return !!this._def.checks.find((ch) => ch.kind === "emoji"); + } + get isUUID() { + return !!this._def.checks.find((ch) => ch.kind === "uuid"); + } + get isNANOID() { + return !!this._def.checks.find((ch) => ch.kind === "nanoid"); + } + get isCUID() { + return !!this._def.checks.find((ch) => ch.kind === "cuid"); + } + get isCUID2() { + return !!this._def.checks.find((ch) => ch.kind === "cuid2"); + } + get isULID() { + return !!this._def.checks.find((ch) => ch.kind === "ulid"); + } + get isIP() { + return !!this._def.checks.find((ch) => ch.kind === "ip"); + } + get isCIDR() { + return !!this._def.checks.find((ch) => ch.kind === "cidr"); + } + get isBase64() { + return !!this._def.checks.find((ch) => ch.kind === "base64"); + } + get isBase64url() { + return !!this._def.checks.find((ch) => ch.kind === "base64url"); + } + get minLength() { + let min = null; + for (const ch of this._def.checks) { + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } + } + return min; + } + get maxLength() { + let max = null; + for (const ch of this._def.checks) { + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; + } + } + return max; + } +}; +ZodString.create = (params) => { + return new ZodString({ + checks: [], + typeName: ZodFirstPartyTypeKind.ZodString, + coerce: params?.coerce ?? false, + ...processCreateParams(params) + }); +}; +function floatSafeRemainder(val, step) { + const valDecCount = (val.toString().split(".")[1] || "").length; + const stepDecCount = (step.toString().split(".")[1] || "").length; + const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; + const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); + const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); + return valInt % stepInt / 10 ** decCount; +} +var ZodNumber = class _ZodNumber extends ZodType { + constructor() { + super(...arguments); + this.min = this.gte; + this.max = this.lte; + this.step = this.multipleOf; + } + _parse(input) { + if (this._def.coerce) { + input.data = Number(input.data); + } + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.number) { + const ctx2 = this._getOrReturnCtx(input); + addIssueToContext(ctx2, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.number, + received: ctx2.parsedType + }); + return INVALID; + } + let ctx = void 0; + const status = new ParseStatus(); + for (const check2 of this._def.checks) { + if (check2.kind === "int") { + if (!util.isInteger(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: "integer", + received: "float", + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "min") { + const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; + if (tooSmall) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check2.value, + type: "number", + inclusive: check2.inclusive, + exact: false, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "max") { + const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; + if (tooBig) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check2.value, + type: "number", + inclusive: check2.inclusive, + exact: false, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "multipleOf") { + if (floatSafeRemainder(input.data, check2.value) !== 0) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.not_multiple_of, + multipleOf: check2.value, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "finite") { + if (!Number.isFinite(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.not_finite, + message: check2.message + }); + status.dirty(); + } + } else { + util.assertNever(check2); + } + } + return { status: status.value, value: input.data }; + } + gte(value, message) { + return this.setLimit("min", value, true, errorUtil.toString(message)); + } + gt(value, message) { + return this.setLimit("min", value, false, errorUtil.toString(message)); + } + lte(value, message) { + return this.setLimit("max", value, true, errorUtil.toString(message)); + } + lt(value, message) { + return this.setLimit("max", value, false, errorUtil.toString(message)); + } + setLimit(kind, value, inclusive, message) { + return new _ZodNumber({ + ...this._def, + checks: [ + ...this._def.checks, + { + kind, + value, + inclusive, + message: errorUtil.toString(message) + } + ] + }); + } + _addCheck(check2) { + return new _ZodNumber({ + ...this._def, + checks: [...this._def.checks, check2] + }); + } + int(message) { + return this._addCheck({ + kind: "int", + message: errorUtil.toString(message) + }); + } + positive(message) { + return this._addCheck({ + kind: "min", + value: 0, + inclusive: false, + message: errorUtil.toString(message) + }); + } + negative(message) { + return this._addCheck({ + kind: "max", + value: 0, + inclusive: false, + message: errorUtil.toString(message) + }); + } + nonpositive(message) { + return this._addCheck({ + kind: "max", + value: 0, + inclusive: true, + message: errorUtil.toString(message) + }); + } + nonnegative(message) { + return this._addCheck({ + kind: "min", + value: 0, + inclusive: true, + message: errorUtil.toString(message) + }); + } + multipleOf(value, message) { + return this._addCheck({ + kind: "multipleOf", + value, + message: errorUtil.toString(message) + }); + } + finite(message) { + return this._addCheck({ + kind: "finite", + message: errorUtil.toString(message) + }); + } + safe(message) { + return this._addCheck({ + kind: "min", + inclusive: true, + value: Number.MIN_SAFE_INTEGER, + message: errorUtil.toString(message) + })._addCheck({ + kind: "max", + inclusive: true, + value: Number.MAX_SAFE_INTEGER, + message: errorUtil.toString(message) + }); + } + get minValue() { + let min = null; + for (const ch of this._def.checks) { + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } + } + return min; + } + get maxValue() { + let max = null; + for (const ch of this._def.checks) { + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; + } + } + return max; + } + get isInt() { + return !!this._def.checks.find((ch) => ch.kind === "int" || ch.kind === "multipleOf" && util.isInteger(ch.value)); + } + get isFinite() { + let max = null; + let min = null; + for (const ch of this._def.checks) { + if (ch.kind === "finite" || ch.kind === "int" || ch.kind === "multipleOf") { + return true; + } else if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } else if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; + } + } + return Number.isFinite(min) && Number.isFinite(max); + } +}; +ZodNumber.create = (params) => { + return new ZodNumber({ + checks: [], + typeName: ZodFirstPartyTypeKind.ZodNumber, + coerce: params?.coerce || false, + ...processCreateParams(params) + }); +}; +var ZodBigInt = class _ZodBigInt extends ZodType { + constructor() { + super(...arguments); + this.min = this.gte; + this.max = this.lte; + } + _parse(input) { + if (this._def.coerce) { + try { + input.data = BigInt(input.data); + } catch { + return this._getInvalidInput(input); + } + } + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.bigint) { + return this._getInvalidInput(input); + } + let ctx = void 0; + const status = new ParseStatus(); + for (const check2 of this._def.checks) { + if (check2.kind === "min") { + const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; + if (tooSmall) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + type: "bigint", + minimum: check2.value, + inclusive: check2.inclusive, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "max") { + const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; + if (tooBig) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + type: "bigint", + maximum: check2.value, + inclusive: check2.inclusive, + message: check2.message + }); + status.dirty(); + } + } else if (check2.kind === "multipleOf") { + if (input.data % check2.value !== BigInt(0)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.not_multiple_of, + multipleOf: check2.value, + message: check2.message + }); + status.dirty(); + } + } else { + util.assertNever(check2); + } + } + return { status: status.value, value: input.data }; + } + _getInvalidInput(input) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.bigint, + received: ctx.parsedType + }); + return INVALID; + } + gte(value, message) { + return this.setLimit("min", value, true, errorUtil.toString(message)); + } + gt(value, message) { + return this.setLimit("min", value, false, errorUtil.toString(message)); + } + lte(value, message) { + return this.setLimit("max", value, true, errorUtil.toString(message)); + } + lt(value, message) { + return this.setLimit("max", value, false, errorUtil.toString(message)); + } + setLimit(kind, value, inclusive, message) { + return new _ZodBigInt({ + ...this._def, + checks: [ + ...this._def.checks, + { + kind, + value, + inclusive, + message: errorUtil.toString(message) + } + ] + }); + } + _addCheck(check2) { + return new _ZodBigInt({ + ...this._def, + checks: [...this._def.checks, check2] + }); + } + positive(message) { + return this._addCheck({ + kind: "min", + value: BigInt(0), + inclusive: false, + message: errorUtil.toString(message) + }); + } + negative(message) { + return this._addCheck({ + kind: "max", + value: BigInt(0), + inclusive: false, + message: errorUtil.toString(message) + }); + } + nonpositive(message) { + return this._addCheck({ + kind: "max", + value: BigInt(0), + inclusive: true, + message: errorUtil.toString(message) + }); + } + nonnegative(message) { + return this._addCheck({ + kind: "min", + value: BigInt(0), + inclusive: true, + message: errorUtil.toString(message) + }); + } + multipleOf(value, message) { + return this._addCheck({ + kind: "multipleOf", + value, + message: errorUtil.toString(message) + }); + } + get minValue() { + let min = null; + for (const ch of this._def.checks) { + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } + } + return min; + } + get maxValue() { + let max = null; + for (const ch of this._def.checks) { + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; + } + } + return max; + } +}; +ZodBigInt.create = (params) => { + return new ZodBigInt({ + checks: [], + typeName: ZodFirstPartyTypeKind.ZodBigInt, + coerce: params?.coerce ?? false, + ...processCreateParams(params) + }); +}; +var ZodBoolean = class extends ZodType { + _parse(input) { + if (this._def.coerce) { + input.data = Boolean(input.data); + } + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.boolean) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.boolean, + received: ctx.parsedType + }); + return INVALID; + } + return OK(input.data); + } +}; +ZodBoolean.create = (params) => { + return new ZodBoolean({ + typeName: ZodFirstPartyTypeKind.ZodBoolean, + coerce: params?.coerce || false, + ...processCreateParams(params) + }); +}; +var ZodDate = class _ZodDate extends ZodType { + _parse(input) { + if (this._def.coerce) { + input.data = new Date(input.data); + } + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.date) { + const ctx2 = this._getOrReturnCtx(input); + addIssueToContext(ctx2, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.date, + received: ctx2.parsedType + }); + return INVALID; + } + if (Number.isNaN(input.data.getTime())) { + const ctx2 = this._getOrReturnCtx(input); + addIssueToContext(ctx2, { + code: ZodIssueCode.invalid_date + }); + return INVALID; + } + const status = new ParseStatus(); + let ctx = void 0; + for (const check2 of this._def.checks) { + if (check2.kind === "min") { + if (input.data.getTime() < check2.value) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + message: check2.message, + inclusive: true, + exact: false, + minimum: check2.value, + type: "date" + }); + status.dirty(); + } + } else if (check2.kind === "max") { + if (input.data.getTime() > check2.value) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + message: check2.message, + inclusive: true, + exact: false, + maximum: check2.value, + type: "date" + }); + status.dirty(); + } + } else { + util.assertNever(check2); + } + } + return { + status: status.value, + value: new Date(input.data.getTime()) + }; + } + _addCheck(check2) { + return new _ZodDate({ + ...this._def, + checks: [...this._def.checks, check2] + }); + } + min(minDate, message) { + return this._addCheck({ + kind: "min", + value: minDate.getTime(), + message: errorUtil.toString(message) + }); + } + max(maxDate, message) { + return this._addCheck({ + kind: "max", + value: maxDate.getTime(), + message: errorUtil.toString(message) + }); + } + get minDate() { + let min = null; + for (const ch of this._def.checks) { + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } + } + return min != null ? new Date(min) : null; + } + get maxDate() { + let max = null; + for (const ch of this._def.checks) { + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; + } + } + return max != null ? new Date(max) : null; + } +}; +ZodDate.create = (params) => { + return new ZodDate({ + checks: [], + coerce: params?.coerce || false, + typeName: ZodFirstPartyTypeKind.ZodDate, + ...processCreateParams(params) + }); +}; +var ZodSymbol = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.symbol) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.symbol, + received: ctx.parsedType + }); + return INVALID; + } + return OK(input.data); + } +}; +ZodSymbol.create = (params) => { + return new ZodSymbol({ + typeName: ZodFirstPartyTypeKind.ZodSymbol, + ...processCreateParams(params) + }); +}; +var ZodUndefined = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.undefined) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.undefined, + received: ctx.parsedType + }); + return INVALID; + } + return OK(input.data); + } +}; +ZodUndefined.create = (params) => { + return new ZodUndefined({ + typeName: ZodFirstPartyTypeKind.ZodUndefined, + ...processCreateParams(params) + }); +}; +var ZodNull = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.null) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.null, + received: ctx.parsedType + }); + return INVALID; + } + return OK(input.data); + } +}; +ZodNull.create = (params) => { + return new ZodNull({ + typeName: ZodFirstPartyTypeKind.ZodNull, + ...processCreateParams(params) + }); +}; +var ZodAny = class extends ZodType { + constructor() { + super(...arguments); + this._any = true; + } + _parse(input) { + return OK(input.data); + } +}; +ZodAny.create = (params) => { + return new ZodAny({ + typeName: ZodFirstPartyTypeKind.ZodAny, + ...processCreateParams(params) + }); +}; +var ZodUnknown = class extends ZodType { + constructor() { + super(...arguments); + this._unknown = true; + } + _parse(input) { + return OK(input.data); + } +}; +ZodUnknown.create = (params) => { + return new ZodUnknown({ + typeName: ZodFirstPartyTypeKind.ZodUnknown, + ...processCreateParams(params) + }); +}; +var ZodNever = class extends ZodType { + _parse(input) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.never, + received: ctx.parsedType + }); + return INVALID; + } +}; +ZodNever.create = (params) => { + return new ZodNever({ + typeName: ZodFirstPartyTypeKind.ZodNever, + ...processCreateParams(params) + }); +}; +var ZodVoid = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.undefined) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.void, + received: ctx.parsedType + }); + return INVALID; + } + return OK(input.data); + } +}; +ZodVoid.create = (params) => { + return new ZodVoid({ + typeName: ZodFirstPartyTypeKind.ZodVoid, + ...processCreateParams(params) + }); +}; +var ZodArray = class _ZodArray extends ZodType { + _parse(input) { + const { ctx, status } = this._processInputParams(input); + const def = this._def; + if (ctx.parsedType !== ZodParsedType.array) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.array, + received: ctx.parsedType + }); + return INVALID; + } + if (def.exactLength !== null) { + const tooBig = ctx.data.length > def.exactLength.value; + const tooSmall = ctx.data.length < def.exactLength.value; + if (tooBig || tooSmall) { + addIssueToContext(ctx, { + code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small, + minimum: tooSmall ? def.exactLength.value : void 0, + maximum: tooBig ? def.exactLength.value : void 0, + type: "array", + inclusive: true, + exact: true, + message: def.exactLength.message + }); + status.dirty(); + } + } + if (def.minLength !== null) { + if (ctx.data.length < def.minLength.value) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: def.minLength.value, + type: "array", + inclusive: true, + exact: false, + message: def.minLength.message + }); + status.dirty(); + } + } + if (def.maxLength !== null) { + if (ctx.data.length > def.maxLength.value) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: def.maxLength.value, + type: "array", + inclusive: true, + exact: false, + message: def.maxLength.message + }); + status.dirty(); + } + } + if (ctx.common.async) { + return Promise.all([...ctx.data].map((item, i) => { + return def.type._parseAsync(new ParseInputLazyPath(ctx, item, ctx.path, i)); + })).then((result2) => { + return ParseStatus.mergeArray(status, result2); + }); + } + const result = [...ctx.data].map((item, i) => { + return def.type._parseSync(new ParseInputLazyPath(ctx, item, ctx.path, i)); + }); + return ParseStatus.mergeArray(status, result); + } + get element() { + return this._def.type; + } + min(minLength, message) { + return new _ZodArray({ + ...this._def, + minLength: { value: minLength, message: errorUtil.toString(message) } + }); + } + max(maxLength, message) { + return new _ZodArray({ + ...this._def, + maxLength: { value: maxLength, message: errorUtil.toString(message) } + }); + } + length(len, message) { + return new _ZodArray({ + ...this._def, + exactLength: { value: len, message: errorUtil.toString(message) } + }); + } + nonempty(message) { + return this.min(1, message); + } +}; +ZodArray.create = (schema, params) => { + return new ZodArray({ + type: schema, + minLength: null, + maxLength: null, + exactLength: null, + typeName: ZodFirstPartyTypeKind.ZodArray, + ...processCreateParams(params) + }); +}; +function deepPartialify(schema) { + if (schema instanceof ZodObject) { + const newShape = {}; + for (const key in schema.shape) { + const fieldSchema = schema.shape[key]; + newShape[key] = ZodOptional.create(deepPartialify(fieldSchema)); + } + return new ZodObject({ + ...schema._def, + shape: () => newShape + }); + } else if (schema instanceof ZodArray) { + return new ZodArray({ + ...schema._def, + type: deepPartialify(schema.element) + }); + } else if (schema instanceof ZodOptional) { + return ZodOptional.create(deepPartialify(schema.unwrap())); + } else if (schema instanceof ZodNullable) { + return ZodNullable.create(deepPartialify(schema.unwrap())); + } else if (schema instanceof ZodTuple) { + return ZodTuple.create(schema.items.map((item) => deepPartialify(item))); + } else { + return schema; + } +} +var ZodObject = class _ZodObject extends ZodType { + constructor() { + super(...arguments); + this._cached = null; + this.nonstrict = this.passthrough; + this.augment = this.extend; + } + _getCached() { + if (this._cached !== null) + return this._cached; + const shape = this._def.shape(); + const keys = util.objectKeys(shape); + this._cached = { shape, keys }; + return this._cached; + } + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.object) { + const ctx2 = this._getOrReturnCtx(input); + addIssueToContext(ctx2, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.object, + received: ctx2.parsedType + }); + return INVALID; + } + const { status, ctx } = this._processInputParams(input); + const { shape, keys: shapeKeys } = this._getCached(); + const extraKeys = []; + if (!(this._def.catchall instanceof ZodNever && this._def.unknownKeys === "strip")) { + for (const key in ctx.data) { + if (!shapeKeys.includes(key)) { + extraKeys.push(key); + } + } + } + const pairs = []; + for (const key of shapeKeys) { + const keyValidator = shape[key]; + const value = ctx.data[key]; + pairs.push({ + key: { status: "valid", value: key }, + value: keyValidator._parse(new ParseInputLazyPath(ctx, value, ctx.path, key)), + alwaysSet: key in ctx.data + }); + } + if (this._def.catchall instanceof ZodNever) { + const unknownKeys = this._def.unknownKeys; + if (unknownKeys === "passthrough") { + for (const key of extraKeys) { + pairs.push({ + key: { status: "valid", value: key }, + value: { status: "valid", value: ctx.data[key] } + }); + } + } else if (unknownKeys === "strict") { + if (extraKeys.length > 0) { + addIssueToContext(ctx, { + code: ZodIssueCode.unrecognized_keys, + keys: extraKeys + }); + status.dirty(); + } + } else if (unknownKeys === "strip") { + } else { + throw new Error(`Internal ZodObject error: invalid unknownKeys value.`); + } + } else { + const catchall = this._def.catchall; + for (const key of extraKeys) { + const value = ctx.data[key]; + pairs.push({ + key: { status: "valid", value: key }, + value: catchall._parse( + new ParseInputLazyPath(ctx, value, ctx.path, key) + //, ctx.child(key), value, getParsedType(value) + ), + alwaysSet: key in ctx.data + }); + } + } + if (ctx.common.async) { + return Promise.resolve().then(async () => { + const syncPairs = []; + for (const pair of pairs) { + const key = await pair.key; + const value = await pair.value; + syncPairs.push({ + key, + value, + alwaysSet: pair.alwaysSet + }); + } + return syncPairs; + }).then((syncPairs) => { + return ParseStatus.mergeObjectSync(status, syncPairs); + }); + } else { + return ParseStatus.mergeObjectSync(status, pairs); + } + } + get shape() { + return this._def.shape(); + } + strict(message) { + errorUtil.errToObj; + return new _ZodObject({ + ...this._def, + unknownKeys: "strict", + ...message !== void 0 ? { + errorMap: (issue2, ctx) => { + const defaultError = this._def.errorMap?.(issue2, ctx).message ?? ctx.defaultError; + if (issue2.code === "unrecognized_keys") + return { + message: errorUtil.errToObj(message).message ?? defaultError + }; + return { + message: defaultError + }; + } + } : {} + }); + } + strip() { + return new _ZodObject({ + ...this._def, + unknownKeys: "strip" + }); + } + passthrough() { + return new _ZodObject({ + ...this._def, + unknownKeys: "passthrough" + }); + } + // const AugmentFactory = + // (def: Def) => + // ( + // augmentation: Augmentation + // ): ZodObject< + // extendShape, Augmentation>, + // Def["unknownKeys"], + // Def["catchall"] + // > => { + // return new ZodObject({ + // ...def, + // shape: () => ({ + // ...def.shape(), + // ...augmentation, + // }), + // }) as any; + // }; + extend(augmentation) { + return new _ZodObject({ + ...this._def, + shape: () => ({ + ...this._def.shape(), + ...augmentation + }) + }); + } + /** + * Prior to zod@1.0.12 there was a bug in the + * inferred type of merged objects. Please + * upgrade if you are experiencing issues. + */ + merge(merging) { + const merged = new _ZodObject({ + unknownKeys: merging._def.unknownKeys, + catchall: merging._def.catchall, + shape: () => ({ + ...this._def.shape(), + ...merging._def.shape() + }), + typeName: ZodFirstPartyTypeKind.ZodObject + }); + return merged; + } + // merge< + // Incoming extends AnyZodObject, + // Augmentation extends Incoming["shape"], + // NewOutput extends { + // [k in keyof Augmentation | keyof Output]: k extends keyof Augmentation + // ? Augmentation[k]["_output"] + // : k extends keyof Output + // ? Output[k] + // : never; + // }, + // NewInput extends { + // [k in keyof Augmentation | keyof Input]: k extends keyof Augmentation + // ? Augmentation[k]["_input"] + // : k extends keyof Input + // ? Input[k] + // : never; + // } + // >( + // merging: Incoming + // ): ZodObject< + // extendShape>, + // Incoming["_def"]["unknownKeys"], + // Incoming["_def"]["catchall"], + // NewOutput, + // NewInput + // > { + // const merged: any = new ZodObject({ + // unknownKeys: merging._def.unknownKeys, + // catchall: merging._def.catchall, + // shape: () => + // objectUtil.mergeShapes(this._def.shape(), merging._def.shape()), + // typeName: ZodFirstPartyTypeKind.ZodObject, + // }) as any; + // return merged; + // } + setKey(key, schema) { + return this.augment({ [key]: schema }); + } + // merge( + // merging: Incoming + // ): //ZodObject = (merging) => { + // ZodObject< + // extendShape>, + // Incoming["_def"]["unknownKeys"], + // Incoming["_def"]["catchall"] + // > { + // // const mergedShape = objectUtil.mergeShapes( + // // this._def.shape(), + // // merging._def.shape() + // // ); + // const merged: any = new ZodObject({ + // unknownKeys: merging._def.unknownKeys, + // catchall: merging._def.catchall, + // shape: () => + // objectUtil.mergeShapes(this._def.shape(), merging._def.shape()), + // typeName: ZodFirstPartyTypeKind.ZodObject, + // }) as any; + // return merged; + // } + catchall(index) { + return new _ZodObject({ + ...this._def, + catchall: index + }); + } + pick(mask) { + const shape = {}; + for (const key of util.objectKeys(mask)) { + if (mask[key] && this.shape[key]) { + shape[key] = this.shape[key]; + } + } + return new _ZodObject({ + ...this._def, + shape: () => shape + }); + } + omit(mask) { + const shape = {}; + for (const key of util.objectKeys(this.shape)) { + if (!mask[key]) { + shape[key] = this.shape[key]; + } + } + return new _ZodObject({ + ...this._def, + shape: () => shape + }); + } + /** + * @deprecated + */ + deepPartial() { + return deepPartialify(this); + } + partial(mask) { + const newShape = {}; + for (const key of util.objectKeys(this.shape)) { + const fieldSchema = this.shape[key]; + if (mask && !mask[key]) { + newShape[key] = fieldSchema; + } else { + newShape[key] = fieldSchema.optional(); + } + } + return new _ZodObject({ + ...this._def, + shape: () => newShape + }); + } + required(mask) { + const newShape = {}; + for (const key of util.objectKeys(this.shape)) { + if (mask && !mask[key]) { + newShape[key] = this.shape[key]; + } else { + const fieldSchema = this.shape[key]; + let newField = fieldSchema; + while (newField instanceof ZodOptional) { + newField = newField._def.innerType; + } + newShape[key] = newField; + } + } + return new _ZodObject({ + ...this._def, + shape: () => newShape + }); + } + keyof() { + return createZodEnum(util.objectKeys(this.shape)); + } +}; +ZodObject.create = (shape, params) => { + return new ZodObject({ + shape: () => shape, + unknownKeys: "strip", + catchall: ZodNever.create(), + typeName: ZodFirstPartyTypeKind.ZodObject, + ...processCreateParams(params) + }); +}; +ZodObject.strictCreate = (shape, params) => { + return new ZodObject({ + shape: () => shape, + unknownKeys: "strict", + catchall: ZodNever.create(), + typeName: ZodFirstPartyTypeKind.ZodObject, + ...processCreateParams(params) + }); +}; +ZodObject.lazycreate = (shape, params) => { + return new ZodObject({ + shape, + unknownKeys: "strip", + catchall: ZodNever.create(), + typeName: ZodFirstPartyTypeKind.ZodObject, + ...processCreateParams(params) + }); +}; +var ZodUnion = class extends ZodType { + _parse(input) { + const { ctx } = this._processInputParams(input); + const options2 = this._def.options; + function handleResults(results) { + for (const result of results) { + if (result.result.status === "valid") { + return result.result; + } + } + for (const result of results) { + if (result.result.status === "dirty") { + ctx.common.issues.push(...result.ctx.common.issues); + return result.result; + } + } + const unionErrors = results.map((result) => new ZodError(result.ctx.common.issues)); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_union, + unionErrors + }); + return INVALID; + } + if (ctx.common.async) { + return Promise.all(options2.map(async (option) => { + const childCtx = { + ...ctx, + common: { + ...ctx.common, + issues: [] + }, + parent: null + }; + return { + result: await option._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: childCtx + }), + ctx: childCtx + }; + })).then(handleResults); + } else { + let dirty = void 0; + const issues = []; + for (const option of options2) { + const childCtx = { + ...ctx, + common: { + ...ctx.common, + issues: [] + }, + parent: null + }; + const result = option._parseSync({ + data: ctx.data, + path: ctx.path, + parent: childCtx + }); + if (result.status === "valid") { + return result; + } else if (result.status === "dirty" && !dirty) { + dirty = { result, ctx: childCtx }; + } + if (childCtx.common.issues.length) { + issues.push(childCtx.common.issues); + } + } + if (dirty) { + ctx.common.issues.push(...dirty.ctx.common.issues); + return dirty.result; + } + const unionErrors = issues.map((issues2) => new ZodError(issues2)); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_union, + unionErrors + }); + return INVALID; + } + } + get options() { + return this._def.options; + } +}; +ZodUnion.create = (types, params) => { + return new ZodUnion({ + options: types, + typeName: ZodFirstPartyTypeKind.ZodUnion, + ...processCreateParams(params) + }); +}; +var getDiscriminator = (type) => { + if (type instanceof ZodLazy) { + return getDiscriminator(type.schema); + } else if (type instanceof ZodEffects) { + return getDiscriminator(type.innerType()); + } else if (type instanceof ZodLiteral) { + return [type.value]; + } else if (type instanceof ZodEnum) { + return type.options; + } else if (type instanceof ZodNativeEnum) { + return util.objectValues(type.enum); + } else if (type instanceof ZodDefault) { + return getDiscriminator(type._def.innerType); + } else if (type instanceof ZodUndefined) { + return [void 0]; + } else if (type instanceof ZodNull) { + return [null]; + } else if (type instanceof ZodOptional) { + return [void 0, ...getDiscriminator(type.unwrap())]; + } else if (type instanceof ZodNullable) { + return [null, ...getDiscriminator(type.unwrap())]; + } else if (type instanceof ZodBranded) { + return getDiscriminator(type.unwrap()); + } else if (type instanceof ZodReadonly) { + return getDiscriminator(type.unwrap()); + } else if (type instanceof ZodCatch) { + return getDiscriminator(type._def.innerType); + } else { + return []; + } +}; +var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { + _parse(input) { + const { ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.object) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.object, + received: ctx.parsedType + }); + return INVALID; + } + const discriminator = this.discriminator; + const discriminatorValue = ctx.data[discriminator]; + const option = this.optionsMap.get(discriminatorValue); + if (!option) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_union_discriminator, + options: Array.from(this.optionsMap.keys()), + path: [discriminator] + }); + return INVALID; + } + if (ctx.common.async) { + return option._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + } else { + return option._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + } + } + get discriminator() { + return this._def.discriminator; + } + get options() { + return this._def.options; + } + get optionsMap() { + return this._def.optionsMap; + } + /** + * The constructor of the discriminated union schema. Its behaviour is very similar to that of the normal z.union() constructor. + * However, it only allows a union of objects, all of which need to share a discriminator property. This property must + * have a different value for each object in the union. + * @param discriminator the name of the discriminator property + * @param types an array of object schemas + * @param params + */ + static create(discriminator, options2, params) { + const optionsMap = /* @__PURE__ */ new Map(); + for (const type of options2) { + const discriminatorValues = getDiscriminator(type.shape[discriminator]); + if (!discriminatorValues.length) { + throw new Error(`A discriminator value for key \`${discriminator}\` could not be extracted from all schema options`); + } + for (const value of discriminatorValues) { + if (optionsMap.has(value)) { + throw new Error(`Discriminator property ${String(discriminator)} has duplicate value ${String(value)}`); + } + optionsMap.set(value, type); + } + } + return new _ZodDiscriminatedUnion({ + typeName: ZodFirstPartyTypeKind.ZodDiscriminatedUnion, + discriminator, + options: options2, + optionsMap, + ...processCreateParams(params) + }); + } +}; +function mergeValues(a, b) { + const aType = getParsedType(a); + const bType = getParsedType(b); + if (a === b) { + return { valid: true, data: a }; + } else if (aType === ZodParsedType.object && bType === ZodParsedType.object) { + const bKeys = util.objectKeys(b); + const sharedKeys = util.objectKeys(a).filter((key) => bKeys.indexOf(key) !== -1); + const newObj = { ...a, ...b }; + for (const key of sharedKeys) { + const sharedValue = mergeValues(a[key], b[key]); + if (!sharedValue.valid) { + return { valid: false }; + } + newObj[key] = sharedValue.data; + } + return { valid: true, data: newObj }; + } else if (aType === ZodParsedType.array && bType === ZodParsedType.array) { + if (a.length !== b.length) { + return { valid: false }; + } + const newArray = []; + for (let index = 0; index < a.length; index++) { + const itemA = a[index]; + const itemB = b[index]; + const sharedValue = mergeValues(itemA, itemB); + if (!sharedValue.valid) { + return { valid: false }; + } + newArray.push(sharedValue.data); + } + return { valid: true, data: newArray }; + } else if (aType === ZodParsedType.date && bType === ZodParsedType.date && +a === +b) { + return { valid: true, data: a }; + } else { + return { valid: false }; + } +} +var ZodIntersection = class extends ZodType { + _parse(input) { + const { status, ctx } = this._processInputParams(input); + const handleParsed = (parsedLeft, parsedRight) => { + if (isAborted(parsedLeft) || isAborted(parsedRight)) { + return INVALID; + } + const merged = mergeValues(parsedLeft.value, parsedRight.value); + if (!merged.valid) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_intersection_types + }); + return INVALID; + } + if (isDirty(parsedLeft) || isDirty(parsedRight)) { + status.dirty(); + } + return { status: status.value, value: merged.data }; + }; + if (ctx.common.async) { + return Promise.all([ + this._def.left._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }), + this._def.right._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }) + ]).then(([left, right]) => handleParsed(left, right)); + } else { + return handleParsed(this._def.left._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }), this._def.right._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + })); + } + } +}; +ZodIntersection.create = (left, right, params) => { + return new ZodIntersection({ + left, + right, + typeName: ZodFirstPartyTypeKind.ZodIntersection, + ...processCreateParams(params) + }); +}; +var ZodTuple = class _ZodTuple extends ZodType { + _parse(input) { + const { status, ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.array) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.array, + received: ctx.parsedType + }); + return INVALID; + } + if (ctx.data.length < this._def.items.length) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: this._def.items.length, + inclusive: true, + exact: false, + type: "array" + }); + return INVALID; + } + const rest = this._def.rest; + if (!rest && ctx.data.length > this._def.items.length) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: this._def.items.length, + inclusive: true, + exact: false, + type: "array" + }); + status.dirty(); + } + const items = [...ctx.data].map((item, itemIndex) => { + const schema = this._def.items[itemIndex] || this._def.rest; + if (!schema) + return null; + return schema._parse(new ParseInputLazyPath(ctx, item, ctx.path, itemIndex)); + }).filter((x) => !!x); + if (ctx.common.async) { + return Promise.all(items).then((results) => { + return ParseStatus.mergeArray(status, results); + }); + } else { + return ParseStatus.mergeArray(status, items); + } + } + get items() { + return this._def.items; + } + rest(rest) { + return new _ZodTuple({ + ...this._def, + rest + }); + } +}; +ZodTuple.create = (schemas, params) => { + if (!Array.isArray(schemas)) { + throw new Error("You must pass an array of schemas to z.tuple([ ... ])"); + } + return new ZodTuple({ + items: schemas, + typeName: ZodFirstPartyTypeKind.ZodTuple, + rest: null, + ...processCreateParams(params) + }); +}; +var ZodRecord = class _ZodRecord extends ZodType { + get keySchema() { + return this._def.keyType; + } + get valueSchema() { + return this._def.valueType; + } + _parse(input) { + const { status, ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.object) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.object, + received: ctx.parsedType + }); + return INVALID; + } + const pairs = []; + const keyType = this._def.keyType; + const valueType = this._def.valueType; + for (const key in ctx.data) { + pairs.push({ + key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, key)), + value: valueType._parse(new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key)), + alwaysSet: key in ctx.data + }); + } + if (ctx.common.async) { + return ParseStatus.mergeObjectAsync(status, pairs); + } else { + return ParseStatus.mergeObjectSync(status, pairs); + } + } + get element() { + return this._def.valueType; + } + static create(first, second, third) { + if (second instanceof ZodType) { + return new _ZodRecord({ + keyType: first, + valueType: second, + typeName: ZodFirstPartyTypeKind.ZodRecord, + ...processCreateParams(third) + }); + } + return new _ZodRecord({ + keyType: ZodString.create(), + valueType: first, + typeName: ZodFirstPartyTypeKind.ZodRecord, + ...processCreateParams(second) + }); + } +}; +var ZodMap = class extends ZodType { + get keySchema() { + return this._def.keyType; + } + get valueSchema() { + return this._def.valueType; + } + _parse(input) { + const { status, ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.map) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.map, + received: ctx.parsedType + }); + return INVALID; + } + const keyType = this._def.keyType; + const valueType = this._def.valueType; + const pairs = [...ctx.data.entries()].map(([key, value], index) => { + return { + key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, [index, "key"])), + value: valueType._parse(new ParseInputLazyPath(ctx, value, ctx.path, [index, "value"])) + }; + }); + if (ctx.common.async) { + const finalMap = /* @__PURE__ */ new Map(); + return Promise.resolve().then(async () => { + for (const pair of pairs) { + const key = await pair.key; + const value = await pair.value; + if (key.status === "aborted" || value.status === "aborted") { + return INVALID; + } + if (key.status === "dirty" || value.status === "dirty") { + status.dirty(); + } + finalMap.set(key.value, value.value); + } + return { status: status.value, value: finalMap }; + }); + } else { + const finalMap = /* @__PURE__ */ new Map(); + for (const pair of pairs) { + const key = pair.key; + const value = pair.value; + if (key.status === "aborted" || value.status === "aborted") { + return INVALID; + } + if (key.status === "dirty" || value.status === "dirty") { + status.dirty(); + } + finalMap.set(key.value, value.value); + } + return { status: status.value, value: finalMap }; + } + } +}; +ZodMap.create = (keyType, valueType, params) => { + return new ZodMap({ + valueType, + keyType, + typeName: ZodFirstPartyTypeKind.ZodMap, + ...processCreateParams(params) + }); +}; +var ZodSet = class _ZodSet extends ZodType { + _parse(input) { + const { status, ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.set) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.set, + received: ctx.parsedType + }); + return INVALID; + } + const def = this._def; + if (def.minSize !== null) { + if (ctx.data.size < def.minSize.value) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: def.minSize.value, + type: "set", + inclusive: true, + exact: false, + message: def.minSize.message + }); + status.dirty(); + } + } + if (def.maxSize !== null) { + if (ctx.data.size > def.maxSize.value) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: def.maxSize.value, + type: "set", + inclusive: true, + exact: false, + message: def.maxSize.message + }); + status.dirty(); + } + } + const valueType = this._def.valueType; + function finalizeSet(elements2) { + const parsedSet = /* @__PURE__ */ new Set(); + for (const element of elements2) { + if (element.status === "aborted") + return INVALID; + if (element.status === "dirty") + status.dirty(); + parsedSet.add(element.value); + } + return { status: status.value, value: parsedSet }; + } + const elements = [...ctx.data.values()].map((item, i) => valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i))); + if (ctx.common.async) { + return Promise.all(elements).then((elements2) => finalizeSet(elements2)); + } else { + return finalizeSet(elements); + } + } + min(minSize, message) { + return new _ZodSet({ + ...this._def, + minSize: { value: minSize, message: errorUtil.toString(message) } + }); + } + max(maxSize, message) { + return new _ZodSet({ + ...this._def, + maxSize: { value: maxSize, message: errorUtil.toString(message) } + }); + } + size(size, message) { + return this.min(size, message).max(size, message); + } + nonempty(message) { + return this.min(1, message); + } +}; +ZodSet.create = (valueType, params) => { + return new ZodSet({ + valueType, + minSize: null, + maxSize: null, + typeName: ZodFirstPartyTypeKind.ZodSet, + ...processCreateParams(params) + }); +}; +var ZodFunction = class _ZodFunction extends ZodType { + constructor() { + super(...arguments); + this.validate = this.implement; + } + _parse(input) { + const { ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.function) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.function, + received: ctx.parsedType + }); + return INVALID; + } + function makeArgsIssue(args, error2) { + return makeIssue({ + data: args, + path: ctx.path, + errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x) => !!x), + issueData: { + code: ZodIssueCode.invalid_arguments, + argumentsError: error2 + } + }); + } + function makeReturnsIssue(returns, error2) { + return makeIssue({ + data: returns, + path: ctx.path, + errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x) => !!x), + issueData: { + code: ZodIssueCode.invalid_return_type, + returnTypeError: error2 + } + }); + } + const params = { errorMap: ctx.common.contextualErrorMap }; + const fn = ctx.data; + if (this._def.returns instanceof ZodPromise) { + const me = this; + return OK(async function(...args) { + const error2 = new ZodError([]); + const parsedArgs = await me._def.args.parseAsync(args, params).catch((e) => { + error2.addIssue(makeArgsIssue(args, e)); + throw error2; + }); + const result = await Reflect.apply(fn, this, parsedArgs); + const parsedReturns = await me._def.returns._def.type.parseAsync(result, params).catch((e) => { + error2.addIssue(makeReturnsIssue(result, e)); + throw error2; + }); + return parsedReturns; + }); + } else { + const me = this; + return OK(function(...args) { + const parsedArgs = me._def.args.safeParse(args, params); + if (!parsedArgs.success) { + throw new ZodError([makeArgsIssue(args, parsedArgs.error)]); + } + const result = Reflect.apply(fn, this, parsedArgs.data); + const parsedReturns = me._def.returns.safeParse(result, params); + if (!parsedReturns.success) { + throw new ZodError([makeReturnsIssue(result, parsedReturns.error)]); + } + return parsedReturns.data; + }); + } + } + parameters() { + return this._def.args; + } + returnType() { + return this._def.returns; + } + args(...items) { + return new _ZodFunction({ + ...this._def, + args: ZodTuple.create(items).rest(ZodUnknown.create()) + }); + } + returns(returnType) { + return new _ZodFunction({ + ...this._def, + returns: returnType + }); + } + implement(func) { + const validatedFunc = this.parse(func); + return validatedFunc; + } + strictImplement(func) { + const validatedFunc = this.parse(func); + return validatedFunc; + } + static create(args, returns, params) { + return new _ZodFunction({ + args: args ? args : ZodTuple.create([]).rest(ZodUnknown.create()), + returns: returns || ZodUnknown.create(), + typeName: ZodFirstPartyTypeKind.ZodFunction, + ...processCreateParams(params) + }); + } +}; +var ZodLazy = class extends ZodType { + get schema() { + return this._def.getter(); + } + _parse(input) { + const { ctx } = this._processInputParams(input); + const lazySchema = this._def.getter(); + return lazySchema._parse({ data: ctx.data, path: ctx.path, parent: ctx }); + } +}; +ZodLazy.create = (getter, params) => { + return new ZodLazy({ + getter, + typeName: ZodFirstPartyTypeKind.ZodLazy, + ...processCreateParams(params) + }); +}; +var ZodLiteral = class extends ZodType { + _parse(input) { + if (input.data !== this._def.value) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + received: ctx.data, + code: ZodIssueCode.invalid_literal, + expected: this._def.value + }); + return INVALID; + } + return { status: "valid", value: input.data }; + } + get value() { + return this._def.value; + } +}; +ZodLiteral.create = (value, params) => { + return new ZodLiteral({ + value, + typeName: ZodFirstPartyTypeKind.ZodLiteral, + ...processCreateParams(params) + }); +}; +function createZodEnum(values, params) { + return new ZodEnum({ + values, + typeName: ZodFirstPartyTypeKind.ZodEnum, + ...processCreateParams(params) + }); +} +var ZodEnum = class _ZodEnum extends ZodType { + _parse(input) { + if (typeof input.data !== "string") { + const ctx = this._getOrReturnCtx(input); + const expectedValues = this._def.values; + addIssueToContext(ctx, { + expected: util.joinValues(expectedValues), + received: ctx.parsedType, + code: ZodIssueCode.invalid_type + }); + return INVALID; + } + if (!this._cache) { + this._cache = new Set(this._def.values); + } + if (!this._cache.has(input.data)) { + const ctx = this._getOrReturnCtx(input); + const expectedValues = this._def.values; + addIssueToContext(ctx, { + received: ctx.data, + code: ZodIssueCode.invalid_enum_value, + options: expectedValues + }); + return INVALID; + } + return OK(input.data); + } + get options() { + return this._def.values; + } + get enum() { + const enumValues = {}; + for (const val of this._def.values) { + enumValues[val] = val; + } + return enumValues; + } + get Values() { + const enumValues = {}; + for (const val of this._def.values) { + enumValues[val] = val; + } + return enumValues; + } + get Enum() { + const enumValues = {}; + for (const val of this._def.values) { + enumValues[val] = val; + } + return enumValues; + } + extract(values, newDef = this._def) { + return _ZodEnum.create(values, { + ...this._def, + ...newDef + }); + } + exclude(values, newDef = this._def) { + return _ZodEnum.create(this.options.filter((opt) => !values.includes(opt)), { + ...this._def, + ...newDef + }); + } +}; +ZodEnum.create = createZodEnum; +var ZodNativeEnum = class extends ZodType { + _parse(input) { + const nativeEnumValues = util.getValidEnumValues(this._def.values); + const ctx = this._getOrReturnCtx(input); + if (ctx.parsedType !== ZodParsedType.string && ctx.parsedType !== ZodParsedType.number) { + const expectedValues = util.objectValues(nativeEnumValues); + addIssueToContext(ctx, { + expected: util.joinValues(expectedValues), + received: ctx.parsedType, + code: ZodIssueCode.invalid_type + }); + return INVALID; + } + if (!this._cache) { + this._cache = new Set(util.getValidEnumValues(this._def.values)); + } + if (!this._cache.has(input.data)) { + const expectedValues = util.objectValues(nativeEnumValues); + addIssueToContext(ctx, { + received: ctx.data, + code: ZodIssueCode.invalid_enum_value, + options: expectedValues + }); + return INVALID; + } + return OK(input.data); + } + get enum() { + return this._def.values; + } +}; +ZodNativeEnum.create = (values, params) => { + return new ZodNativeEnum({ + values, + typeName: ZodFirstPartyTypeKind.ZodNativeEnum, + ...processCreateParams(params) + }); +}; +var ZodPromise = class extends ZodType { + unwrap() { + return this._def.type; + } + _parse(input) { + const { ctx } = this._processInputParams(input); + if (ctx.parsedType !== ZodParsedType.promise && ctx.common.async === false) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.promise, + received: ctx.parsedType + }); + return INVALID; + } + const promisified = ctx.parsedType === ZodParsedType.promise ? ctx.data : Promise.resolve(ctx.data); + return OK(promisified.then((data) => { + return this._def.type.parseAsync(data, { + path: ctx.path, + errorMap: ctx.common.contextualErrorMap + }); + })); + } +}; +ZodPromise.create = (schema, params) => { + return new ZodPromise({ + type: schema, + typeName: ZodFirstPartyTypeKind.ZodPromise, + ...processCreateParams(params) + }); +}; +var ZodEffects = class extends ZodType { + innerType() { + return this._def.schema; + } + sourceType() { + return this._def.schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects ? this._def.schema.sourceType() : this._def.schema; + } + _parse(input) { + const { status, ctx } = this._processInputParams(input); + const effect = this._def.effect || null; + const checkCtx = { + addIssue: (arg) => { + addIssueToContext(ctx, arg); + if (arg.fatal) { + status.abort(); + } else { + status.dirty(); + } + }, + get path() { + return ctx.path; + } + }; + checkCtx.addIssue = checkCtx.addIssue.bind(checkCtx); + if (effect.type === "preprocess") { + const processed = effect.transform(ctx.data, checkCtx); + if (ctx.common.async) { + return Promise.resolve(processed).then(async (processed2) => { + if (status.value === "aborted") + return INVALID; + const result = await this._def.schema._parseAsync({ + data: processed2, + path: ctx.path, + parent: ctx + }); + if (result.status === "aborted") + return INVALID; + if (result.status === "dirty") + return DIRTY(result.value); + if (status.value === "dirty") + return DIRTY(result.value); + return result; + }); + } else { + if (status.value === "aborted") + return INVALID; + const result = this._def.schema._parseSync({ + data: processed, + path: ctx.path, + parent: ctx + }); + if (result.status === "aborted") + return INVALID; + if (result.status === "dirty") + return DIRTY(result.value); + if (status.value === "dirty") + return DIRTY(result.value); + return result; + } + } + if (effect.type === "refinement") { + const executeRefinement = (acc) => { + const result = effect.refinement(acc, checkCtx); + if (ctx.common.async) { + return Promise.resolve(result); + } + if (result instanceof Promise) { + throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead."); + } + return acc; + }; + if (ctx.common.async === false) { + const inner = this._def.schema._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + if (inner.status === "aborted") + return INVALID; + if (inner.status === "dirty") + status.dirty(); + executeRefinement(inner.value); + return { status: status.value, value: inner.value }; + } else { + return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((inner) => { + if (inner.status === "aborted") + return INVALID; + if (inner.status === "dirty") + status.dirty(); + return executeRefinement(inner.value).then(() => { + return { status: status.value, value: inner.value }; + }); + }); + } + } + if (effect.type === "transform") { + if (ctx.common.async === false) { + const base = this._def.schema._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + if (!isValid(base)) + return INVALID; + const result = effect.transform(base.value, checkCtx); + if (result instanceof Promise) { + throw new Error(`Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.`); + } + return { status: status.value, value: result }; + } else { + return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((base) => { + if (!isValid(base)) + return INVALID; + return Promise.resolve(effect.transform(base.value, checkCtx)).then((result) => ({ + status: status.value, + value: result + })); + }); + } + } + util.assertNever(effect); + } +}; +ZodEffects.create = (schema, effect, params) => { + return new ZodEffects({ + schema, + typeName: ZodFirstPartyTypeKind.ZodEffects, + effect, + ...processCreateParams(params) + }); +}; +ZodEffects.createWithPreprocess = (preprocess2, schema, params) => { + return new ZodEffects({ + schema, + effect: { type: "preprocess", transform: preprocess2 }, + typeName: ZodFirstPartyTypeKind.ZodEffects, + ...processCreateParams(params) + }); +}; +var ZodOptional = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 === ZodParsedType.undefined) { + return OK(void 0); + } + return this._def.innerType._parse(input); + } + unwrap() { + return this._def.innerType; + } +}; +ZodOptional.create = (type, params) => { + return new ZodOptional({ + innerType: type, + typeName: ZodFirstPartyTypeKind.ZodOptional, + ...processCreateParams(params) + }); +}; +var ZodNullable = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 === ZodParsedType.null) { + return OK(null); + } + return this._def.innerType._parse(input); + } + unwrap() { + return this._def.innerType; + } +}; +ZodNullable.create = (type, params) => { + return new ZodNullable({ + innerType: type, + typeName: ZodFirstPartyTypeKind.ZodNullable, + ...processCreateParams(params) + }); +}; +var ZodDefault = class extends ZodType { + _parse(input) { + const { ctx } = this._processInputParams(input); + let data = ctx.data; + if (ctx.parsedType === ZodParsedType.undefined) { + data = this._def.defaultValue(); + } + return this._def.innerType._parse({ + data, + path: ctx.path, + parent: ctx + }); + } + removeDefault() { + return this._def.innerType; + } +}; +ZodDefault.create = (type, params) => { + return new ZodDefault({ + innerType: type, + typeName: ZodFirstPartyTypeKind.ZodDefault, + defaultValue: typeof params.default === "function" ? params.default : () => params.default, + ...processCreateParams(params) + }); +}; +var ZodCatch = class extends ZodType { + _parse(input) { + const { ctx } = this._processInputParams(input); + const newCtx = { + ...ctx, + common: { + ...ctx.common, + issues: [] + } + }; + const result = this._def.innerType._parse({ + data: newCtx.data, + path: newCtx.path, + parent: { + ...newCtx + } + }); + if (isAsync(result)) { + return result.then((result2) => { + return { + status: "valid", + value: result2.status === "valid" ? result2.value : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data + }) + }; + }); + } else { + return { + status: "valid", + value: result.status === "valid" ? result.value : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data + }) + }; + } + } + removeCatch() { + return this._def.innerType; + } +}; +ZodCatch.create = (type, params) => { + return new ZodCatch({ + innerType: type, + typeName: ZodFirstPartyTypeKind.ZodCatch, + catchValue: typeof params.catch === "function" ? params.catch : () => params.catch, + ...processCreateParams(params) + }); +}; +var ZodNaN = class extends ZodType { + _parse(input) { + const parsedType2 = this._getType(input); + if (parsedType2 !== ZodParsedType.nan) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.nan, + received: ctx.parsedType + }); + return INVALID; + } + return { status: "valid", value: input.data }; + } +}; +ZodNaN.create = (params) => { + return new ZodNaN({ + typeName: ZodFirstPartyTypeKind.ZodNaN, + ...processCreateParams(params) + }); +}; +var ZodBranded = class extends ZodType { + _parse(input) { + const { ctx } = this._processInputParams(input); + const data = ctx.data; + return this._def.type._parse({ + data, + path: ctx.path, + parent: ctx + }); + } + unwrap() { + return this._def.type; + } +}; +var ZodPipeline = class _ZodPipeline extends ZodType { + _parse(input) { + const { status, ctx } = this._processInputParams(input); + if (ctx.common.async) { + const handleAsync = async () => { + const inResult = await this._def.in._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + if (inResult.status === "aborted") + return INVALID; + if (inResult.status === "dirty") { + status.dirty(); + return DIRTY(inResult.value); + } else { + return this._def.out._parseAsync({ + data: inResult.value, + path: ctx.path, + parent: ctx + }); + } + }; + return handleAsync(); + } else { + const inResult = this._def.in._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }); + if (inResult.status === "aborted") + return INVALID; + if (inResult.status === "dirty") { + status.dirty(); + return { + status: "dirty", + value: inResult.value + }; + } else { + return this._def.out._parseSync({ + data: inResult.value, + path: ctx.path, + parent: ctx + }); + } + } + } + static create(a, b) { + return new _ZodPipeline({ + in: a, + out: b, + typeName: ZodFirstPartyTypeKind.ZodPipeline + }); + } +}; +var ZodReadonly = class extends ZodType { + _parse(input) { + const result = this._def.innerType._parse(input); + const freeze = (data) => { + if (isValid(data)) { + data.value = Object.freeze(data.value); + } + return data; + }; + return isAsync(result) ? result.then((data) => freeze(data)) : freeze(result); + } + unwrap() { + return this._def.innerType; + } +}; +ZodReadonly.create = (type, params) => { + return new ZodReadonly({ + innerType: type, + typeName: ZodFirstPartyTypeKind.ZodReadonly, + ...processCreateParams(params) + }); +}; +var late = { + object: ZodObject.lazycreate +}; +var ZodFirstPartyTypeKind; +(function(ZodFirstPartyTypeKind3) { + ZodFirstPartyTypeKind3["ZodString"] = "ZodString"; + ZodFirstPartyTypeKind3["ZodNumber"] = "ZodNumber"; + ZodFirstPartyTypeKind3["ZodNaN"] = "ZodNaN"; + ZodFirstPartyTypeKind3["ZodBigInt"] = "ZodBigInt"; + ZodFirstPartyTypeKind3["ZodBoolean"] = "ZodBoolean"; + ZodFirstPartyTypeKind3["ZodDate"] = "ZodDate"; + ZodFirstPartyTypeKind3["ZodSymbol"] = "ZodSymbol"; + ZodFirstPartyTypeKind3["ZodUndefined"] = "ZodUndefined"; + ZodFirstPartyTypeKind3["ZodNull"] = "ZodNull"; + ZodFirstPartyTypeKind3["ZodAny"] = "ZodAny"; + ZodFirstPartyTypeKind3["ZodUnknown"] = "ZodUnknown"; + ZodFirstPartyTypeKind3["ZodNever"] = "ZodNever"; + ZodFirstPartyTypeKind3["ZodVoid"] = "ZodVoid"; + ZodFirstPartyTypeKind3["ZodArray"] = "ZodArray"; + ZodFirstPartyTypeKind3["ZodObject"] = "ZodObject"; + ZodFirstPartyTypeKind3["ZodUnion"] = "ZodUnion"; + ZodFirstPartyTypeKind3["ZodDiscriminatedUnion"] = "ZodDiscriminatedUnion"; + ZodFirstPartyTypeKind3["ZodIntersection"] = "ZodIntersection"; + ZodFirstPartyTypeKind3["ZodTuple"] = "ZodTuple"; + ZodFirstPartyTypeKind3["ZodRecord"] = "ZodRecord"; + ZodFirstPartyTypeKind3["ZodMap"] = "ZodMap"; + ZodFirstPartyTypeKind3["ZodSet"] = "ZodSet"; + ZodFirstPartyTypeKind3["ZodFunction"] = "ZodFunction"; + ZodFirstPartyTypeKind3["ZodLazy"] = "ZodLazy"; + ZodFirstPartyTypeKind3["ZodLiteral"] = "ZodLiteral"; + ZodFirstPartyTypeKind3["ZodEnum"] = "ZodEnum"; + ZodFirstPartyTypeKind3["ZodEffects"] = "ZodEffects"; + ZodFirstPartyTypeKind3["ZodNativeEnum"] = "ZodNativeEnum"; + ZodFirstPartyTypeKind3["ZodOptional"] = "ZodOptional"; + ZodFirstPartyTypeKind3["ZodNullable"] = "ZodNullable"; + ZodFirstPartyTypeKind3["ZodDefault"] = "ZodDefault"; + ZodFirstPartyTypeKind3["ZodCatch"] = "ZodCatch"; + ZodFirstPartyTypeKind3["ZodPromise"] = "ZodPromise"; + ZodFirstPartyTypeKind3["ZodBranded"] = "ZodBranded"; + ZodFirstPartyTypeKind3["ZodPipeline"] = "ZodPipeline"; + ZodFirstPartyTypeKind3["ZodReadonly"] = "ZodReadonly"; +})(ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {})); +var stringType = ZodString.create; +var numberType = ZodNumber.create; +var nanType = ZodNaN.create; +var bigIntType = ZodBigInt.create; +var booleanType = ZodBoolean.create; +var dateType = ZodDate.create; +var symbolType = ZodSymbol.create; +var undefinedType = ZodUndefined.create; +var nullType = ZodNull.create; +var anyType = ZodAny.create; +var unknownType = ZodUnknown.create; +var neverType = ZodNever.create; +var voidType = ZodVoid.create; +var arrayType = ZodArray.create; +var objectType = ZodObject.create; +var strictObjectType = ZodObject.strictCreate; +var unionType = ZodUnion.create; +var discriminatedUnionType = ZodDiscriminatedUnion.create; +var intersectionType = ZodIntersection.create; +var tupleType = ZodTuple.create; +var recordType = ZodRecord.create; +var mapType = ZodMap.create; +var setType = ZodSet.create; +var functionType = ZodFunction.create; +var lazyType = ZodLazy.create; +var literalType = ZodLiteral.create; +var enumType = ZodEnum.create; +var nativeEnumType = ZodNativeEnum.create; +var promiseType = ZodPromise.create; +var effectsType = ZodEffects.create; +var optionalType = ZodOptional.create; +var nullableType = ZodNullable.create; +var preprocessType = ZodEffects.createWithPreprocess; +var pipelineType = ZodPipeline.create; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/core.js +var NEVER = Object.freeze({ + status: "aborted" +}); +// @__NO_SIDE_EFFECTS__ +function $constructor(name, initializer3, params) { + function init(inst, def) { + if (!inst._zod) { + Object.defineProperty(inst, "_zod", { + value: { + def, + constr: _, + traits: /* @__PURE__ */ new Set() + }, + enumerable: false + }); + } + if (inst._zod.traits.has(name)) { + return; + } + inst._zod.traits.add(name); + initializer3(inst, def); + const proto = _.prototype; + const keys = Object.keys(proto); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!(k in inst)) { + inst[k] = proto[k].bind(inst); + } + } + } + const Parent = params?.Parent ?? Object; + class Definition extends Parent { + } + Object.defineProperty(Definition, "name", { value: name }); + function _(def) { + var _a2; + const inst = params?.Parent ? new Definition() : this; + init(inst, def); + (_a2 = inst._zod).deferred ?? (_a2.deferred = []); + for (const fn of inst._zod.deferred) { + fn(); + } + return inst; + } + Object.defineProperty(_, "init", { value: init }); + Object.defineProperty(_, Symbol.hasInstance, { + value: (inst) => { + if (params?.Parent && inst instanceof params.Parent) + return true; + return inst?._zod?.traits?.has(name); + } + }); + Object.defineProperty(_, "name", { value: name }); + return _; +} +var $ZodAsyncError = class extends Error { + constructor() { + super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); + } +}; +var $ZodEncodeError = class extends Error { + constructor(name) { + super(`Encountered unidirectional transform during encode: ${name}`); + this.name = "ZodEncodeError"; + } +}; +var globalConfig = {}; +function config(newConfig) { + if (newConfig) + Object.assign(globalConfig, newConfig); + return globalConfig; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/util.js +var util_exports = {}; +__export(util_exports, { + BIGINT_FORMAT_RANGES: () => BIGINT_FORMAT_RANGES, + Class: () => Class, + NUMBER_FORMAT_RANGES: () => NUMBER_FORMAT_RANGES, + aborted: () => aborted, + allowsEval: () => allowsEval, + assert: () => assert, + assertEqual: () => assertEqual, + assertIs: () => assertIs, + assertNever: () => assertNever, + assertNotEqual: () => assertNotEqual, + assignProp: () => assignProp, + base64ToUint8Array: () => base64ToUint8Array, + base64urlToUint8Array: () => base64urlToUint8Array, + cached: () => cached, + captureStackTrace: () => captureStackTrace, + cleanEnum: () => cleanEnum, + cleanRegex: () => cleanRegex, + clone: () => clone, + cloneDef: () => cloneDef, + createTransparentProxy: () => createTransparentProxy, + defineLazy: () => defineLazy, + esc: () => esc, + escapeRegex: () => escapeRegex, + extend: () => extend, + finalizeIssue: () => finalizeIssue, + floatSafeRemainder: () => floatSafeRemainder2, + getElementAtPath: () => getElementAtPath, + getEnumValues: () => getEnumValues, + getLengthableOrigin: () => getLengthableOrigin, + getParsedType: () => getParsedType2, + getSizableOrigin: () => getSizableOrigin, + hexToUint8Array: () => hexToUint8Array, + isObject: () => isObject, + isPlainObject: () => isPlainObject, + issue: () => issue, + joinValues: () => joinValues, + jsonStringifyReplacer: () => jsonStringifyReplacer, + merge: () => merge, + mergeDefs: () => mergeDefs, + normalizeParams: () => normalizeParams, + nullish: () => nullish, + numKeys: () => numKeys, + objectClone: () => objectClone, + omit: () => omit, + optionalKeys: () => optionalKeys, + parsedType: () => parsedType, + partial: () => partial, + pick: () => pick, + prefixIssues: () => prefixIssues, + primitiveTypes: () => primitiveTypes, + promiseAllObject: () => promiseAllObject, + propertyKeyTypes: () => propertyKeyTypes, + randomString: () => randomString, + required: () => required, + safeExtend: () => safeExtend, + shallowClone: () => shallowClone, + slugify: () => slugify, + stringifyPrimitive: () => stringifyPrimitive, + uint8ArrayToBase64: () => uint8ArrayToBase64, + uint8ArrayToBase64url: () => uint8ArrayToBase64url, + uint8ArrayToHex: () => uint8ArrayToHex, + unwrapMessage: () => unwrapMessage +}); +function assertEqual(val) { + return val; +} +function assertNotEqual(val) { + return val; +} +function assertIs(_arg) { +} +function assertNever(_x) { + throw new Error("Unexpected value in exhaustive check"); +} +function assert(_) { +} +function getEnumValues(entries) { + const numericValues = Object.values(entries).filter((v) => typeof v === "number"); + const values = Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v); + return values; +} +function joinValues(array2, separator = "|") { + return array2.map((val) => stringifyPrimitive(val)).join(separator); +} +function jsonStringifyReplacer(_, value) { + if (typeof value === "bigint") + return value.toString(); + return value; +} +function cached(getter) { + const set2 = false; + return { + get value() { + if (!set2) { + const value = getter(); + Object.defineProperty(this, "value", { value }); + return value; + } + throw new Error("cached value already set"); + } + }; +} +function nullish(input) { + return input === null || input === void 0; +} +function cleanRegex(source) { + const start = source.startsWith("^") ? 1 : 0; + const end = source.endsWith("$") ? source.length - 1 : source.length; + return source.slice(start, end); +} +function floatSafeRemainder2(val, step) { + const valDecCount = (val.toString().split(".")[1] || "").length; + const stepString = step.toString(); + let stepDecCount = (stepString.split(".")[1] || "").length; + if (stepDecCount === 0 && /\d?e-\d?/.test(stepString)) { + const match = stepString.match(/\d?e-(\d?)/); + if (match?.[1]) { + stepDecCount = Number.parseInt(match[1]); + } + } + const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; + const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); + const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); + return valInt % stepInt / 10 ** decCount; +} +var EVALUATING = /* @__PURE__ */ Symbol("evaluating"); +function defineLazy(object3, key, getter) { + let value = void 0; + Object.defineProperty(object3, key, { + get() { + if (value === EVALUATING) { + return void 0; + } + if (value === void 0) { + value = EVALUATING; + value = getter(); + } + return value; + }, + set(v) { + Object.defineProperty(object3, key, { + value: v + // configurable: true, + }); + }, + configurable: true + }); +} +function objectClone(obj) { + return Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)); +} +function assignProp(target, prop, value) { + Object.defineProperty(target, prop, { + value, + writable: true, + enumerable: true, + configurable: true + }); +} +function mergeDefs(...defs) { + const mergedDescriptors = {}; + for (const def of defs) { + const descriptors = Object.getOwnPropertyDescriptors(def); + Object.assign(mergedDescriptors, descriptors); + } + return Object.defineProperties({}, mergedDescriptors); +} +function cloneDef(schema) { + return mergeDefs(schema._zod.def); +} +function getElementAtPath(obj, path2) { + if (!path2) + return obj; + return path2.reduce((acc, key) => acc?.[key], obj); +} +function promiseAllObject(promisesObj) { + const keys = Object.keys(promisesObj); + const promises = keys.map((key) => promisesObj[key]); + return Promise.all(promises).then((results) => { + const resolvedObj = {}; + for (let i = 0; i < keys.length; i++) { + resolvedObj[keys[i]] = results[i]; + } + return resolvedObj; + }); +} +function randomString(length = 10) { + const chars = "abcdefghijklmnopqrstuvwxyz"; + let str2 = ""; + for (let i = 0; i < length; i++) { + str2 += chars[Math.floor(Math.random() * chars.length)]; + } + return str2; +} +function esc(str2) { + return JSON.stringify(str2); +} +function slugify(input) { + return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); +} +var captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => { +}; +function isObject(data) { + return typeof data === "object" && data !== null && !Array.isArray(data); +} +var allowsEval = cached(() => { + if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { + return false; + } + try { + const F = Function; + new F(""); + return true; + } catch (_) { + return false; + } +}); +function isPlainObject(o) { + if (isObject(o) === false) + return false; + const ctor = o.constructor; + if (ctor === void 0) + return true; + if (typeof ctor !== "function") + return true; + const prot = ctor.prototype; + if (isObject(prot) === false) + return false; + if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) { + return false; + } + return true; +} +function shallowClone(o) { + if (isPlainObject(o)) + return { ...o }; + if (Array.isArray(o)) + return [...o]; + return o; +} +function numKeys(data) { + let keyCount = 0; + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + keyCount++; + } + } + return keyCount; +} +var getParsedType2 = (data) => { + const t = typeof data; + switch (t) { + case "undefined": + return "undefined"; + case "string": + return "string"; + case "number": + return Number.isNaN(data) ? "nan" : "number"; + case "boolean": + return "boolean"; + case "function": + return "function"; + case "bigint": + return "bigint"; + case "symbol": + return "symbol"; + case "object": + if (Array.isArray(data)) { + return "array"; + } + if (data === null) { + return "null"; + } + if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { + return "promise"; + } + if (typeof Map !== "undefined" && data instanceof Map) { + return "map"; + } + if (typeof Set !== "undefined" && data instanceof Set) { + return "set"; + } + if (typeof Date !== "undefined" && data instanceof Date) { + return "date"; + } + if (typeof File !== "undefined" && data instanceof File) { + return "file"; + } + return "object"; + default: + throw new Error(`Unknown data type: ${t}`); + } +}; +var propertyKeyTypes = /* @__PURE__ */ new Set(["string", "number", "symbol"]); +var primitiveTypes = /* @__PURE__ */ new Set(["string", "number", "bigint", "boolean", "symbol", "undefined"]); +function escapeRegex(str2) { + return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function clone(inst, def, params) { + const cl = new inst._zod.constr(def ?? inst._zod.def); + if (!def || params?.parent) + cl._zod.parent = inst; + return cl; +} +function normalizeParams(_params) { + const params = _params; + if (!params) + return {}; + if (typeof params === "string") + return { error: () => params }; + if (params?.message !== void 0) { + if (params?.error !== void 0) + throw new Error("Cannot specify both `message` and `error` params"); + params.error = params.message; + } + delete params.message; + if (typeof params.error === "string") + return { ...params, error: () => params.error }; + return params; +} +function createTransparentProxy(getter) { + let target; + return new Proxy({}, { + get(_, prop, receiver) { + target ?? (target = getter()); + return Reflect.get(target, prop, receiver); + }, + set(_, prop, value, receiver) { + target ?? (target = getter()); + return Reflect.set(target, prop, value, receiver); + }, + has(_, prop) { + target ?? (target = getter()); + return Reflect.has(target, prop); + }, + deleteProperty(_, prop) { + target ?? (target = getter()); + return Reflect.deleteProperty(target, prop); + }, + ownKeys(_) { + target ?? (target = getter()); + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(_, prop) { + target ?? (target = getter()); + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + defineProperty(_, prop, descriptor) { + target ?? (target = getter()); + return Reflect.defineProperty(target, prop, descriptor); + } + }); +} +function stringifyPrimitive(value) { + if (typeof value === "bigint") + return value.toString() + "n"; + if (typeof value === "string") + return `"${value}"`; + return `${value}`; +} +function optionalKeys(shape) { + return Object.keys(shape).filter((k) => { + return shape[k]._zod.optin === "optional" && shape[k]._zod.optout === "optional"; + }); +} +var NUMBER_FORMAT_RANGES = { + safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + int32: [-2147483648, 2147483647], + uint32: [0, 4294967295], + float32: [-34028234663852886e22, 34028234663852886e22], + float64: [-Number.MAX_VALUE, Number.MAX_VALUE] +}; +var BIGINT_FORMAT_RANGES = { + int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], + uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] +}; +function pick(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(".pick() cannot be used on object schemas containing refinements"); + } + const def = mergeDefs(schema._zod.def, { + get shape() { + const newShape = {}; + for (const key in mask) { + if (!(key in currDef.shape)) { + throw new Error(`Unrecognized key: "${key}"`); + } + if (!mask[key]) + continue; + newShape[key] = currDef.shape[key]; + } + assignProp(this, "shape", newShape); + return newShape; + }, + checks: [] + }); + return clone(schema, def); +} +function omit(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(".omit() cannot be used on object schemas containing refinements"); + } + const def = mergeDefs(schema._zod.def, { + get shape() { + const newShape = { ...schema._zod.def.shape }; + for (const key in mask) { + if (!(key in currDef.shape)) { + throw new Error(`Unrecognized key: "${key}"`); + } + if (!mask[key]) + continue; + delete newShape[key]; + } + assignProp(this, "shape", newShape); + return newShape; + }, + checks: [] + }); + return clone(schema, def); +} +function extend(schema, shape) { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to extend: expected a plain object"); + } + const checks = schema._zod.def.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + const existingShape = schema._zod.def.shape; + for (const key in shape) { + if (Object.getOwnPropertyDescriptor(existingShape, key) !== void 0) { + throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead."); + } + } + } + const def = mergeDefs(schema._zod.def, { + get shape() { + const _shape = { ...schema._zod.def.shape, ...shape }; + assignProp(this, "shape", _shape); + return _shape; + } + }); + return clone(schema, def); +} +function safeExtend(schema, shape) { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to safeExtend: expected a plain object"); + } + const def = mergeDefs(schema._zod.def, { + get shape() { + const _shape = { ...schema._zod.def.shape, ...shape }; + assignProp(this, "shape", _shape); + return _shape; + } + }); + return clone(schema, def); +} +function merge(a, b) { + const def = mergeDefs(a._zod.def, { + get shape() { + const _shape = { ...a._zod.def.shape, ...b._zod.def.shape }; + assignProp(this, "shape", _shape); + return _shape; + }, + get catchall() { + return b._zod.def.catchall; + }, + checks: [] + // delete existing checks + }); + return clone(a, def); +} +function partial(Class2, schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(".partial() cannot be used on object schemas containing refinements"); + } + const def = mergeDefs(schema._zod.def, { + get shape() { + const oldShape = schema._zod.def.shape; + const shape = { ...oldShape }; + if (mask) { + for (const key in mask) { + if (!(key in oldShape)) { + throw new Error(`Unrecognized key: "${key}"`); + } + if (!mask[key]) + continue; + shape[key] = Class2 ? new Class2({ + type: "optional", + innerType: oldShape[key] + }) : oldShape[key]; + } + } else { + for (const key in oldShape) { + shape[key] = Class2 ? new Class2({ + type: "optional", + innerType: oldShape[key] + }) : oldShape[key]; + } + } + assignProp(this, "shape", shape); + return shape; + }, + checks: [] + }); + return clone(schema, def); +} +function required(Class2, schema, mask) { + const def = mergeDefs(schema._zod.def, { + get shape() { + const oldShape = schema._zod.def.shape; + const shape = { ...oldShape }; + if (mask) { + for (const key in mask) { + if (!(key in shape)) { + throw new Error(`Unrecognized key: "${key}"`); + } + if (!mask[key]) + continue; + shape[key] = new Class2({ + type: "nonoptional", + innerType: oldShape[key] + }); + } + } else { + for (const key in oldShape) { + shape[key] = new Class2({ + type: "nonoptional", + innerType: oldShape[key] + }); + } + } + assignProp(this, "shape", shape); + return shape; + } + }); + return clone(schema, def); +} +function aborted(x, startIndex = 0) { + if (x.aborted === true) + return true; + for (let i = startIndex; i < x.issues.length; i++) { + if (x.issues[i]?.continue !== true) { + return true; + } + } + return false; +} +function prefixIssues(path2, issues) { + return issues.map((iss) => { + var _a2; + (_a2 = iss).path ?? (_a2.path = []); + iss.path.unshift(path2); + return iss; + }); +} +function unwrapMessage(message) { + return typeof message === "string" ? message : message?.message; +} +function finalizeIssue(iss, ctx, config2) { + const full = { ...iss, path: iss.path ?? [] }; + if (!iss.message) { + const message = unwrapMessage(iss.inst?._zod.def?.error?.(iss)) ?? unwrapMessage(ctx?.error?.(iss)) ?? unwrapMessage(config2.customError?.(iss)) ?? unwrapMessage(config2.localeError?.(iss)) ?? "Invalid input"; + full.message = message; + } + delete full.inst; + delete full.continue; + if (!ctx?.reportInput) { + delete full.input; + } + return full; +} +function getSizableOrigin(input) { + if (input instanceof Set) + return "set"; + if (input instanceof Map) + return "map"; + if (input instanceof File) + return "file"; + return "unknown"; +} +function getLengthableOrigin(input) { + if (Array.isArray(input)) + return "array"; + if (typeof input === "string") + return "string"; + return "unknown"; +} +function parsedType(data) { + const t = typeof data; + switch (t) { + case "number": { + return Number.isNaN(data) ? "nan" : "number"; + } + case "object": { + if (data === null) { + return "null"; + } + if (Array.isArray(data)) { + return "array"; + } + const obj = data; + if (obj && Object.getPrototypeOf(obj) !== Object.prototype && "constructor" in obj && obj.constructor) { + return obj.constructor.name; + } + } + } + return t; +} +function issue(...args) { + const [iss, input, inst] = args; + if (typeof iss === "string") { + return { + message: iss, + code: "custom", + input, + inst + }; + } + return { ...iss }; +} +function cleanEnum(obj) { + return Object.entries(obj).filter(([k, _]) => { + return Number.isNaN(Number.parseInt(k, 10)); + }).map((el) => el[1]); +} +function base64ToUint8Array(base643) { + const binaryString = atob(base643); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} +function uint8ArrayToBase64(bytes) { + let binaryString = ""; + for (let i = 0; i < bytes.length; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + return btoa(binaryString); +} +function base64urlToUint8Array(base64url3) { + const base643 = base64url3.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - base643.length % 4) % 4); + return base64ToUint8Array(base643 + padding); +} +function uint8ArrayToBase64url(bytes) { + return uint8ArrayToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} +function hexToUint8Array(hex3) { + const cleanHex = hex3.replace(/^0x/, ""); + if (cleanHex.length % 2 !== 0) { + throw new Error("Invalid hex string length"); + } + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = Number.parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} +function uint8ArrayToHex(bytes) { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); +} +var Class = class { + constructor(..._args) { + } +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/errors.js +var initializer = (inst, def) => { + inst.name = "$ZodError"; + Object.defineProperty(inst, "_zod", { + value: inst._zod, + enumerable: false + }); + Object.defineProperty(inst, "issues", { + value: def, + enumerable: false + }); + inst.message = JSON.stringify(def, jsonStringifyReplacer, 2); + Object.defineProperty(inst, "toString", { + value: () => inst.message, + enumerable: false + }); +}; +var $ZodError = $constructor("$ZodError", initializer); +var $ZodRealError = $constructor("$ZodError", initializer, { Parent: Error }); +function flattenError(error2, mapper = (issue2) => issue2.message) { + const fieldErrors = {}; + const formErrors = []; + for (const sub of error2.issues) { + if (sub.path.length > 0) { + fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || []; + fieldErrors[sub.path[0]].push(mapper(sub)); + } else { + formErrors.push(mapper(sub)); + } + } + return { formErrors, fieldErrors }; +} +function formatError(error2, mapper = (issue2) => issue2.message) { + const fieldErrors = { _errors: [] }; + const processError = (error3) => { + for (const issue2 of error3.issues) { + if (issue2.code === "invalid_union" && issue2.errors.length) { + issue2.errors.map((issues) => processError({ issues })); + } else if (issue2.code === "invalid_key") { + processError({ issues: issue2.issues }); + } else if (issue2.code === "invalid_element") { + processError({ issues: issue2.issues }); + } else if (issue2.path.length === 0) { + fieldErrors._errors.push(mapper(issue2)); + } else { + let curr = fieldErrors; + let i = 0; + while (i < issue2.path.length) { + const el = issue2.path[i]; + const terminal = i === issue2.path.length - 1; + if (!terminal) { + curr[el] = curr[el] || { _errors: [] }; + } else { + curr[el] = curr[el] || { _errors: [] }; + curr[el]._errors.push(mapper(issue2)); + } + curr = curr[el]; + i++; + } + } + } + }; + processError(error2); + return fieldErrors; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/parse.js +var _parse = (_Err) => (schema, value, _ctx, _params) => { + const ctx = _ctx ? Object.assign(_ctx, { async: false }) : { async: false }; + const result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) { + throw new $ZodAsyncError(); + } + if (result.issues.length) { + const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, _params?.callee); + throw e; + } + return result.value; +}; +var parse2 = /* @__PURE__ */ _parse($ZodRealError); +var _parseAsync = (_Err) => async (schema, value, _ctx, params) => { + const ctx = _ctx ? Object.assign(_ctx, { async: true }) : { async: true }; + let result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) + result = await result; + if (result.issues.length) { + const e = new (params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, params?.callee); + throw e; + } + return result.value; +}; +var parseAsync = /* @__PURE__ */ _parseAsync($ZodRealError); +var _safeParse = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; + const result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) { + throw new $ZodAsyncError(); + } + return result.issues.length ? { + success: false, + error: new (_Err ?? $ZodError)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + } : { success: true, data: result.value }; +}; +var safeParse = /* @__PURE__ */ _safeParse($ZodRealError); +var _safeParseAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? Object.assign(_ctx, { async: true }) : { async: true }; + let result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) + result = await result; + return result.issues.length ? { + success: false, + error: new _Err(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + } : { success: true, data: result.value }; +}; +var safeParseAsync = /* @__PURE__ */ _safeParseAsync($ZodRealError); +var _encode = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; + return _parse(_Err)(schema, value, ctx); +}; +var _decode = (_Err) => (schema, value, _ctx) => { + return _parse(_Err)(schema, value, _ctx); +}; +var _encodeAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; + return _parseAsync(_Err)(schema, value, ctx); +}; +var _decodeAsync = (_Err) => async (schema, value, _ctx) => { + return _parseAsync(_Err)(schema, value, _ctx); +}; +var _safeEncode = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; + return _safeParse(_Err)(schema, value, ctx); +}; +var _safeDecode = (_Err) => (schema, value, _ctx) => { + return _safeParse(_Err)(schema, value, _ctx); +}; +var _safeEncodeAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; + return _safeParseAsync(_Err)(schema, value, ctx); +}; +var _safeDecodeAsync = (_Err) => async (schema, value, _ctx) => { + return _safeParseAsync(_Err)(schema, value, _ctx); +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/regexes.js +var regexes_exports = {}; +__export(regexes_exports, { + base64: () => base64, + base64url: () => base64url, + bigint: () => bigint, + boolean: () => boolean, + browserEmail: () => browserEmail, + cidrv4: () => cidrv4, + cidrv6: () => cidrv6, + cuid: () => cuid, + cuid2: () => cuid2, + date: () => date, + datetime: () => datetime, + domain: () => domain, + duration: () => duration, + e164: () => e164, + email: () => email, + emoji: () => emoji, + extendedDuration: () => extendedDuration, + guid: () => guid, + hex: () => hex, + hostname: () => hostname, + html5Email: () => html5Email, + idnEmail: () => idnEmail, + integer: () => integer, + ipv4: () => ipv4, + ipv6: () => ipv6, + ksuid: () => ksuid, + lowercase: () => lowercase, + mac: () => mac, + md5_base64: () => md5_base64, + md5_base64url: () => md5_base64url, + md5_hex: () => md5_hex, + nanoid: () => nanoid, + null: () => _null, + number: () => number, + rfc5322Email: () => rfc5322Email, + sha1_base64: () => sha1_base64, + sha1_base64url: () => sha1_base64url, + sha1_hex: () => sha1_hex, + sha256_base64: () => sha256_base64, + sha256_base64url: () => sha256_base64url, + sha256_hex: () => sha256_hex, + sha384_base64: () => sha384_base64, + sha384_base64url: () => sha384_base64url, + sha384_hex: () => sha384_hex, + sha512_base64: () => sha512_base64, + sha512_base64url: () => sha512_base64url, + sha512_hex: () => sha512_hex, + string: () => string, + time: () => time, + ulid: () => ulid, + undefined: () => _undefined, + unicodeEmail: () => unicodeEmail, + uppercase: () => uppercase, + uuid: () => uuid, + uuid4: () => uuid4, + uuid6: () => uuid6, + uuid7: () => uuid7, + xid: () => xid +}); +var cuid = /^[cC][^\s-]{8,}$/; +var cuid2 = /^[0-9a-z]+$/; +var ulid = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/; +var xid = /^[0-9a-vA-V]{20}$/; +var ksuid = /^[A-Za-z0-9]{27}$/; +var nanoid = /^[a-zA-Z0-9_-]{21}$/; +var duration = /^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/; +var extendedDuration = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; +var guid = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; +var uuid = (version2) => { + if (!version2) + return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/; + return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version2}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`); +}; +var uuid4 = /* @__PURE__ */ uuid(4); +var uuid6 = /* @__PURE__ */ uuid(6); +var uuid7 = /* @__PURE__ */ uuid(7); +var email = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/; +var html5Email = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +var rfc5322Email = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +var unicodeEmail = /^[^\s@"]{1,64}@[^\s@]{1,255}$/u; +var idnEmail = unicodeEmail; +var browserEmail = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +var _emoji = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; +function emoji() { + return new RegExp(_emoji, "u"); +} +var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/; +var mac = (delimiter) => { + const escapedDelim = escapeRegex(delimiter ?? ":"); + return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`); +}; +var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/; +var cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; +var base64 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/; +var base64url = /^[A-Za-z0-9_-]*$/; +var hostname = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/; +var domain = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; +var e164 = /^\+[1-9]\d{6,14}$/; +var dateSource = `(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`; +var date = /* @__PURE__ */ new RegExp(`^${dateSource}$`); +function timeSource(args) { + const hhmm = `(?:[01]\\d|2[0-3]):[0-5]\\d`; + const regex = typeof args.precision === "number" ? args.precision === -1 ? `${hhmm}` : args.precision === 0 ? `${hhmm}:[0-5]\\d` : `${hhmm}:[0-5]\\d\\.\\d{${args.precision}}` : `${hhmm}(?::[0-5]\\d(?:\\.\\d+)?)?`; + return regex; +} +function time(args) { + return new RegExp(`^${timeSource(args)}$`); +} +function datetime(args) { + const time3 = timeSource({ precision: args.precision }); + const opts = ["Z"]; + if (args.local) + opts.push(""); + if (args.offset) + opts.push(`([+-](?:[01]\\d|2[0-3]):[0-5]\\d)`); + const timeRegex2 = `${time3}(?:${opts.join("|")})`; + return new RegExp(`^${dateSource}T(?:${timeRegex2})$`); +} +var string = (params) => { + const regex = params ? `[\\s\\S]{${params?.minimum ?? 0},${params?.maximum ?? ""}}` : `[\\s\\S]*`; + return new RegExp(`^${regex}$`); +}; +var bigint = /^-?\d+n?$/; +var integer = /^-?\d+$/; +var number = /^-?\d+(?:\.\d+)?$/; +var boolean = /^(?:true|false)$/i; +var _null = /^null$/i; +var _undefined = /^undefined$/i; +var lowercase = /^[^A-Z]*$/; +var uppercase = /^[^a-z]*$/; +var hex = /^[0-9a-fA-F]*$/; +function fixedBase64(bodyLength, padding) { + return new RegExp(`^[A-Za-z0-9+/]{${bodyLength}}${padding}$`); +} +function fixedBase64url(length) { + return new RegExp(`^[A-Za-z0-9_-]{${length}}$`); +} +var md5_hex = /^[0-9a-fA-F]{32}$/; +var md5_base64 = /* @__PURE__ */ fixedBase64(22, "=="); +var md5_base64url = /* @__PURE__ */ fixedBase64url(22); +var sha1_hex = /^[0-9a-fA-F]{40}$/; +var sha1_base64 = /* @__PURE__ */ fixedBase64(27, "="); +var sha1_base64url = /* @__PURE__ */ fixedBase64url(27); +var sha256_hex = /^[0-9a-fA-F]{64}$/; +var sha256_base64 = /* @__PURE__ */ fixedBase64(43, "="); +var sha256_base64url = /* @__PURE__ */ fixedBase64url(43); +var sha384_hex = /^[0-9a-fA-F]{96}$/; +var sha384_base64 = /* @__PURE__ */ fixedBase64(64, ""); +var sha384_base64url = /* @__PURE__ */ fixedBase64url(64); +var sha512_hex = /^[0-9a-fA-F]{128}$/; +var sha512_base64 = /* @__PURE__ */ fixedBase64(86, "=="); +var sha512_base64url = /* @__PURE__ */ fixedBase64url(86); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/checks.js +var $ZodCheck = /* @__PURE__ */ $constructor("$ZodCheck", (inst, def) => { + var _a2; + inst._zod ?? (inst._zod = {}); + inst._zod.def = def; + (_a2 = inst._zod).onattach ?? (_a2.onattach = []); +}); +var numericOriginMap = { + number: "number", + bigint: "bigint", + object: "date" +}; +var $ZodCheckLessThan = /* @__PURE__ */ $constructor("$ZodCheckLessThan", (inst, def) => { + $ZodCheck.init(inst, def); + const origin = numericOriginMap[typeof def.value]; + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + const curr = (def.inclusive ? bag.maximum : bag.exclusiveMaximum) ?? Number.POSITIVE_INFINITY; + if (def.value < curr) { + if (def.inclusive) + bag.maximum = def.value; + else + bag.exclusiveMaximum = def.value; + } + }); + inst._zod.check = (payload) => { + if (def.inclusive ? payload.value <= def.value : payload.value < def.value) { + return; + } + payload.issues.push({ + origin, + code: "too_big", + maximum: typeof def.value === "object" ? def.value.getTime() : def.value, + input: payload.value, + inclusive: def.inclusive, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckGreaterThan = /* @__PURE__ */ $constructor("$ZodCheckGreaterThan", (inst, def) => { + $ZodCheck.init(inst, def); + const origin = numericOriginMap[typeof def.value]; + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + const curr = (def.inclusive ? bag.minimum : bag.exclusiveMinimum) ?? Number.NEGATIVE_INFINITY; + if (def.value > curr) { + if (def.inclusive) + bag.minimum = def.value; + else + bag.exclusiveMinimum = def.value; + } + }); + inst._zod.check = (payload) => { + if (def.inclusive ? payload.value >= def.value : payload.value > def.value) { + return; + } + payload.issues.push({ + origin, + code: "too_small", + minimum: typeof def.value === "object" ? def.value.getTime() : def.value, + input: payload.value, + inclusive: def.inclusive, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckMultipleOf = /* @__PURE__ */ $constructor("$ZodCheckMultipleOf", (inst, def) => { + $ZodCheck.init(inst, def); + inst._zod.onattach.push((inst2) => { + var _a2; + (_a2 = inst2._zod.bag).multipleOf ?? (_a2.multipleOf = def.value); + }); + inst._zod.check = (payload) => { + if (typeof payload.value !== typeof def.value) + throw new Error("Cannot mix number and bigint in multiple_of check."); + const isMultiple = typeof payload.value === "bigint" ? payload.value % def.value === BigInt(0) : floatSafeRemainder2(payload.value, def.value) === 0; + if (isMultiple) + return; + payload.issues.push({ + origin: typeof payload.value, + code: "not_multiple_of", + divisor: def.value, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckNumberFormat = /* @__PURE__ */ $constructor("$ZodCheckNumberFormat", (inst, def) => { + $ZodCheck.init(inst, def); + def.format = def.format || "float64"; + const isInt = def.format?.includes("int"); + const origin = isInt ? "int" : "number"; + const [minimum, maximum] = NUMBER_FORMAT_RANGES[def.format]; + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.format = def.format; + bag.minimum = minimum; + bag.maximum = maximum; + if (isInt) + bag.pattern = integer; + }); + inst._zod.check = (payload) => { + const input = payload.value; + if (isInt) { + if (!Number.isInteger(input)) { + payload.issues.push({ + expected: origin, + format: def.format, + code: "invalid_type", + continue: false, + input, + inst + }); + return; + } + if (!Number.isSafeInteger(input)) { + if (input > 0) { + payload.issues.push({ + input, + code: "too_big", + maximum: Number.MAX_SAFE_INTEGER, + note: "Integers must be within the safe integer range.", + inst, + origin, + inclusive: true, + continue: !def.abort + }); + } else { + payload.issues.push({ + input, + code: "too_small", + minimum: Number.MIN_SAFE_INTEGER, + note: "Integers must be within the safe integer range.", + inst, + origin, + inclusive: true, + continue: !def.abort + }); + } + return; + } + } + if (input < minimum) { + payload.issues.push({ + origin: "number", + input, + code: "too_small", + minimum, + inclusive: true, + inst, + continue: !def.abort + }); + } + if (input > maximum) { + payload.issues.push({ + origin: "number", + input, + code: "too_big", + maximum, + inclusive: true, + inst, + continue: !def.abort + }); + } + }; +}); +var $ZodCheckBigIntFormat = /* @__PURE__ */ $constructor("$ZodCheckBigIntFormat", (inst, def) => { + $ZodCheck.init(inst, def); + const [minimum, maximum] = BIGINT_FORMAT_RANGES[def.format]; + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.format = def.format; + bag.minimum = minimum; + bag.maximum = maximum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + if (input < minimum) { + payload.issues.push({ + origin: "bigint", + input, + code: "too_small", + minimum, + inclusive: true, + inst, + continue: !def.abort + }); + } + if (input > maximum) { + payload.issues.push({ + origin: "bigint", + input, + code: "too_big", + maximum, + inclusive: true, + inst, + continue: !def.abort + }); + } + }; +}); +var $ZodCheckMaxSize = /* @__PURE__ */ $constructor("$ZodCheckMaxSize", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.size !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const curr = inst2._zod.bag.maximum ?? Number.POSITIVE_INFINITY; + if (def.maximum < curr) + inst2._zod.bag.maximum = def.maximum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const size = input.size; + if (size <= def.maximum) + return; + payload.issues.push({ + origin: getSizableOrigin(input), + code: "too_big", + maximum: def.maximum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckMinSize = /* @__PURE__ */ $constructor("$ZodCheckMinSize", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.size !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const curr = inst2._zod.bag.minimum ?? Number.NEGATIVE_INFINITY; + if (def.minimum > curr) + inst2._zod.bag.minimum = def.minimum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const size = input.size; + if (size >= def.minimum) + return; + payload.issues.push({ + origin: getSizableOrigin(input), + code: "too_small", + minimum: def.minimum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckSizeEquals = /* @__PURE__ */ $constructor("$ZodCheckSizeEquals", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.size !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.minimum = def.size; + bag.maximum = def.size; + bag.size = def.size; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const size = input.size; + if (size === def.size) + return; + const tooBig = size > def.size; + payload.issues.push({ + origin: getSizableOrigin(input), + ...tooBig ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size }, + inclusive: true, + exact: true, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckMaxLength = /* @__PURE__ */ $constructor("$ZodCheckMaxLength", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const curr = inst2._zod.bag.maximum ?? Number.POSITIVE_INFINITY; + if (def.maximum < curr) + inst2._zod.bag.maximum = def.maximum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const length = input.length; + if (length <= def.maximum) + return; + const origin = getLengthableOrigin(input); + payload.issues.push({ + origin, + code: "too_big", + maximum: def.maximum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckMinLength = /* @__PURE__ */ $constructor("$ZodCheckMinLength", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const curr = inst2._zod.bag.minimum ?? Number.NEGATIVE_INFINITY; + if (def.minimum > curr) + inst2._zod.bag.minimum = def.minimum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const length = input.length; + if (length >= def.minimum) + return; + const origin = getLengthableOrigin(input); + payload.issues.push({ + origin, + code: "too_small", + minimum: def.minimum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckLengthEquals = /* @__PURE__ */ $constructor("$ZodCheckLengthEquals", (inst, def) => { + var _a2; + $ZodCheck.init(inst, def); + (_a2 = inst._zod.def).when ?? (_a2.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.minimum = def.length; + bag.maximum = def.length; + bag.length = def.length; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const length = input.length; + if (length === def.length) + return; + const origin = getLengthableOrigin(input); + const tooBig = length > def.length; + payload.issues.push({ + origin, + ...tooBig ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length }, + inclusive: true, + exact: true, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckStringFormat = /* @__PURE__ */ $constructor("$ZodCheckStringFormat", (inst, def) => { + var _a2, _b; + $ZodCheck.init(inst, def); + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.format = def.format; + if (def.pattern) { + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(def.pattern); + } + }); + if (def.pattern) + (_a2 = inst._zod).check ?? (_a2.check = (payload) => { + def.pattern.lastIndex = 0; + if (def.pattern.test(payload.value)) + return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: def.format, + input: payload.value, + ...def.pattern ? { pattern: def.pattern.toString() } : {}, + inst, + continue: !def.abort + }); + }); + else + (_b = inst._zod).check ?? (_b.check = () => { + }); +}); +var $ZodCheckRegex = /* @__PURE__ */ $constructor("$ZodCheckRegex", (inst, def) => { + $ZodCheckStringFormat.init(inst, def); + inst._zod.check = (payload) => { + def.pattern.lastIndex = 0; + if (def.pattern.test(payload.value)) + return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "regex", + input: payload.value, + pattern: def.pattern.toString(), + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckLowerCase = /* @__PURE__ */ $constructor("$ZodCheckLowerCase", (inst, def) => { + def.pattern ?? (def.pattern = lowercase); + $ZodCheckStringFormat.init(inst, def); +}); +var $ZodCheckUpperCase = /* @__PURE__ */ $constructor("$ZodCheckUpperCase", (inst, def) => { + def.pattern ?? (def.pattern = uppercase); + $ZodCheckStringFormat.init(inst, def); +}); +var $ZodCheckIncludes = /* @__PURE__ */ $constructor("$ZodCheckIncludes", (inst, def) => { + $ZodCheck.init(inst, def); + const escapedRegex = escapeRegex(def.includes); + const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex); + def.pattern = pattern; + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.includes(def.includes, def.position)) + return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "includes", + includes: def.includes, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckStartsWith = /* @__PURE__ */ $constructor("$ZodCheckStartsWith", (inst, def) => { + $ZodCheck.init(inst, def); + const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`); + def.pattern ?? (def.pattern = pattern); + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.startsWith(def.prefix)) + return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "starts_with", + prefix: def.prefix, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckEndsWith = /* @__PURE__ */ $constructor("$ZodCheckEndsWith", (inst, def) => { + $ZodCheck.init(inst, def); + const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`); + def.pattern ?? (def.pattern = pattern); + inst._zod.onattach.push((inst2) => { + const bag = inst2._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.endsWith(def.suffix)) + return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "ends_with", + suffix: def.suffix, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +function handleCheckPropertyResult(result, payload, property) { + if (result.issues.length) { + payload.issues.push(...prefixIssues(property, result.issues)); + } +} +var $ZodCheckProperty = /* @__PURE__ */ $constructor("$ZodCheckProperty", (inst, def) => { + $ZodCheck.init(inst, def); + inst._zod.check = (payload) => { + const result = def.schema._zod.run({ + value: payload.value[def.property], + issues: [] + }, {}); + if (result instanceof Promise) { + return result.then((result2) => handleCheckPropertyResult(result2, payload, def.property)); + } + handleCheckPropertyResult(result, payload, def.property); + return; + }; +}); +var $ZodCheckMimeType = /* @__PURE__ */ $constructor("$ZodCheckMimeType", (inst, def) => { + $ZodCheck.init(inst, def); + const mimeSet = new Set(def.mime); + inst._zod.onattach.push((inst2) => { + inst2._zod.bag.mime = def.mime; + }); + inst._zod.check = (payload) => { + if (mimeSet.has(payload.value.type)) + return; + payload.issues.push({ + code: "invalid_value", + values: def.mime, + input: payload.value.type, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCheckOverwrite = /* @__PURE__ */ $constructor("$ZodCheckOverwrite", (inst, def) => { + $ZodCheck.init(inst, def); + inst._zod.check = (payload) => { + payload.value = def.tx(payload.value); + }; +}); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/doc.js +var Doc = class { + constructor(args = []) { + this.content = []; + this.indent = 0; + if (this) + this.args = args; + } + indented(fn) { + this.indent += 1; + fn(this); + this.indent -= 1; + } + write(arg) { + if (typeof arg === "function") { + arg(this, { execution: "sync" }); + arg(this, { execution: "async" }); + return; + } + const content = arg; + const lines = content.split("\n").filter((x) => x); + const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length)); + const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x); + for (const line of dedented) { + this.content.push(line); + } + } + compile() { + const F = Function; + const args = this?.args; + const content = this?.content ?? [``]; + const lines = [...content.map((x) => ` ${x}`)]; + return new F(...args, lines.join("\n")); + } +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/versions.js +var version = { + major: 4, + minor: 3, + patch: 6 +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/schemas.js +var $ZodType = /* @__PURE__ */ $constructor("$ZodType", (inst, def) => { + var _a2; + inst ?? (inst = {}); + inst._zod.def = def; + inst._zod.bag = inst._zod.bag || {}; + inst._zod.version = version; + const checks = [...inst._zod.def.checks ?? []]; + if (inst._zod.traits.has("$ZodCheck")) { + checks.unshift(inst); + } + for (const ch of checks) { + for (const fn of ch._zod.onattach) { + fn(inst); + } + } + if (checks.length === 0) { + (_a2 = inst._zod).deferred ?? (_a2.deferred = []); + inst._zod.deferred?.push(() => { + inst._zod.run = inst._zod.parse; + }); + } else { + const runChecks = (payload, checks2, ctx) => { + let isAborted2 = aborted(payload); + let asyncResult; + for (const ch of checks2) { + if (ch._zod.def.when) { + const shouldRun = ch._zod.def.when(payload); + if (!shouldRun) + continue; + } else if (isAborted2) { + continue; + } + const currLen = payload.issues.length; + const _ = ch._zod.check(payload); + if (_ instanceof Promise && ctx?.async === false) { + throw new $ZodAsyncError(); + } + if (asyncResult || _ instanceof Promise) { + asyncResult = (asyncResult ?? Promise.resolve()).then(async () => { + await _; + const nextLen = payload.issues.length; + if (nextLen === currLen) + return; + if (!isAborted2) + isAborted2 = aborted(payload, currLen); + }); + } else { + const nextLen = payload.issues.length; + if (nextLen === currLen) + continue; + if (!isAborted2) + isAborted2 = aborted(payload, currLen); + } + } + if (asyncResult) { + return asyncResult.then(() => { + return payload; + }); + } + return payload; + }; + const handleCanaryResult = (canary, payload, ctx) => { + if (aborted(canary)) { + canary.aborted = true; + return canary; + } + const checkResult = runChecks(payload, checks, ctx); + if (checkResult instanceof Promise) { + if (ctx.async === false) + throw new $ZodAsyncError(); + return checkResult.then((checkResult2) => inst._zod.parse(checkResult2, ctx)); + } + return inst._zod.parse(checkResult, ctx); + }; + inst._zod.run = (payload, ctx) => { + if (ctx.skipChecks) { + return inst._zod.parse(payload, ctx); + } + if (ctx.direction === "backward") { + const canary = inst._zod.parse({ value: payload.value, issues: [] }, { ...ctx, skipChecks: true }); + if (canary instanceof Promise) { + return canary.then((canary2) => { + return handleCanaryResult(canary2, payload, ctx); + }); + } + return handleCanaryResult(canary, payload, ctx); + } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) + throw new $ZodAsyncError(); + return result.then((result2) => runChecks(result2, checks, ctx)); + } + return runChecks(result, checks, ctx); + }; + } + defineLazy(inst, "~standard", () => ({ + validate: (value) => { + try { + const r = safeParse(inst, value); + return r.success ? { value: r.data } : { issues: r.error?.issues }; + } catch (_) { + return safeParseAsync(inst, value).then((r) => r.success ? { value: r.data } : { issues: r.error?.issues }); + } + }, + vendor: "zod", + version: 1 + })); +}); +var $ZodString = /* @__PURE__ */ $constructor("$ZodString", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = [...inst?._zod.bag?.patterns ?? []].pop() ?? string(inst._zod.bag); + inst._zod.parse = (payload, _) => { + if (def.coerce) + try { + payload.value = String(payload.value); + } catch (_2) { + } + if (typeof payload.value === "string") + return payload; + payload.issues.push({ + expected: "string", + code: "invalid_type", + input: payload.value, + inst + }); + return payload; + }; +}); +var $ZodStringFormat = /* @__PURE__ */ $constructor("$ZodStringFormat", (inst, def) => { + $ZodCheckStringFormat.init(inst, def); + $ZodString.init(inst, def); +}); +var $ZodGUID = /* @__PURE__ */ $constructor("$ZodGUID", (inst, def) => { + def.pattern ?? (def.pattern = guid); + $ZodStringFormat.init(inst, def); +}); +var $ZodUUID = /* @__PURE__ */ $constructor("$ZodUUID", (inst, def) => { + if (def.version) { + const versionMap = { + v1: 1, + v2: 2, + v3: 3, + v4: 4, + v5: 5, + v6: 6, + v7: 7, + v8: 8 + }; + const v = versionMap[def.version]; + if (v === void 0) + throw new Error(`Invalid UUID version: "${def.version}"`); + def.pattern ?? (def.pattern = uuid(v)); + } else + def.pattern ?? (def.pattern = uuid()); + $ZodStringFormat.init(inst, def); +}); +var $ZodEmail = /* @__PURE__ */ $constructor("$ZodEmail", (inst, def) => { + def.pattern ?? (def.pattern = email); + $ZodStringFormat.init(inst, def); +}); +var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => { + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + try { + const trimmed = payload.value.trim(); + const url2 = new URL(trimmed); + if (def.hostname) { + def.hostname.lastIndex = 0; + if (!def.hostname.test(url2.hostname)) { + payload.issues.push({ + code: "invalid_format", + format: "url", + note: "Invalid hostname", + pattern: def.hostname.source, + input: payload.value, + inst, + continue: !def.abort + }); + } + } + if (def.protocol) { + def.protocol.lastIndex = 0; + if (!def.protocol.test(url2.protocol.endsWith(":") ? url2.protocol.slice(0, -1) : url2.protocol)) { + payload.issues.push({ + code: "invalid_format", + format: "url", + note: "Invalid protocol", + pattern: def.protocol.source, + input: payload.value, + inst, + continue: !def.abort + }); + } + } + if (def.normalize) { + payload.value = url2.href; + } else { + payload.value = trimmed; + } + return; + } catch (_) { + payload.issues.push({ + code: "invalid_format", + format: "url", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; +}); +var $ZodEmoji = /* @__PURE__ */ $constructor("$ZodEmoji", (inst, def) => { + def.pattern ?? (def.pattern = emoji()); + $ZodStringFormat.init(inst, def); +}); +var $ZodNanoID = /* @__PURE__ */ $constructor("$ZodNanoID", (inst, def) => { + def.pattern ?? (def.pattern = nanoid); + $ZodStringFormat.init(inst, def); +}); +var $ZodCUID = /* @__PURE__ */ $constructor("$ZodCUID", (inst, def) => { + def.pattern ?? (def.pattern = cuid); + $ZodStringFormat.init(inst, def); +}); +var $ZodCUID2 = /* @__PURE__ */ $constructor("$ZodCUID2", (inst, def) => { + def.pattern ?? (def.pattern = cuid2); + $ZodStringFormat.init(inst, def); +}); +var $ZodULID = /* @__PURE__ */ $constructor("$ZodULID", (inst, def) => { + def.pattern ?? (def.pattern = ulid); + $ZodStringFormat.init(inst, def); +}); +var $ZodXID = /* @__PURE__ */ $constructor("$ZodXID", (inst, def) => { + def.pattern ?? (def.pattern = xid); + $ZodStringFormat.init(inst, def); +}); +var $ZodKSUID = /* @__PURE__ */ $constructor("$ZodKSUID", (inst, def) => { + def.pattern ?? (def.pattern = ksuid); + $ZodStringFormat.init(inst, def); +}); +var $ZodISODateTime = /* @__PURE__ */ $constructor("$ZodISODateTime", (inst, def) => { + def.pattern ?? (def.pattern = datetime(def)); + $ZodStringFormat.init(inst, def); +}); +var $ZodISODate = /* @__PURE__ */ $constructor("$ZodISODate", (inst, def) => { + def.pattern ?? (def.pattern = date); + $ZodStringFormat.init(inst, def); +}); +var $ZodISOTime = /* @__PURE__ */ $constructor("$ZodISOTime", (inst, def) => { + def.pattern ?? (def.pattern = time(def)); + $ZodStringFormat.init(inst, def); +}); +var $ZodISODuration = /* @__PURE__ */ $constructor("$ZodISODuration", (inst, def) => { + def.pattern ?? (def.pattern = duration); + $ZodStringFormat.init(inst, def); +}); +var $ZodIPv4 = /* @__PURE__ */ $constructor("$ZodIPv4", (inst, def) => { + def.pattern ?? (def.pattern = ipv4); + $ZodStringFormat.init(inst, def); + inst._zod.bag.format = `ipv4`; +}); +var $ZodIPv6 = /* @__PURE__ */ $constructor("$ZodIPv6", (inst, def) => { + def.pattern ?? (def.pattern = ipv6); + $ZodStringFormat.init(inst, def); + inst._zod.bag.format = `ipv6`; + inst._zod.check = (payload) => { + try { + new URL(`http://[${payload.value}]`); + } catch { + payload.issues.push({ + code: "invalid_format", + format: "ipv6", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; +}); +var $ZodMAC = /* @__PURE__ */ $constructor("$ZodMAC", (inst, def) => { + def.pattern ?? (def.pattern = mac(def.delimiter)); + $ZodStringFormat.init(inst, def); + inst._zod.bag.format = `mac`; +}); +var $ZodCIDRv4 = /* @__PURE__ */ $constructor("$ZodCIDRv4", (inst, def) => { + def.pattern ?? (def.pattern = cidrv4); + $ZodStringFormat.init(inst, def); +}); +var $ZodCIDRv6 = /* @__PURE__ */ $constructor("$ZodCIDRv6", (inst, def) => { + def.pattern ?? (def.pattern = cidrv6); + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + const parts = payload.value.split("/"); + try { + if (parts.length !== 2) + throw new Error(); + const [address, prefix] = parts; + if (!prefix) + throw new Error(); + const prefixNum = Number(prefix); + if (`${prefixNum}` !== prefix) + throw new Error(); + if (prefixNum < 0 || prefixNum > 128) + throw new Error(); + new URL(`http://[${address}]`); + } catch { + payload.issues.push({ + code: "invalid_format", + format: "cidrv6", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; +}); +function isValidBase64(data) { + if (data === "") + return true; + if (data.length % 4 !== 0) + return false; + try { + atob(data); + return true; + } catch { + return false; + } +} +var $ZodBase64 = /* @__PURE__ */ $constructor("$ZodBase64", (inst, def) => { + def.pattern ?? (def.pattern = base64); + $ZodStringFormat.init(inst, def); + inst._zod.bag.contentEncoding = "base64"; + inst._zod.check = (payload) => { + if (isValidBase64(payload.value)) + return; + payload.issues.push({ + code: "invalid_format", + format: "base64", + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +function isValidBase64URL(data) { + if (!base64url.test(data)) + return false; + const base643 = data.replace(/[-_]/g, (c) => c === "-" ? "+" : "/"); + const padded = base643.padEnd(Math.ceil(base643.length / 4) * 4, "="); + return isValidBase64(padded); +} +var $ZodBase64URL = /* @__PURE__ */ $constructor("$ZodBase64URL", (inst, def) => { + def.pattern ?? (def.pattern = base64url); + $ZodStringFormat.init(inst, def); + inst._zod.bag.contentEncoding = "base64url"; + inst._zod.check = (payload) => { + if (isValidBase64URL(payload.value)) + return; + payload.issues.push({ + code: "invalid_format", + format: "base64url", + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodE164 = /* @__PURE__ */ $constructor("$ZodE164", (inst, def) => { + def.pattern ?? (def.pattern = e164); + $ZodStringFormat.init(inst, def); +}); +function isValidJWT2(token, algorithm = null) { + try { + const tokensParts = token.split("."); + if (tokensParts.length !== 3) + return false; + const [header] = tokensParts; + if (!header) + return false; + const parsedHeader = JSON.parse(atob(header)); + if ("typ" in parsedHeader && parsedHeader?.typ !== "JWT") + return false; + if (!parsedHeader.alg) + return false; + if (algorithm && (!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)) + return false; + return true; + } catch { + return false; + } +} +var $ZodJWT = /* @__PURE__ */ $constructor("$ZodJWT", (inst, def) => { + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + if (isValidJWT2(payload.value, def.alg)) + return; + payload.issues.push({ + code: "invalid_format", + format: "jwt", + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodCustomStringFormat = /* @__PURE__ */ $constructor("$ZodCustomStringFormat", (inst, def) => { + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + if (def.fn(payload.value)) + return; + payload.issues.push({ + code: "invalid_format", + format: def.format, + input: payload.value, + inst, + continue: !def.abort + }); + }; +}); +var $ZodNumber = /* @__PURE__ */ $constructor("$ZodNumber", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = inst._zod.bag.pattern ?? number; + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) + try { + payload.value = Number(payload.value); + } catch (_) { + } + const input = payload.value; + if (typeof input === "number" && !Number.isNaN(input) && Number.isFinite(input)) { + return payload; + } + const received = typeof input === "number" ? Number.isNaN(input) ? "NaN" : !Number.isFinite(input) ? "Infinity" : void 0 : void 0; + payload.issues.push({ + expected: "number", + code: "invalid_type", + input, + inst, + ...received ? { received } : {} + }); + return payload; + }; +}); +var $ZodNumberFormat = /* @__PURE__ */ $constructor("$ZodNumberFormat", (inst, def) => { + $ZodCheckNumberFormat.init(inst, def); + $ZodNumber.init(inst, def); +}); +var $ZodBoolean = /* @__PURE__ */ $constructor("$ZodBoolean", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = boolean; + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) + try { + payload.value = Boolean(payload.value); + } catch (_) { + } + const input = payload.value; + if (typeof input === "boolean") + return payload; + payload.issues.push({ + expected: "boolean", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodBigInt = /* @__PURE__ */ $constructor("$ZodBigInt", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = bigint; + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) + try { + payload.value = BigInt(payload.value); + } catch (_) { + } + if (typeof payload.value === "bigint") + return payload; + payload.issues.push({ + expected: "bigint", + code: "invalid_type", + input: payload.value, + inst + }); + return payload; + }; +}); +var $ZodBigIntFormat = /* @__PURE__ */ $constructor("$ZodBigIntFormat", (inst, def) => { + $ZodCheckBigIntFormat.init(inst, def); + $ZodBigInt.init(inst, def); +}); +var $ZodSymbol = /* @__PURE__ */ $constructor("$ZodSymbol", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (typeof input === "symbol") + return payload; + payload.issues.push({ + expected: "symbol", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodUndefined = /* @__PURE__ */ $constructor("$ZodUndefined", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = _undefined; + inst._zod.values = /* @__PURE__ */ new Set([void 0]); + inst._zod.optin = "optional"; + inst._zod.optout = "optional"; + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (typeof input === "undefined") + return payload; + payload.issues.push({ + expected: "undefined", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodNull = /* @__PURE__ */ $constructor("$ZodNull", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = _null; + inst._zod.values = /* @__PURE__ */ new Set([null]); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (input === null) + return payload; + payload.issues.push({ + expected: "null", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodAny = /* @__PURE__ */ $constructor("$ZodAny", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload) => payload; +}); +var $ZodUnknown = /* @__PURE__ */ $constructor("$ZodUnknown", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload) => payload; +}); +var $ZodNever = /* @__PURE__ */ $constructor("$ZodNever", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + payload.issues.push({ + expected: "never", + code: "invalid_type", + input: payload.value, + inst + }); + return payload; + }; +}); +var $ZodVoid = /* @__PURE__ */ $constructor("$ZodVoid", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (typeof input === "undefined") + return payload; + payload.issues.push({ + expected: "void", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) { + try { + payload.value = new Date(payload.value); + } catch (_err) { + } + } + const input = payload.value; + const isDate = input instanceof Date; + const isValidDate = isDate && !Number.isNaN(input.getTime()); + if (isValidDate) + return payload; + payload.issues.push({ + expected: "date", + code: "invalid_type", + input, + ...isDate ? { received: "Invalid Date" } : {}, + inst + }); + return payload; + }; +}); +function handleArrayResult(result, final, index) { + if (result.issues.length) { + final.issues.push(...prefixIssues(index, result.issues)); + } + final.value[index] = result.value; +} +var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!Array.isArray(input)) { + payload.issues.push({ + expected: "array", + code: "invalid_type", + input, + inst + }); + return payload; + } + payload.value = Array(input.length); + const proms = []; + for (let i = 0; i < input.length; i++) { + const item = input[i]; + const result = def.element._zod.run({ + value: item, + issues: [] + }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => handleArrayResult(result2, payload, i))); + } else { + handleArrayResult(result, payload, i); + } + } + if (proms.length) { + return Promise.all(proms).then(() => payload); + } + return payload; + }; +}); +function handlePropertyResult(result, final, key, input, isOptionalOut) { + if (result.issues.length) { + if (isOptionalOut && !(key in input)) { + return; + } + final.issues.push(...prefixIssues(key, result.issues)); + } + if (result.value === void 0) { + if (key in input) { + final.value[key] = void 0; + } + } else { + final.value[key] = result.value; + } +} +function normalizeDef(def) { + const keys = Object.keys(def.shape); + for (const k of keys) { + if (!def.shape?.[k]?._zod?.traits?.has("$ZodType")) { + throw new Error(`Invalid element at key "${k}": expected a Zod schema`); + } + } + const okeys = optionalKeys(def.shape); + return { + ...def, + keys, + keySet: new Set(keys), + numKeys: keys.length, + optionalKeys: new Set(okeys) + }; +} +function handleCatchall(proms, input, payload, ctx, def, inst) { + const unrecognized = []; + const keySet = def.keySet; + const _catchall = def.catchall._zod; + const t = _catchall.def.type; + const isOptionalOut = _catchall.optout === "optional"; + for (const key in input) { + if (keySet.has(key)) + continue; + if (t === "never") { + unrecognized.push(key); + continue; + } + const r = _catchall.run({ value: input[key], issues: [] }, ctx); + if (r instanceof Promise) { + proms.push(r.then((r2) => handlePropertyResult(r2, payload, key, input, isOptionalOut))); + } else { + handlePropertyResult(r, payload, key, input, isOptionalOut); + } + } + if (unrecognized.length) { + payload.issues.push({ + code: "unrecognized_keys", + keys: unrecognized, + input, + inst + }); + } + if (!proms.length) + return payload; + return Promise.all(proms).then(() => { + return payload; + }); +} +var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => { + $ZodType.init(inst, def); + const desc = Object.getOwnPropertyDescriptor(def, "shape"); + if (!desc?.get) { + const sh = def.shape; + Object.defineProperty(def, "shape", { + get: () => { + const newSh = { ...sh }; + Object.defineProperty(def, "shape", { + value: newSh + }); + return newSh; + } + }); + } + const _normalized = cached(() => normalizeDef(def)); + defineLazy(inst._zod, "propValues", () => { + const shape = def.shape; + const propValues = {}; + for (const key in shape) { + const field = shape[key]._zod; + if (field.values) { + propValues[key] ?? (propValues[key] = /* @__PURE__ */ new Set()); + for (const v of field.values) + propValues[key].add(v); + } + } + return propValues; + }); + const isObject2 = isObject; + const catchall = def.catchall; + let value; + inst._zod.parse = (payload, ctx) => { + value ?? (value = _normalized.value); + const input = payload.value; + if (!isObject2(input)) { + payload.issues.push({ + expected: "object", + code: "invalid_type", + input, + inst + }); + return payload; + } + payload.value = {}; + const proms = []; + const shape = value.shape; + for (const key of value.keys) { + const el = shape[key]; + const isOptionalOut = el._zod.optout === "optional"; + const r = el._zod.run({ value: input[key], issues: [] }, ctx); + if (r instanceof Promise) { + proms.push(r.then((r2) => handlePropertyResult(r2, payload, key, input, isOptionalOut))); + } else { + handlePropertyResult(r, payload, key, input, isOptionalOut); + } + } + if (!catchall) { + return proms.length ? Promise.all(proms).then(() => payload) : payload; + } + return handleCatchall(proms, input, payload, ctx, _normalized.value, inst); + }; +}); +var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) => { + $ZodObject.init(inst, def); + const superParse = inst._zod.parse; + const _normalized = cached(() => normalizeDef(def)); + const generateFastpass = (shape) => { + const doc = new Doc(["shape", "payload", "ctx"]); + const normalized = _normalized.value; + const parseStr = (key) => { + const k = esc(key); + return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`; + }; + doc.write(`const input = payload.value;`); + const ids = /* @__PURE__ */ Object.create(null); + let counter = 0; + for (const key of normalized.keys) { + ids[key] = `key_${counter++}`; + } + doc.write(`const newResult = {};`); + for (const key of normalized.keys) { + const id = ids[key]; + const k = esc(key); + const schema = shape[key]; + const isOptionalOut = schema?._zod?.optout === "optional"; + doc.write(`const ${id} = ${parseStr(key)};`); + if (isOptionalOut) { + doc.write(` + if (${id}.issues.length) { + if (${k} in input) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + } + + if (${id}.value === undefined) { + if (${k} in input) { + newResult[${k}] = undefined; + } + } else { + newResult[${k}] = ${id}.value; + } + + `); + } else { + doc.write(` + if (${id}.issues.length) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + + if (${id}.value === undefined) { + if (${k} in input) { + newResult[${k}] = undefined; + } + } else { + newResult[${k}] = ${id}.value; + } + + `); + } + } + doc.write(`payload.value = newResult;`); + doc.write(`return payload;`); + const fn = doc.compile(); + return (payload, ctx) => fn(shape, payload, ctx); + }; + let fastpass; + const isObject2 = isObject; + const jit = !globalConfig.jitless; + const allowsEval2 = allowsEval; + const fastEnabled = jit && allowsEval2.value; + const catchall = def.catchall; + let value; + inst._zod.parse = (payload, ctx) => { + value ?? (value = _normalized.value); + const input = payload.value; + if (!isObject2(input)) { + payload.issues.push({ + expected: "object", + code: "invalid_type", + input, + inst + }); + return payload; + } + if (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) { + if (!fastpass) + fastpass = generateFastpass(def.shape); + payload = fastpass(payload, ctx); + if (!catchall) + return payload; + return handleCatchall([], input, payload, ctx, value, inst); + } + return superParse(payload, ctx); + }; +}); +function handleUnionResults(results, final, inst, ctx) { + for (const result of results) { + if (result.issues.length === 0) { + final.value = result.value; + return final; + } + } + const nonaborted = results.filter((r) => !aborted(r)); + if (nonaborted.length === 1) { + final.value = nonaborted[0].value; + return nonaborted[0]; + } + final.issues.push({ + code: "invalid_union", + input: final.value, + inst, + errors: results.map((result) => result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + }); + return final; +} +var $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "optin", () => def.options.some((o) => o._zod.optin === "optional") ? "optional" : void 0); + defineLazy(inst._zod, "optout", () => def.options.some((o) => o._zod.optout === "optional") ? "optional" : void 0); + defineLazy(inst._zod, "values", () => { + if (def.options.every((o) => o._zod.values)) { + return new Set(def.options.flatMap((option) => Array.from(option._zod.values))); + } + return void 0; + }); + defineLazy(inst._zod, "pattern", () => { + if (def.options.every((o) => o._zod.pattern)) { + const patterns = def.options.map((o) => o._zod.pattern); + return new RegExp(`^(${patterns.map((p) => cleanRegex(p.source)).join("|")})$`); + } + return void 0; + }); + const single = def.options.length === 1; + const first = def.options[0]._zod.run; + inst._zod.parse = (payload, ctx) => { + if (single) { + return first(payload, ctx); + } + let async = false; + const results = []; + for (const option of def.options) { + const result = option._zod.run({ + value: payload.value, + issues: [] + }, ctx); + if (result instanceof Promise) { + results.push(result); + async = true; + } else { + if (result.issues.length === 0) + return result; + results.push(result); + } + } + if (!async) + return handleUnionResults(results, payload, inst, ctx); + return Promise.all(results).then((results2) => { + return handleUnionResults(results2, payload, inst, ctx); + }); + }; +}); +function handleExclusiveUnionResults(results, final, inst, ctx) { + const successes = results.filter((r) => r.issues.length === 0); + if (successes.length === 1) { + final.value = successes[0].value; + return final; + } + if (successes.length === 0) { + final.issues.push({ + code: "invalid_union", + input: final.value, + inst, + errors: results.map((result) => result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + }); + } else { + final.issues.push({ + code: "invalid_union", + input: final.value, + inst, + errors: [], + inclusive: false + }); + } + return final; +} +var $ZodXor = /* @__PURE__ */ $constructor("$ZodXor", (inst, def) => { + $ZodUnion.init(inst, def); + def.inclusive = false; + const single = def.options.length === 1; + const first = def.options[0]._zod.run; + inst._zod.parse = (payload, ctx) => { + if (single) { + return first(payload, ctx); + } + let async = false; + const results = []; + for (const option of def.options) { + const result = option._zod.run({ + value: payload.value, + issues: [] + }, ctx); + if (result instanceof Promise) { + results.push(result); + async = true; + } else { + results.push(result); + } + } + if (!async) + return handleExclusiveUnionResults(results, payload, inst, ctx); + return Promise.all(results).then((results2) => { + return handleExclusiveUnionResults(results2, payload, inst, ctx); + }); + }; +}); +var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnion", (inst, def) => { + def.inclusive = false; + $ZodUnion.init(inst, def); + const _super = inst._zod.parse; + defineLazy(inst._zod, "propValues", () => { + const propValues = {}; + for (const option of def.options) { + const pv = option._zod.propValues; + if (!pv || Object.keys(pv).length === 0) + throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`); + for (const [k, v] of Object.entries(pv)) { + if (!propValues[k]) + propValues[k] = /* @__PURE__ */ new Set(); + for (const val of v) { + propValues[k].add(val); + } + } + } + return propValues; + }); + const disc = cached(() => { + const opts = def.options; + const map2 = /* @__PURE__ */ new Map(); + for (const o of opts) { + const values = o._zod.propValues?.[def.discriminator]; + if (!values || values.size === 0) + throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(o)}"`); + for (const v of values) { + if (map2.has(v)) { + throw new Error(`Duplicate discriminator value "${String(v)}"`); + } + map2.set(v, o); + } + } + return map2; + }); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!isObject(input)) { + payload.issues.push({ + code: "invalid_type", + expected: "object", + input, + inst + }); + return payload; + } + const opt = disc.value.get(input?.[def.discriminator]); + if (opt) { + return opt._zod.run(payload, ctx); + } + if (def.unionFallback) { + return _super(payload, ctx); + } + payload.issues.push({ + code: "invalid_union", + errors: [], + note: "No matching discriminator", + discriminator: def.discriminator, + input, + path: [def.discriminator], + inst + }); + return payload; + }; +}); +var $ZodIntersection = /* @__PURE__ */ $constructor("$ZodIntersection", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + const left = def.left._zod.run({ value: input, issues: [] }, ctx); + const right = def.right._zod.run({ value: input, issues: [] }, ctx); + const async = left instanceof Promise || right instanceof Promise; + if (async) { + return Promise.all([left, right]).then(([left2, right2]) => { + return handleIntersectionResults(payload, left2, right2); + }); + } + return handleIntersectionResults(payload, left, right); + }; +}); +function mergeValues2(a, b) { + if (a === b) { + return { valid: true, data: a }; + } + if (a instanceof Date && b instanceof Date && +a === +b) { + return { valid: true, data: a }; + } + if (isPlainObject(a) && isPlainObject(b)) { + const bKeys = Object.keys(b); + const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1); + const newObj = { ...a, ...b }; + for (const key of sharedKeys) { + const sharedValue = mergeValues2(a[key], b[key]); + if (!sharedValue.valid) { + return { + valid: false, + mergeErrorPath: [key, ...sharedValue.mergeErrorPath] + }; + } + newObj[key] = sharedValue.data; + } + return { valid: true, data: newObj }; + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return { valid: false, mergeErrorPath: [] }; + } + const newArray = []; + for (let index = 0; index < a.length; index++) { + const itemA = a[index]; + const itemB = b[index]; + const sharedValue = mergeValues2(itemA, itemB); + if (!sharedValue.valid) { + return { + valid: false, + mergeErrorPath: [index, ...sharedValue.mergeErrorPath] + }; + } + newArray.push(sharedValue.data); + } + return { valid: true, data: newArray }; + } + return { valid: false, mergeErrorPath: [] }; +} +function handleIntersectionResults(result, left, right) { + const unrecKeys = /* @__PURE__ */ new Map(); + let unrecIssue; + for (const iss of left.issues) { + if (iss.code === "unrecognized_keys") { + unrecIssue ?? (unrecIssue = iss); + for (const k of iss.keys) { + if (!unrecKeys.has(k)) + unrecKeys.set(k, {}); + unrecKeys.get(k).l = true; + } + } else { + result.issues.push(iss); + } + } + for (const iss of right.issues) { + if (iss.code === "unrecognized_keys") { + for (const k of iss.keys) { + if (!unrecKeys.has(k)) + unrecKeys.set(k, {}); + unrecKeys.get(k).r = true; + } + } else { + result.issues.push(iss); + } + } + const bothKeys = [...unrecKeys].filter(([, f]) => f.l && f.r).map(([k]) => k); + if (bothKeys.length && unrecIssue) { + result.issues.push({ ...unrecIssue, keys: bothKeys }); + } + if (aborted(result)) + return result; + const merged = mergeValues2(left.value, right.value); + if (!merged.valid) { + throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(merged.mergeErrorPath)}`); + } + result.value = merged.data; + return result; +} +var $ZodTuple = /* @__PURE__ */ $constructor("$ZodTuple", (inst, def) => { + $ZodType.init(inst, def); + const items = def.items; + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!Array.isArray(input)) { + payload.issues.push({ + input, + inst, + expected: "tuple", + code: "invalid_type" + }); + return payload; + } + payload.value = []; + const proms = []; + const reversedIndex = [...items].reverse().findIndex((item) => item._zod.optin !== "optional"); + const optStart = reversedIndex === -1 ? 0 : items.length - reversedIndex; + if (!def.rest) { + const tooBig = input.length > items.length; + const tooSmall = input.length < optStart - 1; + if (tooBig || tooSmall) { + payload.issues.push({ + ...tooBig ? { code: "too_big", maximum: items.length, inclusive: true } : { code: "too_small", minimum: items.length }, + input, + inst, + origin: "array" + }); + return payload; + } + } + let i = -1; + for (const item of items) { + i++; + if (i >= input.length) { + if (i >= optStart) + continue; + } + const result = item._zod.run({ + value: input[i], + issues: [] + }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => handleTupleResult(result2, payload, i))); + } else { + handleTupleResult(result, payload, i); + } + } + if (def.rest) { + const rest = input.slice(items.length); + for (const el of rest) { + i++; + const result = def.rest._zod.run({ + value: el, + issues: [] + }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => handleTupleResult(result2, payload, i))); + } else { + handleTupleResult(result, payload, i); + } + } + } + if (proms.length) + return Promise.all(proms).then(() => payload); + return payload; + }; +}); +function handleTupleResult(result, final, index) { + if (result.issues.length) { + final.issues.push(...prefixIssues(index, result.issues)); + } + final.value[index] = result.value; +} +var $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!isPlainObject(input)) { + payload.issues.push({ + expected: "record", + code: "invalid_type", + input, + inst + }); + return payload; + } + const proms = []; + const values = def.keyType._zod.values; + if (values) { + payload.value = {}; + const recordKeys = /* @__PURE__ */ new Set(); + for (const key of values) { + if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") { + recordKeys.add(typeof key === "number" ? key.toString() : key); + const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => { + if (result2.issues.length) { + payload.issues.push(...prefixIssues(key, result2.issues)); + } + payload.value[key] = result2.value; + })); + } else { + if (result.issues.length) { + payload.issues.push(...prefixIssues(key, result.issues)); + } + payload.value[key] = result.value; + } + } + } + let unrecognized; + for (const key in input) { + if (!recordKeys.has(key)) { + unrecognized = unrecognized ?? []; + unrecognized.push(key); + } + } + if (unrecognized && unrecognized.length > 0) { + payload.issues.push({ + code: "unrecognized_keys", + input, + inst, + keys: unrecognized + }); + } + } else { + payload.value = {}; + for (const key of Reflect.ownKeys(input)) { + if (key === "__proto__") + continue; + let keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx); + if (keyResult instanceof Promise) { + throw new Error("Async schemas not supported in object keys currently"); + } + const checkNumericKey = typeof key === "string" && number.test(key) && keyResult.issues.length; + if (checkNumericKey) { + const retryResult = def.keyType._zod.run({ value: Number(key), issues: [] }, ctx); + if (retryResult instanceof Promise) { + throw new Error("Async schemas not supported in object keys currently"); + } + if (retryResult.issues.length === 0) { + keyResult = retryResult; + } + } + if (keyResult.issues.length) { + if (def.mode === "loose") { + payload.value[key] = input[key]; + } else { + payload.issues.push({ + code: "invalid_key", + origin: "record", + issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())), + input: key, + path: [key], + inst + }); + } + continue; + } + const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => { + if (result2.issues.length) { + payload.issues.push(...prefixIssues(key, result2.issues)); + } + payload.value[keyResult.value] = result2.value; + })); + } else { + if (result.issues.length) { + payload.issues.push(...prefixIssues(key, result.issues)); + } + payload.value[keyResult.value] = result.value; + } + } + } + if (proms.length) { + return Promise.all(proms).then(() => payload); + } + return payload; + }; +}); +var $ZodMap = /* @__PURE__ */ $constructor("$ZodMap", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!(input instanceof Map)) { + payload.issues.push({ + expected: "map", + code: "invalid_type", + input, + inst + }); + return payload; + } + const proms = []; + payload.value = /* @__PURE__ */ new Map(); + for (const [key, value] of input) { + const keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx); + const valueResult = def.valueType._zod.run({ value, issues: [] }, ctx); + if (keyResult instanceof Promise || valueResult instanceof Promise) { + proms.push(Promise.all([keyResult, valueResult]).then(([keyResult2, valueResult2]) => { + handleMapResult(keyResult2, valueResult2, payload, key, input, inst, ctx); + })); + } else { + handleMapResult(keyResult, valueResult, payload, key, input, inst, ctx); + } + } + if (proms.length) + return Promise.all(proms).then(() => payload); + return payload; + }; +}); +function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) { + if (keyResult.issues.length) { + if (propertyKeyTypes.has(typeof key)) { + final.issues.push(...prefixIssues(key, keyResult.issues)); + } else { + final.issues.push({ + code: "invalid_key", + origin: "map", + input, + inst, + issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())) + }); + } + } + if (valueResult.issues.length) { + if (propertyKeyTypes.has(typeof key)) { + final.issues.push(...prefixIssues(key, valueResult.issues)); + } else { + final.issues.push({ + origin: "map", + code: "invalid_element", + input, + inst, + key, + issues: valueResult.issues.map((iss) => finalizeIssue(iss, ctx, config())) + }); + } + } + final.value.set(keyResult.value, valueResult.value); +} +var $ZodSet = /* @__PURE__ */ $constructor("$ZodSet", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!(input instanceof Set)) { + payload.issues.push({ + input, + inst, + expected: "set", + code: "invalid_type" + }); + return payload; + } + const proms = []; + payload.value = /* @__PURE__ */ new Set(); + for (const item of input) { + const result = def.valueType._zod.run({ value: item, issues: [] }, ctx); + if (result instanceof Promise) { + proms.push(result.then((result2) => handleSetResult(result2, payload))); + } else + handleSetResult(result, payload); + } + if (proms.length) + return Promise.all(proms).then(() => payload); + return payload; + }; +}); +function handleSetResult(result, final) { + if (result.issues.length) { + final.issues.push(...result.issues); + } + final.value.add(result.value); +} +var $ZodEnum = /* @__PURE__ */ $constructor("$ZodEnum", (inst, def) => { + $ZodType.init(inst, def); + const values = getEnumValues(def.entries); + const valuesSet = new Set(values); + inst._zod.values = valuesSet; + inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (valuesSet.has(input)) { + return payload; + } + payload.issues.push({ + code: "invalid_value", + values, + input, + inst + }); + return payload; + }; +}); +var $ZodLiteral = /* @__PURE__ */ $constructor("$ZodLiteral", (inst, def) => { + $ZodType.init(inst, def); + if (def.values.length === 0) { + throw new Error("Cannot create literal schema with no valid values"); + } + const values = new Set(def.values); + inst._zod.values = values; + inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (values.has(input)) { + return payload; + } + payload.issues.push({ + code: "invalid_value", + values: def.values, + input, + inst + }); + return payload; + }; +}); +var $ZodFile = /* @__PURE__ */ $constructor("$ZodFile", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (input instanceof File) + return payload; + payload.issues.push({ + expected: "file", + code: "invalid_type", + input, + inst + }); + return payload; + }; +}); +var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + throw new $ZodEncodeError(inst.constructor.name); + } + const _out = def.transform(payload.value, payload); + if (ctx.async) { + const output = _out instanceof Promise ? _out : Promise.resolve(_out); + return output.then((output2) => { + payload.value = output2; + return payload; + }); + } + if (_out instanceof Promise) { + throw new $ZodAsyncError(); + } + payload.value = _out; + return payload; + }; +}); +function handleOptionalResult(result, input) { + if (result.issues.length && input === void 0) { + return { issues: [], value: void 0 }; + } + return result; +} +var $ZodOptional = /* @__PURE__ */ $constructor("$ZodOptional", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + inst._zod.optout = "optional"; + defineLazy(inst._zod, "values", () => { + return def.innerType._zod.values ? /* @__PURE__ */ new Set([...def.innerType._zod.values, void 0]) : void 0; + }); + defineLazy(inst._zod, "pattern", () => { + const pattern = def.innerType._zod.pattern; + return pattern ? new RegExp(`^(${cleanRegex(pattern.source)})?$`) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + if (def.innerType._zod.optin === "optional") { + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) + return result.then((r) => handleOptionalResult(r, payload.value)); + return handleOptionalResult(result, payload.value); + } + if (payload.value === void 0) { + return payload; + } + return def.innerType._zod.run(payload, ctx); + }; +}); +var $ZodExactOptional = /* @__PURE__ */ $constructor("$ZodExactOptional", (inst, def) => { + $ZodOptional.init(inst, def); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + defineLazy(inst._zod, "pattern", () => def.innerType._zod.pattern); + inst._zod.parse = (payload, ctx) => { + return def.innerType._zod.run(payload, ctx); + }; +}); +var $ZodNullable = /* @__PURE__ */ $constructor("$ZodNullable", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); + defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); + defineLazy(inst._zod, "pattern", () => { + const pattern = def.innerType._zod.pattern; + return pattern ? new RegExp(`^(${cleanRegex(pattern.source)}|null)$`) : void 0; + }); + defineLazy(inst._zod, "values", () => { + return def.innerType._zod.values ? /* @__PURE__ */ new Set([...def.innerType._zod.values, null]) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + if (payload.value === null) + return payload; + return def.innerType._zod.run(payload, ctx); + }; +}); +var $ZodDefault = /* @__PURE__ */ $constructor("$ZodDefault", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + return def.innerType._zod.run(payload, ctx); + } + if (payload.value === void 0) { + payload.value = def.defaultValue; + return payload; + } + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) { + return result.then((result2) => handleDefaultResult(result2, def)); + } + return handleDefaultResult(result, def); + }; +}); +function handleDefaultResult(payload, def) { + if (payload.value === void 0) { + payload.value = def.defaultValue; + } + return payload; +} +var $ZodPrefault = /* @__PURE__ */ $constructor("$ZodPrefault", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + return def.innerType._zod.run(payload, ctx); + } + if (payload.value === void 0) { + payload.value = def.defaultValue; + } + return def.innerType._zod.run(payload, ctx); + }; +}); +var $ZodNonOptional = /* @__PURE__ */ $constructor("$ZodNonOptional", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "values", () => { + const v = def.innerType._zod.values; + return v ? new Set([...v].filter((x) => x !== void 0)) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) { + return result.then((result2) => handleNonOptionalResult(result2, inst)); + } + return handleNonOptionalResult(result, inst); + }; +}); +function handleNonOptionalResult(payload, inst) { + if (!payload.issues.length && payload.value === void 0) { + payload.issues.push({ + code: "invalid_type", + expected: "nonoptional", + input: payload.value, + inst + }); + } + return payload; +} +var $ZodSuccess = /* @__PURE__ */ $constructor("$ZodSuccess", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + throw new $ZodEncodeError("ZodSuccess"); + } + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) { + return result.then((result2) => { + payload.value = result2.issues.length === 0; + return payload; + }); + } + payload.value = result.issues.length === 0; + return payload; + }; +}); +var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); + defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + return def.innerType._zod.run(payload, ctx); + } + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) { + return result.then((result2) => { + payload.value = result2.value; + if (result2.issues.length) { + payload.value = def.catchValue({ + ...payload, + error: { + issues: result2.issues.map((iss) => finalizeIssue(iss, ctx, config())) + }, + input: payload.value + }); + payload.issues = []; + } + return payload; + }); + } + payload.value = result.value; + if (result.issues.length) { + payload.value = def.catchValue({ + ...payload, + error: { + issues: result.issues.map((iss) => finalizeIssue(iss, ctx, config())) + }, + input: payload.value + }); + payload.issues = []; + } + return payload; + }; +}); +var $ZodNaN = /* @__PURE__ */ $constructor("$ZodNaN", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + if (typeof payload.value !== "number" || !Number.isNaN(payload.value)) { + payload.issues.push({ + input: payload.value, + inst, + expected: "nan", + code: "invalid_type" + }); + return payload; + } + return payload; + }; +}); +var $ZodPipe = /* @__PURE__ */ $constructor("$ZodPipe", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "values", () => def.in._zod.values); + defineLazy(inst._zod, "optin", () => def.in._zod.optin); + defineLazy(inst._zod, "optout", () => def.out._zod.optout); + defineLazy(inst._zod, "propValues", () => def.in._zod.propValues); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + const right = def.out._zod.run(payload, ctx); + if (right instanceof Promise) { + return right.then((right2) => handlePipeResult(right2, def.in, ctx)); + } + return handlePipeResult(right, def.in, ctx); + } + const left = def.in._zod.run(payload, ctx); + if (left instanceof Promise) { + return left.then((left2) => handlePipeResult(left2, def.out, ctx)); + } + return handlePipeResult(left, def.out, ctx); + }; +}); +function handlePipeResult(left, next, ctx) { + if (left.issues.length) { + left.aborted = true; + return left; + } + return next._zod.run({ value: left.value, issues: left.issues }, ctx); +} +var $ZodCodec = /* @__PURE__ */ $constructor("$ZodCodec", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "values", () => def.in._zod.values); + defineLazy(inst._zod, "optin", () => def.in._zod.optin); + defineLazy(inst._zod, "optout", () => def.out._zod.optout); + defineLazy(inst._zod, "propValues", () => def.in._zod.propValues); + inst._zod.parse = (payload, ctx) => { + const direction = ctx.direction || "forward"; + if (direction === "forward") { + const left = def.in._zod.run(payload, ctx); + if (left instanceof Promise) { + return left.then((left2) => handleCodecAResult(left2, def, ctx)); + } + return handleCodecAResult(left, def, ctx); + } else { + const right = def.out._zod.run(payload, ctx); + if (right instanceof Promise) { + return right.then((right2) => handleCodecAResult(right2, def, ctx)); + } + return handleCodecAResult(right, def, ctx); + } + }; +}); +function handleCodecAResult(result, def, ctx) { + if (result.issues.length) { + result.aborted = true; + return result; + } + const direction = ctx.direction || "forward"; + if (direction === "forward") { + const transformed = def.transform(result.value, result); + if (transformed instanceof Promise) { + return transformed.then((value) => handleCodecTxResult(result, value, def.out, ctx)); + } + return handleCodecTxResult(result, transformed, def.out, ctx); + } else { + const transformed = def.reverseTransform(result.value, result); + if (transformed instanceof Promise) { + return transformed.then((value) => handleCodecTxResult(result, value, def.in, ctx)); + } + return handleCodecTxResult(result, transformed, def.in, ctx); + } +} +function handleCodecTxResult(left, value, nextSchema, ctx) { + if (left.issues.length) { + left.aborted = true; + return left; + } + return nextSchema._zod.run({ value, issues: left.issues }, ctx); +} +var $ZodReadonly = /* @__PURE__ */ $constructor("$ZodReadonly", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "propValues", () => def.innerType._zod.propValues); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + defineLazy(inst._zod, "optin", () => def.innerType?._zod?.optin); + defineLazy(inst._zod, "optout", () => def.innerType?._zod?.optout); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + return def.innerType._zod.run(payload, ctx); + } + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) { + return result.then(handleReadonlyResult); + } + return handleReadonlyResult(result); + }; +}); +function handleReadonlyResult(payload) { + payload.value = Object.freeze(payload.value); + return payload; +} +var $ZodTemplateLiteral = /* @__PURE__ */ $constructor("$ZodTemplateLiteral", (inst, def) => { + $ZodType.init(inst, def); + const regexParts = []; + for (const part of def.parts) { + if (typeof part === "object" && part !== null) { + if (!part._zod.pattern) { + throw new Error(`Invalid template literal part, no pattern found: ${[...part._zod.traits].shift()}`); + } + const source = part._zod.pattern instanceof RegExp ? part._zod.pattern.source : part._zod.pattern; + if (!source) + throw new Error(`Invalid template literal part: ${part._zod.traits}`); + const start = source.startsWith("^") ? 1 : 0; + const end = source.endsWith("$") ? source.length - 1 : source.length; + regexParts.push(source.slice(start, end)); + } else if (part === null || primitiveTypes.has(typeof part)) { + regexParts.push(escapeRegex(`${part}`)); + } else { + throw new Error(`Invalid template literal part: ${part}`); + } + } + inst._zod.pattern = new RegExp(`^${regexParts.join("")}$`); + inst._zod.parse = (payload, _ctx) => { + if (typeof payload.value !== "string") { + payload.issues.push({ + input: payload.value, + inst, + expected: "string", + code: "invalid_type" + }); + return payload; + } + inst._zod.pattern.lastIndex = 0; + if (!inst._zod.pattern.test(payload.value)) { + payload.issues.push({ + input: payload.value, + inst, + code: "invalid_format", + format: def.format ?? "template_literal", + pattern: inst._zod.pattern.source + }); + return payload; + } + return payload; + }; +}); +var $ZodFunction = /* @__PURE__ */ $constructor("$ZodFunction", (inst, def) => { + $ZodType.init(inst, def); + inst._def = def; + inst._zod.def = def; + inst.implement = (func) => { + if (typeof func !== "function") { + throw new Error("implement() must be called with a function"); + } + return function(...args) { + const parsedArgs = inst._def.input ? parse2(inst._def.input, args) : args; + const result = Reflect.apply(func, this, parsedArgs); + if (inst._def.output) { + return parse2(inst._def.output, result); + } + return result; + }; + }; + inst.implementAsync = (func) => { + if (typeof func !== "function") { + throw new Error("implementAsync() must be called with a function"); + } + return async function(...args) { + const parsedArgs = inst._def.input ? await parseAsync(inst._def.input, args) : args; + const result = await Reflect.apply(func, this, parsedArgs); + if (inst._def.output) { + return await parseAsync(inst._def.output, result); + } + return result; + }; + }; + inst._zod.parse = (payload, _ctx) => { + if (typeof payload.value !== "function") { + payload.issues.push({ + code: "invalid_type", + expected: "function", + input: payload.value, + inst + }); + return payload; + } + const hasPromiseOutput = inst._def.output && inst._def.output._zod.def.type === "promise"; + if (hasPromiseOutput) { + payload.value = inst.implementAsync(payload.value); + } else { + payload.value = inst.implement(payload.value); + } + return payload; + }; + inst.input = (...args) => { + const F = inst.constructor; + if (Array.isArray(args[0])) { + return new F({ + type: "function", + input: new $ZodTuple({ + type: "tuple", + items: args[0], + rest: args[1] + }), + output: inst._def.output + }); + } + return new F({ + type: "function", + input: args[0], + output: inst._def.output + }); + }; + inst.output = (output) => { + const F = inst.constructor; + return new F({ + type: "function", + input: inst._def.input, + output + }); + }; + return inst; +}); +var $ZodPromise = /* @__PURE__ */ $constructor("$ZodPromise", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + return Promise.resolve(payload.value).then((inner) => def.innerType._zod.run({ value: inner, issues: [] }, ctx)); + }; +}); +var $ZodLazy = /* @__PURE__ */ $constructor("$ZodLazy", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "innerType", () => def.getter()); + defineLazy(inst._zod, "pattern", () => inst._zod.innerType?._zod?.pattern); + defineLazy(inst._zod, "propValues", () => inst._zod.innerType?._zod?.propValues); + defineLazy(inst._zod, "optin", () => inst._zod.innerType?._zod?.optin ?? void 0); + defineLazy(inst._zod, "optout", () => inst._zod.innerType?._zod?.optout ?? void 0); + inst._zod.parse = (payload, ctx) => { + const inner = inst._zod.innerType; + return inner._zod.run(payload, ctx); + }; +}); +var $ZodCustom = /* @__PURE__ */ $constructor("$ZodCustom", (inst, def) => { + $ZodCheck.init(inst, def); + $ZodType.init(inst, def); + inst._zod.parse = (payload, _) => { + return payload; + }; + inst._zod.check = (payload) => { + const input = payload.value; + const r = def.fn(input); + if (r instanceof Promise) { + return r.then((r2) => handleRefineResult(r2, payload, input, inst)); + } + handleRefineResult(r, payload, input, inst); + return; + }; +}); +function handleRefineResult(result, payload, input, inst) { + if (!result) { + const _iss = { + code: "custom", + input, + inst, + // incorporates params.error into issue reporting + path: [...inst._zod.def.path ?? []], + // incorporates params.error into issue reporting + continue: !inst._zod.def.abort + // params: inst._zod.def.params, + }; + if (inst._zod.def.params) + _iss.params = inst._zod.def.params; + payload.issues.push(issue(_iss)); + } +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/en.js +var error = () => { + const Sizable = { + string: { unit: "characters", verb: "to have" }, + file: { unit: "bytes", verb: "to have" }, + array: { unit: "items", verb: "to have" }, + set: { unit: "items", verb: "to have" }, + map: { unit: "entries", verb: "to have" } + }; + function getSizing(origin) { + return Sizable[origin] ?? null; + } + const FormatDictionary = { + regex: "input", + email: "email address", + url: "URL", + emoji: "emoji", + uuid: "UUID", + uuidv4: "UUIDv4", + uuidv6: "UUIDv6", + nanoid: "nanoid", + guid: "GUID", + cuid: "cuid", + cuid2: "cuid2", + ulid: "ULID", + xid: "XID", + ksuid: "KSUID", + datetime: "ISO datetime", + date: "ISO date", + time: "ISO time", + duration: "ISO duration", + ipv4: "IPv4 address", + ipv6: "IPv6 address", + mac: "MAC address", + cidrv4: "IPv4 range", + cidrv6: "IPv6 range", + base64: "base64-encoded string", + base64url: "base64url-encoded string", + json_string: "JSON string", + e164: "E.164 number", + jwt: "JWT", + template_literal: "input" + }; + const TypeDictionary = { + // Compatibility: "nan" -> "NaN" for display + nan: "NaN" + // All other type names omitted - they fall back to raw values via ?? operator + }; + return (issue2) => { + switch (issue2.code) { + case "invalid_type": { + const expected = TypeDictionary[issue2.expected] ?? issue2.expected; + const receivedType = parsedType(issue2.input); + const received = TypeDictionary[receivedType] ?? receivedType; + return `Invalid input: expected ${expected}, received ${received}`; + } + case "invalid_value": + if (issue2.values.length === 1) + return `Invalid input: expected ${stringifyPrimitive(issue2.values[0])}`; + return `Invalid option: expected one of ${joinValues(issue2.values, "|")}`; + case "too_big": { + const adj = issue2.inclusive ? "<=" : "<"; + const sizing = getSizing(issue2.origin); + if (sizing) + return `Too big: expected ${issue2.origin ?? "value"} to have ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elements"}`; + return `Too big: expected ${issue2.origin ?? "value"} to be ${adj}${issue2.maximum.toString()}`; + } + case "too_small": { + const adj = issue2.inclusive ? ">=" : ">"; + const sizing = getSizing(issue2.origin); + if (sizing) { + return `Too small: expected ${issue2.origin} to have ${adj}${issue2.minimum.toString()} ${sizing.unit}`; + } + return `Too small: expected ${issue2.origin} to be ${adj}${issue2.minimum.toString()}`; + } + case "invalid_format": { + const _issue = issue2; + if (_issue.format === "starts_with") { + return `Invalid string: must start with "${_issue.prefix}"`; + } + if (_issue.format === "ends_with") + return `Invalid string: must end with "${_issue.suffix}"`; + if (_issue.format === "includes") + return `Invalid string: must include "${_issue.includes}"`; + if (_issue.format === "regex") + return `Invalid string: must match pattern ${_issue.pattern}`; + return `Invalid ${FormatDictionary[_issue.format] ?? issue2.format}`; + } + case "not_multiple_of": + return `Invalid number: must be a multiple of ${issue2.divisor}`; + case "unrecognized_keys": + return `Unrecognized key${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; + case "invalid_key": + return `Invalid key in ${issue2.origin}`; + case "invalid_union": + return "Invalid input"; + case "invalid_element": + return `Invalid value in ${issue2.origin}`; + default: + return `Invalid input`; + } + }; +}; +function en_default2() { + return { + localeError: error() + }; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/registries.js +var _a; +var $ZodRegistry = class { + constructor() { + this._map = /* @__PURE__ */ new WeakMap(); + this._idmap = /* @__PURE__ */ new Map(); + } + add(schema, ..._meta) { + const meta3 = _meta[0]; + this._map.set(schema, meta3); + if (meta3 && typeof meta3 === "object" && "id" in meta3) { + this._idmap.set(meta3.id, schema); + } + return this; + } + clear() { + this._map = /* @__PURE__ */ new WeakMap(); + this._idmap = /* @__PURE__ */ new Map(); + return this; + } + remove(schema) { + const meta3 = this._map.get(schema); + if (meta3 && typeof meta3 === "object" && "id" in meta3) { + this._idmap.delete(meta3.id); + } + this._map.delete(schema); + return this; + } + get(schema) { + const p = schema._zod.parent; + if (p) { + const pm = { ...this.get(p) ?? {} }; + delete pm.id; + const f = { ...pm, ...this._map.get(schema) }; + return Object.keys(f).length ? f : void 0; + } + return this._map.get(schema); + } + has(schema) { + return this._map.has(schema); + } +}; +function registry() { + return new $ZodRegistry(); +} +(_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry()); +var globalRegistry = globalThis.__zod_globalRegistry; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/api.js +// @__NO_SIDE_EFFECTS__ +function _string(Class2, params) { + return new Class2({ + type: "string", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _email(Class2, params) { + return new Class2({ + type: "string", + format: "email", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _guid(Class2, params) { + return new Class2({ + type: "string", + format: "guid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uuid(Class2, params) { + return new Class2({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uuidv4(Class2, params) { + return new Class2({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v4", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uuidv6(Class2, params) { + return new Class2({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v6", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uuidv7(Class2, params) { + return new Class2({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v7", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _url(Class2, params) { + return new Class2({ + type: "string", + format: "url", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _emoji2(Class2, params) { + return new Class2({ + type: "string", + format: "emoji", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _nanoid(Class2, params) { + return new Class2({ + type: "string", + format: "nanoid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _cuid(Class2, params) { + return new Class2({ + type: "string", + format: "cuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _cuid2(Class2, params) { + return new Class2({ + type: "string", + format: "cuid2", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _ulid(Class2, params) { + return new Class2({ + type: "string", + format: "ulid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _xid(Class2, params) { + return new Class2({ + type: "string", + format: "xid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _ksuid(Class2, params) { + return new Class2({ + type: "string", + format: "ksuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _ipv4(Class2, params) { + return new Class2({ + type: "string", + format: "ipv4", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _ipv6(Class2, params) { + return new Class2({ + type: "string", + format: "ipv6", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _mac(Class2, params) { + return new Class2({ + type: "string", + format: "mac", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _cidrv4(Class2, params) { + return new Class2({ + type: "string", + format: "cidrv4", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _cidrv6(Class2, params) { + return new Class2({ + type: "string", + format: "cidrv6", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _base64(Class2, params) { + return new Class2({ + type: "string", + format: "base64", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _base64url(Class2, params) { + return new Class2({ + type: "string", + format: "base64url", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _e164(Class2, params) { + return new Class2({ + type: "string", + format: "e164", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _jwt(Class2, params) { + return new Class2({ + type: "string", + format: "jwt", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _isoDateTime(Class2, params) { + return new Class2({ + type: "string", + format: "datetime", + check: "string_format", + offset: false, + local: false, + precision: null, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _isoDate(Class2, params) { + return new Class2({ + type: "string", + format: "date", + check: "string_format", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _isoTime(Class2, params) { + return new Class2({ + type: "string", + format: "time", + check: "string_format", + precision: null, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _isoDuration(Class2, params) { + return new Class2({ + type: "string", + format: "duration", + check: "string_format", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _number(Class2, params) { + return new Class2({ + type: "number", + checks: [], + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _int(Class2, params) { + return new Class2({ + type: "number", + check: "number_format", + abort: false, + format: "safeint", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _float32(Class2, params) { + return new Class2({ + type: "number", + check: "number_format", + abort: false, + format: "float32", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _float64(Class2, params) { + return new Class2({ + type: "number", + check: "number_format", + abort: false, + format: "float64", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _int32(Class2, params) { + return new Class2({ + type: "number", + check: "number_format", + abort: false, + format: "int32", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uint32(Class2, params) { + return new Class2({ + type: "number", + check: "number_format", + abort: false, + format: "uint32", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _boolean(Class2, params) { + return new Class2({ + type: "boolean", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _bigint(Class2, params) { + return new Class2({ + type: "bigint", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _int64(Class2, params) { + return new Class2({ + type: "bigint", + check: "bigint_format", + abort: false, + format: "int64", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uint64(Class2, params) { + return new Class2({ + type: "bigint", + check: "bigint_format", + abort: false, + format: "uint64", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _symbol(Class2, params) { + return new Class2({ + type: "symbol", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _undefined2(Class2, params) { + return new Class2({ + type: "undefined", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _null2(Class2, params) { + return new Class2({ + type: "null", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _any(Class2) { + return new Class2({ + type: "any" + }); +} +// @__NO_SIDE_EFFECTS__ +function _unknown(Class2) { + return new Class2({ + type: "unknown" + }); +} +// @__NO_SIDE_EFFECTS__ +function _never(Class2, params) { + return new Class2({ + type: "never", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _void(Class2, params) { + return new Class2({ + type: "void", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _date(Class2, params) { + return new Class2({ + type: "date", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _nan(Class2, params) { + return new Class2({ + type: "nan", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _lt(value, params) { + return new $ZodCheckLessThan({ + check: "less_than", + ...normalizeParams(params), + value, + inclusive: false + }); +} +// @__NO_SIDE_EFFECTS__ +function _lte(value, params) { + return new $ZodCheckLessThan({ + check: "less_than", + ...normalizeParams(params), + value, + inclusive: true + }); +} +// @__NO_SIDE_EFFECTS__ +function _gt(value, params) { + return new $ZodCheckGreaterThan({ + check: "greater_than", + ...normalizeParams(params), + value, + inclusive: false + }); +} +// @__NO_SIDE_EFFECTS__ +function _gte(value, params) { + return new $ZodCheckGreaterThan({ + check: "greater_than", + ...normalizeParams(params), + value, + inclusive: true + }); +} +// @__NO_SIDE_EFFECTS__ +function _positive(params) { + return /* @__PURE__ */ _gt(0, params); +} +// @__NO_SIDE_EFFECTS__ +function _negative(params) { + return /* @__PURE__ */ _lt(0, params); +} +// @__NO_SIDE_EFFECTS__ +function _nonpositive(params) { + return /* @__PURE__ */ _lte(0, params); +} +// @__NO_SIDE_EFFECTS__ +function _nonnegative(params) { + return /* @__PURE__ */ _gte(0, params); +} +// @__NO_SIDE_EFFECTS__ +function _multipleOf(value, params) { + return new $ZodCheckMultipleOf({ + check: "multiple_of", + ...normalizeParams(params), + value + }); +} +// @__NO_SIDE_EFFECTS__ +function _maxSize(maximum, params) { + return new $ZodCheckMaxSize({ + check: "max_size", + ...normalizeParams(params), + maximum + }); +} +// @__NO_SIDE_EFFECTS__ +function _minSize(minimum, params) { + return new $ZodCheckMinSize({ + check: "min_size", + ...normalizeParams(params), + minimum + }); +} +// @__NO_SIDE_EFFECTS__ +function _size(size, params) { + return new $ZodCheckSizeEquals({ + check: "size_equals", + ...normalizeParams(params), + size + }); +} +// @__NO_SIDE_EFFECTS__ +function _maxLength(maximum, params) { + const ch = new $ZodCheckMaxLength({ + check: "max_length", + ...normalizeParams(params), + maximum + }); + return ch; +} +// @__NO_SIDE_EFFECTS__ +function _minLength(minimum, params) { + return new $ZodCheckMinLength({ + check: "min_length", + ...normalizeParams(params), + minimum + }); +} +// @__NO_SIDE_EFFECTS__ +function _length(length, params) { + return new $ZodCheckLengthEquals({ + check: "length_equals", + ...normalizeParams(params), + length + }); +} +// @__NO_SIDE_EFFECTS__ +function _regex(pattern, params) { + return new $ZodCheckRegex({ + check: "string_format", + format: "regex", + ...normalizeParams(params), + pattern + }); +} +// @__NO_SIDE_EFFECTS__ +function _lowercase(params) { + return new $ZodCheckLowerCase({ + check: "string_format", + format: "lowercase", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _uppercase(params) { + return new $ZodCheckUpperCase({ + check: "string_format", + format: "uppercase", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _includes(includes, params) { + return new $ZodCheckIncludes({ + check: "string_format", + format: "includes", + ...normalizeParams(params), + includes + }); +} +// @__NO_SIDE_EFFECTS__ +function _startsWith(prefix, params) { + return new $ZodCheckStartsWith({ + check: "string_format", + format: "starts_with", + ...normalizeParams(params), + prefix + }); +} +// @__NO_SIDE_EFFECTS__ +function _endsWith(suffix, params) { + return new $ZodCheckEndsWith({ + check: "string_format", + format: "ends_with", + ...normalizeParams(params), + suffix + }); +} +// @__NO_SIDE_EFFECTS__ +function _property(property, schema, params) { + return new $ZodCheckProperty({ + check: "property", + property, + schema, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _mime(types, params) { + return new $ZodCheckMimeType({ + check: "mime_type", + mime: types, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _overwrite(tx) { + return new $ZodCheckOverwrite({ + check: "overwrite", + tx + }); +} +// @__NO_SIDE_EFFECTS__ +function _normalize(form) { + return /* @__PURE__ */ _overwrite((input) => input.normalize(form)); +} +// @__NO_SIDE_EFFECTS__ +function _trim() { + return /* @__PURE__ */ _overwrite((input) => input.trim()); +} +// @__NO_SIDE_EFFECTS__ +function _toLowerCase() { + return /* @__PURE__ */ _overwrite((input) => input.toLowerCase()); +} +// @__NO_SIDE_EFFECTS__ +function _toUpperCase() { + return /* @__PURE__ */ _overwrite((input) => input.toUpperCase()); +} +// @__NO_SIDE_EFFECTS__ +function _slugify() { + return /* @__PURE__ */ _overwrite((input) => slugify(input)); +} +// @__NO_SIDE_EFFECTS__ +function _array(Class2, element, params) { + return new Class2({ + type: "array", + element, + // get element() { + // return element; + // }, + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _file(Class2, params) { + return new Class2({ + type: "file", + ...normalizeParams(params) + }); +} +// @__NO_SIDE_EFFECTS__ +function _custom(Class2, fn, _params) { + const norm = normalizeParams(_params); + norm.abort ?? (norm.abort = true); + const schema = new Class2({ + type: "custom", + check: "custom", + fn, + ...norm + }); + return schema; +} +// @__NO_SIDE_EFFECTS__ +function _refine(Class2, fn, _params) { + const schema = new Class2({ + type: "custom", + check: "custom", + fn, + ...normalizeParams(_params) + }); + return schema; +} +// @__NO_SIDE_EFFECTS__ +function _superRefine(fn) { + const ch = /* @__PURE__ */ _check((payload) => { + payload.addIssue = (issue2) => { + if (typeof issue2 === "string") { + payload.issues.push(issue(issue2, payload.value, ch._zod.def)); + } else { + const _issue = issue2; + if (_issue.fatal) + _issue.continue = false; + _issue.code ?? (_issue.code = "custom"); + _issue.input ?? (_issue.input = payload.value); + _issue.inst ?? (_issue.inst = ch); + _issue.continue ?? (_issue.continue = !ch._zod.def.abort); + payload.issues.push(issue(_issue)); + } + }; + return fn(payload.value, payload); + }); + return ch; +} +// @__NO_SIDE_EFFECTS__ +function _check(fn, params) { + const ch = new $ZodCheck({ + check: "custom", + ...normalizeParams(params) + }); + ch._zod.check = fn; + return ch; +} +// @__NO_SIDE_EFFECTS__ +function describe(description) { + const ch = new $ZodCheck({ check: "describe" }); + ch._zod.onattach = [ + (inst) => { + const existing = globalRegistry.get(inst) ?? {}; + globalRegistry.add(inst, { ...existing, description }); + } + ]; + ch._zod.check = () => { + }; + return ch; +} +// @__NO_SIDE_EFFECTS__ +function meta(metadata) { + const ch = new $ZodCheck({ check: "meta" }); + ch._zod.onattach = [ + (inst) => { + const existing = globalRegistry.get(inst) ?? {}; + globalRegistry.add(inst, { ...existing, ...metadata }); + } + ]; + ch._zod.check = () => { + }; + return ch; +} +// @__NO_SIDE_EFFECTS__ +function _stringbool(Classes, _params) { + const params = normalizeParams(_params); + let truthyArray = params.truthy ?? ["true", "1", "yes", "on", "y", "enabled"]; + let falsyArray = params.falsy ?? ["false", "0", "no", "off", "n", "disabled"]; + if (params.case !== "sensitive") { + truthyArray = truthyArray.map((v) => typeof v === "string" ? v.toLowerCase() : v); + falsyArray = falsyArray.map((v) => typeof v === "string" ? v.toLowerCase() : v); + } + const truthySet = new Set(truthyArray); + const falsySet = new Set(falsyArray); + const _Codec = Classes.Codec ?? $ZodCodec; + const _Boolean = Classes.Boolean ?? $ZodBoolean; + const _String = Classes.String ?? $ZodString; + const stringSchema = new _String({ type: "string", error: params.error }); + const booleanSchema = new _Boolean({ type: "boolean", error: params.error }); + const codec2 = new _Codec({ + type: "pipe", + in: stringSchema, + out: booleanSchema, + transform: ((input, payload) => { + let data = input; + if (params.case !== "sensitive") + data = data.toLowerCase(); + if (truthySet.has(data)) { + return true; + } else if (falsySet.has(data)) { + return false; + } else { + payload.issues.push({ + code: "invalid_value", + expected: "stringbool", + values: [...truthySet, ...falsySet], + input: payload.value, + inst: codec2, + continue: false + }); + return {}; + } + }), + reverseTransform: ((input, _payload) => { + if (input === true) { + return truthyArray[0] || "true"; + } else { + return falsyArray[0] || "false"; + } + }), + error: params.error + }); + return codec2; +} +// @__NO_SIDE_EFFECTS__ +function _stringFormat(Class2, format, fnOrRegex, _params = {}) { + const params = normalizeParams(_params); + const def = { + ...normalizeParams(_params), + check: "string_format", + type: "string", + format, + fn: typeof fnOrRegex === "function" ? fnOrRegex : (val) => fnOrRegex.test(val), + ...params + }; + if (fnOrRegex instanceof RegExp) { + def.pattern = fnOrRegex; + } + const inst = new Class2(def); + return inst; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/to-json-schema.js +function initializeContext(params) { + let target = params?.target ?? "draft-2020-12"; + if (target === "draft-4") + target = "draft-04"; + if (target === "draft-7") + target = "draft-07"; + return { + processors: params.processors ?? {}, + metadataRegistry: params?.metadata ?? globalRegistry, + target, + unrepresentable: params?.unrepresentable ?? "throw", + override: params?.override ?? (() => { + }), + io: params?.io ?? "output", + counter: 0, + seen: /* @__PURE__ */ new Map(), + cycles: params?.cycles ?? "ref", + reused: params?.reused ?? "inline", + external: params?.external ?? void 0 + }; +} +function process2(schema, ctx, _params = { path: [], schemaPath: [] }) { + var _a2; + const def = schema._zod.def; + const seen = ctx.seen.get(schema); + if (seen) { + seen.count++; + const isCycle = _params.schemaPath.includes(schema); + if (isCycle) { + seen.cycle = _params.path; + } + return seen.schema; + } + const result = { schema: {}, count: 1, cycle: void 0, path: _params.path }; + ctx.seen.set(schema, result); + const overrideSchema = schema._zod.toJSONSchema?.(); + if (overrideSchema) { + result.schema = overrideSchema; + } else { + const params = { + ..._params, + schemaPath: [..._params.schemaPath, schema], + path: _params.path + }; + if (schema._zod.processJSONSchema) { + schema._zod.processJSONSchema(ctx, result.schema, params); + } else { + const _json = result.schema; + const processor = ctx.processors[def.type]; + if (!processor) { + throw new Error(`[toJSONSchema]: Non-representable type encountered: ${def.type}`); + } + processor(schema, ctx, _json, params); + } + const parent = schema._zod.parent; + if (parent) { + if (!result.ref) + result.ref = parent; + process2(parent, ctx, params); + ctx.seen.get(parent).isParent = true; + } + } + const meta3 = ctx.metadataRegistry.get(schema); + if (meta3) + Object.assign(result.schema, meta3); + if (ctx.io === "input" && isTransforming(schema)) { + delete result.schema.examples; + delete result.schema.default; + } + if (ctx.io === "input" && result.schema._prefault) + (_a2 = result.schema).default ?? (_a2.default = result.schema._prefault); + delete result.schema._prefault; + const _result = ctx.seen.get(schema); + return _result.schema; +} +function extractDefs(ctx, schema) { + const root = ctx.seen.get(schema); + if (!root) + throw new Error("Unprocessed schema. This is a bug in Zod."); + const idToSchema = /* @__PURE__ */ new Map(); + for (const entry of ctx.seen.entries()) { + const id = ctx.metadataRegistry.get(entry[0])?.id; + if (id) { + const existing = idToSchema.get(id); + if (existing && existing !== entry[0]) { + throw new Error(`Duplicate schema id "${id}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`); + } + idToSchema.set(id, entry[0]); + } + } + const makeURI = (entry) => { + const defsSegment = ctx.target === "draft-2020-12" ? "$defs" : "definitions"; + if (ctx.external) { + const externalId = ctx.external.registry.get(entry[0])?.id; + const uriGenerator = ctx.external.uri ?? ((id2) => id2); + if (externalId) { + return { ref: uriGenerator(externalId) }; + } + const id = entry[1].defId ?? entry[1].schema.id ?? `schema${ctx.counter++}`; + entry[1].defId = id; + return { defId: id, ref: `${uriGenerator("__shared")}#/${defsSegment}/${id}` }; + } + if (entry[1] === root) { + return { ref: "#" }; + } + const uriPrefix = `#`; + const defUriPrefix = `${uriPrefix}/${defsSegment}/`; + const defId = entry[1].schema.id ?? `__schema${ctx.counter++}`; + return { defId, ref: defUriPrefix + defId }; + }; + const extractToDef = (entry) => { + if (entry[1].schema.$ref) { + return; + } + const seen = entry[1]; + const { ref, defId } = makeURI(entry); + seen.def = { ...seen.schema }; + if (defId) + seen.defId = defId; + const schema2 = seen.schema; + for (const key in schema2) { + delete schema2[key]; + } + schema2.$ref = ref; + }; + if (ctx.cycles === "throw") { + for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (seen.cycle) { + throw new Error(`Cycle detected: #/${seen.cycle?.join("/")}/ + +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`); + } + } + } + for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (schema === entry[0]) { + extractToDef(entry); + continue; + } + if (ctx.external) { + const ext = ctx.external.registry.get(entry[0])?.id; + if (schema !== entry[0] && ext) { + extractToDef(entry); + continue; + } + } + const id = ctx.metadataRegistry.get(entry[0])?.id; + if (id) { + extractToDef(entry); + continue; + } + if (seen.cycle) { + extractToDef(entry); + continue; + } + if (seen.count > 1) { + if (ctx.reused === "ref") { + extractToDef(entry); + continue; + } + } + } +} +function finalize(ctx, schema) { + const root = ctx.seen.get(schema); + if (!root) + throw new Error("Unprocessed schema. This is a bug in Zod."); + const flattenRef = (zodSchema) => { + const seen = ctx.seen.get(zodSchema); + if (seen.ref === null) + return; + const schema2 = seen.def ?? seen.schema; + const _cached = { ...schema2 }; + const ref = seen.ref; + seen.ref = null; + if (ref) { + flattenRef(ref); + const refSeen = ctx.seen.get(ref); + const refSchema = refSeen.schema; + if (refSchema.$ref && (ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0")) { + schema2.allOf = schema2.allOf ?? []; + schema2.allOf.push(refSchema); + } else { + Object.assign(schema2, refSchema); + } + Object.assign(schema2, _cached); + const isParentRef = zodSchema._zod.parent === ref; + if (isParentRef) { + for (const key in schema2) { + if (key === "$ref" || key === "allOf") + continue; + if (!(key in _cached)) { + delete schema2[key]; + } + } + } + if (refSchema.$ref && refSeen.def) { + for (const key in schema2) { + if (key === "$ref" || key === "allOf") + continue; + if (key in refSeen.def && JSON.stringify(schema2[key]) === JSON.stringify(refSeen.def[key])) { + delete schema2[key]; + } + } + } + } + const parent = zodSchema._zod.parent; + if (parent && parent !== ref) { + flattenRef(parent); + const parentSeen = ctx.seen.get(parent); + if (parentSeen?.schema.$ref) { + schema2.$ref = parentSeen.schema.$ref; + if (parentSeen.def) { + for (const key in schema2) { + if (key === "$ref" || key === "allOf") + continue; + if (key in parentSeen.def && JSON.stringify(schema2[key]) === JSON.stringify(parentSeen.def[key])) { + delete schema2[key]; + } + } + } + } + } + ctx.override({ + zodSchema, + jsonSchema: schema2, + path: seen.path ?? [] + }); + }; + for (const entry of [...ctx.seen.entries()].reverse()) { + flattenRef(entry[0]); + } + const result = {}; + if (ctx.target === "draft-2020-12") { + result.$schema = "https://json-schema.org/draft/2020-12/schema"; + } else if (ctx.target === "draft-07") { + result.$schema = "http://json-schema.org/draft-07/schema#"; + } else if (ctx.target === "draft-04") { + result.$schema = "http://json-schema.org/draft-04/schema#"; + } else if (ctx.target === "openapi-3.0") { + } else { + } + if (ctx.external?.uri) { + const id = ctx.external.registry.get(schema)?.id; + if (!id) + throw new Error("Schema is missing an `id` property"); + result.$id = ctx.external.uri(id); + } + Object.assign(result, root.def ?? root.schema); + const defs = ctx.external?.defs ?? {}; + for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (seen.def && seen.defId) { + defs[seen.defId] = seen.def; + } + } + if (ctx.external) { + } else { + if (Object.keys(defs).length > 0) { + if (ctx.target === "draft-2020-12") { + result.$defs = defs; + } else { + result.definitions = defs; + } + } + } + try { + const finalized = JSON.parse(JSON.stringify(result)); + Object.defineProperty(finalized, "~standard", { + value: { + ...schema["~standard"], + jsonSchema: { + input: createStandardJSONSchemaMethod(schema, "input", ctx.processors), + output: createStandardJSONSchemaMethod(schema, "output", ctx.processors) + } + }, + enumerable: false, + writable: false + }); + return finalized; + } catch (_err) { + throw new Error("Error converting schema to JSON."); + } +} +function isTransforming(_schema, _ctx) { + const ctx = _ctx ?? { seen: /* @__PURE__ */ new Set() }; + if (ctx.seen.has(_schema)) + return false; + ctx.seen.add(_schema); + const def = _schema._zod.def; + if (def.type === "transform") + return true; + if (def.type === "array") + return isTransforming(def.element, ctx); + if (def.type === "set") + return isTransforming(def.valueType, ctx); + if (def.type === "lazy") + return isTransforming(def.getter(), ctx); + if (def.type === "promise" || def.type === "optional" || def.type === "nonoptional" || def.type === "nullable" || def.type === "readonly" || def.type === "default" || def.type === "prefault") { + return isTransforming(def.innerType, ctx); + } + if (def.type === "intersection") { + return isTransforming(def.left, ctx) || isTransforming(def.right, ctx); + } + if (def.type === "record" || def.type === "map") { + return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx); + } + if (def.type === "pipe") { + return isTransforming(def.in, ctx) || isTransforming(def.out, ctx); + } + if (def.type === "object") { + for (const key in def.shape) { + if (isTransforming(def.shape[key], ctx)) + return true; + } + return false; + } + if (def.type === "union") { + for (const option of def.options) { + if (isTransforming(option, ctx)) + return true; + } + return false; + } + if (def.type === "tuple") { + for (const item of def.items) { + if (isTransforming(item, ctx)) + return true; + } + if (def.rest && isTransforming(def.rest, ctx)) + return true; + return false; + } + return false; +} +var createToJSONSchemaMethod = (schema, processors = {}) => (params) => { + const ctx = initializeContext({ ...params, processors }); + process2(schema, ctx); + extractDefs(ctx, schema); + return finalize(ctx, schema); +}; +var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) => { + const { libraryOptions, target } = params ?? {}; + const ctx = initializeContext({ ...libraryOptions ?? {}, target, io, processors }); + process2(schema, ctx); + extractDefs(ctx, schema); + return finalize(ctx, schema); +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema-processors.js +var formatMap = { + guid: "uuid", + url: "uri", + datetime: "date-time", + json_string: "json-string", + regex: "" + // do not set +}; +var stringProcessor = (schema, ctx, _json, _params) => { + const json2 = _json; + json2.type = "string"; + const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag; + if (typeof minimum === "number") + json2.minLength = minimum; + if (typeof maximum === "number") + json2.maxLength = maximum; + if (format) { + json2.format = formatMap[format] ?? format; + if (json2.format === "") + delete json2.format; + if (format === "time") { + delete json2.format; + } + } + if (contentEncoding) + json2.contentEncoding = contentEncoding; + if (patterns && patterns.size > 0) { + const regexes = [...patterns]; + if (regexes.length === 1) + json2.pattern = regexes[0].source; + else if (regexes.length > 1) { + json2.allOf = [ + ...regexes.map((regex) => ({ + ...ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0" ? { type: "string" } : {}, + pattern: regex.source + })) + ]; + } + } +}; +var numberProcessor = (schema, ctx, _json, _params) => { + const json2 = _json; + const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag; + if (typeof format === "string" && format.includes("int")) + json2.type = "integer"; + else + json2.type = "number"; + if (typeof exclusiveMinimum === "number") { + if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { + json2.minimum = exclusiveMinimum; + json2.exclusiveMinimum = true; + } else { + json2.exclusiveMinimum = exclusiveMinimum; + } + } + if (typeof minimum === "number") { + json2.minimum = minimum; + if (typeof exclusiveMinimum === "number" && ctx.target !== "draft-04") { + if (exclusiveMinimum >= minimum) + delete json2.minimum; + else + delete json2.exclusiveMinimum; + } + } + if (typeof exclusiveMaximum === "number") { + if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { + json2.maximum = exclusiveMaximum; + json2.exclusiveMaximum = true; + } else { + json2.exclusiveMaximum = exclusiveMaximum; + } + } + if (typeof maximum === "number") { + json2.maximum = maximum; + if (typeof exclusiveMaximum === "number" && ctx.target !== "draft-04") { + if (exclusiveMaximum <= maximum) + delete json2.maximum; + else + delete json2.exclusiveMaximum; + } + } + if (typeof multipleOf === "number") + json2.multipleOf = multipleOf; +}; +var booleanProcessor = (_schema, _ctx, json2, _params) => { + json2.type = "boolean"; +}; +var bigintProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("BigInt cannot be represented in JSON Schema"); + } +}; +var symbolProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Symbols cannot be represented in JSON Schema"); + } +}; +var nullProcessor = (_schema, ctx, json2, _params) => { + if (ctx.target === "openapi-3.0") { + json2.type = "string"; + json2.nullable = true; + json2.enum = [null]; + } else { + json2.type = "null"; + } +}; +var undefinedProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Undefined cannot be represented in JSON Schema"); + } +}; +var voidProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Void cannot be represented in JSON Schema"); + } +}; +var neverProcessor = (_schema, _ctx, json2, _params) => { + json2.not = {}; +}; +var anyProcessor = (_schema, _ctx, _json, _params) => { +}; +var unknownProcessor = (_schema, _ctx, _json, _params) => { +}; +var dateProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Date cannot be represented in JSON Schema"); + } +}; +var enumProcessor = (schema, _ctx, json2, _params) => { + const def = schema._zod.def; + const values = getEnumValues(def.entries); + if (values.every((v) => typeof v === "number")) + json2.type = "number"; + if (values.every((v) => typeof v === "string")) + json2.type = "string"; + json2.enum = values; +}; +var literalProcessor = (schema, ctx, json2, _params) => { + const def = schema._zod.def; + const vals = []; + for (const val of def.values) { + if (val === void 0) { + if (ctx.unrepresentable === "throw") { + throw new Error("Literal `undefined` cannot be represented in JSON Schema"); + } else { + } + } else if (typeof val === "bigint") { + if (ctx.unrepresentable === "throw") { + throw new Error("BigInt literals cannot be represented in JSON Schema"); + } else { + vals.push(Number(val)); + } + } else { + vals.push(val); + } + } + if (vals.length === 0) { + } else if (vals.length === 1) { + const val = vals[0]; + json2.type = val === null ? "null" : typeof val; + if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { + json2.enum = [val]; + } else { + json2.const = val; + } + } else { + if (vals.every((v) => typeof v === "number")) + json2.type = "number"; + if (vals.every((v) => typeof v === "string")) + json2.type = "string"; + if (vals.every((v) => typeof v === "boolean")) + json2.type = "boolean"; + if (vals.every((v) => v === null)) + json2.type = "null"; + json2.enum = vals; + } +}; +var nanProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("NaN cannot be represented in JSON Schema"); + } +}; +var templateLiteralProcessor = (schema, _ctx, json2, _params) => { + const _json = json2; + const pattern = schema._zod.pattern; + if (!pattern) + throw new Error("Pattern not found in template literal"); + _json.type = "string"; + _json.pattern = pattern.source; +}; +var fileProcessor = (schema, _ctx, json2, _params) => { + const _json = json2; + const file2 = { + type: "string", + format: "binary", + contentEncoding: "binary" + }; + const { minimum, maximum, mime } = schema._zod.bag; + if (minimum !== void 0) + file2.minLength = minimum; + if (maximum !== void 0) + file2.maxLength = maximum; + if (mime) { + if (mime.length === 1) { + file2.contentMediaType = mime[0]; + Object.assign(_json, file2); + } else { + Object.assign(_json, file2); + _json.anyOf = mime.map((m) => ({ contentMediaType: m })); + } + } else { + Object.assign(_json, file2); + } +}; +var successProcessor = (_schema, _ctx, json2, _params) => { + json2.type = "boolean"; +}; +var customProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Custom types cannot be represented in JSON Schema"); + } +}; +var functionProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Function types cannot be represented in JSON Schema"); + } +}; +var transformProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Transforms cannot be represented in JSON Schema"); + } +}; +var mapProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Map cannot be represented in JSON Schema"); + } +}; +var setProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") { + throw new Error("Set cannot be represented in JSON Schema"); + } +}; +var arrayProcessor = (schema, ctx, _json, params) => { + const json2 = _json; + const def = schema._zod.def; + const { minimum, maximum } = schema._zod.bag; + if (typeof minimum === "number") + json2.minItems = minimum; + if (typeof maximum === "number") + json2.maxItems = maximum; + json2.type = "array"; + json2.items = process2(def.element, ctx, { ...params, path: [...params.path, "items"] }); +}; +var objectProcessor = (schema, ctx, _json, params) => { + const json2 = _json; + const def = schema._zod.def; + json2.type = "object"; + json2.properties = {}; + const shape = def.shape; + for (const key in shape) { + json2.properties[key] = process2(shape[key], ctx, { + ...params, + path: [...params.path, "properties", key] + }); + } + const allKeys = new Set(Object.keys(shape)); + const requiredKeys = new Set([...allKeys].filter((key) => { + const v = def.shape[key]._zod; + if (ctx.io === "input") { + return v.optin === void 0; + } else { + return v.optout === void 0; + } + })); + if (requiredKeys.size > 0) { + json2.required = Array.from(requiredKeys); + } + if (def.catchall?._zod.def.type === "never") { + json2.additionalProperties = false; + } else if (!def.catchall) { + if (ctx.io === "output") + json2.additionalProperties = false; + } else if (def.catchall) { + json2.additionalProperties = process2(def.catchall, ctx, { + ...params, + path: [...params.path, "additionalProperties"] + }); + } +}; +var unionProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + const isExclusive = def.inclusive === false; + const options2 = def.options.map((x, i) => process2(x, ctx, { + ...params, + path: [...params.path, isExclusive ? "oneOf" : "anyOf", i] + })); + if (isExclusive) { + json2.oneOf = options2; + } else { + json2.anyOf = options2; + } +}; +var intersectionProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + const a = process2(def.left, ctx, { + ...params, + path: [...params.path, "allOf", 0] + }); + const b = process2(def.right, ctx, { + ...params, + path: [...params.path, "allOf", 1] + }); + const isSimpleIntersection = (val) => "allOf" in val && Object.keys(val).length === 1; + const allOf = [ + ...isSimpleIntersection(a) ? a.allOf : [a], + ...isSimpleIntersection(b) ? b.allOf : [b] + ]; + json2.allOf = allOf; +}; +var tupleProcessor = (schema, ctx, _json, params) => { + const json2 = _json; + const def = schema._zod.def; + json2.type = "array"; + const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items"; + const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems"; + const prefixItems = def.items.map((x, i) => process2(x, ctx, { + ...params, + path: [...params.path, prefixPath, i] + })); + const rest = def.rest ? process2(def.rest, ctx, { + ...params, + path: [...params.path, restPath, ...ctx.target === "openapi-3.0" ? [def.items.length] : []] + }) : null; + if (ctx.target === "draft-2020-12") { + json2.prefixItems = prefixItems; + if (rest) { + json2.items = rest; + } + } else if (ctx.target === "openapi-3.0") { + json2.items = { + anyOf: prefixItems + }; + if (rest) { + json2.items.anyOf.push(rest); + } + json2.minItems = prefixItems.length; + if (!rest) { + json2.maxItems = prefixItems.length; + } + } else { + json2.items = prefixItems; + if (rest) { + json2.additionalItems = rest; + } + } + const { minimum, maximum } = schema._zod.bag; + if (typeof minimum === "number") + json2.minItems = minimum; + if (typeof maximum === "number") + json2.maxItems = maximum; +}; +var recordProcessor = (schema, ctx, _json, params) => { + const json2 = _json; + const def = schema._zod.def; + json2.type = "object"; + const keyType = def.keyType; + const keyBag = keyType._zod.bag; + const patterns = keyBag?.patterns; + if (def.mode === "loose" && patterns && patterns.size > 0) { + const valueSchema = process2(def.valueType, ctx, { + ...params, + path: [...params.path, "patternProperties", "*"] + }); + json2.patternProperties = {}; + for (const pattern of patterns) { + json2.patternProperties[pattern.source] = valueSchema; + } + } else { + if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") { + json2.propertyNames = process2(def.keyType, ctx, { + ...params, + path: [...params.path, "propertyNames"] + }); + } + json2.additionalProperties = process2(def.valueType, ctx, { + ...params, + path: [...params.path, "additionalProperties"] + }); + } + const keyValues = keyType._zod.values; + if (keyValues) { + const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number"); + if (validKeyValues.length > 0) { + json2.required = validKeyValues; + } + } +}; +var nullableProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + const inner = process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + if (ctx.target === "openapi-3.0") { + seen.ref = def.innerType; + json2.nullable = true; + } else { + json2.anyOf = [inner, { type: "null" }]; + } +}; +var nonoptionalProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; +}; +var defaultProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + json2.default = JSON.parse(JSON.stringify(def.defaultValue)); +}; +var prefaultProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + if (ctx.io === "input") + json2._prefault = JSON.parse(JSON.stringify(def.defaultValue)); +}; +var catchProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + let catchValue; + try { + catchValue = def.catchValue(void 0); + } catch { + throw new Error("Dynamic catch values are not supported in JSON Schema"); + } + json2.default = catchValue; +}; +var pipeProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + const innerType = ctx.io === "input" ? def.in._zod.def.type === "transform" ? def.out : def.in : def.out; + process2(innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = innerType; +}; +var readonlyProcessor = (schema, ctx, json2, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + json2.readOnly = true; +}; +var promiseProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; +}; +var optionalProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + process2(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; +}; +var lazyProcessor = (schema, ctx, _json, params) => { + const innerType = schema._zod.innerType; + process2(innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = innerType; +}; + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js +function isZ4Schema(s) { + const schema = s; + return !!schema._zod; +} +function safeParse2(schema, data) { + if (isZ4Schema(schema)) { + const result2 = safeParse(schema, data); + return result2; + } + const v3Schema = schema; + const result = v3Schema.safeParse(data); + return result; +} +function getObjectShape(schema) { + if (!schema) + return void 0; + let rawShape; + if (isZ4Schema(schema)) { + const v4Schema = schema; + rawShape = v4Schema._zod?.def?.shape; + } else { + const v3Schema = schema; + rawShape = v3Schema.shape; + } + if (!rawShape) + return void 0; + if (typeof rawShape === "function") { + try { + return rawShape(); + } catch { + return void 0; + } + } + return rawShape; +} +function getLiteralValue(schema) { + if (isZ4Schema(schema)) { + const v4Schema = schema; + const def2 = v4Schema._zod?.def; + if (def2) { + if (def2.value !== void 0) + return def2.value; + if (Array.isArray(def2.values) && def2.values.length > 0) { + return def2.values[0]; + } + } + } + const v3Schema = schema; + const def = v3Schema._def; + if (def) { + if (def.value !== void 0) + return def.value; + if (Array.isArray(def.values) && def.values.length > 0) { + return def.values[0]; + } + } + const directValue = schema.value; + if (directValue !== void 0) + return directValue; + return void 0; +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js +var schemas_exports3 = {}; +__export(schemas_exports3, { + ZodAny: () => ZodAny2, + ZodArray: () => ZodArray2, + ZodBase64: () => ZodBase64, + ZodBase64URL: () => ZodBase64URL, + ZodBigInt: () => ZodBigInt2, + ZodBigIntFormat: () => ZodBigIntFormat, + ZodBoolean: () => ZodBoolean2, + ZodCIDRv4: () => ZodCIDRv4, + ZodCIDRv6: () => ZodCIDRv6, + ZodCUID: () => ZodCUID, + ZodCUID2: () => ZodCUID2, + ZodCatch: () => ZodCatch2, + ZodCodec: () => ZodCodec, + ZodCustom: () => ZodCustom, + ZodCustomStringFormat: () => ZodCustomStringFormat, + ZodDate: () => ZodDate2, + ZodDefault: () => ZodDefault2, + ZodDiscriminatedUnion: () => ZodDiscriminatedUnion2, + ZodE164: () => ZodE164, + ZodEmail: () => ZodEmail, + ZodEmoji: () => ZodEmoji, + ZodEnum: () => ZodEnum2, + ZodExactOptional: () => ZodExactOptional, + ZodFile: () => ZodFile, + ZodFunction: () => ZodFunction2, + ZodGUID: () => ZodGUID, + ZodIPv4: () => ZodIPv4, + ZodIPv6: () => ZodIPv6, + ZodIntersection: () => ZodIntersection2, + ZodJWT: () => ZodJWT, + ZodKSUID: () => ZodKSUID, + ZodLazy: () => ZodLazy2, + ZodLiteral: () => ZodLiteral2, + ZodMAC: () => ZodMAC, + ZodMap: () => ZodMap2, + ZodNaN: () => ZodNaN2, + ZodNanoID: () => ZodNanoID, + ZodNever: () => ZodNever2, + ZodNonOptional: () => ZodNonOptional, + ZodNull: () => ZodNull2, + ZodNullable: () => ZodNullable2, + ZodNumber: () => ZodNumber2, + ZodNumberFormat: () => ZodNumberFormat, + ZodObject: () => ZodObject2, + ZodOptional: () => ZodOptional2, + ZodPipe: () => ZodPipe, + ZodPrefault: () => ZodPrefault, + ZodPromise: () => ZodPromise2, + ZodReadonly: () => ZodReadonly2, + ZodRecord: () => ZodRecord2, + ZodSet: () => ZodSet2, + ZodString: () => ZodString2, + ZodStringFormat: () => ZodStringFormat, + ZodSuccess: () => ZodSuccess, + ZodSymbol: () => ZodSymbol2, + ZodTemplateLiteral: () => ZodTemplateLiteral, + ZodTransform: () => ZodTransform, + ZodTuple: () => ZodTuple2, + ZodType: () => ZodType2, + ZodULID: () => ZodULID, + ZodURL: () => ZodURL, + ZodUUID: () => ZodUUID, + ZodUndefined: () => ZodUndefined2, + ZodUnion: () => ZodUnion2, + ZodUnknown: () => ZodUnknown2, + ZodVoid: () => ZodVoid2, + ZodXID: () => ZodXID, + ZodXor: () => ZodXor, + _ZodString: () => _ZodString, + _default: () => _default, + _function: () => _function, + any: () => any, + array: () => array, + base64: () => base642, + base64url: () => base64url2, + bigint: () => bigint2, + boolean: () => boolean2, + catch: () => _catch, + check: () => check, + cidrv4: () => cidrv42, + cidrv6: () => cidrv62, + codec: () => codec, + cuid: () => cuid3, + cuid2: () => cuid22, + custom: () => custom, + date: () => date3, + describe: () => describe2, + discriminatedUnion: () => discriminatedUnion, + e164: () => e1642, + email: () => email2, + emoji: () => emoji2, + enum: () => _enum, + exactOptional: () => exactOptional, + file: () => file, + float32: () => float32, + float64: () => float64, + function: () => _function, + guid: () => guid2, + hash: () => hash, + hex: () => hex2, + hostname: () => hostname2, + httpUrl: () => httpUrl, + instanceof: () => _instanceof, + int: () => int, + int32: () => int32, + int64: () => int64, + intersection: () => intersection, + ipv4: () => ipv42, + ipv6: () => ipv62, + json: () => json, + jwt: () => jwt, + keyof: () => keyof, + ksuid: () => ksuid2, + lazy: () => lazy, + literal: () => literal, + looseObject: () => looseObject, + looseRecord: () => looseRecord, + mac: () => mac2, + map: () => map, + meta: () => meta2, + nan: () => nan, + nanoid: () => nanoid2, + nativeEnum: () => nativeEnum, + never: () => never, + nonoptional: () => nonoptional, + null: () => _null3, + nullable: () => nullable, + nullish: () => nullish2, + number: () => number2, + object: () => object2, + optional: () => optional, + partialRecord: () => partialRecord, + pipe: () => pipe, + prefault: () => prefault, + preprocess: () => preprocess, + promise: () => promise, + readonly: () => readonly, + record: () => record, + refine: () => refine, + set: () => set, + strictObject: () => strictObject, + string: () => string2, + stringFormat: () => stringFormat, + stringbool: () => stringbool, + success: () => success, + superRefine: () => superRefine, + symbol: () => symbol, + templateLiteral: () => templateLiteral, + transform: () => transform, + tuple: () => tuple, + uint32: () => uint32, + uint64: () => uint64, + ulid: () => ulid2, + undefined: () => _undefined3, + union: () => union, + unknown: () => unknown, + url: () => url, + uuid: () => uuid2, + uuidv4: () => uuidv4, + uuidv6: () => uuidv6, + uuidv7: () => uuidv7, + void: () => _void2, + xid: () => xid2, + xor: () => xor +}); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/checks.js +var checks_exports2 = {}; +__export(checks_exports2, { + endsWith: () => _endsWith, + gt: () => _gt, + gte: () => _gte, + includes: () => _includes, + length: () => _length, + lowercase: () => _lowercase, + lt: () => _lt, + lte: () => _lte, + maxLength: () => _maxLength, + maxSize: () => _maxSize, + mime: () => _mime, + minLength: () => _minLength, + minSize: () => _minSize, + multipleOf: () => _multipleOf, + negative: () => _negative, + nonnegative: () => _nonnegative, + nonpositive: () => _nonpositive, + normalize: () => _normalize, + overwrite: () => _overwrite, + positive: () => _positive, + property: () => _property, + regex: () => _regex, + size: () => _size, + slugify: () => _slugify, + startsWith: () => _startsWith, + toLowerCase: () => _toLowerCase, + toUpperCase: () => _toUpperCase, + trim: () => _trim, + uppercase: () => _uppercase +}); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/iso.js +var iso_exports2 = {}; +__export(iso_exports2, { + ZodISODate: () => ZodISODate, + ZodISODateTime: () => ZodISODateTime, + ZodISODuration: () => ZodISODuration, + ZodISOTime: () => ZodISOTime, + date: () => date2, + datetime: () => datetime2, + duration: () => duration2, + time: () => time2 +}); +var ZodISODateTime = /* @__PURE__ */ $constructor("ZodISODateTime", (inst, def) => { + $ZodISODateTime.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function datetime2(params) { + return _isoDateTime(ZodISODateTime, params); +} +var ZodISODate = /* @__PURE__ */ $constructor("ZodISODate", (inst, def) => { + $ZodISODate.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function date2(params) { + return _isoDate(ZodISODate, params); +} +var ZodISOTime = /* @__PURE__ */ $constructor("ZodISOTime", (inst, def) => { + $ZodISOTime.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function time2(params) { + return _isoTime(ZodISOTime, params); +} +var ZodISODuration = /* @__PURE__ */ $constructor("ZodISODuration", (inst, def) => { + $ZodISODuration.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function duration2(params) { + return _isoDuration(ZodISODuration, params); +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/errors.js +var initializer2 = (inst, issues) => { + $ZodError.init(inst, issues); + inst.name = "ZodError"; + Object.defineProperties(inst, { + format: { + value: (mapper) => formatError(inst, mapper) + // enumerable: false, + }, + flatten: { + value: (mapper) => flattenError(inst, mapper) + // enumerable: false, + }, + addIssue: { + value: (issue2) => { + inst.issues.push(issue2); + inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); + } + // enumerable: false, + }, + addIssues: { + value: (issues2) => { + inst.issues.push(...issues2); + inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); + } + // enumerable: false, + }, + isEmpty: { + get() { + return inst.issues.length === 0; + } + // enumerable: false, + } + }); +}; +var ZodError2 = $constructor("ZodError", initializer2); +var ZodRealError = $constructor("ZodError", initializer2, { + Parent: Error +}); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/parse.js +var parse3 = /* @__PURE__ */ _parse(ZodRealError); +var parseAsync2 = /* @__PURE__ */ _parseAsync(ZodRealError); +var safeParse3 = /* @__PURE__ */ _safeParse(ZodRealError); +var safeParseAsync2 = /* @__PURE__ */ _safeParseAsync(ZodRealError); +var encode2 = /* @__PURE__ */ _encode(ZodRealError); +var decode2 = /* @__PURE__ */ _decode(ZodRealError); +var encodeAsync2 = /* @__PURE__ */ _encodeAsync(ZodRealError); +var decodeAsync2 = /* @__PURE__ */ _decodeAsync(ZodRealError); +var safeEncode2 = /* @__PURE__ */ _safeEncode(ZodRealError); +var safeDecode2 = /* @__PURE__ */ _safeDecode(ZodRealError); +var safeEncodeAsync2 = /* @__PURE__ */ _safeEncodeAsync(ZodRealError); +var safeDecodeAsync2 = /* @__PURE__ */ _safeDecodeAsync(ZodRealError); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js +var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { + $ZodType.init(inst, def); + Object.assign(inst["~standard"], { + jsonSchema: { + input: createStandardJSONSchemaMethod(inst, "input"), + output: createStandardJSONSchemaMethod(inst, "output") + } + }); + inst.toJSONSchema = createToJSONSchemaMethod(inst, {}); + inst.def = def; + inst.type = def.type; + Object.defineProperty(inst, "_def", { value: def }); + inst.check = (...checks) => { + return inst.clone(util_exports.mergeDefs(def, { + checks: [ + ...def.checks ?? [], + ...checks.map((ch) => typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch) + ] + }), { + parent: true + }); + }; + inst.with = inst.check; + inst.clone = (def2, params) => clone(inst, def2, params); + inst.brand = () => inst; + inst.register = ((reg, meta3) => { + reg.add(inst, meta3); + return inst; + }); + inst.parse = (data, params) => parse3(inst, data, params, { callee: inst.parse }); + inst.safeParse = (data, params) => safeParse3(inst, data, params); + inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync }); + inst.safeParseAsync = async (data, params) => safeParseAsync2(inst, data, params); + inst.spa = inst.safeParseAsync; + inst.encode = (data, params) => encode2(inst, data, params); + inst.decode = (data, params) => decode2(inst, data, params); + inst.encodeAsync = async (data, params) => encodeAsync2(inst, data, params); + inst.decodeAsync = async (data, params) => decodeAsync2(inst, data, params); + inst.safeEncode = (data, params) => safeEncode2(inst, data, params); + inst.safeDecode = (data, params) => safeDecode2(inst, data, params); + inst.safeEncodeAsync = async (data, params) => safeEncodeAsync2(inst, data, params); + inst.safeDecodeAsync = async (data, params) => safeDecodeAsync2(inst, data, params); + inst.refine = (check2, params) => inst.check(refine(check2, params)); + inst.superRefine = (refinement) => inst.check(superRefine(refinement)); + inst.overwrite = (fn) => inst.check(_overwrite(fn)); + inst.optional = () => optional(inst); + inst.exactOptional = () => exactOptional(inst); + inst.nullable = () => nullable(inst); + inst.nullish = () => optional(nullable(inst)); + inst.nonoptional = (params) => nonoptional(inst, params); + inst.array = () => array(inst); + inst.or = (arg) => union([inst, arg]); + inst.and = (arg) => intersection(inst, arg); + inst.transform = (tx) => pipe(inst, transform(tx)); + inst.default = (def2) => _default(inst, def2); + inst.prefault = (def2) => prefault(inst, def2); + inst.catch = (params) => _catch(inst, params); + inst.pipe = (target) => pipe(inst, target); + inst.readonly = () => readonly(inst); + inst.describe = (description) => { + const cl = inst.clone(); + globalRegistry.add(cl, { description }); + return cl; + }; + Object.defineProperty(inst, "description", { + get() { + return globalRegistry.get(inst)?.description; + }, + configurable: true + }); + inst.meta = (...args) => { + if (args.length === 0) { + return globalRegistry.get(inst); + } + const cl = inst.clone(); + globalRegistry.add(cl, args[0]); + return cl; + }; + inst.isOptional = () => inst.safeParse(void 0).success; + inst.isNullable = () => inst.safeParse(null).success; + inst.apply = (fn) => fn(inst); + return inst; +}); +var _ZodString = /* @__PURE__ */ $constructor("_ZodString", (inst, def) => { + $ZodString.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => stringProcessor(inst, ctx, json2, params); + const bag = inst._zod.bag; + inst.format = bag.format ?? null; + inst.minLength = bag.minimum ?? null; + inst.maxLength = bag.maximum ?? null; + inst.regex = (...args) => inst.check(_regex(...args)); + inst.includes = (...args) => inst.check(_includes(...args)); + inst.startsWith = (...args) => inst.check(_startsWith(...args)); + inst.endsWith = (...args) => inst.check(_endsWith(...args)); + inst.min = (...args) => inst.check(_minLength(...args)); + inst.max = (...args) => inst.check(_maxLength(...args)); + inst.length = (...args) => inst.check(_length(...args)); + inst.nonempty = (...args) => inst.check(_minLength(1, ...args)); + inst.lowercase = (params) => inst.check(_lowercase(params)); + inst.uppercase = (params) => inst.check(_uppercase(params)); + inst.trim = () => inst.check(_trim()); + inst.normalize = (...args) => inst.check(_normalize(...args)); + inst.toLowerCase = () => inst.check(_toLowerCase()); + inst.toUpperCase = () => inst.check(_toUpperCase()); + inst.slugify = () => inst.check(_slugify()); +}); +var ZodString2 = /* @__PURE__ */ $constructor("ZodString", (inst, def) => { + $ZodString.init(inst, def); + _ZodString.init(inst, def); + inst.email = (params) => inst.check(_email(ZodEmail, params)); + inst.url = (params) => inst.check(_url(ZodURL, params)); + inst.jwt = (params) => inst.check(_jwt(ZodJWT, params)); + inst.emoji = (params) => inst.check(_emoji2(ZodEmoji, params)); + inst.guid = (params) => inst.check(_guid(ZodGUID, params)); + inst.uuid = (params) => inst.check(_uuid(ZodUUID, params)); + inst.uuidv4 = (params) => inst.check(_uuidv4(ZodUUID, params)); + inst.uuidv6 = (params) => inst.check(_uuidv6(ZodUUID, params)); + inst.uuidv7 = (params) => inst.check(_uuidv7(ZodUUID, params)); + inst.nanoid = (params) => inst.check(_nanoid(ZodNanoID, params)); + inst.guid = (params) => inst.check(_guid(ZodGUID, params)); + inst.cuid = (params) => inst.check(_cuid(ZodCUID, params)); + inst.cuid2 = (params) => inst.check(_cuid2(ZodCUID2, params)); + inst.ulid = (params) => inst.check(_ulid(ZodULID, params)); + inst.base64 = (params) => inst.check(_base64(ZodBase64, params)); + inst.base64url = (params) => inst.check(_base64url(ZodBase64URL, params)); + inst.xid = (params) => inst.check(_xid(ZodXID, params)); + inst.ksuid = (params) => inst.check(_ksuid(ZodKSUID, params)); + inst.ipv4 = (params) => inst.check(_ipv4(ZodIPv4, params)); + inst.ipv6 = (params) => inst.check(_ipv6(ZodIPv6, params)); + inst.cidrv4 = (params) => inst.check(_cidrv4(ZodCIDRv4, params)); + inst.cidrv6 = (params) => inst.check(_cidrv6(ZodCIDRv6, params)); + inst.e164 = (params) => inst.check(_e164(ZodE164, params)); + inst.datetime = (params) => inst.check(datetime2(params)); + inst.date = (params) => inst.check(date2(params)); + inst.time = (params) => inst.check(time2(params)); + inst.duration = (params) => inst.check(duration2(params)); +}); +function string2(params) { + return _string(ZodString2, params); +} +var ZodStringFormat = /* @__PURE__ */ $constructor("ZodStringFormat", (inst, def) => { + $ZodStringFormat.init(inst, def); + _ZodString.init(inst, def); +}); +var ZodEmail = /* @__PURE__ */ $constructor("ZodEmail", (inst, def) => { + $ZodEmail.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function email2(params) { + return _email(ZodEmail, params); +} +var ZodGUID = /* @__PURE__ */ $constructor("ZodGUID", (inst, def) => { + $ZodGUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function guid2(params) { + return _guid(ZodGUID, params); +} +var ZodUUID = /* @__PURE__ */ $constructor("ZodUUID", (inst, def) => { + $ZodUUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function uuid2(params) { + return _uuid(ZodUUID, params); +} +function uuidv4(params) { + return _uuidv4(ZodUUID, params); +} +function uuidv6(params) { + return _uuidv6(ZodUUID, params); +} +function uuidv7(params) { + return _uuidv7(ZodUUID, params); +} +var ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => { + $ZodURL.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function url(params) { + return _url(ZodURL, params); +} +function httpUrl(params) { + return _url(ZodURL, { + protocol: /^https?$/, + hostname: regexes_exports.domain, + ...util_exports.normalizeParams(params) + }); +} +var ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => { + $ZodEmoji.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function emoji2(params) { + return _emoji2(ZodEmoji, params); +} +var ZodNanoID = /* @__PURE__ */ $constructor("ZodNanoID", (inst, def) => { + $ZodNanoID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function nanoid2(params) { + return _nanoid(ZodNanoID, params); +} +var ZodCUID = /* @__PURE__ */ $constructor("ZodCUID", (inst, def) => { + $ZodCUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function cuid3(params) { + return _cuid(ZodCUID, params); +} +var ZodCUID2 = /* @__PURE__ */ $constructor("ZodCUID2", (inst, def) => { + $ZodCUID2.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function cuid22(params) { + return _cuid2(ZodCUID2, params); +} +var ZodULID = /* @__PURE__ */ $constructor("ZodULID", (inst, def) => { + $ZodULID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function ulid2(params) { + return _ulid(ZodULID, params); +} +var ZodXID = /* @__PURE__ */ $constructor("ZodXID", (inst, def) => { + $ZodXID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function xid2(params) { + return _xid(ZodXID, params); +} +var ZodKSUID = /* @__PURE__ */ $constructor("ZodKSUID", (inst, def) => { + $ZodKSUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function ksuid2(params) { + return _ksuid(ZodKSUID, params); +} +var ZodIPv4 = /* @__PURE__ */ $constructor("ZodIPv4", (inst, def) => { + $ZodIPv4.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function ipv42(params) { + return _ipv4(ZodIPv4, params); +} +var ZodMAC = /* @__PURE__ */ $constructor("ZodMAC", (inst, def) => { + $ZodMAC.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function mac2(params) { + return _mac(ZodMAC, params); +} +var ZodIPv6 = /* @__PURE__ */ $constructor("ZodIPv6", (inst, def) => { + $ZodIPv6.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function ipv62(params) { + return _ipv6(ZodIPv6, params); +} +var ZodCIDRv4 = /* @__PURE__ */ $constructor("ZodCIDRv4", (inst, def) => { + $ZodCIDRv4.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function cidrv42(params) { + return _cidrv4(ZodCIDRv4, params); +} +var ZodCIDRv6 = /* @__PURE__ */ $constructor("ZodCIDRv6", (inst, def) => { + $ZodCIDRv6.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function cidrv62(params) { + return _cidrv6(ZodCIDRv6, params); +} +var ZodBase64 = /* @__PURE__ */ $constructor("ZodBase64", (inst, def) => { + $ZodBase64.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function base642(params) { + return _base64(ZodBase64, params); +} +var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => { + $ZodBase64URL.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function base64url2(params) { + return _base64url(ZodBase64URL, params); +} +var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => { + $ZodE164.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function e1642(params) { + return _e164(ZodE164, params); +} +var ZodJWT = /* @__PURE__ */ $constructor("ZodJWT", (inst, def) => { + $ZodJWT.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function jwt(params) { + return _jwt(ZodJWT, params); +} +var ZodCustomStringFormat = /* @__PURE__ */ $constructor("ZodCustomStringFormat", (inst, def) => { + $ZodCustomStringFormat.init(inst, def); + ZodStringFormat.init(inst, def); +}); +function stringFormat(format, fnOrRegex, _params = {}) { + return _stringFormat(ZodCustomStringFormat, format, fnOrRegex, _params); +} +function hostname2(_params) { + return _stringFormat(ZodCustomStringFormat, "hostname", regexes_exports.hostname, _params); +} +function hex2(_params) { + return _stringFormat(ZodCustomStringFormat, "hex", regexes_exports.hex, _params); +} +function hash(alg, params) { + const enc = params?.enc ?? "hex"; + const format = `${alg}_${enc}`; + const regex = regexes_exports[format]; + if (!regex) + throw new Error(`Unrecognized hash format: ${format}`); + return _stringFormat(ZodCustomStringFormat, format, regex, params); +} +var ZodNumber2 = /* @__PURE__ */ $constructor("ZodNumber", (inst, def) => { + $ZodNumber.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => numberProcessor(inst, ctx, json2, params); + inst.gt = (value, params) => inst.check(_gt(value, params)); + inst.gte = (value, params) => inst.check(_gte(value, params)); + inst.min = (value, params) => inst.check(_gte(value, params)); + inst.lt = (value, params) => inst.check(_lt(value, params)); + inst.lte = (value, params) => inst.check(_lte(value, params)); + inst.max = (value, params) => inst.check(_lte(value, params)); + inst.int = (params) => inst.check(int(params)); + inst.safe = (params) => inst.check(int(params)); + inst.positive = (params) => inst.check(_gt(0, params)); + inst.nonnegative = (params) => inst.check(_gte(0, params)); + inst.negative = (params) => inst.check(_lt(0, params)); + inst.nonpositive = (params) => inst.check(_lte(0, params)); + inst.multipleOf = (value, params) => inst.check(_multipleOf(value, params)); + inst.step = (value, params) => inst.check(_multipleOf(value, params)); + inst.finite = () => inst; + const bag = inst._zod.bag; + inst.minValue = Math.max(bag.minimum ?? Number.NEGATIVE_INFINITY, bag.exclusiveMinimum ?? Number.NEGATIVE_INFINITY) ?? null; + inst.maxValue = Math.min(bag.maximum ?? Number.POSITIVE_INFINITY, bag.exclusiveMaximum ?? Number.POSITIVE_INFINITY) ?? null; + inst.isInt = (bag.format ?? "").includes("int") || Number.isSafeInteger(bag.multipleOf ?? 0.5); + inst.isFinite = true; + inst.format = bag.format ?? null; +}); +function number2(params) { + return _number(ZodNumber2, params); +} +var ZodNumberFormat = /* @__PURE__ */ $constructor("ZodNumberFormat", (inst, def) => { + $ZodNumberFormat.init(inst, def); + ZodNumber2.init(inst, def); +}); +function int(params) { + return _int(ZodNumberFormat, params); +} +function float32(params) { + return _float32(ZodNumberFormat, params); +} +function float64(params) { + return _float64(ZodNumberFormat, params); +} +function int32(params) { + return _int32(ZodNumberFormat, params); +} +function uint32(params) { + return _uint32(ZodNumberFormat, params); +} +var ZodBoolean2 = /* @__PURE__ */ $constructor("ZodBoolean", (inst, def) => { + $ZodBoolean.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => booleanProcessor(inst, ctx, json2, params); +}); +function boolean2(params) { + return _boolean(ZodBoolean2, params); +} +var ZodBigInt2 = /* @__PURE__ */ $constructor("ZodBigInt", (inst, def) => { + $ZodBigInt.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => bigintProcessor(inst, ctx, json2, params); + inst.gte = (value, params) => inst.check(_gte(value, params)); + inst.min = (value, params) => inst.check(_gte(value, params)); + inst.gt = (value, params) => inst.check(_gt(value, params)); + inst.gte = (value, params) => inst.check(_gte(value, params)); + inst.min = (value, params) => inst.check(_gte(value, params)); + inst.lt = (value, params) => inst.check(_lt(value, params)); + inst.lte = (value, params) => inst.check(_lte(value, params)); + inst.max = (value, params) => inst.check(_lte(value, params)); + inst.positive = (params) => inst.check(_gt(BigInt(0), params)); + inst.negative = (params) => inst.check(_lt(BigInt(0), params)); + inst.nonpositive = (params) => inst.check(_lte(BigInt(0), params)); + inst.nonnegative = (params) => inst.check(_gte(BigInt(0), params)); + inst.multipleOf = (value, params) => inst.check(_multipleOf(value, params)); + const bag = inst._zod.bag; + inst.minValue = bag.minimum ?? null; + inst.maxValue = bag.maximum ?? null; + inst.format = bag.format ?? null; +}); +function bigint2(params) { + return _bigint(ZodBigInt2, params); +} +var ZodBigIntFormat = /* @__PURE__ */ $constructor("ZodBigIntFormat", (inst, def) => { + $ZodBigIntFormat.init(inst, def); + ZodBigInt2.init(inst, def); +}); +function int64(params) { + return _int64(ZodBigIntFormat, params); +} +function uint64(params) { + return _uint64(ZodBigIntFormat, params); +} +var ZodSymbol2 = /* @__PURE__ */ $constructor("ZodSymbol", (inst, def) => { + $ZodSymbol.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => symbolProcessor(inst, ctx, json2, params); +}); +function symbol(params) { + return _symbol(ZodSymbol2, params); +} +var ZodUndefined2 = /* @__PURE__ */ $constructor("ZodUndefined", (inst, def) => { + $ZodUndefined.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => undefinedProcessor(inst, ctx, json2, params); +}); +function _undefined3(params) { + return _undefined2(ZodUndefined2, params); +} +var ZodNull2 = /* @__PURE__ */ $constructor("ZodNull", (inst, def) => { + $ZodNull.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => nullProcessor(inst, ctx, json2, params); +}); +function _null3(params) { + return _null2(ZodNull2, params); +} +var ZodAny2 = /* @__PURE__ */ $constructor("ZodAny", (inst, def) => { + $ZodAny.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => anyProcessor(inst, ctx, json2, params); +}); +function any() { + return _any(ZodAny2); +} +var ZodUnknown2 = /* @__PURE__ */ $constructor("ZodUnknown", (inst, def) => { + $ZodUnknown.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => unknownProcessor(inst, ctx, json2, params); +}); +function unknown() { + return _unknown(ZodUnknown2); +} +var ZodNever2 = /* @__PURE__ */ $constructor("ZodNever", (inst, def) => { + $ZodNever.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => neverProcessor(inst, ctx, json2, params); +}); +function never(params) { + return _never(ZodNever2, params); +} +var ZodVoid2 = /* @__PURE__ */ $constructor("ZodVoid", (inst, def) => { + $ZodVoid.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => voidProcessor(inst, ctx, json2, params); +}); +function _void2(params) { + return _void(ZodVoid2, params); +} +var ZodDate2 = /* @__PURE__ */ $constructor("ZodDate", (inst, def) => { + $ZodDate.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => dateProcessor(inst, ctx, json2, params); + inst.min = (value, params) => inst.check(_gte(value, params)); + inst.max = (value, params) => inst.check(_lte(value, params)); + const c = inst._zod.bag; + inst.minDate = c.minimum ? new Date(c.minimum) : null; + inst.maxDate = c.maximum ? new Date(c.maximum) : null; +}); +function date3(params) { + return _date(ZodDate2, params); +} +var ZodArray2 = /* @__PURE__ */ $constructor("ZodArray", (inst, def) => { + $ZodArray.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => arrayProcessor(inst, ctx, json2, params); + inst.element = def.element; + inst.min = (minLength, params) => inst.check(_minLength(minLength, params)); + inst.nonempty = (params) => inst.check(_minLength(1, params)); + inst.max = (maxLength, params) => inst.check(_maxLength(maxLength, params)); + inst.length = (len, params) => inst.check(_length(len, params)); + inst.unwrap = () => inst.element; +}); +function array(element, params) { + return _array(ZodArray2, element, params); +} +function keyof(schema) { + const shape = schema._zod.def.shape; + return _enum(Object.keys(shape)); +} +var ZodObject2 = /* @__PURE__ */ $constructor("ZodObject", (inst, def) => { + $ZodObjectJIT.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => objectProcessor(inst, ctx, json2, params); + util_exports.defineLazy(inst, "shape", () => { + return def.shape; + }); + inst.keyof = () => _enum(Object.keys(inst._zod.def.shape)); + inst.catchall = (catchall) => inst.clone({ ...inst._zod.def, catchall }); + inst.passthrough = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); + inst.loose = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); + inst.strict = () => inst.clone({ ...inst._zod.def, catchall: never() }); + inst.strip = () => inst.clone({ ...inst._zod.def, catchall: void 0 }); + inst.extend = (incoming) => { + return util_exports.extend(inst, incoming); + }; + inst.safeExtend = (incoming) => { + return util_exports.safeExtend(inst, incoming); + }; + inst.merge = (other) => util_exports.merge(inst, other); + inst.pick = (mask) => util_exports.pick(inst, mask); + inst.omit = (mask) => util_exports.omit(inst, mask); + inst.partial = (...args) => util_exports.partial(ZodOptional2, inst, args[0]); + inst.required = (...args) => util_exports.required(ZodNonOptional, inst, args[0]); +}); +function object2(shape, params) { + const def = { + type: "object", + shape: shape ?? {}, + ...util_exports.normalizeParams(params) + }; + return new ZodObject2(def); +} +function strictObject(shape, params) { + return new ZodObject2({ + type: "object", + shape, + catchall: never(), + ...util_exports.normalizeParams(params) + }); +} +function looseObject(shape, params) { + return new ZodObject2({ + type: "object", + shape, + catchall: unknown(), + ...util_exports.normalizeParams(params) + }); +} +var ZodUnion2 = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { + $ZodUnion.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => unionProcessor(inst, ctx, json2, params); + inst.options = def.options; +}); +function union(options2, params) { + return new ZodUnion2({ + type: "union", + options: options2, + ...util_exports.normalizeParams(params) + }); +} +var ZodXor = /* @__PURE__ */ $constructor("ZodXor", (inst, def) => { + ZodUnion2.init(inst, def); + $ZodXor.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => unionProcessor(inst, ctx, json2, params); + inst.options = def.options; +}); +function xor(options2, params) { + return new ZodXor({ + type: "union", + options: options2, + inclusive: false, + ...util_exports.normalizeParams(params) + }); +} +var ZodDiscriminatedUnion2 = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => { + ZodUnion2.init(inst, def); + $ZodDiscriminatedUnion.init(inst, def); +}); +function discriminatedUnion(discriminator, options2, params) { + return new ZodDiscriminatedUnion2({ + type: "union", + options: options2, + discriminator, + ...util_exports.normalizeParams(params) + }); +} +var ZodIntersection2 = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => { + $ZodIntersection.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => intersectionProcessor(inst, ctx, json2, params); +}); +function intersection(left, right) { + return new ZodIntersection2({ + type: "intersection", + left, + right + }); +} +var ZodTuple2 = /* @__PURE__ */ $constructor("ZodTuple", (inst, def) => { + $ZodTuple.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => tupleProcessor(inst, ctx, json2, params); + inst.rest = (rest) => inst.clone({ + ...inst._zod.def, + rest + }); +}); +function tuple(items, _paramsOrRest, _params) { + const hasRest = _paramsOrRest instanceof $ZodType; + const params = hasRest ? _params : _paramsOrRest; + const rest = hasRest ? _paramsOrRest : null; + return new ZodTuple2({ + type: "tuple", + items, + rest, + ...util_exports.normalizeParams(params) + }); +} +var ZodRecord2 = /* @__PURE__ */ $constructor("ZodRecord", (inst, def) => { + $ZodRecord.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => recordProcessor(inst, ctx, json2, params); + inst.keyType = def.keyType; + inst.valueType = def.valueType; +}); +function record(keyType, valueType, params) { + return new ZodRecord2({ + type: "record", + keyType, + valueType, + ...util_exports.normalizeParams(params) + }); +} +function partialRecord(keyType, valueType, params) { + const k = clone(keyType); + k._zod.values = void 0; + return new ZodRecord2({ + type: "record", + keyType: k, + valueType, + ...util_exports.normalizeParams(params) + }); +} +function looseRecord(keyType, valueType, params) { + return new ZodRecord2({ + type: "record", + keyType, + valueType, + mode: "loose", + ...util_exports.normalizeParams(params) + }); +} +var ZodMap2 = /* @__PURE__ */ $constructor("ZodMap", (inst, def) => { + $ZodMap.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => mapProcessor(inst, ctx, json2, params); + inst.keyType = def.keyType; + inst.valueType = def.valueType; + inst.min = (...args) => inst.check(_minSize(...args)); + inst.nonempty = (params) => inst.check(_minSize(1, params)); + inst.max = (...args) => inst.check(_maxSize(...args)); + inst.size = (...args) => inst.check(_size(...args)); +}); +function map(keyType, valueType, params) { + return new ZodMap2({ + type: "map", + keyType, + valueType, + ...util_exports.normalizeParams(params) + }); +} +var ZodSet2 = /* @__PURE__ */ $constructor("ZodSet", (inst, def) => { + $ZodSet.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => setProcessor(inst, ctx, json2, params); + inst.min = (...args) => inst.check(_minSize(...args)); + inst.nonempty = (params) => inst.check(_minSize(1, params)); + inst.max = (...args) => inst.check(_maxSize(...args)); + inst.size = (...args) => inst.check(_size(...args)); +}); +function set(valueType, params) { + return new ZodSet2({ + type: "set", + valueType, + ...util_exports.normalizeParams(params) + }); +} +var ZodEnum2 = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => { + $ZodEnum.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => enumProcessor(inst, ctx, json2, params); + inst.enum = def.entries; + inst.options = Object.values(def.entries); + const keys = new Set(Object.keys(def.entries)); + inst.extract = (values, params) => { + const newEntries = {}; + for (const value of values) { + if (keys.has(value)) { + newEntries[value] = def.entries[value]; + } else + throw new Error(`Key ${value} not found in enum`); + } + return new ZodEnum2({ + ...def, + checks: [], + ...util_exports.normalizeParams(params), + entries: newEntries + }); + }; + inst.exclude = (values, params) => { + const newEntries = { ...def.entries }; + for (const value of values) { + if (keys.has(value)) { + delete newEntries[value]; + } else + throw new Error(`Key ${value} not found in enum`); + } + return new ZodEnum2({ + ...def, + checks: [], + ...util_exports.normalizeParams(params), + entries: newEntries + }); + }; +}); +function _enum(values, params) { + const entries = Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values; + return new ZodEnum2({ + type: "enum", + entries, + ...util_exports.normalizeParams(params) + }); +} +function nativeEnum(entries, params) { + return new ZodEnum2({ + type: "enum", + entries, + ...util_exports.normalizeParams(params) + }); +} +var ZodLiteral2 = /* @__PURE__ */ $constructor("ZodLiteral", (inst, def) => { + $ZodLiteral.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => literalProcessor(inst, ctx, json2, params); + inst.values = new Set(def.values); + Object.defineProperty(inst, "value", { + get() { + if (def.values.length > 1) { + throw new Error("This schema contains multiple valid literal values. Use `.values` instead."); + } + return def.values[0]; + } + }); +}); +function literal(value, params) { + return new ZodLiteral2({ + type: "literal", + values: Array.isArray(value) ? value : [value], + ...util_exports.normalizeParams(params) + }); +} +var ZodFile = /* @__PURE__ */ $constructor("ZodFile", (inst, def) => { + $ZodFile.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => fileProcessor(inst, ctx, json2, params); + inst.min = (size, params) => inst.check(_minSize(size, params)); + inst.max = (size, params) => inst.check(_maxSize(size, params)); + inst.mime = (types, params) => inst.check(_mime(Array.isArray(types) ? types : [types], params)); +}); +function file(params) { + return _file(ZodFile, params); +} +var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => { + $ZodTransform.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => transformProcessor(inst, ctx, json2, params); + inst._zod.parse = (payload, _ctx) => { + if (_ctx.direction === "backward") { + throw new $ZodEncodeError(inst.constructor.name); + } + payload.addIssue = (issue2) => { + if (typeof issue2 === "string") { + payload.issues.push(util_exports.issue(issue2, payload.value, def)); + } else { + const _issue = issue2; + if (_issue.fatal) + _issue.continue = false; + _issue.code ?? (_issue.code = "custom"); + _issue.input ?? (_issue.input = payload.value); + _issue.inst ?? (_issue.inst = inst); + payload.issues.push(util_exports.issue(_issue)); + } + }; + const output = def.transform(payload.value, payload); + if (output instanceof Promise) { + return output.then((output2) => { + payload.value = output2; + return payload; + }); + } + payload.value = output; + return payload; + }; +}); +function transform(fn) { + return new ZodTransform({ + type: "transform", + transform: fn + }); +} +var ZodOptional2 = /* @__PURE__ */ $constructor("ZodOptional", (inst, def) => { + $ZodOptional.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => optionalProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function optional(innerType) { + return new ZodOptional2({ + type: "optional", + innerType + }); +} +var ZodExactOptional = /* @__PURE__ */ $constructor("ZodExactOptional", (inst, def) => { + $ZodExactOptional.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => optionalProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function exactOptional(innerType) { + return new ZodExactOptional({ + type: "optional", + innerType + }); +} +var ZodNullable2 = /* @__PURE__ */ $constructor("ZodNullable", (inst, def) => { + $ZodNullable.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => nullableProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function nullable(innerType) { + return new ZodNullable2({ + type: "nullable", + innerType + }); +} +function nullish2(innerType) { + return optional(nullable(innerType)); +} +var ZodDefault2 = /* @__PURE__ */ $constructor("ZodDefault", (inst, def) => { + $ZodDefault.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => defaultProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeDefault = inst.unwrap; +}); +function _default(innerType, defaultValue) { + return new ZodDefault2({ + type: "default", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : util_exports.shallowClone(defaultValue); + } + }); +} +var ZodPrefault = /* @__PURE__ */ $constructor("ZodPrefault", (inst, def) => { + $ZodPrefault.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => prefaultProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function prefault(innerType, defaultValue) { + return new ZodPrefault({ + type: "prefault", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : util_exports.shallowClone(defaultValue); + } + }); +} +var ZodNonOptional = /* @__PURE__ */ $constructor("ZodNonOptional", (inst, def) => { + $ZodNonOptional.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => nonoptionalProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function nonoptional(innerType, params) { + return new ZodNonOptional({ + type: "nonoptional", + innerType, + ...util_exports.normalizeParams(params) + }); +} +var ZodSuccess = /* @__PURE__ */ $constructor("ZodSuccess", (inst, def) => { + $ZodSuccess.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => successProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function success(innerType) { + return new ZodSuccess({ + type: "success", + innerType + }); +} +var ZodCatch2 = /* @__PURE__ */ $constructor("ZodCatch", (inst, def) => { + $ZodCatch.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => catchProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeCatch = inst.unwrap; +}); +function _catch(innerType, catchValue) { + return new ZodCatch2({ + type: "catch", + innerType, + catchValue: typeof catchValue === "function" ? catchValue : () => catchValue + }); +} +var ZodNaN2 = /* @__PURE__ */ $constructor("ZodNaN", (inst, def) => { + $ZodNaN.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => nanProcessor(inst, ctx, json2, params); +}); +function nan(params) { + return _nan(ZodNaN2, params); +} +var ZodPipe = /* @__PURE__ */ $constructor("ZodPipe", (inst, def) => { + $ZodPipe.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => pipeProcessor(inst, ctx, json2, params); + inst.in = def.in; + inst.out = def.out; +}); +function pipe(in_, out) { + return new ZodPipe({ + type: "pipe", + in: in_, + out + // ...util.normalizeParams(params), + }); +} +var ZodCodec = /* @__PURE__ */ $constructor("ZodCodec", (inst, def) => { + ZodPipe.init(inst, def); + $ZodCodec.init(inst, def); +}); +function codec(in_, out, params) { + return new ZodCodec({ + type: "pipe", + in: in_, + out, + transform: params.decode, + reverseTransform: params.encode + }); +} +var ZodReadonly2 = /* @__PURE__ */ $constructor("ZodReadonly", (inst, def) => { + $ZodReadonly.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => readonlyProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function readonly(innerType) { + return new ZodReadonly2({ + type: "readonly", + innerType + }); +} +var ZodTemplateLiteral = /* @__PURE__ */ $constructor("ZodTemplateLiteral", (inst, def) => { + $ZodTemplateLiteral.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => templateLiteralProcessor(inst, ctx, json2, params); +}); +function templateLiteral(parts, params) { + return new ZodTemplateLiteral({ + type: "template_literal", + parts, + ...util_exports.normalizeParams(params) + }); +} +var ZodLazy2 = /* @__PURE__ */ $constructor("ZodLazy", (inst, def) => { + $ZodLazy.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => lazyProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.getter(); +}); +function lazy(getter) { + return new ZodLazy2({ + type: "lazy", + getter + }); +} +var ZodPromise2 = /* @__PURE__ */ $constructor("ZodPromise", (inst, def) => { + $ZodPromise.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => promiseProcessor(inst, ctx, json2, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function promise(innerType) { + return new ZodPromise2({ + type: "promise", + innerType + }); +} +var ZodFunction2 = /* @__PURE__ */ $constructor("ZodFunction", (inst, def) => { + $ZodFunction.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => functionProcessor(inst, ctx, json2, params); +}); +function _function(params) { + return new ZodFunction2({ + type: "function", + input: Array.isArray(params?.input) ? tuple(params?.input) : params?.input ?? array(unknown()), + output: params?.output ?? unknown() + }); +} +var ZodCustom = /* @__PURE__ */ $constructor("ZodCustom", (inst, def) => { + $ZodCustom.init(inst, def); + ZodType2.init(inst, def); + inst._zod.processJSONSchema = (ctx, json2, params) => customProcessor(inst, ctx, json2, params); +}); +function check(fn) { + const ch = new $ZodCheck({ + check: "custom" + // ...util.normalizeParams(params), + }); + ch._zod.check = fn; + return ch; +} +function custom(fn, _params) { + return _custom(ZodCustom, fn ?? (() => true), _params); +} +function refine(fn, _params = {}) { + return _refine(ZodCustom, fn, _params); +} +function superRefine(fn) { + return _superRefine(fn); +} +var describe2 = describe; +var meta2 = meta; +function _instanceof(cls, params = {}) { + const inst = new ZodCustom({ + type: "custom", + check: "custom", + fn: (data) => data instanceof cls, + abort: true, + ...util_exports.normalizeParams(params) + }); + inst._zod.bag.Class = cls; + inst._zod.check = (payload) => { + if (!(payload.value instanceof cls)) { + payload.issues.push({ + code: "invalid_type", + expected: cls.name, + input: payload.value, + inst, + path: [...inst._zod.def.path ?? []] + }); + } + }; + return inst; +} +var stringbool = (...args) => _stringbool({ + Codec: ZodCodec, + Boolean: ZodBoolean2, + String: ZodString2 +}, ...args); +function json(params) { + const jsonSchema = lazy(() => { + return union([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); + }); + return jsonSchema; +} +function preprocess(fn, schema) { + return pipe(transform(fn), schema); +} + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/compat.js +var ZodFirstPartyTypeKind2; +/* @__PURE__ */ (function(ZodFirstPartyTypeKind3) { +})(ZodFirstPartyTypeKind2 || (ZodFirstPartyTypeKind2 = {})); + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/from-json-schema.js +var z = { + ...schemas_exports3, + ...checks_exports2, + iso: iso_exports2 +}; + +// node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js +config(en_default2()); + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/types.js +var LATEST_PROTOCOL_VERSION = "2025-11-25"; +var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]; +var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; +var JSONRPC_VERSION = "2.0"; +var AssertObjectSchema = custom((v) => v !== null && (typeof v === "object" || typeof v === "function")); +var ProgressTokenSchema = union([string2(), number2().int()]); +var CursorSchema = string2(); +var TaskCreationParamsSchema = looseObject({ + /** + * Time in milliseconds to keep task results available after completion. + * If null, the task has unlimited lifetime until manually cleaned up. + */ + ttl: union([number2(), _null3()]).optional(), + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval: number2().optional() +}); +var TaskMetadataSchema = object2({ + ttl: number2().optional() +}); +var RelatedTaskMetadataSchema = object2({ + taskId: string2() +}); +var RequestMetaSchema = looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * If specified, this request is related to the provided task. + */ + [RELATED_TASK_META_KEY]: RelatedTaskMetadataSchema.optional() +}); +var BaseRequestParamsSchema = object2({ + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta: RequestMetaSchema.optional() +}); +var TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a CreateTaskResult immediately, and the actual result can be + * retrieved later via tasks/result. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task: TaskMetadataSchema.optional() +}); +var isTaskAugmentedRequestParams = (value) => TaskAugmentedRequestParamsSchema.safeParse(value).success; +var RequestSchema = object2({ + method: string2(), + params: BaseRequestParamsSchema.loose().optional() +}); +var NotificationsParamsSchema = object2({ + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: RequestMetaSchema.optional() +}); +var NotificationSchema = object2({ + method: string2(), + params: NotificationsParamsSchema.loose().optional() +}); +var ResultSchema = looseObject({ + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: RequestMetaSchema.optional() +}); +var RequestIdSchema = union([string2(), number2().int()]); +var JSONRPCRequestSchema = object2({ + jsonrpc: literal(JSONRPC_VERSION), + id: RequestIdSchema, + ...RequestSchema.shape +}).strict(); +var isJSONRPCRequest = (value) => JSONRPCRequestSchema.safeParse(value).success; +var JSONRPCNotificationSchema = object2({ + jsonrpc: literal(JSONRPC_VERSION), + ...NotificationSchema.shape +}).strict(); +var isJSONRPCNotification = (value) => JSONRPCNotificationSchema.safeParse(value).success; +var JSONRPCResultResponseSchema = object2({ + jsonrpc: literal(JSONRPC_VERSION), + id: RequestIdSchema, + result: ResultSchema +}).strict(); +var isJSONRPCResultResponse = (value) => JSONRPCResultResponseSchema.safeParse(value).success; +var ErrorCode; +(function(ErrorCode2) { + ErrorCode2[ErrorCode2["ConnectionClosed"] = -32e3] = "ConnectionClosed"; + ErrorCode2[ErrorCode2["RequestTimeout"] = -32001] = "RequestTimeout"; + ErrorCode2[ErrorCode2["ParseError"] = -32700] = "ParseError"; + ErrorCode2[ErrorCode2["InvalidRequest"] = -32600] = "InvalidRequest"; + ErrorCode2[ErrorCode2["MethodNotFound"] = -32601] = "MethodNotFound"; + ErrorCode2[ErrorCode2["InvalidParams"] = -32602] = "InvalidParams"; + ErrorCode2[ErrorCode2["InternalError"] = -32603] = "InternalError"; + ErrorCode2[ErrorCode2["UrlElicitationRequired"] = -32042] = "UrlElicitationRequired"; +})(ErrorCode || (ErrorCode = {})); +var JSONRPCErrorResponseSchema = object2({ + jsonrpc: literal(JSONRPC_VERSION), + id: RequestIdSchema.optional(), + error: object2({ + /** + * The error type that occurred. + */ + code: number2().int(), + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string2(), + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data: unknown().optional() + }) +}).strict(); +var isJSONRPCErrorResponse = (value) => JSONRPCErrorResponseSchema.safeParse(value).success; +var JSONRPCMessageSchema = union([ + JSONRPCRequestSchema, + JSONRPCNotificationSchema, + JSONRPCResultResponseSchema, + JSONRPCErrorResponseSchema +]); +var JSONRPCResponseSchema = union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); +var EmptyResultSchema = ResultSchema.strict(); +var CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestIdSchema.optional(), + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason: string2().optional() +}); +var CancelledNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/cancelled"), + params: CancelledNotificationParamsSchema +}); +var IconSchema = object2({ + /** + * URL or data URI for the icon. + */ + src: string2(), + /** + * Optional MIME type for the icon. + */ + mimeType: string2().optional(), + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes: array(string2()).optional(), + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme: _enum(["light", "dark"]).optional() +}); +var IconsSchema = object2({ + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons: array(IconSchema).optional() +}); +var BaseMetadataSchema = object2({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: string2(), + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title: string2().optional() +}); +var ImplementationSchema = BaseMetadataSchema.extend({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + version: string2(), + /** + * An optional URL of the website for this implementation. + */ + websiteUrl: string2().optional(), + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description: string2().optional() +}); +var FormElicitationCapabilitySchema = intersection(object2({ + applyDefaults: boolean2().optional() +}), record(string2(), unknown())); +var ElicitationCapabilitySchema = preprocess((value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + if (Object.keys(value).length === 0) { + return { form: {} }; + } + } + return value; +}, intersection(object2({ + form: FormElicitationCapabilitySchema.optional(), + url: AssertObjectSchema.optional() +}), record(string2(), unknown()).optional())); +var ClientTasksCapabilitySchema = looseObject({ + /** + * Present if the client supports listing tasks. + */ + list: AssertObjectSchema.optional(), + /** + * Present if the client supports cancelling tasks. + */ + cancel: AssertObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: looseObject({ + /** + * Task support for sampling requests. + */ + sampling: looseObject({ + createMessage: AssertObjectSchema.optional() + }).optional(), + /** + * Task support for elicitation requests. + */ + elicitation: looseObject({ + create: AssertObjectSchema.optional() + }).optional() + }).optional() +}); +var ServerTasksCapabilitySchema = looseObject({ + /** + * Present if the server supports listing tasks. + */ + list: AssertObjectSchema.optional(), + /** + * Present if the server supports cancelling tasks. + */ + cancel: AssertObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: looseObject({ + /** + * Task support for tool requests. + */ + tools: looseObject({ + call: AssertObjectSchema.optional() + }).optional() + }).optional() +}); +var ClientCapabilitiesSchema = object2({ + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental: record(string2(), AssertObjectSchema).optional(), + /** + * Present if the client supports sampling from an LLM. + */ + sampling: object2({ + /** + * Present if the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context: AssertObjectSchema.optional(), + /** + * Present if the client supports tool use via tools and toolChoice parameters. + */ + tools: AssertObjectSchema.optional() + }).optional(), + /** + * Present if the client supports eliciting user input. + */ + elicitation: ElicitationCapabilitySchema.optional(), + /** + * Present if the client supports listing roots. + */ + roots: object2({ + /** + * Whether the client supports issuing notifications for changes to the roots list. + */ + listChanged: boolean2().optional() + }).optional(), + /** + * Present if the client supports task creation. + */ + tasks: ClientTasksCapabilitySchema.optional() +}); +var InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string2(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema +}); +var InitializeRequestSchema = RequestSchema.extend({ + method: literal("initialize"), + params: InitializeRequestParamsSchema +}); +var ServerCapabilitiesSchema = object2({ + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental: record(string2(), AssertObjectSchema).optional(), + /** + * Present if the server supports sending log messages to the client. + */ + logging: AssertObjectSchema.optional(), + /** + * Present if the server supports sending completions to the client. + */ + completions: AssertObjectSchema.optional(), + /** + * Present if the server offers any prompt templates. + */ + prompts: object2({ + /** + * Whether this server supports issuing notifications for changes to the prompt list. + */ + listChanged: boolean2().optional() + }).optional(), + /** + * Present if the server offers any resources to read. + */ + resources: object2({ + /** + * Whether this server supports clients subscribing to resource updates. + */ + subscribe: boolean2().optional(), + /** + * Whether this server supports issuing notifications for changes to the resource list. + */ + listChanged: boolean2().optional() + }).optional(), + /** + * Present if the server offers any tools to call. + */ + tools: object2({ + /** + * Whether this server supports issuing notifications for changes to the tool list. + */ + listChanged: boolean2().optional() + }).optional(), + /** + * Present if the server supports task creation. + */ + tasks: ServerTasksCapabilitySchema.optional() +}); +var InitializeResultSchema = ResultSchema.extend({ + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string2(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions: string2().optional() +}); +var InitializedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/initialized"), + params: NotificationsParamsSchema.optional() +}); +var PingRequestSchema = RequestSchema.extend({ + method: literal("ping"), + params: BaseRequestParamsSchema.optional() +}); +var ProgressSchema = object2({ + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + */ + progress: number2(), + /** + * Total number of items to process (or total progress required), if known. + */ + total: optional(number2()), + /** + * An optional message describing the current progress. + */ + message: optional(string2()) +}); +var ProgressNotificationParamsSchema = object2({ + ...NotificationsParamsSchema.shape, + ...ProgressSchema.shape, + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressTokenSchema +}); +var ProgressNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/progress"), + params: ProgressNotificationParamsSchema +}); +var PaginatedRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor: CursorSchema.optional() +}); +var PaginatedRequestSchema = RequestSchema.extend({ + params: PaginatedRequestParamsSchema.optional() +}); +var PaginatedResultSchema = ResultSchema.extend({ + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor: CursorSchema.optional() +}); +var TaskStatusSchema = _enum(["working", "input_required", "completed", "failed", "cancelled"]); +var TaskSchema = object2({ + taskId: string2(), + status: TaskStatusSchema, + /** + * Time in milliseconds to keep task results available after completion. + * If null, the task has unlimited lifetime until manually cleaned up. + */ + ttl: union([number2(), _null3()]), + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string2(), + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string2(), + pollInterval: optional(number2()), + /** + * Optional diagnostic message for failed tasks or other status information. + */ + statusMessage: optional(string2()) +}); +var CreateTaskResultSchema = ResultSchema.extend({ + task: TaskSchema +}); +var TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); +var TaskStatusNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/tasks/status"), + params: TaskStatusNotificationParamsSchema +}); +var GetTaskRequestSchema = RequestSchema.extend({ + method: literal("tasks/get"), + params: BaseRequestParamsSchema.extend({ + taskId: string2() + }) +}); +var GetTaskResultSchema = ResultSchema.merge(TaskSchema); +var GetTaskPayloadRequestSchema = RequestSchema.extend({ + method: literal("tasks/result"), + params: BaseRequestParamsSchema.extend({ + taskId: string2() + }) +}); +var GetTaskPayloadResultSchema = ResultSchema.loose(); +var ListTasksRequestSchema = PaginatedRequestSchema.extend({ + method: literal("tasks/list") +}); +var ListTasksResultSchema = PaginatedResultSchema.extend({ + tasks: array(TaskSchema) +}); +var CancelTaskRequestSchema = RequestSchema.extend({ + method: literal("tasks/cancel"), + params: BaseRequestParamsSchema.extend({ + taskId: string2() + }) +}); +var CancelTaskResultSchema = ResultSchema.merge(TaskSchema); +var ResourceContentsSchema = object2({ + /** + * The URI of this resource. + */ + uri: string2(), + /** + * The MIME type of this resource, if known. + */ + mimeType: optional(string2()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var TextResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string2() +}); +var Base64Schema = string2().refine((val) => { + try { + atob(val); + return true; + } catch { + return false; + } +}, { message: "Invalid Base64 string" }); +var BlobResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * A base64-encoded string representing the binary data of the item. + */ + blob: Base64Schema +}); +var RoleSchema = _enum(["user", "assistant"]); +var AnnotationsSchema = object2({ + /** + * Intended audience(s) for the resource. + */ + audience: array(RoleSchema).optional(), + /** + * Importance hint for the resource, from 0 (least) to 1 (most). + */ + priority: number2().min(0).max(1).optional(), + /** + * ISO 8601 timestamp for the most recent modification. + */ + lastModified: iso_exports2.datetime({ offset: true }).optional() +}); +var ResourceSchema = object2({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * The URI of this resource. + */ + uri: string2(), + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: optional(string2()), + /** + * The MIME type of this resource, if known. + */ + mimeType: optional(string2()), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: optional(looseObject({})) +}); +var ResourceTemplateSchema = object2({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: string2(), + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: optional(string2()), + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType: optional(string2()), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: optional(looseObject({})) +}); +var ListResourcesRequestSchema = PaginatedRequestSchema.extend({ + method: literal("resources/list") +}); +var ListResourcesResultSchema = PaginatedResultSchema.extend({ + resources: array(ResourceSchema) +}); +var ListResourceTemplatesRequestSchema = PaginatedRequestSchema.extend({ + method: literal("resources/templates/list") +}); +var ListResourceTemplatesResultSchema = PaginatedResultSchema.extend({ + resourceTemplates: array(ResourceTemplateSchema) +}); +var ResourceRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string2() +}); +var ReadResourceRequestParamsSchema = ResourceRequestParamsSchema; +var ReadResourceRequestSchema = RequestSchema.extend({ + method: literal("resources/read"), + params: ReadResourceRequestParamsSchema +}); +var ReadResourceResultSchema = ResultSchema.extend({ + contents: array(union([TextResourceContentsSchema, BlobResourceContentsSchema])) +}); +var ResourceListChangedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/resources/list_changed"), + params: NotificationsParamsSchema.optional() +}); +var SubscribeRequestParamsSchema = ResourceRequestParamsSchema; +var SubscribeRequestSchema = RequestSchema.extend({ + method: literal("resources/subscribe"), + params: SubscribeRequestParamsSchema +}); +var UnsubscribeRequestParamsSchema = ResourceRequestParamsSchema; +var UnsubscribeRequestSchema = RequestSchema.extend({ + method: literal("resources/unsubscribe"), + params: UnsubscribeRequestParamsSchema +}); +var ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + */ + uri: string2() +}); +var ResourceUpdatedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/resources/updated"), + params: ResourceUpdatedNotificationParamsSchema +}); +var PromptArgumentSchema = object2({ + /** + * The name of the argument. + */ + name: string2(), + /** + * A human-readable description of the argument. + */ + description: optional(string2()), + /** + * Whether this argument must be provided. + */ + required: optional(boolean2()) +}); +var PromptSchema = object2({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * An optional description of what this prompt provides + */ + description: optional(string2()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: optional(array(PromptArgumentSchema)), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: optional(looseObject({})) +}); +var ListPromptsRequestSchema = PaginatedRequestSchema.extend({ + method: literal("prompts/list") +}); +var ListPromptsResultSchema = PaginatedResultSchema.extend({ + prompts: array(PromptSchema) +}); +var GetPromptRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The name of the prompt or prompt template. + */ + name: string2(), + /** + * Arguments to use for templating the prompt. + */ + arguments: record(string2(), string2()).optional() +}); +var GetPromptRequestSchema = RequestSchema.extend({ + method: literal("prompts/get"), + params: GetPromptRequestParamsSchema +}); +var TextContentSchema = object2({ + type: literal("text"), + /** + * The text content of the message. + */ + text: string2(), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var ImageContentSchema = object2({ + type: literal("image"), + /** + * The base64-encoded image data. + */ + data: Base64Schema, + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string2(), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var AudioContentSchema = object2({ + type: literal("audio"), + /** + * The base64-encoded audio data. + */ + data: Base64Schema, + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string2(), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var ToolUseContentSchema = object2({ + type: literal("tool_use"), + /** + * The name of the tool to invoke. + * Must match a tool name from the request's tools array. + */ + name: string2(), + /** + * Unique identifier for this tool call. + * Used to correlate with ToolResultContent in subsequent messages. + */ + id: string2(), + /** + * Arguments to pass to the tool. + * Must conform to the tool's inputSchema. + */ + input: record(string2(), unknown()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var EmbeddedResourceSchema = object2({ + type: literal("resource"), + resource: union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var ResourceLinkSchema = ResourceSchema.extend({ + type: literal("resource_link") +}); +var ContentBlockSchema = union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema +]); +var PromptMessageSchema = object2({ + role: RoleSchema, + content: ContentBlockSchema +}); +var GetPromptResultSchema = ResultSchema.extend({ + /** + * An optional description for the prompt. + */ + description: string2().optional(), + messages: array(PromptMessageSchema) +}); +var PromptListChangedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/prompts/list_changed"), + params: NotificationsParamsSchema.optional() +}); +var ToolAnnotationsSchema = object2({ + /** + * A human-readable title for the tool. + */ + title: string2().optional(), + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint: boolean2().optional(), + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint: boolean2().optional(), + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint: boolean2().optional(), + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint: boolean2().optional() +}); +var ToolExecutionSchema = object2({ + /** + * Indicates the tool's preference for task-augmented execution. + * - "required": Clients MUST invoke the tool as a task + * - "optional": Clients MAY invoke the tool as a task or normal request + * - "forbidden": Clients MUST NOT attempt to invoke the tool as a task + * + * If not present, defaults to "forbidden". + */ + taskSupport: _enum(["required", "optional", "forbidden"]).optional() +}); +var ToolSchema = object2({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A human-readable description of the tool. + */ + description: string2().optional(), + /** + * A JSON Schema 2020-12 object defining the expected parameters for the tool. + * Must have type: 'object' at the root level per MCP spec. + */ + inputSchema: object2({ + type: literal("object"), + properties: record(string2(), AssertObjectSchema).optional(), + required: array(string2()).optional() + }).catchall(unknown()), + /** + * An optional JSON Schema 2020-12 object defining the structure of the tool's output + * returned in the structuredContent field of a CallToolResult. + * Must have type: 'object' at the root level per MCP spec. + */ + outputSchema: object2({ + type: literal("object"), + properties: record(string2(), AssertObjectSchema).optional(), + required: array(string2()).optional() + }).catchall(unknown()).optional(), + /** + * Optional additional tool information. + */ + annotations: ToolAnnotationsSchema.optional(), + /** + * Execution-related properties for this tool. + */ + execution: ToolExecutionSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var ListToolsRequestSchema = PaginatedRequestSchema.extend({ + method: literal("tools/list") +}); +var ListToolsResultSchema = PaginatedResultSchema.extend({ + tools: array(ToolSchema) +}); +var CallToolResultSchema = ResultSchema.extend({ + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool does not define an outputSchema, this field MUST be present in the result. + * For backwards compatibility, this field is always present, but it may be empty. + */ + content: array(ContentBlockSchema).default([]), + /** + * An object containing structured tool output. + * + * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: record(string2(), unknown()).optional(), + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError: boolean2().optional() +}); +var CompatibilityCallToolResultSchema = CallToolResultSchema.or(ResultSchema.extend({ + toolResult: unknown() +})); +var CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The name of the tool to call. + */ + name: string2(), + /** + * Arguments to pass to the tool. + */ + arguments: record(string2(), unknown()).optional() +}); +var CallToolRequestSchema = RequestSchema.extend({ + method: literal("tools/call"), + params: CallToolRequestParamsSchema +}); +var ToolListChangedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/tools/list_changed"), + params: NotificationsParamsSchema.optional() +}); +var ListChangedOptionsBaseSchema = object2({ + /** + * If true, the list will be refreshed automatically when a list changed notification is received. + * The callback will be called with the updated list. + * + * If false, the callback will be called with null items, allowing manual refresh. + * + * @default true + */ + autoRefresh: boolean2().default(true), + /** + * Debounce time in milliseconds for list changed notification processing. + * + * Multiple notifications received within this timeframe will only trigger one refresh. + * Set to 0 to disable debouncing. + * + * @default 300 + */ + debounceMs: number2().int().nonnegative().default(300) +}); +var LoggingLevelSchema = _enum(["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]); +var SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/logging/message. + */ + level: LoggingLevelSchema +}); +var SetLevelRequestSchema = RequestSchema.extend({ + method: literal("logging/setLevel"), + params: SetLevelRequestParamsSchema +}); +var LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The severity of this log message. + */ + level: LoggingLevelSchema, + /** + * An optional name of the logger issuing this message. + */ + logger: string2().optional(), + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown() +}); +var LoggingMessageNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/message"), + params: LoggingMessageNotificationParamsSchema +}); +var ModelHintSchema = object2({ + /** + * A hint for a model name. + */ + name: string2().optional() +}); +var ModelPreferencesSchema = object2({ + /** + * Optional hints to use for model selection. + */ + hints: array(ModelHintSchema).optional(), + /** + * How much to prioritize cost when selecting a model. + */ + costPriority: number2().min(0).max(1).optional(), + /** + * How much to prioritize sampling speed (latency) when selecting a model. + */ + speedPriority: number2().min(0).max(1).optional(), + /** + * How much to prioritize intelligence and capabilities when selecting a model. + */ + intelligencePriority: number2().min(0).max(1).optional() +}); +var ToolChoiceSchema = object2({ + /** + * Controls when tools are used: + * - "auto": Model decides whether to use tools (default) + * - "required": Model MUST use at least one tool before completing + * - "none": Model MUST NOT use any tools + */ + mode: _enum(["auto", "required", "none"]).optional() +}); +var ToolResultContentSchema = object2({ + type: literal("tool_result"), + toolUseId: string2().describe("The unique identifier for the corresponding tool call."), + content: array(ContentBlockSchema).default([]), + structuredContent: object2({}).loose().optional(), + isError: boolean2().optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var SamplingContentSchema = discriminatedUnion("type", [TextContentSchema, ImageContentSchema, AudioContentSchema]); +var SamplingMessageContentBlockSchema = discriminatedUnion("type", [ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ToolUseContentSchema, + ToolResultContentSchema +]); +var SamplingMessageSchema = object2({ + role: RoleSchema, + content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + messages: array(SamplingMessageSchema), + /** + * The server's preferences for which model to select. The client MAY modify or omit this request. + */ + modelPreferences: ModelPreferencesSchema.optional(), + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt: string2().optional(), + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + */ + includeContext: _enum(["none", "thisServer", "allServers"]).optional(), + temperature: number2().optional(), + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number2().int(), + stopSequences: array(string2()).optional(), + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata: AssertObjectSchema.optional(), + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + */ + tools: array(ToolSchema).optional(), + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice: ToolChoiceSchema.optional() +}); +var CreateMessageRequestSchema = RequestSchema.extend({ + method: literal("sampling/createMessage"), + params: CreateMessageRequestParamsSchema +}); +var CreateMessageResultSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: string2(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: optional(_enum(["endTurn", "stopSequence", "maxTokens"]).or(string2())), + role: RoleSchema, + /** + * Response content. Single content block (text, image, or audio). + */ + content: SamplingContentSchema +}); +var CreateMessageResultWithToolsSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: string2(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * - "toolUse": The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: optional(_enum(["endTurn", "stopSequence", "maxTokens", "toolUse"]).or(string2())), + role: RoleSchema, + /** + * Response content. May be a single block or array. May include ToolUseContent if stopReason is "toolUse". + */ + content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) +}); +var BooleanSchemaSchema = object2({ + type: literal("boolean"), + title: string2().optional(), + description: string2().optional(), + default: boolean2().optional() +}); +var StringSchemaSchema = object2({ + type: literal("string"), + title: string2().optional(), + description: string2().optional(), + minLength: number2().optional(), + maxLength: number2().optional(), + format: _enum(["email", "uri", "date", "date-time"]).optional(), + default: string2().optional() +}); +var NumberSchemaSchema = object2({ + type: _enum(["number", "integer"]), + title: string2().optional(), + description: string2().optional(), + minimum: number2().optional(), + maximum: number2().optional(), + default: number2().optional() +}); +var UntitledSingleSelectEnumSchemaSchema = object2({ + type: literal("string"), + title: string2().optional(), + description: string2().optional(), + enum: array(string2()), + default: string2().optional() +}); +var TitledSingleSelectEnumSchemaSchema = object2({ + type: literal("string"), + title: string2().optional(), + description: string2().optional(), + oneOf: array(object2({ + const: string2(), + title: string2() + })), + default: string2().optional() +}); +var LegacyTitledEnumSchemaSchema = object2({ + type: literal("string"), + title: string2().optional(), + description: string2().optional(), + enum: array(string2()), + enumNames: array(string2()).optional(), + default: string2().optional() +}); +var SingleSelectEnumSchemaSchema = union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); +var UntitledMultiSelectEnumSchemaSchema = object2({ + type: literal("array"), + title: string2().optional(), + description: string2().optional(), + minItems: number2().optional(), + maxItems: number2().optional(), + items: object2({ + type: literal("string"), + enum: array(string2()) + }), + default: array(string2()).optional() +}); +var TitledMultiSelectEnumSchemaSchema = object2({ + type: literal("array"), + title: string2().optional(), + description: string2().optional(), + minItems: number2().optional(), + maxItems: number2().optional(), + items: object2({ + anyOf: array(object2({ + const: string2(), + title: string2() + })) + }), + default: array(string2()).optional() +}); +var MultiSelectEnumSchemaSchema = union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); +var EnumSchemaSchema = union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); +var PrimitiveSchemaDefinitionSchema = union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); +var ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + * + * Optional for backward compatibility. Clients MUST treat missing mode as "form". + */ + mode: literal("form").optional(), + /** + * The message to present to the user describing what information is being requested. + */ + message: string2(), + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: object2({ + type: literal("object"), + properties: record(string2(), PrimitiveSchemaDefinitionSchema), + required: array(string2()).optional() + }) +}); +var ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + */ + mode: literal("url"), + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string2(), + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string2(), + /** + * The URL that the user should navigate to. + */ + url: string2().url() +}); +var ElicitRequestParamsSchema = union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); +var ElicitRequestSchema = RequestSchema.extend({ + method: literal("elicitation/create"), + params: ElicitRequestParamsSchema +}); +var ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the elicitation that completed. + */ + elicitationId: string2() +}); +var ElicitationCompleteNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/elicitation/complete"), + params: ElicitationCompleteNotificationParamsSchema +}); +var ElicitResultSchema = ResultSchema.extend({ + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly decline the action + * - "cancel": User dismissed without making an explicit choice + */ + action: _enum(["accept", "decline", "cancel"]), + /** + * The submitted form data, only present when action is "accept". + * Contains values matching the requested schema. + * Per MCP spec, content is "typically omitted" for decline/cancel actions. + * We normalize null to undefined for leniency while maintaining type compatibility. + */ + content: preprocess((val) => val === null ? void 0 : val, record(string2(), union([string2(), number2(), boolean2(), array(string2())])).optional()) +}); +var ResourceTemplateReferenceSchema = object2({ + type: literal("ref/resource"), + /** + * The URI or URI template of the resource. + */ + uri: string2() +}); +var PromptReferenceSchema = object2({ + type: literal("ref/prompt"), + /** + * The name of the prompt or prompt template + */ + name: string2() +}); +var CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ + ref: union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + /** + * The argument's information + */ + argument: object2({ + /** + * The name of the argument + */ + name: string2(), + /** + * The value of the argument to use for completion matching. + */ + value: string2() + }), + context: object2({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: record(string2(), string2()).optional() + }).optional() +}); +var CompleteRequestSchema = RequestSchema.extend({ + method: literal("completion/complete"), + params: CompleteRequestParamsSchema +}); +var CompleteResultSchema = ResultSchema.extend({ + completion: looseObject({ + /** + * An array of completion values. Must not exceed 100 items. + */ + values: array(string2()).max(100), + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total: optional(number2().int()), + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore: optional(boolean2()) + }) +}); +var RootSchema = object2({ + /** + * The URI identifying the root. This *must* start with file:// for now. + */ + uri: string2().startsWith("file://"), + /** + * An optional name for the root. + */ + name: string2().optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: record(string2(), unknown()).optional() +}); +var ListRootsRequestSchema = RequestSchema.extend({ + method: literal("roots/list"), + params: BaseRequestParamsSchema.optional() +}); +var ListRootsResultSchema = ResultSchema.extend({ + roots: array(RootSchema) +}); +var RootsListChangedNotificationSchema = NotificationSchema.extend({ + method: literal("notifications/roots/list_changed"), + params: NotificationsParamsSchema.optional() +}); +var ClientRequestSchema = union([ + PingRequestSchema, + InitializeRequestSchema, + CompleteRequestSchema, + SetLevelRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + CallToolRequestSchema, + ListToolsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); +var ClientNotificationSchema = union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema +]); +var ClientResultSchema = union([ + EmptyResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + ListRootsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); +var ServerRequestSchema = union([ + PingRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); +var ServerNotificationSchema = union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema +]); +var ServerResultSchema = union([ + EmptyResultSchema, + InitializeResultSchema, + CompleteResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, + ListToolsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); +var McpError = class _McpError extends Error { + constructor(code, message, data) { + super(`MCP error ${code}: ${message}`); + this.code = code; + this.data = data; + this.name = "McpError"; + } + /** + * Factory method to create the appropriate error type based on the error code and data + */ + static fromError(code, message, data) { + if (code === ErrorCode.UrlElicitationRequired && data) { + const errorData = data; + if (errorData.elicitations) { + return new UrlElicitationRequiredError(errorData.elicitations, message); + } + } + return new _McpError(code, message, data); + } +}; +var UrlElicitationRequiredError = class extends McpError { + constructor(elicitations, message = `URL elicitation${elicitations.length > 1 ? "s" : ""} required`) { + super(ErrorCode.UrlElicitationRequired, message, { + elicitations + }); + } + get elicitations() { + return this.data?.elicitations ?? []; + } +}; + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js +function isTerminal(status) { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +// node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/string.js +var ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789"); + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js +function getMethodLiteral(schema) { + const shape = getObjectShape(schema); + const methodSchema = shape?.method; + if (!methodSchema) { + throw new Error("Schema is missing a method literal"); + } + const value = getLiteralValue(methodSchema); + if (typeof value !== "string") { + throw new Error("Schema method literal must be a string"); + } + return value; +} +function parseWithCompat(schema, data) { + const result = safeParse2(schema, data); + if (!result.success) { + throw result.error; + } + return result.data; +} + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js +var DEFAULT_REQUEST_TIMEOUT_MSEC = 6e4; +var Protocol = class { + constructor(_options) { + this._options = _options; + this._requestMessageId = 0; + this._requestHandlers = /* @__PURE__ */ new Map(); + this._requestHandlerAbortControllers = /* @__PURE__ */ new Map(); + this._notificationHandlers = /* @__PURE__ */ new Map(); + this._responseHandlers = /* @__PURE__ */ new Map(); + this._progressHandlers = /* @__PURE__ */ new Map(); + this._timeoutInfo = /* @__PURE__ */ new Map(); + this._pendingDebouncedNotifications = /* @__PURE__ */ new Set(); + this._taskProgressTokens = /* @__PURE__ */ new Map(); + this._requestResolvers = /* @__PURE__ */ new Map(); + this.setNotificationHandler(CancelledNotificationSchema, (notification) => { + this._oncancel(notification); + }); + this.setNotificationHandler(ProgressNotificationSchema, (notification) => { + this._onprogress(notification); + }); + this.setRequestHandler( + PingRequestSchema, + // Automatic pong by default. + (_request) => ({}) + ); + this._taskStore = _options?.taskStore; + this._taskMessageQueue = _options?.taskMessageQueue; + if (this._taskStore) { + this.setRequestHandler(GetTaskRequestSchema, async (request, extra) => { + const task = await this._taskStore.getTask(request.params.taskId, extra.sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, "Failed to retrieve task: Task not found"); + } + return { + ...task + }; + }); + this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => { + const handleTaskResult = async () => { + const taskId = request.params.taskId; + if (this._taskMessageQueue) { + let queuedMessage; + while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) { + if (queuedMessage.type === "response" || queuedMessage.type === "error") { + const message = queuedMessage.message; + const requestId = message.id; + const resolver = this._requestResolvers.get(requestId); + if (resolver) { + this._requestResolvers.delete(requestId); + if (queuedMessage.type === "response") { + resolver(message); + } else { + const errorMessage = message; + const error2 = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error2); + } + } else { + const messageType = queuedMessage.type === "response" ? "Response" : "Error"; + this._onerror(new Error(`${messageType} handler missing for request ${requestId}`)); + } + continue; + } + await this._transport?.send(queuedMessage.message, { relatedRequestId: extra.requestId }); + } + } + const task = await this._taskStore.getTask(taskId, extra.sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`); + } + if (!isTerminal(task.status)) { + await this._waitForTaskUpdate(taskId, extra.signal); + return await handleTaskResult(); + } + if (isTerminal(task.status)) { + const result = await this._taskStore.getTaskResult(taskId, extra.sessionId); + this._clearTaskQueue(taskId); + return { + ...result, + _meta: { + ...result._meta, + [RELATED_TASK_META_KEY]: { + taskId + } + } + }; + } + return await handleTaskResult(); + }; + return await handleTaskResult(); + }); + this.setRequestHandler(ListTasksRequestSchema, async (request, extra) => { + try { + const { tasks, nextCursor } = await this._taskStore.listTasks(request.params?.cursor, extra.sessionId); + return { + tasks, + nextCursor, + _meta: {} + }; + } catch (error2) { + throw new McpError(ErrorCode.InvalidParams, `Failed to list tasks: ${error2 instanceof Error ? error2.message : String(error2)}`); + } + }); + this.setRequestHandler(CancelTaskRequestSchema, async (request, extra) => { + try { + const task = await this._taskStore.getTask(request.params.taskId, extra.sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`); + } + if (isTerminal(task.status)) { + throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); + } + await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId); + this._clearTaskQueue(request.params.taskId); + const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId); + if (!cancelledTask) { + throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`); + } + return { + _meta: {}, + ...cancelledTask + }; + } catch (error2) { + if (error2 instanceof McpError) { + throw error2; + } + throw new McpError(ErrorCode.InvalidRequest, `Failed to cancel task: ${error2 instanceof Error ? error2.message : String(error2)}`); + } + }); + } + } + async _oncancel(notification) { + if (!notification.params.requestId) { + return; + } + const controller = this._requestHandlerAbortControllers.get(notification.params.requestId); + controller?.abort(notification.params.reason); + } + _setupTimeout(messageId, timeout, maxTotalTimeout, onTimeout, resetTimeoutOnProgress = false) { + this._timeoutInfo.set(messageId, { + timeoutId: setTimeout(onTimeout, timeout), + startTime: Date.now(), + timeout, + maxTotalTimeout, + resetTimeoutOnProgress, + onTimeout + }); + } + _resetTimeout(messageId) { + const info = this._timeoutInfo.get(messageId); + if (!info) + return false; + const totalElapsed = Date.now() - info.startTime; + if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) { + this._timeoutInfo.delete(messageId); + throw McpError.fromError(ErrorCode.RequestTimeout, "Maximum total timeout exceeded", { + maxTotalTimeout: info.maxTotalTimeout, + totalElapsed + }); + } + clearTimeout(info.timeoutId); + info.timeoutId = setTimeout(info.onTimeout, info.timeout); + return true; + } + _cleanupTimeout(messageId) { + const info = this._timeoutInfo.get(messageId); + if (info) { + clearTimeout(info.timeoutId); + this._timeoutInfo.delete(messageId); + } + } + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * + * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. + */ + async connect(transport) { + if (this._transport) { + throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection."); + } + this._transport = transport; + const _onclose = this.transport?.onclose; + this._transport.onclose = () => { + _onclose?.(); + this._onclose(); + }; + const _onerror = this.transport?.onerror; + this._transport.onerror = (error2) => { + _onerror?.(error2); + this._onerror(error2); + }; + const _onmessage = this._transport?.onmessage; + this._transport.onmessage = (message, extra) => { + _onmessage?.(message, extra); + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._onresponse(message); + } else if (isJSONRPCRequest(message)) { + this._onrequest(message, extra); + } else if (isJSONRPCNotification(message)) { + this._onnotification(message); + } else { + this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + } + }; + await this._transport.start(); + } + _onclose() { + const responseHandlers = this._responseHandlers; + this._responseHandlers = /* @__PURE__ */ new Map(); + this._progressHandlers.clear(); + this._taskProgressTokens.clear(); + this._pendingDebouncedNotifications.clear(); + for (const controller of this._requestHandlerAbortControllers.values()) { + controller.abort(); + } + this._requestHandlerAbortControllers.clear(); + const error2 = McpError.fromError(ErrorCode.ConnectionClosed, "Connection closed"); + this._transport = void 0; + this.onclose?.(); + for (const handler of responseHandlers.values()) { + handler(error2); + } + } + _onerror(error2) { + this.onerror?.(error2); + } + _onnotification(notification) { + const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + if (handler === void 0) { + return; + } + Promise.resolve().then(() => handler(notification)).catch((error2) => this._onerror(new Error(`Uncaught error in notification handler: ${error2}`))); + } + _onrequest(request, extra) { + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + const capturedTransport = this._transport; + const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId; + if (handler === void 0) { + const errorResponse = { + jsonrpc: "2.0", + id: request.id, + error: { + code: ErrorCode.MethodNotFound, + message: "Method not found" + } + }; + if (relatedTaskId && this._taskMessageQueue) { + this._enqueueTaskMessage(relatedTaskId, { + type: "error", + message: errorResponse, + timestamp: Date.now() + }, capturedTransport?.sessionId).catch((error2) => this._onerror(new Error(`Failed to enqueue error response: ${error2}`))); + } else { + capturedTransport?.send(errorResponse).catch((error2) => this._onerror(new Error(`Failed to send an error response: ${error2}`))); + } + return; + } + const abortController = new AbortController(); + this._requestHandlerAbortControllers.set(request.id, abortController); + const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : void 0; + const taskStore = this._taskStore ? this.requestTaskStore(request, capturedTransport?.sessionId) : void 0; + const fullExtra = { + signal: abortController.signal, + sessionId: capturedTransport?.sessionId, + _meta: request.params?._meta, + sendNotification: async (notification) => { + if (abortController.signal.aborted) + return; + const notificationOptions = { relatedRequestId: request.id }; + if (relatedTaskId) { + notificationOptions.relatedTask = { taskId: relatedTaskId }; + } + await this.notification(notification, notificationOptions); + }, + sendRequest: async (r, resultSchema, options2) => { + if (abortController.signal.aborted) { + throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled"); + } + const requestOptions = { ...options2, relatedRequestId: request.id }; + if (relatedTaskId && !requestOptions.relatedTask) { + requestOptions.relatedTask = { taskId: relatedTaskId }; + } + const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; + if (effectiveTaskId && taskStore) { + await taskStore.updateTaskStatus(effectiveTaskId, "input_required"); + } + return await this.request(r, resultSchema, requestOptions); + }, + authInfo: extra?.authInfo, + requestId: request.id, + requestInfo: extra?.requestInfo, + taskId: relatedTaskId, + taskStore, + taskRequestedTtl: taskCreationParams?.ttl, + closeSSEStream: extra?.closeSSEStream, + closeStandaloneSSEStream: extra?.closeStandaloneSSEStream + }; + Promise.resolve().then(() => { + if (taskCreationParams) { + this.assertTaskHandlerCapability(request.method); + } + }).then(() => handler(request, fullExtra)).then(async (result) => { + if (abortController.signal.aborted) { + return; + } + const response = { + result, + jsonrpc: "2.0", + id: request.id + }; + if (relatedTaskId && this._taskMessageQueue) { + await this._enqueueTaskMessage(relatedTaskId, { + type: "response", + message: response, + timestamp: Date.now() + }, capturedTransport?.sessionId); + } else { + await capturedTransport?.send(response); + } + }, async (error2) => { + if (abortController.signal.aborted) { + return; + } + const errorResponse = { + jsonrpc: "2.0", + id: request.id, + error: { + code: Number.isSafeInteger(error2["code"]) ? error2["code"] : ErrorCode.InternalError, + message: error2.message ?? "Internal error", + ...error2["data"] !== void 0 && { data: error2["data"] } + } + }; + if (relatedTaskId && this._taskMessageQueue) { + await this._enqueueTaskMessage(relatedTaskId, { + type: "error", + message: errorResponse, + timestamp: Date.now() + }, capturedTransport?.sessionId); + } else { + await capturedTransport?.send(errorResponse); + } + }).catch((error2) => this._onerror(new Error(`Failed to send response: ${error2}`))).finally(() => { + this._requestHandlerAbortControllers.delete(request.id); + }); + } + _onprogress(notification) { + const { progressToken, ...params } = notification.params; + const messageId = Number(progressToken); + const handler = this._progressHandlers.get(messageId); + if (!handler) { + this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); + return; + } + const responseHandler = this._responseHandlers.get(messageId); + const timeoutInfo = this._timeoutInfo.get(messageId); + if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { + try { + this._resetTimeout(messageId); + } catch (error2) { + this._responseHandlers.delete(messageId); + this._progressHandlers.delete(messageId); + this._cleanupTimeout(messageId); + responseHandler(error2); + return; + } + } + handler(params); + } + _onresponse(response) { + const messageId = Number(response.id); + const resolver = this._requestResolvers.get(messageId); + if (resolver) { + this._requestResolvers.delete(messageId); + if (isJSONRPCResultResponse(response)) { + resolver(response); + } else { + const error2 = new McpError(response.error.code, response.error.message, response.error.data); + resolver(error2); + } + return; + } + const handler = this._responseHandlers.get(messageId); + if (handler === void 0) { + this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); + return; + } + this._responseHandlers.delete(messageId); + this._cleanupTimeout(messageId); + let isTaskResponse = false; + if (isJSONRPCResultResponse(response) && response.result && typeof response.result === "object") { + const result = response.result; + if (result.task && typeof result.task === "object") { + const task = result.task; + if (typeof task.taskId === "string") { + isTaskResponse = true; + this._taskProgressTokens.set(task.taskId, messageId); + } + } + } + if (!isTaskResponse) { + this._progressHandlers.delete(messageId); + } + if (isJSONRPCResultResponse(response)) { + handler(response); + } else { + const error2 = McpError.fromError(response.error.code, response.error.message, response.error.data); + handler(error2); + } + } + get transport() { + return this._transport; + } + /** + * Closes the connection. + */ + async close() { + await this._transport?.close(); + } + /** + * Sends a request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * @example + * ```typescript + * const stream = protocol.requestStream(request, resultSchema, options); + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @experimental Use `client.experimental.tasks.requestStream()` to access this method. + */ + async *requestStream(request, resultSchema, options2) { + const { task } = options2 ?? {}; + if (!task) { + try { + const result = await this.request(request, resultSchema, options2); + yield { type: "result", result }; + } catch (error2) { + yield { + type: "error", + error: error2 instanceof McpError ? error2 : new McpError(ErrorCode.InternalError, String(error2)) + }; + } + return; + } + let taskId; + try { + const createResult = await this.request(request, CreateTaskResultSchema, options2); + if (createResult.task) { + taskId = createResult.task.taskId; + yield { type: "taskCreated", task: createResult.task }; + } else { + throw new McpError(ErrorCode.InternalError, "Task creation did not return a task"); + } + while (true) { + const task2 = await this.getTask({ taskId }, options2); + yield { type: "taskStatus", task: task2 }; + if (isTerminal(task2.status)) { + if (task2.status === "completed") { + const result = await this.getTaskResult({ taskId }, resultSchema, options2); + yield { type: "result", result }; + } else if (task2.status === "failed") { + yield { + type: "error", + error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`) + }; + } else if (task2.status === "cancelled") { + yield { + type: "error", + error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`) + }; + } + return; + } + if (task2.status === "input_required") { + const result = await this.getTaskResult({ taskId }, resultSchema, options2); + yield { type: "result", result }; + return; + } + const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3; + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + options2?.signal?.throwIfAborted(); + } + } catch (error2) { + yield { + type: "error", + error: error2 instanceof McpError ? error2 : new McpError(ErrorCode.InternalError, String(error2)) + }; + } + } + /** + * Sends a request and waits for a response. + * + * Do not use this method to emit notifications! Use notification() instead. + */ + request(request, resultSchema, options2) { + const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options2 ?? {}; + return new Promise((resolve, reject) => { + const earlyReject = (error2) => { + reject(error2); + }; + if (!this._transport) { + earlyReject(new Error("Not connected")); + return; + } + if (this._options?.enforceStrictCapabilities === true) { + try { + this.assertCapabilityForMethod(request.method); + if (task) { + this.assertTaskCapability(request.method); + } + } catch (e) { + earlyReject(e); + return; + } + } + options2?.signal?.throwIfAborted(); + const messageId = this._requestMessageId++; + const jsonrpcRequest = { + ...request, + jsonrpc: "2.0", + id: messageId + }; + if (options2?.onprogress) { + this._progressHandlers.set(messageId, options2.onprogress); + jsonrpcRequest.params = { + ...request.params, + _meta: { + ...request.params?._meta || {}, + progressToken: messageId + } + }; + } + if (task) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + task + }; + } + if (relatedTask) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + _meta: { + ...jsonrpcRequest.params?._meta || {}, + [RELATED_TASK_META_KEY]: relatedTask + } + }; + } + const cancel = (reason) => { + this._responseHandlers.delete(messageId); + this._progressHandlers.delete(messageId); + this._cleanupTimeout(messageId); + this._transport?.send({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { + requestId: messageId, + reason: String(reason) + } + }, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error3) => this._onerror(new Error(`Failed to send cancellation: ${error3}`))); + const error2 = reason instanceof McpError ? reason : new McpError(ErrorCode.RequestTimeout, String(reason)); + reject(error2); + }; + this._responseHandlers.set(messageId, (response) => { + if (options2?.signal?.aborted) { + return; + } + if (response instanceof Error) { + return reject(response); + } + try { + const parseResult = safeParse2(resultSchema, response.result); + if (!parseResult.success) { + reject(parseResult.error); + } else { + resolve(parseResult.data); + } + } catch (error2) { + reject(error2); + } + }); + options2?.signal?.addEventListener("abort", () => { + cancel(options2?.signal?.reason); + }); + const timeout = options2?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, "Request timed out", { timeout })); + this._setupTimeout(messageId, timeout, options2?.maxTotalTimeout, timeoutHandler, options2?.resetTimeoutOnProgress ?? false); + const relatedTaskId = relatedTask?.taskId; + if (relatedTaskId) { + const responseResolver = (response) => { + const handler = this._responseHandlers.get(messageId); + if (handler) { + handler(response); + } else { + this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); + } + }; + this._requestResolvers.set(messageId, responseResolver); + this._enqueueTaskMessage(relatedTaskId, { + type: "request", + message: jsonrpcRequest, + timestamp: Date.now() + }).catch((error2) => { + this._cleanupTimeout(messageId); + reject(error2); + }); + } else { + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error2) => { + this._cleanupTimeout(messageId); + reject(error2); + }); + } + }); + } + /** + * Gets the current status of a task. + * + * @experimental Use `client.experimental.tasks.getTask()` to access this method. + */ + async getTask(params, options2) { + return this.request({ method: "tasks/get", params }, GetTaskResultSchema, options2); + } + /** + * Retrieves the result of a completed task. + * + * @experimental Use `client.experimental.tasks.getTaskResult()` to access this method. + */ + async getTaskResult(params, resultSchema, options2) { + return this.request({ method: "tasks/result", params }, resultSchema, options2); + } + /** + * Lists tasks, optionally starting from a pagination cursor. + * + * @experimental Use `client.experimental.tasks.listTasks()` to access this method. + */ + async listTasks(params, options2) { + return this.request({ method: "tasks/list", params }, ListTasksResultSchema, options2); + } + /** + * Cancels a specific task. + * + * @experimental Use `client.experimental.tasks.cancelTask()` to access this method. + */ + async cancelTask(params, options2) { + return this.request({ method: "tasks/cancel", params }, CancelTaskResultSchema, options2); + } + /** + * Emits a notification, which is a one-way message that does not expect a response. + */ + async notification(notification, options2) { + if (!this._transport) { + throw new Error("Not connected"); + } + this.assertNotificationCapability(notification.method); + const relatedTaskId = options2?.relatedTask?.taskId; + if (relatedTaskId) { + const jsonrpcNotification2 = { + ...notification, + jsonrpc: "2.0", + params: { + ...notification.params, + _meta: { + ...notification.params?._meta || {}, + [RELATED_TASK_META_KEY]: options2.relatedTask + } + } + }; + await this._enqueueTaskMessage(relatedTaskId, { + type: "notification", + message: jsonrpcNotification2, + timestamp: Date.now() + }); + return; + } + const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; + const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options2?.relatedRequestId && !options2?.relatedTask; + if (canDebounce) { + if (this._pendingDebouncedNotifications.has(notification.method)) { + return; + } + this._pendingDebouncedNotifications.add(notification.method); + Promise.resolve().then(() => { + this._pendingDebouncedNotifications.delete(notification.method); + if (!this._transport) { + return; + } + let jsonrpcNotification2 = { + ...notification, + jsonrpc: "2.0" + }; + if (options2?.relatedTask) { + jsonrpcNotification2 = { + ...jsonrpcNotification2, + params: { + ...jsonrpcNotification2.params, + _meta: { + ...jsonrpcNotification2.params?._meta || {}, + [RELATED_TASK_META_KEY]: options2.relatedTask + } + } + }; + } + this._transport?.send(jsonrpcNotification2, options2).catch((error2) => this._onerror(error2)); + }); + return; + } + let jsonrpcNotification = { + ...notification, + jsonrpc: "2.0" + }; + if (options2?.relatedTask) { + jsonrpcNotification = { + ...jsonrpcNotification, + params: { + ...jsonrpcNotification.params, + _meta: { + ...jsonrpcNotification.params?._meta || {}, + [RELATED_TASK_META_KEY]: options2.relatedTask + } + } + }; + } + await this._transport.send(jsonrpcNotification, options2); + } + /** + * Registers a handler to invoke when this protocol object receives a request with the given method. + * + * Note that this will replace any previous request handler for the same method. + */ + setRequestHandler(requestSchema, handler) { + const method = getMethodLiteral(requestSchema); + this.assertRequestHandlerCapability(method); + this._requestHandlers.set(method, (request, extra) => { + const parsed = parseWithCompat(requestSchema, request); + return Promise.resolve(handler(parsed, extra)); + }); + } + /** + * Removes the request handler for the given method. + */ + removeRequestHandler(method) { + this._requestHandlers.delete(method); + } + /** + * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. + */ + assertCanSetRequestHandler(method) { + if (this._requestHandlers.has(method)) { + throw new Error(`A request handler for ${method} already exists, which would be overridden`); + } + } + /** + * Registers a handler to invoke when this protocol object receives a notification with the given method. + * + * Note that this will replace any previous notification handler for the same method. + */ + setNotificationHandler(notificationSchema, handler) { + const method = getMethodLiteral(notificationSchema); + this._notificationHandlers.set(method, (notification) => { + const parsed = parseWithCompat(notificationSchema, notification); + return Promise.resolve(handler(parsed)); + }); + } + /** + * Removes the notification handler for the given method. + */ + removeNotificationHandler(method) { + this._notificationHandlers.delete(method); + } + /** + * Cleans up the progress handler associated with a task. + * This should be called when a task reaches a terminal status. + */ + _cleanupTaskProgressHandler(taskId) { + const progressToken = this._taskProgressTokens.get(taskId); + if (progressToken !== void 0) { + this._progressHandlers.delete(progressToken); + this._taskProgressTokens.delete(taskId); + } + } + /** + * Enqueues a task-related message for side-channel delivery via tasks/result. + * @param taskId The task ID to associate the message with + * @param message The message to enqueue + * @param sessionId Optional session ID for binding the operation to a specific session + * @throws Error if taskStore is not configured or if enqueue fails (e.g., queue overflow) + * + * Note: If enqueue fails, it's the TaskMessageQueue implementation's responsibility to handle + * the error appropriately (e.g., by failing the task, logging, etc.). The Protocol layer + * simply propagates the error. + */ + async _enqueueTaskMessage(taskId, message, sessionId) { + if (!this._taskStore || !this._taskMessageQueue) { + throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured"); + } + const maxQueueSize = this._options?.maxTaskQueueSize; + await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize); + } + /** + * Clears the message queue for a task and rejects any pending request resolvers. + * @param taskId The task ID whose queue should be cleared + * @param sessionId Optional session ID for binding the operation to a specific session + */ + async _clearTaskQueue(taskId, sessionId) { + if (this._taskMessageQueue) { + const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); + for (const message of messages) { + if (message.type === "request" && isJSONRPCRequest(message.message)) { + const requestId = message.message.id; + const resolver = this._requestResolvers.get(requestId); + if (resolver) { + resolver(new McpError(ErrorCode.InternalError, "Task cancelled or completed")); + this._requestResolvers.delete(requestId); + } else { + this._onerror(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); + } + } + } + } + } + /** + * Waits for a task update (new messages or status change) with abort signal support. + * Uses polling to check for updates at the task's configured poll interval. + * @param taskId The task ID to wait for + * @param signal Abort signal to cancel the wait + * @returns Promise that resolves when an update occurs or rejects if aborted + */ + async _waitForTaskUpdate(taskId, signal) { + let interval = this._options?.defaultTaskPollInterval ?? 1e3; + try { + const task = await this._taskStore?.getTask(taskId); + if (task?.pollInterval) { + interval = task.pollInterval; + } + } catch { + } + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); + return; + } + const timeoutId = setTimeout(resolve, interval); + signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); + }, { once: true }); + }); + } + requestTaskStore(request, sessionId) { + const taskStore = this._taskStore; + if (!taskStore) { + throw new Error("No task store configured"); + } + return { + createTask: async (taskParams) => { + if (!request) { + throw new Error("No request provided"); + } + return await taskStore.createTask(taskParams, request.id, { + method: request.method, + params: request.params + }, sessionId); + }, + getTask: async (taskId) => { + const task = await taskStore.getTask(taskId, sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, "Failed to retrieve task: Task not found"); + } + return task; + }, + storeTaskResult: async (taskId, status, result) => { + await taskStore.storeTaskResult(taskId, status, result, sessionId); + const task = await taskStore.getTask(taskId, sessionId); + if (task) { + const notification = TaskStatusNotificationSchema.parse({ + method: "notifications/tasks/status", + params: task + }); + await this.notification(notification); + if (isTerminal(task.status)) { + this._cleanupTaskProgressHandler(taskId); + } + } + }, + getTaskResult: (taskId) => { + return taskStore.getTaskResult(taskId, sessionId); + }, + updateTaskStatus: async (taskId, status, statusMessage) => { + const task = await taskStore.getTask(taskId, sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); + } + if (isTerminal(task.status)) { + throw new McpError(ErrorCode.InvalidParams, `Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.`); + } + await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId); + const updatedTask = await taskStore.getTask(taskId, sessionId); + if (updatedTask) { + const notification = TaskStatusNotificationSchema.parse({ + method: "notifications/tasks/status", + params: updatedTask + }); + await this.notification(notification); + if (isTerminal(updatedTask.status)) { + this._cleanupTaskProgressHandler(taskId); + } + } + }, + listTasks: (cursor) => { + return taskStore.listTasks(cursor, sessionId); + } + }; + } +}; +function isPlainObject2(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function mergeCapabilities(base, additional) { + const result = { ...base }; + for (const key in additional) { + const k = key; + const addValue = additional[k]; + if (addValue === void 0) + continue; + const baseValue = result[k]; + if (isPlainObject2(baseValue) && isPlainObject2(addValue)) { + result[k] = { ...baseValue, ...addValue }; + } else { + result[k] = addValue; + } + } + return result; +} + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js +var import_ajv = __toESM(require_ajv(), 1); +var import_ajv_formats = __toESM(require_dist(), 1); +function createDefaultAjvInstance() { + const ajv = new import_ajv.default({ + strict: false, + validateFormats: true, + validateSchema: false, + allErrors: true + }); + const addFormats = import_ajv_formats.default; + addFormats(ajv); + return ajv; +} +var AjvJsonSchemaValidator = class { + /** + * Create an AJV validator + * + * @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created. + * + * @example + * ```typescript + * // Use default configuration (recommended for most cases) + * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * const validator = new AjvJsonSchemaValidator(); + * + * // Or provide custom AJV instance for advanced configuration + * import { Ajv } from 'ajv'; + * import addFormats from 'ajv-formats'; + * + * const ajv = new Ajv({ validateFormats: true }); + * addFormats(ajv); + * const validator = new AjvJsonSchemaValidator(ajv); + * ``` + */ + constructor(ajv) { + this._ajv = ajv ?? createDefaultAjvInstance(); + } + /** + * Create a validator for the given JSON Schema + * + * The validator is compiled once and can be reused multiple times. + * If the schema has an $id, it will be cached by AJV automatically. + * + * @param schema - Standard JSON Schema object + * @returns A validator function that validates input data + */ + getValidator(schema) { + const ajvValidator = "$id" in schema && typeof schema.$id === "string" ? this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema) : this._ajv.compile(schema); + return (input) => { + const valid = ajvValidator(input); + if (valid) { + return { + valid: true, + data: input, + errorMessage: void 0 + }; + } else { + return { + valid: false, + data: void 0, + errorMessage: this._ajv.errorsText(ajvValidator.errors) + }; + } + }; + } +}; + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js +var ExperimentalServerTasks = class { + constructor(_server) { + this._server = _server; + } + /** + * Sends a request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * This method provides streaming access to request processing, allowing you to + * observe intermediate task status updates for task-augmented requests. + * + * @param request - The request to send + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + requestStream(request, resultSchema, options2) { + return this._server.requestStream(request, resultSchema, options2); + } + /** + * Sends a sampling request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages + * before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.createMessageStream({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + * maxTokens: 100 + * }, { + * onprogress: (progress) => { + * // Handle streaming tokens via progress notifications + * console.log('Progress:', progress.message); + * } + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The sampling request parameters + * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + createMessageStream(params, options2) { + const clientCapabilities = this._server.getClientCapabilities(); + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new Error("Client does not support sampling tools capability."); + } + if (params.messages.length > 0) { + const lastMessage = params.messages[params.messages.length - 1]; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some((c) => c.type === "tool_result"); + const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : void 0; + const previousContent = previousMessage ? Array.isArray(previousMessage.content) ? previousMessage.content : [previousMessage.content] : []; + const hasPreviousToolUse = previousContent.some((c) => c.type === "tool_use"); + if (hasToolResults) { + if (lastContent.some((c) => c.type !== "tool_result")) { + throw new Error("The last message must contain only tool_result content if any is present"); + } + if (!hasPreviousToolUse) { + throw new Error("tool_result blocks are not matching any tool_use from the previous message"); + } + } + if (hasPreviousToolUse) { + const toolUseIds = new Set(previousContent.filter((c) => c.type === "tool_use").map((c) => c.id)); + const toolResultIds = new Set(lastContent.filter((c) => c.type === "tool_result").map((c) => c.toolUseId)); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every((id) => toolResultIds.has(id))) { + throw new Error("ids of tool_result blocks and tool_use blocks from previous message do not match"); + } + } + } + return this.requestStream({ + method: "sampling/createMessage", + params + }, CreateMessageResultSchema, options2); + } + /** + * Sends an elicitation request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' + * and 'taskStatus' messages before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.elicitInputStream({ + * mode: 'url', + * message: 'Please authenticate', + * elicitationId: 'auth-123', + * url: 'https://example.com/auth' + * }, { + * task: { ttl: 300000 } // Task-augmented for long-running auth flow + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('User action:', message.result.action); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The elicitation request parameters + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + elicitInputStream(params, options2) { + const clientCapabilities = this._server.getClientCapabilities(); + const mode = params.mode ?? "form"; + switch (mode) { + case "url": { + if (!clientCapabilities?.elicitation?.url) { + throw new Error("Client does not support url elicitation."); + } + break; + } + case "form": { + if (!clientCapabilities?.elicitation?.form) { + throw new Error("Client does not support form elicitation."); + } + break; + } + } + const normalizedParams = mode === "form" && params.mode === void 0 ? { ...params, mode: "form" } : params; + return this.requestStream({ + method: "elicitation/create", + params: normalizedParams + }, ElicitResultSchema, options2); + } + /** + * Gets the current status of a task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * @returns The task status + * + * @experimental + */ + async getTask(taskId, options2) { + return this._server.getTask({ taskId }, options2); + } + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options + * @returns The task result + * + * @experimental + */ + async getTaskResult(taskId, resultSchema, options2) { + return this._server.getTaskResult({ taskId }, resultSchema, options2); + } + /** + * Lists tasks with optional pagination. + * + * @param cursor - Optional pagination cursor + * @param options - Optional request options + * @returns List of tasks with optional next cursor + * + * @experimental + */ + async listTasks(cursor, options2) { + return this._server.listTasks(cursor ? { cursor } : void 0, options2); + } + /** + * Cancels a running task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * + * @experimental + */ + async cancelTask(taskId, options2) { + return this._server.cancelTask({ taskId }, options2); + } +}; + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js +function assertToolsCallTaskCapability(requests, method, entityName) { + if (!requests) { + throw new Error(`${entityName} does not support task creation (required for ${method})`); + } + switch (method) { + case "tools/call": + if (!requests.tools?.call) { + throw new Error(`${entityName} does not support task creation for tools/call (required for ${method})`); + } + break; + default: + break; + } +} +function assertClientRequestTaskCapability(requests, method, entityName) { + if (!requests) { + throw new Error(`${entityName} does not support task creation (required for ${method})`); + } + switch (method) { + case "sampling/createMessage": + if (!requests.sampling?.createMessage) { + throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`); + } + break; + case "elicitation/create": + if (!requests.elicitation?.create) { + throw new Error(`${entityName} does not support task creation for elicitation/create (required for ${method})`); + } + break; + default: + break; + } +} + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js +var Server = class extends Protocol { + /** + * Initializes this server with the given name and version information. + */ + constructor(_serverInfo, options2) { + super(options2); + this._serverInfo = _serverInfo; + this._loggingLevels = /* @__PURE__ */ new Map(); + this.LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + this.isMessageIgnored = (level, sessionId) => { + const currentLevel = this._loggingLevels.get(sessionId); + return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level) < this.LOG_LEVEL_SEVERITY.get(currentLevel) : false; + }; + this._capabilities = options2?.capabilities ?? {}; + this._instructions = options2?.instructions; + this._jsonSchemaValidator = options2?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + this.setRequestHandler(InitializeRequestSchema, (request) => this._oninitialize(request)); + this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); + if (this._capabilities.logging) { + this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { + const transportSessionId = extra.sessionId || extra.requestInfo?.headers["mcp-session-id"] || void 0; + const { level } = request.params; + const parseResult = LoggingLevelSchema.safeParse(level); + if (parseResult.success) { + this._loggingLevels.set(transportSessionId, parseResult.data); + } + return {}; + }); + } + } + /** + * Access experimental features. + * + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + get experimental() { + if (!this._experimental) { + this._experimental = { + tasks: new ExperimentalServerTasks(this) + }; + } + return this._experimental; + } + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + registerCapabilities(capabilities) { + if (this.transport) { + throw new Error("Cannot register capabilities after connecting to transport"); + } + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + /** + * Override request handler registration to enforce server-side validation for tools/call. + */ + setRequestHandler(requestSchema, handler) { + const shape = getObjectShape(requestSchema); + const methodSchema = shape?.method; + if (!methodSchema) { + throw new Error("Schema is missing a method literal"); + } + let methodValue; + if (isZ4Schema(methodSchema)) { + const v4Schema = methodSchema; + const v4Def = v4Schema._zod?.def; + methodValue = v4Def?.value ?? v4Schema.value; + } else { + const v3Schema = methodSchema; + const legacyDef = v3Schema._def; + methodValue = legacyDef?.value ?? v3Schema.value; + } + if (typeof methodValue !== "string") { + throw new Error("Schema method literal must be a string"); + } + const method = methodValue; + if (method === "tools/call") { + const wrappedHandler = async (request, extra) => { + const validatedRequest = safeParse2(CallToolRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); + } + const { params } = validatedRequest.data; + const result = await Promise.resolve(handler(request, extra)); + if (params.task) { + const taskValidationResult = safeParse2(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = taskValidationResult.error instanceof Error ? taskValidationResult.error.message : String(taskValidationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + const validationResult = safeParse2(CallToolResultSchema, result); + if (!validationResult.success) { + const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); + } + return validationResult.data; + }; + return super.setRequestHandler(requestSchema, wrappedHandler); + } + return super.setRequestHandler(requestSchema, handler); + } + assertCapabilityForMethod(method) { + switch (method) { + case "sampling/createMessage": + if (!this._clientCapabilities?.sampling) { + throw new Error(`Client does not support sampling (required for ${method})`); + } + break; + case "elicitation/create": + if (!this._clientCapabilities?.elicitation) { + throw new Error(`Client does not support elicitation (required for ${method})`); + } + break; + case "roots/list": + if (!this._clientCapabilities?.roots) { + throw new Error(`Client does not support listing roots (required for ${method})`); + } + break; + case "ping": + break; + } + } + assertNotificationCapability(method) { + switch (method) { + case "notifications/message": + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + case "notifications/resources/updated": + case "notifications/resources/list_changed": + if (!this._capabilities.resources) { + throw new Error(`Server does not support notifying about resources (required for ${method})`); + } + break; + case "notifications/tools/list_changed": + if (!this._capabilities.tools) { + throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); + } + break; + case "notifications/prompts/list_changed": + if (!this._capabilities.prompts) { + throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); + } + break; + case "notifications/elicitation/complete": + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error(`Client does not support URL elicitation (required for ${method})`); + } + break; + case "notifications/cancelled": + break; + case "notifications/progress": + break; + } + } + assertRequestHandlerCapability(method) { + if (!this._capabilities) { + return; + } + switch (method) { + case "completion/complete": + if (!this._capabilities.completions) { + throw new Error(`Server does not support completions (required for ${method})`); + } + break; + case "logging/setLevel": + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + case "prompts/get": + case "prompts/list": + if (!this._capabilities.prompts) { + throw new Error(`Server does not support prompts (required for ${method})`); + } + break; + case "resources/list": + case "resources/templates/list": + case "resources/read": + if (!this._capabilities.resources) { + throw new Error(`Server does not support resources (required for ${method})`); + } + break; + case "tools/call": + case "tools/list": + if (!this._capabilities.tools) { + throw new Error(`Server does not support tools (required for ${method})`); + } + break; + case "tasks/get": + case "tasks/list": + case "tasks/result": + case "tasks/cancel": + if (!this._capabilities.tasks) { + throw new Error(`Server does not support tasks capability (required for ${method})`); + } + break; + case "ping": + case "initialize": + break; + } + } + assertTaskCapability(method) { + assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, "Client"); + } + assertTaskHandlerCapability(method) { + if (!this._capabilities) { + return; + } + assertToolsCallTaskCapability(this._capabilities.tasks?.requests, method, "Server"); + } + async _oninitialize(request) { + const requestedVersion = request.params.protocolVersion; + this._clientCapabilities = request.params.capabilities; + this._clientVersion = request.params.clientInfo; + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; + return { + protocolVersion, + capabilities: this.getCapabilities(), + serverInfo: this._serverInfo, + ...this._instructions && { instructions: this._instructions } + }; + } + /** + * After initialization has completed, this will be populated with the client's reported capabilities. + */ + getClientCapabilities() { + return this._clientCapabilities; + } + /** + * After initialization has completed, this will be populated with information about the client's name and version. + */ + getClientVersion() { + return this._clientVersion; + } + getCapabilities() { + return this._capabilities; + } + async ping() { + return this.request({ method: "ping" }, EmptyResultSchema); + } + // Implementation + async createMessage(params, options2) { + if (params.tools || params.toolChoice) { + if (!this._clientCapabilities?.sampling?.tools) { + throw new Error("Client does not support sampling tools capability."); + } + } + if (params.messages.length > 0) { + const lastMessage = params.messages[params.messages.length - 1]; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some((c) => c.type === "tool_result"); + const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : void 0; + const previousContent = previousMessage ? Array.isArray(previousMessage.content) ? previousMessage.content : [previousMessage.content] : []; + const hasPreviousToolUse = previousContent.some((c) => c.type === "tool_use"); + if (hasToolResults) { + if (lastContent.some((c) => c.type !== "tool_result")) { + throw new Error("The last message must contain only tool_result content if any is present"); + } + if (!hasPreviousToolUse) { + throw new Error("tool_result blocks are not matching any tool_use from the previous message"); + } + } + if (hasPreviousToolUse) { + const toolUseIds = new Set(previousContent.filter((c) => c.type === "tool_use").map((c) => c.id)); + const toolResultIds = new Set(lastContent.filter((c) => c.type === "tool_result").map((c) => c.toolUseId)); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every((id) => toolResultIds.has(id))) { + throw new Error("ids of tool_result blocks and tool_use blocks from previous message do not match"); + } + } + } + if (params.tools) { + return this.request({ method: "sampling/createMessage", params }, CreateMessageResultWithToolsSchema, options2); + } + return this.request({ method: "sampling/createMessage", params }, CreateMessageResultSchema, options2); + } + /** + * Creates an elicitation request for the given parameters. + * For backwards compatibility, `mode` may be omitted for form requests and will default to `'form'`. + * @param params The parameters for the elicitation request. + * @param options Optional request options. + * @returns The result of the elicitation request. + */ + async elicitInput(params, options2) { + const mode = params.mode ?? "form"; + switch (mode) { + case "url": { + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error("Client does not support url elicitation."); + } + const urlParams = params; + return this.request({ method: "elicitation/create", params: urlParams }, ElicitResultSchema, options2); + } + case "form": { + if (!this._clientCapabilities?.elicitation?.form) { + throw new Error("Client does not support form elicitation."); + } + const formParams = params.mode === "form" ? params : { ...params, mode: "form" }; + const result = await this.request({ method: "elicitation/create", params: formParams }, ElicitResultSchema, options2); + if (result.action === "accept" && result.content && formParams.requestedSchema) { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema); + const validationResult = validator(result.content); + if (!validationResult.valid) { + throw new McpError(ErrorCode.InvalidParams, `Elicitation response content does not match requested schema: ${validationResult.errorMessage}`); + } + } catch (error2) { + if (error2 instanceof McpError) { + throw error2; + } + throw new McpError(ErrorCode.InternalError, `Error validating elicitation response: ${error2 instanceof Error ? error2.message : String(error2)}`); + } + } + return result; + } + } + } + /** + * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` + * notification for the specified elicitation ID. + * + * @param elicitationId The ID of the elicitation to mark as complete. + * @param options Optional notification options. Useful when the completion notification should be related to a prior request. + * @returns A function that emits the completion notification when awaited. + */ + createElicitationCompletionNotifier(elicitationId, options2) { + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error("Client does not support URL elicitation (required for notifications/elicitation/complete)"); + } + return () => this.notification({ + method: "notifications/elicitation/complete", + params: { + elicitationId + } + }, options2); + } + async listRoots(params, options2) { + return this.request({ method: "roots/list", params }, ListRootsResultSchema, options2); + } + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON RPC message + * @see LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + async sendLoggingMessage(params, sessionId) { + if (this._capabilities.logging) { + if (!this.isMessageIgnored(params.level, sessionId)) { + return this.notification({ method: "notifications/message", params }); + } + } + } + async sendResourceUpdated(params) { + return this.notification({ + method: "notifications/resources/updated", + params + }); + } + async sendResourceListChanged() { + return this.notification({ + method: "notifications/resources/list_changed" + }); + } + async sendToolListChanged() { + return this.notification({ method: "notifications/tools/list_changed" }); + } + async sendPromptListChanged() { + return this.notification({ method: "notifications/prompts/list_changed" }); + } +}; + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +var import_node_process = __toESM(require("node:process"), 1); + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js +var ReadBuffer = class { + append(chunk) { + this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + } + readMessage() { + if (!this._buffer) { + return null; + } + const index = this._buffer.indexOf("\n"); + if (index === -1) { + return null; + } + const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, ""); + this._buffer = this._buffer.subarray(index + 1); + return deserializeMessage(line); + } + clear() { + this._buffer = void 0; + } +}; +function deserializeMessage(line) { + return JSONRPCMessageSchema.parse(JSON.parse(line)); +} +function serializeMessage(message) { + return JSON.stringify(message) + "\n"; +} + +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.1_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +var StdioServerTransport = class { + constructor(_stdin = import_node_process.default.stdin, _stdout = import_node_process.default.stdout) { + this._stdin = _stdin; + this._stdout = _stdout; + this._readBuffer = new ReadBuffer(); + this._started = false; + this._ondata = (chunk) => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }; + this._onerror = (error2) => { + this.onerror?.(error2); + }; + } + /** + * Starts listening for messages on stdin. + */ + async start() { + if (this._started) { + throw new Error("StdioServerTransport already started! If using Server class, note that connect() calls start() automatically."); + } + this._started = true; + this._stdin.on("data", this._ondata); + this._stdin.on("error", this._onerror); + } + processReadBuffer() { + while (true) { + try { + const message = this._readBuffer.readMessage(); + if (message === null) { + break; + } + this.onmessage?.(message); + } catch (error2) { + this.onerror?.(error2); + } + } + } + async close() { + this._stdin.off("data", this._ondata); + this._stdin.off("error", this._onerror); + const remainingDataListeners = this._stdin.listenerCount("data"); + if (remainingDataListeners === 0) { + this._stdin.pause(); + } + this._readBuffer.clear(); + this.onclose?.(); + } + send(message) { + return new Promise((resolve) => { + const json2 = serializeMessage(message); + if (this._stdout.write(json2)) { + resolve(); + } else { + this._stdout.once("drain", resolve); + } + }); + } +}; + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/wrapper.mjs +var import_stream = __toESM(require_stream(), 1); +var import_receiver = __toESM(require_receiver(), 1); +var import_sender = __toESM(require_sender(), 1); +var import_websocket = __toESM(require_websocket(), 1); +var import_websocket_server = __toESM(require_websocket_server(), 1); +var wrapper_default = import_websocket.default; + +// mcp-server/vault.js +var import_promises = __toESM(require("node:fs/promises"), 1); +var import_node_path = __toESM(require("node:path"), 1); +var import_gray_matter = __toESM(require_gray_matter(), 1); +async function findMarkdownFiles(dir) { + const results = []; + const items = await import_promises.default.readdir(dir, { withFileTypes: true }); + for (const item of items) { + if (item.name.startsWith(".")) continue; + const full = import_node_path.default.join(dir, item.name); + if (item.isDirectory()) { + results.push(...await findMarkdownFiles(full)); + } else if (item.name.endsWith(".md")) { + results.push(full); + } + } + return results; +} +async function getNote(vaultPath, notePath) { + const absPath = import_node_path.default.isAbsolute(notePath) ? notePath : import_node_path.default.join(vaultPath, notePath); + const raw = await import_promises.default.readFile(absPath, "utf-8"); + const parsed = (0, import_gray_matter.default)(raw); + return { + path: import_node_path.default.relative(vaultPath, absPath), + frontmatter: parsed.data, + content: parsed.content.trim() + }; +} +async function searchNotes(vaultPath, query, limit = 10) { + const files = await findMarkdownFiles(vaultPath); + const q = query.toLowerCase(); + const results = []; + for (const filePath of files) { + if (results.length >= limit) break; + const content = await import_promises.default.readFile(filePath, "utf-8"); + const filename = import_node_path.default.basename(filePath, ".md"); + const titleMatch = extractTitle(content, filename); + const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q); + if (matches) { + const snippet = extractSnippet(content, q); + results.push({ + path: import_node_path.default.relative(vaultPath, filePath), + title: titleMatch, + snippet + }); + } + } + return results; +} +async function vaultContext(vaultPath) { + const files = await findMarkdownFiles(vaultPath); + const typesSet = /* @__PURE__ */ new Set(); + const foldersSet = /* @__PURE__ */ new Set(); + const notesWithMtime = []; + for (const filePath of files) { + const raw = await import_promises.default.readFile(filePath, "utf-8"); + const parsed = (0, import_gray_matter.default)(raw); + const type = parsed.data.type || parsed.data.is_a || null; + if (type) typesSet.add(type); + const rel = import_node_path.default.relative(vaultPath, filePath); + const topFolder = rel.split(import_node_path.default.sep)[0]; + if (topFolder !== rel) foldersSet.add(topFolder + "/"); + const stat = await import_promises.default.stat(filePath); + notesWithMtime.push({ + path: rel, + title: parsed.data.title || extractTitle(raw, import_node_path.default.basename(filePath, ".md")), + type, + mtime: stat.mtimeMs + }); + } + notesWithMtime.sort((a, b) => b.mtime - a.mtime); + const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest); + const configFiles = {}; + try { + const agentsPath = import_node_path.default.join(vaultPath, "config", "agents.md"); + const agentsContent = await import_promises.default.readFile(agentsPath, "utf-8"); + configFiles.agents = agentsContent; + } catch { + } + return { + types: [...typesSet].sort(), + noteCount: files.length, + folders: [...foldersSet].sort(), + recentNotes, + configFiles, + vaultPath + }; +} +function extractTitle(content, fallback) { + const h1Match = content.match(/^#\s+(.+)$/m); + if (h1Match) return h1Match[1].trim(); + const titleMatch = content.match(/^title:\s*(.+)$/m); + if (titleMatch) return titleMatch[1].trim(); + return fallback; +} +function extractSnippet(content, query) { + const body = content.replace(/^---[\s\S]*?---\n?/, "").trim(); + const idx = body.toLowerCase().indexOf(query); + if (idx === -1) return body.slice(0, 120); + const start = Math.max(0, idx - 40); + const end = Math.min(body.length, idx + query.length + 80); + return (start > 0 ? "..." : "") + body.slice(start, end) + (end < body.length ? "..." : ""); +} + +// mcp-server/index.js +var VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + "/Laputa"; +var WS_UI_PORT = parseInt(process.env.WS_UI_PORT || "9711", 10); +var WS_UI_URL = `ws://localhost:${WS_UI_PORT}`; +var uiSocket = null; +var RECONNECT_INTERVAL_MS = 3e3; +function connectUiBridge() { + try { + const ws = new wrapper_default(WS_UI_URL); + ws.on("open", () => { + uiSocket = ws; + console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`); + }); + ws.on("close", () => { + uiSocket = null; + setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS); + }); + ws.on("error", () => { + }); + } catch { + setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS); + } +} +connectUiBridge(); +function broadcastUiAction(action, payload) { + if (!uiSocket || uiSocket.readyState !== wrapper_default.OPEN) return; + uiSocket.send(JSON.stringify({ type: "ui_action", action, ...payload })); +} +var TOOLS = [ + { + name: "search_notes", + description: "Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query string" }, + limit: { type: "number", description: "Maximum number of results (default: 10)" } + }, + required: ["query"] + } + }, + { + name: "get_vault_context", + description: "Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.", + inputSchema: { type: "object", properties: {} } + }, + { + name: "get_note", + description: "Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: 'Relative path to the note (e.g. "project/my-project.md")' } + }, + required: ["path"] + } + }, + { + name: "open_note", + description: "Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Relative path to the note" } + }, + required: ["path"] + } + }, + { + name: "highlight_editor", + description: "Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.", + inputSchema: { + type: "object", + properties: { + element: { type: "string", enum: ["editor", "tab", "properties", "notelist"], description: "Which UI element to highlight" }, + path: { type: "string", description: "Optional note path to associate with the highlight" } + }, + required: ["element"] + } + }, + { + name: "refresh_vault", + description: "Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Optional specific note path that changed" } + } + } + } +]; +var TOOL_HANDLERS = { + search_notes: handleSearchNotes, + get_vault_context: handleVaultContext, + get_note: handleGetNote, + open_note: handleOpenNote, + highlight_editor: handleHighlightEditor, + refresh_vault: handleRefreshVault +}; +async function handleSearchNotes(args) { + const results = await searchNotes(VAULT_PATH, args.query, args.limit); + const text = results.length === 0 ? "No matching notes found." : results.map((r) => `**${r.title}** (${r.path}) +${r.snippet}`).join("\n\n"); + return { content: [{ type: "text", text }] }; +} +async function handleVaultContext() { + const ctx = await vaultContext(VAULT_PATH); + return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] }; +} +async function handleGetNote(args) { + const note = await getNote(VAULT_PATH, args.path); + return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; +} +function handleOpenNote(args) { + broadcastUiAction("vault_changed", { path: args.path }); + broadcastUiAction("open_tab", { path: args.path }); + return { content: [{ type: "text", text: `Opening ${args.path} in Tolaria` }] }; +} +function handleHighlightEditor(args) { + broadcastUiAction("highlight", { element: args.element, path: args.path }); + return { content: [{ type: "text", text: `Highlighting ${args.element}` }] }; +} +function handleRefreshVault(args) { + broadcastUiAction("vault_changed", { path: args?.path }); + return { content: [{ type: "text", text: "Vault refresh triggered" }] }; +} +var server = new Server( + { name: "tolaria-mcp-server", version: "0.3.0" }, + { capabilities: { tools: {} } } +); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS +})); +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const handler = TOOL_HANDLERS[name]; + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + try { + return await handler(args); + } catch (error2) { + return { + content: [{ type: "text", text: `Error: ${error2.message}` }], + isError: true + }; + } +}); +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`); +} +main().catch(console.error); +/*! Bundled license information: + +is-extendable/index.js: + (*! + * is-extendable + * + * Copyright (c) 2015, Jon Schlinkert. + * Licensed under the MIT License. + *) + +strip-bom-string/index.js: + (*! + * strip-bom-string + * + * Copyright (c) 2015, 2017, Jon Schlinkert. + * Released under the MIT License. + *) +*/ diff --git a/src-tauri/gen/apple/assets/mcp-server/package.json b/src-tauri/gen/apple/assets/mcp-server/package.json new file mode 100644 index 0000000..0292b99 --- /dev/null +++ b/src-tauri/gen/apple/assets/mcp-server/package.json @@ -0,0 +1 @@ +{"type":"commonjs"} \ No newline at end of file diff --git a/src-tauri/gen/apple/assets/mcp-server/ws-bridge.js b/src-tauri/gen/apple/assets/mcp-server/ws-bridge.js new file mode 100755 index 0000000..ff316ca --- /dev/null +++ b/src-tauri/gen/apple/assets/mcp-server/ws-bridge.js @@ -0,0 +1,7380 @@ +#!/usr/bin/env node +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js +var require_constants = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js"(exports2, module2) { + "use strict"; + var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; + var hasBlob = typeof Blob !== "undefined"; + if (hasBlob) BINARY_TYPES.push("blob"); + module2.exports = { + BINARY_TYPES, + CLOSE_TIMEOUT: 3e4, + EMPTY_BUFFER: Buffer.alloc(0), + GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", + hasBlob, + kForOnEventAttribute: /* @__PURE__ */ Symbol("kIsForOnEventAttribute"), + kListener: /* @__PURE__ */ Symbol("kListener"), + kStatusCode: /* @__PURE__ */ Symbol("status-code"), + kWebSocket: /* @__PURE__ */ Symbol("websocket"), + NOOP: () => { + } + }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/buffer-util.js +var require_buffer_util = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/buffer-util.js"(exports2, module2) { + "use strict"; + var { EMPTY_BUFFER } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } + return target; + } + function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } + } + function _unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } + } + function toArrayBuffer(buf) { + if (buf.length === buf.buffer.byteLength) { + return buf.buffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); + } + function toBuffer(data) { + toBuffer.readOnly = true; + if (Buffer.isBuffer(data)) return data; + let buf; + if (data instanceof ArrayBuffer) { + buf = new FastBuffer(data); + } else if (ArrayBuffer.isView(data)) { + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + return buf; + } + module2.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask + }; + if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require("bufferutil"); + module2.exports.mask = function(source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + module2.exports.unmask = function(buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/limiter.js +var require_limiter = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/limiter.js"(exports2, module2) { + "use strict"; + var kDone = /* @__PURE__ */ Symbol("kDone"); + var kRun = /* @__PURE__ */ Symbol("kRun"); + var Limiter = class { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + if (this.jobs.length) { + const job = this.jobs.shift(); + this.pending++; + job(this[kDone]); + } + } + }; + module2.exports = Limiter; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/permessage-deflate.js +var require_permessage_deflate = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/permessage-deflate.js"(exports2, module2) { + "use strict"; + var zlib = require("zlib"); + var bufferUtil = require_buffer_util(); + var Limiter = require_limiter(); + var { kStatusCode } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + var TRAILER = Buffer.from([0, 0, 255, 255]); + var kPerMessageDeflate = /* @__PURE__ */ Symbol("permessage-deflate"); + var kTotalLength = /* @__PURE__ */ Symbol("total-length"); + var kCallback = /* @__PURE__ */ Symbol("callback"); + var kBuffers = /* @__PURE__ */ Symbol("buffers"); + var kError = /* @__PURE__ */ Symbol("error"); + var zlibLimiter; + var PerMessageDeflate = class { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length + */ + constructor(options2, isServer, maxPayload) { + this._maxPayload = maxPayload | 0; + this._options = options2 || {}; + this._threshold = this._options.threshold !== void 0 ? this._options.threshold : 1024; + this._isServer = !!isServer; + this._deflate = null; + this._inflate = null; + this.params = null; + if (!zlibLimiter) { + const concurrency = this._options.concurrencyLimit !== void 0 ? this._options.concurrencyLimit : 10; + zlibLimiter = new Limiter(concurrency); + } + } + /** + * @type {String} + */ + static get extensionName() { + return "permessage-deflate"; + } + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + return params; + } + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + this.params = this._isServer ? this.acceptAsServer(configurations) : this.acceptAsClient(configurations); + return this.params; + } + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + if (this._deflate) { + const callback = this._deflate[kCallback]; + this._deflate.close(); + this._deflate = null; + if (callback) { + callback( + new Error( + "The deflate stream was closed while data was being processed" + ) + ); + } + } + } + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { + return false; + } + return true; + }); + if (!accepted) { + throw new Error("None of the extension offers can be accepted"); + } + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === "number") { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === "number") { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if (accepted.client_max_window_bits === true || opts.clientMaxWindowBits === false) { + delete accepted.client_max_window_bits; + } + return accepted; + } + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + if (this._options.clientNoContextTakeover === false && params.client_no_context_takeover) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === "number") { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if (this._options.clientMaxWindowBits === false || typeof this._options.clientMaxWindowBits === "number" && params.client_max_window_bits > this._options.clientMaxWindowBits) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + return params; + } + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + value = value[0]; + if (key === "client_max_window_bits") { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === "server_max_window_bits") { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + params[key] = value; + }); + }); + return configurations; + } + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? "client" : "server"; + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on("error", inflateOnError); + this._inflate.on("data", inflateOnData); + } + this._inflate[kCallback] = callback; + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + this._inflate.flush(() => { + const err = this._inflate[kError]; + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + const data2 = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + callback(null, data2); + }); + } + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? "server" : "client"; + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + this._deflate.on("data", deflateOnData); + } + this._deflate[kCallback] = callback; + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + return; + } + let data2 = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + if (fin) { + data2 = new FastBuffer(data2.buffer, data2.byteOffset, data2.length - 4); + } + this._deflate[kCallback] = null; + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + callback(null, data2); + }); + } + }; + module2.exports = PerMessageDeflate; + function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; + } + function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + if (this[kPerMessageDeflate]._maxPayload < 1 || this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload) { + this[kBuffers].push(chunk); + return; + } + this[kError] = new RangeError("Max payload size exceeded"); + this[kError].code = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"; + this[kError][kStatusCode] = 1009; + this.removeListener("data", inflateOnData); + this.reset(); + } + function inflateOnError(err) { + this[kPerMessageDeflate]._inflate = null; + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + err[kStatusCode] = 1007; + this[kCallback](err); + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/validation.js +var require_validation = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/validation.js"(exports2, module2) { + "use strict"; + var { isUtf8 } = require("buffer"); + var { hasBlob } = require_constants(); + var tokenChars = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 0 - 15 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 16 - 31 + 0, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 0, + // 32 - 47 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + // 48 - 63 + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 64 - 79 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + // 80 - 95 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 96 - 111 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 0 + // 112 - 127 + ]; + function isValidStatusCode(code) { + return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999; + } + function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + while (i < len) { + if ((buf[i] & 128) === 0) { + i++; + } else if ((buf[i] & 224) === 192) { + if (i + 1 === len || (buf[i + 1] & 192) !== 128 || (buf[i] & 254) === 192) { + return false; + } + i += 2; + } else if ((buf[i] & 240) === 224) { + if (i + 2 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || buf[i] === 224 && (buf[i + 1] & 224) === 128 || // Overlong + buf[i] === 237 && (buf[i + 1] & 224) === 160) { + return false; + } + i += 3; + } else if ((buf[i] & 248) === 240) { + if (i + 3 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || (buf[i + 3] & 192) !== 128 || buf[i] === 240 && (buf[i + 1] & 240) === 128 || // Overlong + buf[i] === 244 && buf[i + 1] > 143 || buf[i] > 244) { + return false; + } + i += 4; + } else { + return false; + } + } + return true; + } + function isBlob(value) { + return hasBlob && typeof value === "object" && typeof value.arrayBuffer === "function" && typeof value.type === "string" && typeof value.stream === "function" && (value[Symbol.toStringTag] === "Blob" || value[Symbol.toStringTag] === "File"); + } + module2.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars + }; + if (isUtf8) { + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); + }; + } else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require("utf-8-validate"); + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/receiver.js +var require_receiver = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/receiver.js"(exports2, module2) { + "use strict"; + var { Writable } = require("stream"); + var PerMessageDeflate = require_permessage_deflate(); + var { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket + } = require_constants(); + var { concat, toArrayBuffer, unmask } = require_buffer_util(); + var { isValidStatusCode, isValidUTF8 } = require_validation(); + var FastBuffer = Buffer[Symbol.species]; + var GET_INFO = 0; + var GET_PAYLOAD_LENGTH_16 = 1; + var GET_PAYLOAD_LENGTH_64 = 2; + var GET_MASK = 3; + var GET_DATA = 4; + var INFLATING = 5; + var DEFER_EVENT = 6; + var Receiver2 = class extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options2 = {}) { + super(); + this._allowSynchronousEvents = options2.allowSynchronousEvents !== void 0 ? options2.allowSynchronousEvents : true; + this._binaryType = options2.binaryType || BINARY_TYPES[0]; + this._extensions = options2.extensions || {}; + this._isServer = !!options2.isServer; + this._maxPayload = options2.maxPayload | 0; + this._skipUTF8Validation = !!options2.skipUTF8Validation; + this[kWebSocket] = void 0; + this._bufferedBytes = 0; + this._buffers = []; + this._compressed = false; + this._payloadLength = 0; + this._mask = void 0; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + this._errored = false; + this._loop = false; + this._state = GET_INFO; + } + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 8 && this._state == GET_INFO) return cb(); + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + if (n === this._buffers[0].length) return this._buffers.shift(); + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + return new FastBuffer(buf.buffer, buf.byteOffset, n); + } + const dst = Buffer.allocUnsafe(n); + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + } + n -= buf.length; + } while (n > 0); + return dst; + } + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + this._loop = true; + do { + switch (this._state) { + case GET_INFO: + this.getInfo(cb); + break; + case GET_PAYLOAD_LENGTH_16: + this.getPayloadLength16(cb); + break; + case GET_PAYLOAD_LENGTH_64: + this.getPayloadLength64(cb); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + this.getData(cb); + break; + case INFLATING: + case DEFER_EVENT: + this._loop = false; + return; + } + } while (this._loop); + if (!this._errored) cb(); + } + /** + * Reads the first two bytes of a frame. + * + * @param {Function} cb Callback + * @private + */ + getInfo(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + const buf = this.consume(2); + if ((buf[0] & 48) !== 0) { + const error = this.createError( + RangeError, + "RSV2 and RSV3 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_2_3" + ); + cb(error); + return; + } + const compressed = (buf[0] & 64) === 64; + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + this._fin = (buf[0] & 128) === 128; + this._opcode = buf[0] & 15; + this._payloadLength = buf[1] & 127; + if (this._opcode === 0) { + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (!this._fragmented) { + const error = this.createError( + RangeError, + "invalid opcode 0", + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._opcode = this._fragmented; + } else if (this._opcode === 1 || this._opcode === 2) { + if (this._fragmented) { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._compressed = compressed; + } else if (this._opcode > 7 && this._opcode < 11) { + if (!this._fin) { + const error = this.createError( + RangeError, + "FIN must be set", + true, + 1002, + "WS_ERR_EXPECTED_FIN" + ); + cb(error); + return; + } + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (this._payloadLength > 125 || this._opcode === 8 && this._payloadLength === 1) { + const error = this.createError( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + "WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + } else { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 128) === 128; + if (this._isServer) { + if (!this._masked) { + const error = this.createError( + RangeError, + "MASK must be set", + true, + 1002, + "WS_ERR_EXPECTED_MASK" + ); + cb(error); + return; + } + } else if (this._masked) { + const error = this.createError( + RangeError, + "MASK must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_MASK" + ); + cb(error); + return; + } + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else this.haveLength(cb); + } + /** + * Gets extended payload length (7+16). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength16(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + this._payloadLength = this.consume(2).readUInt16BE(0); + this.haveLength(cb); + } + /** + * Gets extended payload length (7+64). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength64(cb) { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + if (num > Math.pow(2, 53 - 32) - 1) { + const error = this.createError( + RangeError, + "Unsupported WebSocket frame: payload length > 2^53 - 1", + false, + 1009, + "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + this.haveLength(cb); + } + /** + * Payload length has been read. + * + * @param {Function} cb Callback + * @private + */ + haveLength(cb) { + if (this._payloadLength && this._opcode < 8) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + } + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + this._mask = this.consume(4); + this._state = GET_DATA; + } + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + data = this.consume(this._payloadLength); + if (this._masked && (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0) { + unmask(data, this._mask); + } + } + if (this._opcode > 7) { + this.controlMessage(data, cb); + return; + } + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + if (data.length) { + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + this.dataMessage(cb); + } + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + this._fragments.push(buf); + } + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); + }); + } + /** + * Handles a data message. + * + * @param {Function} cb Callback + * @private + */ + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + const messageLength = this._messageLength; + const fragments = this._fragments; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + if (this._opcode === 2) { + let data; + if (this._binaryType === "nodebuffer") { + data = concat(fragments, messageLength); + } else if (this._binaryType === "arraybuffer") { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === "blob") { + data = new Blob(fragments); + } else { + data = fragments; + } + if (this._allowSynchronousEvents) { + this.emit("message", data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit("message", buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + } + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data, cb) { + if (this._opcode === 8) { + if (data.length === 0) { + this._loop = false; + this.emit("conclude", 1005, EMPTY_BUFFER); + this.end(); + } else { + const code = data.readUInt16BE(0); + if (!isValidStatusCode(code)) { + const error = this.createError( + RangeError, + `invalid status code ${code}`, + true, + 1002, + "WS_ERR_INVALID_CLOSE_CODE" + ); + cb(error); + return; + } + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + this._loop = false; + this.emit("conclude", code, buf); + this.end(); + } + this._state = GET_INFO; + return; + } + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; + } + }; + module2.exports = Receiver2; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/sender.js +var require_sender = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/sender.js"(exports2, module2) { + "use strict"; + var { Duplex } = require("stream"); + var { randomFillSync } = require("crypto"); + var PerMessageDeflate = require_permessage_deflate(); + var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants(); + var { isBlob, isValidStatusCode } = require_validation(); + var { mask: applyMask, toBuffer } = require_buffer_util(); + var kByteLength = /* @__PURE__ */ Symbol("kByteLength"); + var maskBuffer = Buffer.alloc(4); + var RANDOM_POOL_SIZE = 8 * 1024; + var randomPool; + var randomPoolPointer = RANDOM_POOL_SIZE; + var DEFAULT = 0; + var DEFLATING = 1; + var GET_BLOB_DATA = 2; + var Sender2 = class _Sender { + /** + * Creates a Sender instance. + * + * @param {Duplex} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + this._socket = socket; + this._firstFragment = true; + this._compress = false; + this._bufferedBytes = 0; + this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = void 0; + } + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options2) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + if (options2.mask) { + mask = options2.maskBuffer || maskBuffer; + if (options2.generateMask) { + options2.generateMask(mask); + } else { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + if (randomPool === void 0) { + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + let dataLength; + if (typeof data === "string") { + if ((!options2.mask || skipMasking) && options2[kByteLength] !== void 0) { + dataLength = options2[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options2.mask && options2.readOnly && !skipMasking; + } + let payloadLength = dataLength; + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + target[0] = options2.fin ? options2.opcode | 128 : options2.opcode; + if (options2.rsv1) target[0] |= 64; + target[1] = payloadLength; + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + if (!options2.mask) return [target, data]; + target[1] |= 128; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + if (skipMasking) return [target, data]; + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + if (code === void 0) { + buf = EMPTY_BUFFER; + } else if (typeof code !== "number" || !isValidStatusCode(code)) { + throw new TypeError("First argument must be a valid error code number"); + } else if (data === void 0 || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + if (length > 123) { + throw new RangeError("The message must not be greater than 123 bytes"); + } + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + if (typeof data === "string") { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + const options2 = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 8, + readOnly: false, + rsv1: false + }; + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(buf, options2), cb); + } + } + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options2 = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 9, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options2, cb]); + } else { + this.getBlobData(data, false, options2, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(data, options2), cb); + } + } + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options2 = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 10, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options2, cb]); + } else { + this.getBlobData(data, false, options2, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options2, cb]); + } else { + this.sendFrame(_Sender.frame(data, options2), cb); + } + } + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options2, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options2.binary ? 2 : 1; + let rsv1 = options2.compress; + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { + this._firstFragment = false; + if (rsv1 && perMessageDeflate && perMessageDeflate.params[perMessageDeflate._isServer ? "server_no_context_takeover" : "client_no_context_takeover"]) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + if (options2.fin) this._firstFragment = true; + const opts = { + [kByteLength]: byteLength, + fin: options2.fin, + generateMask: this._generateMask, + mask: options2.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); + } else { + this.getBlobData(data, this._compress, opts, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } + /** + * Gets the contents of a blob as binary data. + * + * @param {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * the data + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + getBlobData(blob, compress, options2, cb) { + this._bufferedBytes += options2[kByteLength]; + this._state = GET_BLOB_DATA; + blob.arrayBuffer().then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while the blob was being read" + ); + process.nextTick(callCallbacks, this, err, cb); + return; + } + this._bufferedBytes -= options2[kByteLength]; + const data = toBuffer(arrayBuffer); + if (!compress) { + this._state = DEFAULT; + this.sendFrame(_Sender.frame(data, options2), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options2, cb); + } + }).catch((err) => { + process.nextTick(onError, this, err, cb); + }); + } + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options2, cb) { + if (!compress) { + this.sendFrame(_Sender.frame(data, options2), cb); + return; + } + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + this._bufferedBytes += options2[kByteLength]; + this._state = DEFLATING; + perMessageDeflate.compress(data, options2.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while data was being compressed" + ); + callCallbacks(this, err, cb); + return; + } + this._bufferedBytes -= options2[kByteLength]; + this._state = DEFAULT; + options2.readOnly = false; + this.sendFrame(_Sender.frame(buf, options2), cb); + this.dequeue(); + }); + } + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (this._state === DEFAULT && this._queue.length) { + const params = this._queue.shift(); + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + /** + * Sends a frame. + * + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } + }; + module2.exports = Sender2; + function callCallbacks(sender, err, cb) { + if (typeof cb === "function") cb(err); + for (let i = 0; i < sender._queue.length; i++) { + const params = sender._queue[i]; + const callback = params[params.length - 1]; + if (typeof callback === "function") callback(err); + } + } + function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/event-target.js +var require_event_target = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/event-target.js"(exports2, module2) { + "use strict"; + var { kForOnEventAttribute, kListener } = require_constants(); + var kCode = /* @__PURE__ */ Symbol("kCode"); + var kData = /* @__PURE__ */ Symbol("kData"); + var kError = /* @__PURE__ */ Symbol("kError"); + var kMessage = /* @__PURE__ */ Symbol("kMessage"); + var kReason = /* @__PURE__ */ Symbol("kReason"); + var kTarget = /* @__PURE__ */ Symbol("kTarget"); + var kType = /* @__PURE__ */ Symbol("kType"); + var kWasClean = /* @__PURE__ */ Symbol("kWasClean"); + var Event = class { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + /** + * @type {String} + */ + get type() { + return this[kType]; + } + }; + Object.defineProperty(Event.prototype, "target", { enumerable: true }); + Object.defineProperty(Event.prototype, "type", { enumerable: true }); + var CloseEvent = class extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options2 = {}) { + super(type); + this[kCode] = options2.code === void 0 ? 0 : options2.code; + this[kReason] = options2.reason === void 0 ? "" : options2.reason; + this[kWasClean] = options2.wasClean === void 0 ? false : options2.wasClean; + } + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } + }; + Object.defineProperty(CloseEvent.prototype, "code", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "reason", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "wasClean", { enumerable: true }); + var ErrorEvent = class extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options2 = {}) { + super(type); + this[kError] = options2.error === void 0 ? null : options2.error; + this[kMessage] = options2.message === void 0 ? "" : options2.message; + } + /** + * @type {*} + */ + get error() { + return this[kError]; + } + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } + }; + Object.defineProperty(ErrorEvent.prototype, "error", { enumerable: true }); + Object.defineProperty(ErrorEvent.prototype, "message", { enumerable: true }); + var MessageEvent = class extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options2 = {}) { + super(type); + this[kData] = options2.data === void 0 ? null : options2.data; + } + /** + * @type {*} + */ + get data() { + return this[kData]; + } + }; + Object.defineProperty(MessageEvent.prototype, "data", { enumerable: true }); + var EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, handler, options2 = {}) { + for (const listener of this.listeners(type)) { + if (!options2[kForOnEventAttribute] && listener[kListener] === handler && !listener[kForOnEventAttribute]) { + return; + } + } + let wrapper; + if (type === "message") { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent("message", { + data: isBinary ? data : data.toString() + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "close") { + wrapper = function onClose(code, message) { + const event = new CloseEvent("close", { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "error") { + wrapper = function onError(error) { + const event = new ErrorEvent("error", { + error, + message: error.message + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "open") { + wrapper = function onOpen() { + const event = new Event("open"); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else { + return; + } + wrapper[kForOnEventAttribute] = !!options2[kForOnEventAttribute]; + wrapper[kListener] = handler; + if (options2.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {(Function|Object)} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } + }; + module2.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent + }; + function callListener(listener, thisArg, event) { + if (typeof listener === "object" && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/extension.js +var require_extension = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/extension.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function push(dest, name, elem) { + if (dest[name] === void 0) dest[name] = [elem]; + else dest[name].push(elem); + } + function parse2(header) { + const offers = /* @__PURE__ */ Object.create(null); + let params = /* @__PURE__ */ Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + for (; i < header.length; i++) { + code = header.charCodeAt(i); + if (extensionName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 44) { + push(offers, name, params); + params = /* @__PURE__ */ Object.create(null); + } else { + extensionName = name; + } + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 32 || code === 9) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + start = end = -1; + } else if (code === 61 && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 34 && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 92) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 34 && header.charCodeAt(i - 1) === 61) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 32 || code === 9)) { + if (end === -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ""); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + paramName = void 0; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + if (start === -1 || inQuotes || code === 32 || code === 9) { + throw new SyntaxError("Unexpected end of input"); + } + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === void 0) { + push(offers, token, params); + } else { + if (paramName === void 0) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, "")); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + return offers; + } + function format(extensions) { + return Object.keys(extensions).map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations.map((params) => { + return [extension].concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values.map((v) => v === true ? k : `${k}=${v}`).join("; "); + }) + ).join("; "); + }).join(", "); + }).join(", "); + } + module2.exports = { format, parse: parse2 }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js +var require_websocket = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var https = require("https"); + var http = require("http"); + var net = require("net"); + var tls = require("tls"); + var { randomBytes, createHash } = require("crypto"); + var { Duplex, Readable } = require("stream"); + var { URL } = require("url"); + var PerMessageDeflate = require_permessage_deflate(); + var Receiver2 = require_receiver(); + var Sender2 = require_sender(); + var { isBlob } = require_validation(); + var { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP + } = require_constants(); + var { + EventTarget: { addEventListener, removeEventListener } + } = require_event_target(); + var { format, parse: parse2 } = require_extension(); + var { toBuffer } = require_buffer_util(); + var kAborted = /* @__PURE__ */ Symbol("kAborted"); + var protocolVersions = [8, 13]; + var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; + var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + var WebSocket2 = class _WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options2) { + super(); + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ""; + this._readyState = _WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + if (protocols === void 0) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === "object" && protocols !== null) { + options2 = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + initAsClient(this, address, protocols, options2); + } else { + this._autoPong = options2.autoPong; + this._closeTimeout = options2.closeTimeout; + this._isServer = true; + } + } + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + this._binaryType = type; + if (this._receiver) this._receiver._binaryType = type; + } + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + return this._socket._writableState.length + this._sender._bufferedBytes; + } + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + /** + * @type {String} + */ + get url() { + return this._url; + } + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options2) { + const receiver = new Receiver2({ + allowSynchronousEvents: options2.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options2.maxPayload, + skipUTF8Validation: options2.skipUTF8Validation + }); + const sender = new Sender2(socket, this._extensions, options2.generateMask); + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + receiver.on("conclude", receiverOnConclude); + receiver.on("drain", receiverOnDrain); + receiver.on("error", receiverOnError); + receiver.on("message", receiverOnMessage); + receiver.on("ping", receiverOnPing); + receiver.on("pong", receiverOnPong); + sender.onerror = senderOnError; + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + if (head.length > 0) socket.unshift(head); + socket.on("close", socketOnClose); + socket.on("data", socketOnData); + socket.on("end", socketOnEnd); + socket.on("error", socketOnError); + this._readyState = _WebSocket.OPEN; + this.emit("open"); + } + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + return; + } + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + this._receiver.removeAllListeners(); + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + } + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this.readyState === _WebSocket.CLOSING) { + if (this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted)) { + this._socket.end(); + } + return; + } + this._readyState = _WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + if (err) return; + this._closeFrameSent = true; + if (this._closeFrameReceived || this._receiver._writableState.errorEmitted) { + this._socket.end(); + } + }); + setCloseTimer(this); + } + /** + * Pause the socket. + * + * @public + */ + pause() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = true; + this._socket.pause(); + } + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + /** + * Resume the socket. + * + * @public + */ + resume() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options2, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof options2 === "function") { + cb = options2; + options2 = {}; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + const opts = { + binary: typeof data !== "string", + mask: !this._isServer, + compress: true, + fin: true, + ...options2 + }; + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this._socket) { + this._readyState = _WebSocket.CLOSING; + this._socket.destroy(); + } + } + }; + Object.defineProperty(WebSocket2, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket2.prototype, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket2, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket2.prototype, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket2, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket2.prototype, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket2, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + Object.defineProperty(WebSocket2.prototype, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + [ + "binaryType", + "bufferedAmount", + "extensions", + "isPaused", + "protocol", + "readyState", + "url" + ].forEach((property) => { + Object.defineProperty(WebSocket2.prototype, property, { enumerable: true }); + }); + ["open", "error", "close", "message"].forEach((method) => { + Object.defineProperty(WebSocket2.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + if (typeof handler !== "function") return; + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); + }); + WebSocket2.prototype.addEventListener = addEventListener; + WebSocket2.prototype.removeEventListener = removeEventListener; + module2.exports = WebSocket2; + function initAsClient(websocket, address, protocols, options2) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options2, + socketPath: void 0, + hostname: void 0, + protocol: void 0, + timeout: void 0, + method: "GET", + host: void 0, + path: void 0, + port: void 0 + }; + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} (supported versions: ${protocolVersions.join(", ")})` + ); + } + let parsedUrl; + if (address instanceof URL) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + if (parsedUrl.protocol === "http:") { + parsedUrl.protocol = "ws:"; + } else if (parsedUrl.protocol === "https:") { + parsedUrl.protocol = "wss:"; + } + websocket._url = parsedUrl.href; + const isSecure = parsedUrl.protocol === "wss:"; + const isIpcUrl = parsedUrl.protocol === "ws+unix:"; + let invalidUrlMessage; + if (parsedUrl.protocol !== "ws:" && !isSecure && !isIpcUrl) { + invalidUrlMessage = `The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = "The URL contains a fragment identifier"; + } + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString("base64"); + const request = isSecure ? https.request : http.request; + const protocolSet = /* @__PURE__ */ new Set(); + let perMessageDeflate; + opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + "Sec-WebSocket-Version": opts.protocolVersion, + "Sec-WebSocket-Key": key, + Connection: "Upgrade", + Upgrade: "websocket" + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers["Sec-WebSocket-Extensions"] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if (typeof protocol !== "string" || !subprotocolRegex.test(protocol) || protocolSet.has(protocol)) { + throw new SyntaxError( + "An invalid or duplicated subprotocol was specified" + ); + } + protocolSet.add(protocol); + } + opts.headers["Sec-WebSocket-Protocol"] = protocols.join(","); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers["Sec-WebSocket-Origin"] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + if (isIpcUrl) { + const parts = opts.path.split(":"); + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + let req; + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; + const headers = options2 && options2.headers; + options2 = { ...options2, headers: {} }; + if (headers) { + for (const [key2, value] of Object.entries(headers)) { + options2.headers[key2.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount("redirect") === 0) { + const isSameHost = isIpcUrl ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; + if (!isSameHost || websocket._originalSecure && !isSecure) { + delete opts.headers.authorization; + delete opts.headers.cookie; + if (!isSameHost) delete opts.headers.host; + opts.auth = void 0; + } + } + if (opts.auth && !options2.headers.authorization) { + options2.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64"); + } + req = websocket._req = request(opts); + if (websocket._redirects) { + websocket.emit("redirect", websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + if (opts.timeout) { + req.on("timeout", () => { + abortHandshake(websocket, req, "Opening handshake has timed out"); + }); + } + req.on("error", (err) => { + if (req === null || req[kAborted]) return; + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + req.on("response", (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, "Maximum redirects exceeded"); + return; + } + req.abort(); + let addr; + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + initAsClient(websocket, addr, protocols, options2); + } else if (!websocket.emit("unexpected-response", req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + req.on("upgrade", (res, socket, head) => { + websocket.emit("upgrade", res); + if (websocket.readyState !== WebSocket2.CONNECTING) return; + req = websocket._req = null; + const upgrade = res.headers.upgrade; + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + abortHandshake(websocket, socket, "Invalid Upgrade header"); + return; + } + const digest = createHash("sha1").update(key + GUID).digest("base64"); + if (res.headers["sec-websocket-accept"] !== digest) { + abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); + return; + } + const serverProt = res.headers["sec-websocket-protocol"]; + let protError; + if (serverProt !== void 0) { + if (!protocolSet.size) { + protError = "Server sent a subprotocol but none was requested"; + } else if (!protocolSet.has(serverProt)) { + protError = "Server sent an invalid subprotocol"; + } + } else if (protocolSet.size) { + protError = "Server sent no subprotocol"; + } + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + if (serverProt) websocket._protocol = serverProt; + const secWebSocketExtensions = res.headers["sec-websocket-extensions"]; + if (secWebSocketExtensions !== void 0) { + if (!perMessageDeflate) { + const message = "Server sent a Sec-WebSocket-Extensions header but no extension was requested"; + abortHandshake(websocket, socket, message); + return; + } + let extensions; + try { + extensions = parse2(secWebSocketExtensions); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + const extensionNames = Object.keys(extensions); + if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) { + const message = "Server indicated an extension that was not requested"; + abortHandshake(websocket, socket, message); + return; + } + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } + } + function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket2.CLOSING; + websocket._errorEmitted = true; + websocket.emit("error", err); + websocket.emitClose(); + } + function netConnect(options2) { + options2.path = options2.socketPath; + return net.connect(options2); + } + function tlsConnect(options2) { + options2.path = void 0; + if (!options2.servername && options2.servername !== "") { + options2.servername = net.isIP(options2.host) ? "" : options2.host; + } + return tls.connect(options2); + } + function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket2.CLOSING; + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + if (stream.socket && !stream.socket.destroyed) { + stream.socket.destroy(); + } + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once("error", websocket.emit.bind(websocket, "error")); + stream.once("close", websocket.emitClose.bind(websocket)); + } + } + function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} (${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } + } + function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + if (websocket._socket[kWebSocket] === void 0) return; + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + if (code === 1005) websocket.close(); + else websocket.close(code, reason); + } + function receiverOnDrain() { + const websocket = this[kWebSocket]; + if (!websocket.isPaused) websocket._socket.resume(); + } + function receiverOnError(err) { + const websocket = this[kWebSocket]; + if (websocket._socket[kWebSocket] !== void 0) { + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + websocket.close(err[kStatusCode]); + } + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function receiverOnFinish() { + this[kWebSocket].emitClose(); + } + function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit("message", data, isBinary); + } + function receiverOnPing(data) { + const websocket = this[kWebSocket]; + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit("ping", data); + } + function receiverOnPong(data) { + this[kWebSocket].emit("pong", data); + } + function resume(stream) { + stream.resume(); + } + function senderOnError(err) { + const websocket = this[kWebSocket]; + if (websocket.readyState === WebSocket2.CLOSED) return; + if (websocket.readyState === WebSocket2.OPEN) { + websocket._readyState = WebSocket2.CLOSING; + setCloseTimer(websocket); + } + this._socket.end(); + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); + } + function socketOnClose() { + const websocket = this[kWebSocket]; + this.removeListener("close", socketOnClose); + this.removeListener("data", socketOnData); + this.removeListener("end", socketOnEnd); + websocket._readyState = WebSocket2.CLOSING; + if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && this._readableState.length !== 0) { + const chunk = this.read(this._readableState.length); + websocket._receiver.write(chunk); + } + websocket._receiver.end(); + this[kWebSocket] = void 0; + clearTimeout(websocket._closeTimer); + if (websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted) { + websocket.emitClose(); + } else { + websocket._receiver.on("error", receiverOnFinish); + websocket._receiver.on("finish", receiverOnFinish); + } + } + function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } + } + function socketOnEnd() { + const websocket = this[kWebSocket]; + websocket._readyState = WebSocket2.CLOSING; + websocket._receiver.end(); + this.end(); + } + function socketOnError() { + const websocket = this[kWebSocket]; + this.removeListener("error", socketOnError); + this.on("error", NOOP); + if (websocket) { + websocket._readyState = WebSocket2.CLOSING; + this.destroy(); + } + } + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/stream.js +var require_stream = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/stream.js"(exports2, module2) { + "use strict"; + var WebSocket2 = require_websocket(); + var { Duplex } = require("stream"); + function emitClose(stream) { + stream.emit("close"); + } + function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } + } + function duplexOnError(err) { + this.removeListener("error", duplexOnError); + this.destroy(); + if (this.listenerCount("error") === 0) { + this.emit("error", err); + } + } + function createWebSocketStream2(ws, options2) { + let terminateOnDestroy = true; + const duplex = new Duplex({ + ...options2, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + ws.on("message", function message(msg, isBinary) { + const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + if (!duplex.push(data)) ws.pause(); + }); + ws.once("error", function error(err) { + if (duplex.destroyed) return; + terminateOnDestroy = false; + duplex.destroy(err); + }); + ws.once("close", function close() { + if (duplex.destroyed) return; + duplex.push(null); + }); + duplex._destroy = function(err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + let called = false; + ws.once("error", function error(err2) { + called = true; + callback(err2); + }); + ws.once("close", function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + if (terminateOnDestroy) ws.terminate(); + }; + duplex._final = function(callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._final(callback); + }); + return; + } + if (ws._socket === null) return; + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once("finish", function finish() { + callback(); + }); + ws.close(); + } + }; + duplex._read = function() { + if (ws.isPaused) ws.resume(); + }; + duplex._write = function(chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + ws.send(chunk, callback); + }; + duplex.on("end", duplexOnEnd); + duplex.on("error", duplexOnError); + return duplex; + } + module2.exports = createWebSocketStream2; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js +var require_subprotocol = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function parse2(header) { + const protocols = /* @__PURE__ */ new Set(); + let start = -1; + let end = -1; + let i = 0; + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const protocol2 = header.slice(start, end); + if (protocols.has(protocol2)) { + throw new SyntaxError(`The "${protocol2}" subprotocol is duplicated`); + } + protocols.add(protocol2); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + if (start === -1 || end !== -1) { + throw new SyntaxError("Unexpected end of input"); + } + const protocol = header.slice(start, i); + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + protocols.add(protocol); + return protocols; + } + module2.exports = { parse: parse2 }; + } +}); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket-server.js +var require_websocket_server = __commonJS({ + "node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket-server.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var http = require("http"); + var { Duplex } = require("stream"); + var { createHash } = require("crypto"); + var extension = require_extension(); + var PerMessageDeflate = require_permessage_deflate(); + var subprotocol = require_subprotocol(); + var WebSocket2 = require_websocket(); + var { CLOSE_TIMEOUT, GUID, kWebSocket } = require_constants(); + var keyRegex = /^[+/0-9A-Za-z]{22}==$/; + var RUNNING = 0; + var CLOSING = 1; + var CLOSED = 2; + var WebSocketServer2 = class extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to + * wait for the closing handshake to finish after `websocket.close()` is + * called + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options2, callback) { + super(); + options2 = { + allowSynchronousEvents: true, + autoPong: true, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + closeTimeout: CLOSE_TIMEOUT, + verifyClient: null, + noServer: false, + backlog: null, + // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket: WebSocket2, + ...options2 + }; + if (options2.port == null && !options2.server && !options2.noServer || options2.port != null && (options2.server || options2.noServer) || options2.server && options2.noServer) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options must be specified' + ); + } + if (options2.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + res.writeHead(426, { + "Content-Length": body.length, + "Content-Type": "text/plain" + }); + res.end(body); + }); + this._server.listen( + options2.port, + options2.host, + options2.backlog, + callback + ); + } else if (options2.server) { + this._server = options2.server; + } + if (this._server) { + const emitConnection = this.emit.bind(this, "connection"); + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, "listening"), + error: this.emit.bind(this, "error"), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + if (options2.perMessageDeflate === true) options2.perMessageDeflate = {}; + if (options2.clientTracking) { + this.clients = /* @__PURE__ */ new Set(); + this._shouldEmitClose = false; + } + this.options = options2; + this._state = RUNNING; + } + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + if (!this._server) return null; + return this._server.address(); + } + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once("close", () => { + cb(new Error("The server is not running")); + }); + } + process.nextTick(emitClose, this); + return; + } + if (cb) this.once("close", cb); + if (this._state === CLOSING) return; + this._state = CLOSING; + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + this._removeListeners(); + this._removeListeners = this._server = null; + server.close(() => { + emitClose(this); + }); + } + } + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf("?"); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + if (pathname !== this.options.path) return false; + } + return true; + } + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on("error", socketOnError); + const key = req.headers["sec-websocket-key"]; + const upgrade = req.headers.upgrade; + const version = +req.headers["sec-websocket-version"]; + if (req.method !== "GET") { + const message = "Invalid HTTP method"; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + const message = "Invalid Upgrade header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (key === void 0 || !keyRegex.test(key)) { + const message = "Missing or invalid Sec-WebSocket-Key header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (version !== 13 && version !== 8) { + const message = "Missing or invalid Sec-WebSocket-Version header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + "Sec-WebSocket-Version": "13, 8" + }); + return; + } + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; + let protocols = /* @__PURE__ */ new Set(); + if (secWebSocketProtocol !== void 0) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = "Invalid Sec-WebSocket-Protocol header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; + const extensions = {}; + if (this.options.perMessageDeflate && secWebSocketExtensions !== void 0) { + const perMessageDeflate = new PerMessageDeflate( + this.options.perMessageDeflate, + true, + this.options.maxPayload + ); + try { + const offers = extension.parse(secWebSocketExtensions); + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + if (this.options.verifyClient) { + const info = { + origin: req.headers[`${version === 8 ? "sec-websocket-origin" : "origin"}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + if (!socket.readable || !socket.writable) return socket.destroy(); + if (socket[kWebSocket]) { + throw new Error( + "server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration" + ); + } + if (this._state > RUNNING) return abortHandshake(socket, 503); + const digest = createHash("sha1").update(key + GUID).digest("base64"); + const headers = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${digest}` + ]; + const ws = new this.options.WebSocket(null, void 0, this.options); + if (protocols.size) { + const protocol = this.options.handleProtocols ? this.options.handleProtocols(protocols, req) : protocols.values().next().value; + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + this.emit("headers", headers, req); + socket.write(headers.concat("\r\n").join("\r\n")); + socket.removeListener("error", socketOnError); + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + if (this.clients) { + this.clients.add(ws); + ws.on("close", () => { + this.clients.delete(ws); + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + cb(ws, req); + } + }; + module2.exports = WebSocketServer2; + function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; + } + function emitClose(server) { + server._state = CLOSED; + server.emit("close"); + } + function socketOnError() { + this.destroy(); + } + function abortHandshake(socket, code, message, headers) { + message = message || http.STATUS_CODES[code]; + headers = { + Connection: "close", + "Content-Type": "text/html", + "Content-Length": Buffer.byteLength(message), + ...headers + }; + socket.once("finish", socket.destroy); + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r +` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message + ); + } + function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) { + if (server.listenerCount("wsClientError")) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + server.emit("wsClientError", err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } + } + } +}); + +// node_modules/.pnpm/kind-of@6.0.3/node_modules/kind-of/index.js +var require_kind_of = __commonJS({ + "node_modules/.pnpm/kind-of@6.0.3/node_modules/kind-of/index.js"(exports2, module2) { + var toString = Object.prototype.toString; + module2.exports = function kindOf(val) { + if (val === void 0) return "undefined"; + if (val === null) return "null"; + var type = typeof val; + if (type === "boolean") return "boolean"; + if (type === "string") return "string"; + if (type === "number") return "number"; + if (type === "symbol") return "symbol"; + if (type === "function") { + return isGeneratorFn(val) ? "generatorfunction" : "function"; + } + if (isArray(val)) return "array"; + if (isBuffer(val)) return "buffer"; + if (isArguments(val)) return "arguments"; + if (isDate(val)) return "date"; + if (isError(val)) return "error"; + if (isRegexp(val)) return "regexp"; + switch (ctorName(val)) { + case "Symbol": + return "symbol"; + case "Promise": + return "promise"; + // Set, Map, WeakSet, WeakMap + case "WeakMap": + return "weakmap"; + case "WeakSet": + return "weakset"; + case "Map": + return "map"; + case "Set": + return "set"; + // 8-bit typed arrays + case "Int8Array": + return "int8array"; + case "Uint8Array": + return "uint8array"; + case "Uint8ClampedArray": + return "uint8clampedarray"; + // 16-bit typed arrays + case "Int16Array": + return "int16array"; + case "Uint16Array": + return "uint16array"; + // 32-bit typed arrays + case "Int32Array": + return "int32array"; + case "Uint32Array": + return "uint32array"; + case "Float32Array": + return "float32array"; + case "Float64Array": + return "float64array"; + } + if (isGeneratorObj(val)) { + return "generator"; + } + type = toString.call(val); + switch (type) { + case "[object Object]": + return "object"; + // iterators + case "[object Map Iterator]": + return "mapiterator"; + case "[object Set Iterator]": + return "setiterator"; + case "[object String Iterator]": + return "stringiterator"; + case "[object Array Iterator]": + return "arrayiterator"; + } + return type.slice(8, -1).toLowerCase().replace(/\s/g, ""); + }; + function ctorName(val) { + return typeof val.constructor === "function" ? val.constructor.name : null; + } + function isArray(val) { + if (Array.isArray) return Array.isArray(val); + return val instanceof Array; + } + function isError(val) { + return val instanceof Error || typeof val.message === "string" && val.constructor && typeof val.constructor.stackTraceLimit === "number"; + } + function isDate(val) { + if (val instanceof Date) return true; + return typeof val.toDateString === "function" && typeof val.getDate === "function" && typeof val.setDate === "function"; + } + function isRegexp(val) { + if (val instanceof RegExp) return true; + return typeof val.flags === "string" && typeof val.ignoreCase === "boolean" && typeof val.multiline === "boolean" && typeof val.global === "boolean"; + } + function isGeneratorFn(name, val) { + return ctorName(name) === "GeneratorFunction"; + } + function isGeneratorObj(val) { + return typeof val.throw === "function" && typeof val.return === "function" && typeof val.next === "function"; + } + function isArguments(val) { + try { + if (typeof val.length === "number" && typeof val.callee === "function") { + return true; + } + } catch (err) { + if (err.message.indexOf("callee") !== -1) { + return true; + } + } + return false; + } + function isBuffer(val) { + if (val.constructor && typeof val.constructor.isBuffer === "function") { + return val.constructor.isBuffer(val); + } + return false; + } + } +}); + +// node_modules/.pnpm/is-extendable@0.1.1/node_modules/is-extendable/index.js +var require_is_extendable = __commonJS({ + "node_modules/.pnpm/is-extendable@0.1.1/node_modules/is-extendable/index.js"(exports2, module2) { + "use strict"; + module2.exports = function isExtendable(val) { + return typeof val !== "undefined" && val !== null && (typeof val === "object" || typeof val === "function"); + }; + } +}); + +// node_modules/.pnpm/extend-shallow@2.0.1/node_modules/extend-shallow/index.js +var require_extend_shallow = __commonJS({ + "node_modules/.pnpm/extend-shallow@2.0.1/node_modules/extend-shallow/index.js"(exports2, module2) { + "use strict"; + var isObject = require_is_extendable(); + module2.exports = function extend(o) { + if (!isObject(o)) { + o = {}; + } + var len = arguments.length; + for (var i = 1; i < len; i++) { + var obj = arguments[i]; + if (isObject(obj)) { + assign(o, obj); + } + } + return o; + }; + function assign(a, b) { + for (var key in b) { + if (hasOwn(b, key)) { + a[key] = b[key]; + } + } + } + function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); + } + } +}); + +// node_modules/.pnpm/section-matter@1.0.0/node_modules/section-matter/index.js +var require_section_matter = __commonJS({ + "node_modules/.pnpm/section-matter@1.0.0/node_modules/section-matter/index.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var extend = require_extend_shallow(); + module2.exports = function(input, options2) { + if (typeof options2 === "function") { + options2 = { parse: options2 }; + } + var file = toObject(input); + var defaults = { section_delimiter: "---", parse: identity }; + var opts = extend({}, defaults, options2); + var delim = opts.section_delimiter; + var lines = file.content.split(/\r?\n/); + var sections = null; + var section = createSection(); + var content = []; + var stack = []; + function initSections(val) { + file.content = val; + sections = []; + content = []; + } + function closeSection(val) { + if (stack.length) { + section.key = getKey(stack[0], delim); + section.content = val; + opts.parse(section, sections); + sections.push(section); + section = createSection(); + content = []; + stack = []; + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + var len = stack.length; + var ln = line.trim(); + if (isDelimiter(ln, delim)) { + if (ln.length === 3 && i !== 0) { + if (len === 0 || len === 2) { + content.push(line); + continue; + } + stack.push(ln); + section.data = content.join("\n"); + content = []; + continue; + } + if (sections === null) { + initSections(content.join("\n")); + } + if (len === 2) { + closeSection(content.join("\n")); + } + stack.push(ln); + continue; + } + content.push(line); + } + if (sections === null) { + initSections(content.join("\n")); + } else { + closeSection(content.join("\n")); + } + file.sections = sections; + return file; + }; + function isDelimiter(line, delim) { + if (line.slice(0, delim.length) !== delim) { + return false; + } + if (line.charAt(delim.length + 1) === delim.slice(-1)) { + return false; + } + return true; + } + function toObject(input) { + if (typeOf(input) !== "object") { + input = { content: input }; + } + if (typeof input.content !== "string" && !isBuffer(input.content)) { + throw new TypeError("expected a buffer or string"); + } + input.content = input.content.toString(); + input.sections = []; + return input; + } + function getKey(val, delim) { + return val ? val.slice(delim.length).trim() : ""; + } + function createSection() { + return { key: "", data: "", content: "" }; + } + function identity(val) { + return val; + } + function isBuffer(val) { + if (val && val.constructor && typeof val.constructor.isBuffer === "function") { + return val.constructor.isBuffer(val); + } + return false; + } + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/common.js +var require_common = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/common.js"(exports2, module2) { + "use strict"; + function isNothing(subject) { + return typeof subject === "undefined" || subject === null; + } + function isObject(subject) { + return typeof subject === "object" && subject !== null; + } + function toArray(sequence) { + if (Array.isArray(sequence)) return sequence; + else if (isNothing(sequence)) return []; + return [sequence]; + } + function extend(target, source) { + var index, length, key, sourceKeys; + if (source) { + sourceKeys = Object.keys(source); + for (index = 0, length = sourceKeys.length; index < length; index += 1) { + key = sourceKeys[index]; + target[key] = source[key]; + } + } + return target; + } + function repeat(string, count) { + var result = "", cycle; + for (cycle = 0; cycle < count; cycle += 1) { + result += string; + } + return result; + } + function isNegativeZero(number) { + return number === 0 && Number.NEGATIVE_INFINITY === 1 / number; + } + module2.exports.isNothing = isNothing; + module2.exports.isObject = isObject; + module2.exports.toArray = toArray; + module2.exports.repeat = repeat; + module2.exports.isNegativeZero = isNegativeZero; + module2.exports.extend = extend; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/exception.js +var require_exception = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/exception.js"(exports2, module2) { + "use strict"; + function YAMLException(reason, mark) { + Error.call(this); + this.name = "YAMLException"; + this.reason = reason; + this.mark = mark; + this.message = (this.reason || "(unknown reason)") + (this.mark ? " " + this.mark.toString() : ""); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = new Error().stack || ""; + } + } + YAMLException.prototype = Object.create(Error.prototype); + YAMLException.prototype.constructor = YAMLException; + YAMLException.prototype.toString = function toString(compact) { + var result = this.name + ": "; + result += this.reason || "(unknown reason)"; + if (!compact && this.mark) { + result += " " + this.mark.toString(); + } + return result; + }; + module2.exports = YAMLException; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/mark.js +var require_mark = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/mark.js"(exports2, module2) { + "use strict"; + var common = require_common(); + function Mark(name, buffer, position, line, column) { + this.name = name; + this.buffer = buffer; + this.position = position; + this.line = line; + this.column = column; + } + Mark.prototype.getSnippet = function getSnippet(indent, maxLength) { + var head, start, tail, end, snippet; + if (!this.buffer) return null; + indent = indent || 4; + maxLength = maxLength || 75; + head = ""; + start = this.position; + while (start > 0 && "\0\r\n\x85\u2028\u2029".indexOf(this.buffer.charAt(start - 1)) === -1) { + start -= 1; + if (this.position - start > maxLength / 2 - 1) { + head = " ... "; + start += 5; + break; + } + } + tail = ""; + end = this.position; + while (end < this.buffer.length && "\0\r\n\x85\u2028\u2029".indexOf(this.buffer.charAt(end)) === -1) { + end += 1; + if (end - this.position > maxLength / 2 - 1) { + tail = " ... "; + end -= 5; + break; + } + } + snippet = this.buffer.slice(start, end); + return common.repeat(" ", indent) + head + snippet + tail + "\n" + common.repeat(" ", indent + this.position - start + head.length) + "^"; + }; + Mark.prototype.toString = function toString(compact) { + var snippet, where = ""; + if (this.name) { + where += 'in "' + this.name + '" '; + } + where += "at line " + (this.line + 1) + ", column " + (this.column + 1); + if (!compact) { + snippet = this.getSnippet(); + if (snippet) { + where += ":\n" + snippet; + } + } + return where; + }; + module2.exports = Mark; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type.js +var require_type = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type.js"(exports2, module2) { + "use strict"; + var YAMLException = require_exception(); + var TYPE_CONSTRUCTOR_OPTIONS = [ + "kind", + "resolve", + "construct", + "instanceOf", + "predicate", + "represent", + "defaultStyle", + "styleAliases" + ]; + var YAML_NODE_KINDS = [ + "scalar", + "sequence", + "mapping" + ]; + function compileStyleAliases(map) { + var result = {}; + if (map !== null) { + Object.keys(map).forEach(function(style) { + map[style].forEach(function(alias) { + result[String(alias)] = style; + }); + }); + } + return result; + } + function Type(tag, options2) { + options2 = options2 || {}; + Object.keys(options2).forEach(function(name) { + if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) { + throw new YAMLException('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.'); + } + }); + this.tag = tag; + this.kind = options2["kind"] || null; + this.resolve = options2["resolve"] || function() { + return true; + }; + this.construct = options2["construct"] || function(data) { + return data; + }; + this.instanceOf = options2["instanceOf"] || null; + this.predicate = options2["predicate"] || null; + this.represent = options2["represent"] || null; + this.defaultStyle = options2["defaultStyle"] || null; + this.styleAliases = compileStyleAliases(options2["styleAliases"] || null); + if (YAML_NODE_KINDS.indexOf(this.kind) === -1) { + throw new YAMLException('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.'); + } + } + module2.exports = Type; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema.js +var require_schema = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var Type = require_type(); + function compileList(schema, name, result) { + var exclude = []; + schema.include.forEach(function(includedSchema) { + result = compileList(includedSchema, name, result); + }); + schema[name].forEach(function(currentType) { + result.forEach(function(previousType, previousIndex) { + if (previousType.tag === currentType.tag && previousType.kind === currentType.kind) { + exclude.push(previousIndex); + } + }); + result.push(currentType); + }); + return result.filter(function(type, index) { + return exclude.indexOf(index) === -1; + }); + } + function compileMap() { + var result = { + scalar: {}, + sequence: {}, + mapping: {}, + fallback: {} + }, index, length; + function collectType(type) { + result[type.kind][type.tag] = result["fallback"][type.tag] = type; + } + for (index = 0, length = arguments.length; index < length; index += 1) { + arguments[index].forEach(collectType); + } + return result; + } + function Schema(definition) { + this.include = definition.include || []; + this.implicit = definition.implicit || []; + this.explicit = definition.explicit || []; + this.implicit.forEach(function(type) { + if (type.loadKind && type.loadKind !== "scalar") { + throw new YAMLException("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported."); + } + }); + this.compiledImplicit = compileList(this, "implicit", []); + this.compiledExplicit = compileList(this, "explicit", []); + this.compiledTypeMap = compileMap(this.compiledImplicit, this.compiledExplicit); + } + Schema.DEFAULT = null; + Schema.create = function createSchema() { + var schemas, types; + switch (arguments.length) { + case 1: + schemas = Schema.DEFAULT; + types = arguments[0]; + break; + case 2: + schemas = arguments[0]; + types = arguments[1]; + break; + default: + throw new YAMLException("Wrong number of arguments for Schema.create function"); + } + schemas = common.toArray(schemas); + types = common.toArray(types); + if (!schemas.every(function(schema) { + return schema instanceof Schema; + })) { + throw new YAMLException("Specified list of super schemas (or a single Schema object) contains a non-Schema object."); + } + if (!types.every(function(type) { + return type instanceof Type; + })) { + throw new YAMLException("Specified list of YAML types (or a single Type object) contains a non-Type object."); + } + return new Schema({ + include: schemas, + explicit: types + }); + }; + module2.exports = Schema; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/str.js +var require_str = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/str.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:str", { + kind: "scalar", + construct: function(data) { + return data !== null ? data : ""; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/seq.js +var require_seq = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/seq.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:seq", { + kind: "sequence", + construct: function(data) { + return data !== null ? data : []; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/map.js +var require_map = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/map.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + module2.exports = new Type("tag:yaml.org,2002:map", { + kind: "mapping", + construct: function(data) { + return data !== null ? data : {}; + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/failsafe.js +var require_failsafe = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/failsafe.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + explicit: [ + require_str(), + require_seq(), + require_map() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/null.js +var require_null = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/null.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlNull(data) { + if (data === null) return true; + var max = data.length; + return max === 1 && data === "~" || max === 4 && (data === "null" || data === "Null" || data === "NULL"); + } + function constructYamlNull() { + return null; + } + function isNull(object) { + return object === null; + } + module2.exports = new Type("tag:yaml.org,2002:null", { + kind: "scalar", + resolve: resolveYamlNull, + construct: constructYamlNull, + predicate: isNull, + represent: { + canonical: function() { + return "~"; + }, + lowercase: function() { + return "null"; + }, + uppercase: function() { + return "NULL"; + }, + camelcase: function() { + return "Null"; + } + }, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/bool.js +var require_bool = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/bool.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlBoolean(data) { + if (data === null) return false; + var max = data.length; + return max === 4 && (data === "true" || data === "True" || data === "TRUE") || max === 5 && (data === "false" || data === "False" || data === "FALSE"); + } + function constructYamlBoolean(data) { + return data === "true" || data === "True" || data === "TRUE"; + } + function isBoolean(object) { + return Object.prototype.toString.call(object) === "[object Boolean]"; + } + module2.exports = new Type("tag:yaml.org,2002:bool", { + kind: "scalar", + resolve: resolveYamlBoolean, + construct: constructYamlBoolean, + predicate: isBoolean, + represent: { + lowercase: function(object) { + return object ? "true" : "false"; + }, + uppercase: function(object) { + return object ? "TRUE" : "FALSE"; + }, + camelcase: function(object) { + return object ? "True" : "False"; + } + }, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/int.js +var require_int = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/int.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var Type = require_type(); + function isHexCode(c) { + return 48 <= c && c <= 57 || 65 <= c && c <= 70 || 97 <= c && c <= 102; + } + function isOctCode(c) { + return 48 <= c && c <= 55; + } + function isDecCode(c) { + return 48 <= c && c <= 57; + } + function resolveYamlInteger(data) { + if (data === null) return false; + var max = data.length, index = 0, hasDigits = false, ch; + if (!max) return false; + ch = data[index]; + if (ch === "-" || ch === "+") { + ch = data[++index]; + } + if (ch === "0") { + if (index + 1 === max) return true; + ch = data[++index]; + if (ch === "b") { + index++; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (ch !== "0" && ch !== "1") return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + if (ch === "x") { + index++; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (!isHexCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (!isOctCode(data.charCodeAt(index))) return false; + hasDigits = true; + } + return hasDigits && ch !== "_"; + } + if (ch === "_") return false; + for (; index < max; index++) { + ch = data[index]; + if (ch === "_") continue; + if (ch === ":") break; + if (!isDecCode(data.charCodeAt(index))) { + return false; + } + hasDigits = true; + } + if (!hasDigits || ch === "_") return false; + if (ch !== ":") return true; + return /^(:[0-5]?[0-9])+$/.test(data.slice(index)); + } + function constructYamlInteger(data) { + var value = data, sign = 1, ch, base, digits = []; + if (value.indexOf("_") !== -1) { + value = value.replace(/_/g, ""); + } + ch = value[0]; + if (ch === "-" || ch === "+") { + if (ch === "-") sign = -1; + value = value.slice(1); + ch = value[0]; + } + if (value === "0") return 0; + if (ch === "0") { + if (value[1] === "b") return sign * parseInt(value.slice(2), 2); + if (value[1] === "x") return sign * parseInt(value, 16); + return sign * parseInt(value, 8); + } + if (value.indexOf(":") !== -1) { + value.split(":").forEach(function(v) { + digits.unshift(parseInt(v, 10)); + }); + value = 0; + base = 1; + digits.forEach(function(d) { + value += d * base; + base *= 60; + }); + return sign * value; + } + return sign * parseInt(value, 10); + } + function isInteger(object) { + return Object.prototype.toString.call(object) === "[object Number]" && (object % 1 === 0 && !common.isNegativeZero(object)); + } + module2.exports = new Type("tag:yaml.org,2002:int", { + kind: "scalar", + resolve: resolveYamlInteger, + construct: constructYamlInteger, + predicate: isInteger, + represent: { + binary: function(obj) { + return obj >= 0 ? "0b" + obj.toString(2) : "-0b" + obj.toString(2).slice(1); + }, + octal: function(obj) { + return obj >= 0 ? "0" + obj.toString(8) : "-0" + obj.toString(8).slice(1); + }, + decimal: function(obj) { + return obj.toString(10); + }, + /* eslint-disable max-len */ + hexadecimal: function(obj) { + return obj >= 0 ? "0x" + obj.toString(16).toUpperCase() : "-0x" + obj.toString(16).toUpperCase().slice(1); + } + }, + defaultStyle: "decimal", + styleAliases: { + binary: [2, "bin"], + octal: [8, "oct"], + decimal: [10, "dec"], + hexadecimal: [16, "hex"] + } + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/float.js +var require_float = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/float.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var Type = require_type(); + var YAML_FLOAT_PATTERN = new RegExp( + // 2.5e4, 2.5 and integers + "^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$" + ); + function resolveYamlFloat(data) { + if (data === null) return false; + if (!YAML_FLOAT_PATTERN.test(data) || // Quick hack to not allow integers end with `_` + // Probably should update regexp & check speed + data[data.length - 1] === "_") { + return false; + } + return true; + } + function constructYamlFloat(data) { + var value, sign, base, digits; + value = data.replace(/_/g, "").toLowerCase(); + sign = value[0] === "-" ? -1 : 1; + digits = []; + if ("+-".indexOf(value[0]) >= 0) { + value = value.slice(1); + } + if (value === ".inf") { + return sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } else if (value === ".nan") { + return NaN; + } else if (value.indexOf(":") >= 0) { + value.split(":").forEach(function(v) { + digits.unshift(parseFloat(v, 10)); + }); + value = 0; + base = 1; + digits.forEach(function(d) { + value += d * base; + base *= 60; + }); + return sign * value; + } + return sign * parseFloat(value, 10); + } + var SCIENTIFIC_WITHOUT_DOT = /^[-+]?[0-9]+e/; + function representYamlFloat(object, style) { + var res; + if (isNaN(object)) { + switch (style) { + case "lowercase": + return ".nan"; + case "uppercase": + return ".NAN"; + case "camelcase": + return ".NaN"; + } + } else if (Number.POSITIVE_INFINITY === object) { + switch (style) { + case "lowercase": + return ".inf"; + case "uppercase": + return ".INF"; + case "camelcase": + return ".Inf"; + } + } else if (Number.NEGATIVE_INFINITY === object) { + switch (style) { + case "lowercase": + return "-.inf"; + case "uppercase": + return "-.INF"; + case "camelcase": + return "-.Inf"; + } + } else if (common.isNegativeZero(object)) { + return "-0.0"; + } + res = object.toString(10); + return SCIENTIFIC_WITHOUT_DOT.test(res) ? res.replace("e", ".e") : res; + } + function isFloat(object) { + return Object.prototype.toString.call(object) === "[object Number]" && (object % 1 !== 0 || common.isNegativeZero(object)); + } + module2.exports = new Type("tag:yaml.org,2002:float", { + kind: "scalar", + resolve: resolveYamlFloat, + construct: constructYamlFloat, + predicate: isFloat, + represent: representYamlFloat, + defaultStyle: "lowercase" + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/json.js +var require_json = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/json.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_failsafe() + ], + implicit: [ + require_null(), + require_bool(), + require_int(), + require_float() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/core.js +var require_core = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/core.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_json() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/timestamp.js +var require_timestamp = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/timestamp.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var YAML_DATE_REGEXP = new RegExp( + "^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$" + ); + var YAML_TIMESTAMP_REGEXP = new RegExp( + "^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$" + ); + function resolveYamlTimestamp(data) { + if (data === null) return false; + if (YAML_DATE_REGEXP.exec(data) !== null) return true; + if (YAML_TIMESTAMP_REGEXP.exec(data) !== null) return true; + return false; + } + function constructYamlTimestamp(data) { + var match, year, month, day, hour, minute, second, fraction = 0, delta = null, tz_hour, tz_minute, date; + match = YAML_DATE_REGEXP.exec(data); + if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(data); + if (match === null) throw new Error("Date resolve error"); + year = +match[1]; + month = +match[2] - 1; + day = +match[3]; + if (!match[4]) { + return new Date(Date.UTC(year, month, day)); + } + hour = +match[4]; + minute = +match[5]; + second = +match[6]; + if (match[7]) { + fraction = match[7].slice(0, 3); + while (fraction.length < 3) { + fraction += "0"; + } + fraction = +fraction; + } + if (match[9]) { + tz_hour = +match[10]; + tz_minute = +(match[11] || 0); + delta = (tz_hour * 60 + tz_minute) * 6e4; + if (match[9] === "-") delta = -delta; + } + date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)); + if (delta) date.setTime(date.getTime() - delta); + return date; + } + function representYamlTimestamp(object) { + return object.toISOString(); + } + module2.exports = new Type("tag:yaml.org,2002:timestamp", { + kind: "scalar", + resolve: resolveYamlTimestamp, + construct: constructYamlTimestamp, + instanceOf: Date, + represent: representYamlTimestamp + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/merge.js +var require_merge = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/merge.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveYamlMerge(data) { + return data === "<<" || data === null; + } + module2.exports = new Type("tag:yaml.org,2002:merge", { + kind: "scalar", + resolve: resolveYamlMerge + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/binary.js +var require_binary = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/binary.js"(exports2, module2) { + "use strict"; + var NodeBuffer; + try { + _require = require; + NodeBuffer = _require("buffer").Buffer; + } catch (__) { + } + var _require; + var Type = require_type(); + var BASE64_MAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r"; + function resolveYamlBinary(data) { + if (data === null) return false; + var code, idx, bitlen = 0, max = data.length, map = BASE64_MAP; + for (idx = 0; idx < max; idx++) { + code = map.indexOf(data.charAt(idx)); + if (code > 64) continue; + if (code < 0) return false; + bitlen += 6; + } + return bitlen % 8 === 0; + } + function constructYamlBinary(data) { + var idx, tailbits, input = data.replace(/[\r\n=]/g, ""), max = input.length, map = BASE64_MAP, bits = 0, result = []; + for (idx = 0; idx < max; idx++) { + if (idx % 4 === 0 && idx) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } + bits = bits << 6 | map.indexOf(input.charAt(idx)); + } + tailbits = max % 4 * 6; + if (tailbits === 0) { + result.push(bits >> 16 & 255); + result.push(bits >> 8 & 255); + result.push(bits & 255); + } else if (tailbits === 18) { + result.push(bits >> 10 & 255); + result.push(bits >> 2 & 255); + } else if (tailbits === 12) { + result.push(bits >> 4 & 255); + } + if (NodeBuffer) { + return NodeBuffer.from ? NodeBuffer.from(result) : new NodeBuffer(result); + } + return result; + } + function representYamlBinary(object) { + var result = "", bits = 0, idx, tail, max = object.length, map = BASE64_MAP; + for (idx = 0; idx < max; idx++) { + if (idx % 3 === 0 && idx) { + result += map[bits >> 18 & 63]; + result += map[bits >> 12 & 63]; + result += map[bits >> 6 & 63]; + result += map[bits & 63]; + } + bits = (bits << 8) + object[idx]; + } + tail = max % 3; + if (tail === 0) { + result += map[bits >> 18 & 63]; + result += map[bits >> 12 & 63]; + result += map[bits >> 6 & 63]; + result += map[bits & 63]; + } else if (tail === 2) { + result += map[bits >> 10 & 63]; + result += map[bits >> 4 & 63]; + result += map[bits << 2 & 63]; + result += map[64]; + } else if (tail === 1) { + result += map[bits >> 2 & 63]; + result += map[bits << 4 & 63]; + result += map[64]; + result += map[64]; + } + return result; + } + function isBinary(object) { + return NodeBuffer && NodeBuffer.isBuffer(object); + } + module2.exports = new Type("tag:yaml.org,2002:binary", { + kind: "scalar", + resolve: resolveYamlBinary, + construct: constructYamlBinary, + predicate: isBinary, + represent: representYamlBinary + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/omap.js +var require_omap = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/omap.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var _toString = Object.prototype.toString; + function resolveYamlOmap(data) { + if (data === null) return true; + var objectKeys = [], index, length, pair, pairKey, pairHasKey, object = data; + for (index = 0, length = object.length; index < length; index += 1) { + pair = object[index]; + pairHasKey = false; + if (_toString.call(pair) !== "[object Object]") return false; + for (pairKey in pair) { + if (_hasOwnProperty.call(pair, pairKey)) { + if (!pairHasKey) pairHasKey = true; + else return false; + } + } + if (!pairHasKey) return false; + if (objectKeys.indexOf(pairKey) === -1) objectKeys.push(pairKey); + else return false; + } + return true; + } + function constructYamlOmap(data) { + return data !== null ? data : []; + } + module2.exports = new Type("tag:yaml.org,2002:omap", { + kind: "sequence", + resolve: resolveYamlOmap, + construct: constructYamlOmap + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/pairs.js +var require_pairs = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/pairs.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _toString = Object.prototype.toString; + function resolveYamlPairs(data) { + if (data === null) return true; + var index, length, pair, keys, result, object = data; + result = new Array(object.length); + for (index = 0, length = object.length; index < length; index += 1) { + pair = object[index]; + if (_toString.call(pair) !== "[object Object]") return false; + keys = Object.keys(pair); + if (keys.length !== 1) return false; + result[index] = [keys[0], pair[keys[0]]]; + } + return true; + } + function constructYamlPairs(data) { + if (data === null) return []; + var index, length, pair, keys, result, object = data; + result = new Array(object.length); + for (index = 0, length = object.length; index < length; index += 1) { + pair = object[index]; + keys = Object.keys(pair); + result[index] = [keys[0], pair[keys[0]]]; + } + return result; + } + module2.exports = new Type("tag:yaml.org,2002:pairs", { + kind: "sequence", + resolve: resolveYamlPairs, + construct: constructYamlPairs + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/set.js +var require_set = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/set.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + function resolveYamlSet(data) { + if (data === null) return true; + var key, object = data; + for (key in object) { + if (_hasOwnProperty.call(object, key)) { + if (object[key] !== null) return false; + } + } + return true; + } + function constructYamlSet(data) { + return data !== null ? data : {}; + } + module2.exports = new Type("tag:yaml.org,2002:set", { + kind: "mapping", + resolve: resolveYamlSet, + construct: constructYamlSet + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_safe.js +var require_default_safe = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_safe.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = new Schema({ + include: [ + require_core() + ], + implicit: [ + require_timestamp(), + require_merge() + ], + explicit: [ + require_binary(), + require_omap(), + require_pairs(), + require_set() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/undefined.js +var require_undefined = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/undefined.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveJavascriptUndefined() { + return true; + } + function constructJavascriptUndefined() { + return void 0; + } + function representJavascriptUndefined() { + return ""; + } + function isUndefined(object) { + return typeof object === "undefined"; + } + module2.exports = new Type("tag:yaml.org,2002:js/undefined", { + kind: "scalar", + resolve: resolveJavascriptUndefined, + construct: constructJavascriptUndefined, + predicate: isUndefined, + represent: representJavascriptUndefined + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/regexp.js +var require_regexp = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/regexp.js"(exports2, module2) { + "use strict"; + var Type = require_type(); + function resolveJavascriptRegExp(data) { + if (data === null) return false; + if (data.length === 0) return false; + var regexp = data, tail = /\/([gim]*)$/.exec(data), modifiers = ""; + if (regexp[0] === "/") { + if (tail) modifiers = tail[1]; + if (modifiers.length > 3) return false; + if (regexp[regexp.length - modifiers.length - 1] !== "/") return false; + } + return true; + } + function constructJavascriptRegExp(data) { + var regexp = data, tail = /\/([gim]*)$/.exec(data), modifiers = ""; + if (regexp[0] === "/") { + if (tail) modifiers = tail[1]; + regexp = regexp.slice(1, regexp.length - modifiers.length - 1); + } + return new RegExp(regexp, modifiers); + } + function representJavascriptRegExp(object) { + var result = "/" + object.source + "/"; + if (object.global) result += "g"; + if (object.multiline) result += "m"; + if (object.ignoreCase) result += "i"; + return result; + } + function isRegExp(object) { + return Object.prototype.toString.call(object) === "[object RegExp]"; + } + module2.exports = new Type("tag:yaml.org,2002:js/regexp", { + kind: "scalar", + resolve: resolveJavascriptRegExp, + construct: constructJavascriptRegExp, + predicate: isRegExp, + represent: representJavascriptRegExp + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/function.js +var require_function = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/type/js/function.js"(exports2, module2) { + "use strict"; + var esprima; + try { + _require = require; + esprima = _require("esprima"); + } catch (_) { + if (typeof window !== "undefined") esprima = window.esprima; + } + var _require; + var Type = require_type(); + function resolveJavascriptFunction(data) { + if (data === null) return false; + try { + var source = "(" + data + ")", ast = esprima.parse(source, { range: true }); + if (ast.type !== "Program" || ast.body.length !== 1 || ast.body[0].type !== "ExpressionStatement" || ast.body[0].expression.type !== "ArrowFunctionExpression" && ast.body[0].expression.type !== "FunctionExpression") { + return false; + } + return true; + } catch (err) { + return false; + } + } + function constructJavascriptFunction(data) { + var source = "(" + data + ")", ast = esprima.parse(source, { range: true }), params = [], body; + if (ast.type !== "Program" || ast.body.length !== 1 || ast.body[0].type !== "ExpressionStatement" || ast.body[0].expression.type !== "ArrowFunctionExpression" && ast.body[0].expression.type !== "FunctionExpression") { + throw new Error("Failed to resolve function"); + } + ast.body[0].expression.params.forEach(function(param) { + params.push(param.name); + }); + body = ast.body[0].expression.body.range; + if (ast.body[0].expression.body.type === "BlockStatement") { + return new Function(params, source.slice(body[0] + 1, body[1] - 1)); + } + return new Function(params, "return " + source.slice(body[0], body[1])); + } + function representJavascriptFunction(object) { + return object.toString(); + } + function isFunction(object) { + return Object.prototype.toString.call(object) === "[object Function]"; + } + module2.exports = new Type("tag:yaml.org,2002:js/function", { + kind: "scalar", + resolve: resolveJavascriptFunction, + construct: constructJavascriptFunction, + predicate: isFunction, + represent: representJavascriptFunction + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_full.js +var require_default_full = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/schema/default_full.js"(exports2, module2) { + "use strict"; + var Schema = require_schema(); + module2.exports = Schema.DEFAULT = new Schema({ + include: [ + require_default_safe() + ], + explicit: [ + require_undefined(), + require_regexp(), + require_function() + ] + }); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/loader.js +var require_loader = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/loader.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var Mark = require_mark(); + var DEFAULT_SAFE_SCHEMA = require_default_safe(); + var DEFAULT_FULL_SCHEMA = require_default_full(); + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CONTEXT_FLOW_IN = 1; + var CONTEXT_FLOW_OUT = 2; + var CONTEXT_BLOCK_IN = 3; + var CONTEXT_BLOCK_OUT = 4; + var CHOMPING_CLIP = 1; + var CHOMPING_STRIP = 2; + var CHOMPING_KEEP = 3; + var PATTERN_NON_PRINTABLE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/; + var PATTERN_NON_ASCII_LINE_BREAKS = /[\x85\u2028\u2029]/; + var PATTERN_FLOW_INDICATORS = /[,\[\]\{\}]/; + var PATTERN_TAG_HANDLE = /^(?:!|!!|![a-z\-]+!)$/i; + var PATTERN_TAG_URI = /^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i; + function _class(obj) { + return Object.prototype.toString.call(obj); + } + function is_EOL(c) { + return c === 10 || c === 13; + } + function is_WHITE_SPACE(c) { + return c === 9 || c === 32; + } + function is_WS_OR_EOL(c) { + return c === 9 || c === 32 || c === 10 || c === 13; + } + function is_FLOW_INDICATOR(c) { + return c === 44 || c === 91 || c === 93 || c === 123 || c === 125; + } + function fromHexCode(c) { + var lc; + if (48 <= c && c <= 57) { + return c - 48; + } + lc = c | 32; + if (97 <= lc && lc <= 102) { + return lc - 97 + 10; + } + return -1; + } + function escapedHexLen(c) { + if (c === 120) { + return 2; + } + if (c === 117) { + return 4; + } + if (c === 85) { + return 8; + } + return 0; + } + function fromDecimalCode(c) { + if (48 <= c && c <= 57) { + return c - 48; + } + return -1; + } + function simpleEscapeSequence(c) { + return c === 48 ? "\0" : c === 97 ? "\x07" : c === 98 ? "\b" : c === 116 ? " " : c === 9 ? " " : c === 110 ? "\n" : c === 118 ? "\v" : c === 102 ? "\f" : c === 114 ? "\r" : c === 101 ? "\x1B" : c === 32 ? " " : c === 34 ? '"' : c === 47 ? "/" : c === 92 ? "\\" : c === 78 ? "\x85" : c === 95 ? "\xA0" : c === 76 ? "\u2028" : c === 80 ? "\u2029" : ""; + } + function charFromCodepoint(c) { + if (c <= 65535) { + return String.fromCharCode(c); + } + return String.fromCharCode( + (c - 65536 >> 10) + 55296, + (c - 65536 & 1023) + 56320 + ); + } + function setProperty(object, key, value) { + if (key === "__proto__") { + Object.defineProperty(object, key, { + configurable: true, + enumerable: true, + writable: true, + value + }); + } else { + object[key] = value; + } + } + var simpleEscapeCheck = new Array(256); + var simpleEscapeMap = new Array(256); + for (i = 0; i < 256; i++) { + simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0; + simpleEscapeMap[i] = simpleEscapeSequence(i); + } + var i; + function State(input, options2) { + this.input = input; + this.filename = options2["filename"] || null; + this.schema = options2["schema"] || DEFAULT_FULL_SCHEMA; + this.onWarning = options2["onWarning"] || null; + this.legacy = options2["legacy"] || false; + this.json = options2["json"] || false; + this.listener = options2["listener"] || null; + this.implicitTypes = this.schema.compiledImplicit; + this.typeMap = this.schema.compiledTypeMap; + this.length = input.length; + this.position = 0; + this.line = 0; + this.lineStart = 0; + this.lineIndent = 0; + this.documents = []; + } + function generateError(state, message) { + return new YAMLException( + message, + new Mark(state.filename, state.input, state.position, state.line, state.position - state.lineStart) + ); + } + function throwError(state, message) { + throw generateError(state, message); + } + function throwWarning(state, message) { + if (state.onWarning) { + state.onWarning.call(null, generateError(state, message)); + } + } + var directiveHandlers = { + YAML: function handleYamlDirective(state, name, args) { + var match, major, minor; + if (state.version !== null) { + throwError(state, "duplication of %YAML directive"); + } + if (args.length !== 1) { + throwError(state, "YAML directive accepts exactly one argument"); + } + match = /^([0-9]+)\.([0-9]+)$/.exec(args[0]); + if (match === null) { + throwError(state, "ill-formed argument of the YAML directive"); + } + major = parseInt(match[1], 10); + minor = parseInt(match[2], 10); + if (major !== 1) { + throwError(state, "unacceptable YAML version of the document"); + } + state.version = args[0]; + state.checkLineBreaks = minor < 2; + if (minor !== 1 && minor !== 2) { + throwWarning(state, "unsupported YAML version of the document"); + } + }, + TAG: function handleTagDirective(state, name, args) { + var handle, prefix; + if (args.length !== 2) { + throwError(state, "TAG directive accepts exactly two arguments"); + } + handle = args[0]; + prefix = args[1]; + if (!PATTERN_TAG_HANDLE.test(handle)) { + throwError(state, "ill-formed tag handle (first argument) of the TAG directive"); + } + if (_hasOwnProperty.call(state.tagMap, handle)) { + throwError(state, 'there is a previously declared suffix for "' + handle + '" tag handle'); + } + if (!PATTERN_TAG_URI.test(prefix)) { + throwError(state, "ill-formed tag prefix (second argument) of the TAG directive"); + } + state.tagMap[handle] = prefix; + } + }; + function captureSegment(state, start, end, checkJson) { + var _position, _length, _character, _result; + if (start < end) { + _result = state.input.slice(start, end); + if (checkJson) { + for (_position = 0, _length = _result.length; _position < _length; _position += 1) { + _character = _result.charCodeAt(_position); + if (!(_character === 9 || 32 <= _character && _character <= 1114111)) { + throwError(state, "expected valid JSON character"); + } + } + } else if (PATTERN_NON_PRINTABLE.test(_result)) { + throwError(state, "the stream contains non-printable characters"); + } + state.result += _result; + } + } + function mergeMappings(state, destination, source, overridableKeys) { + var sourceKeys, key, index, quantity; + if (!common.isObject(source)) { + throwError(state, "cannot merge mappings; the provided source object is unacceptable"); + } + sourceKeys = Object.keys(source); + for (index = 0, quantity = sourceKeys.length; index < quantity; index += 1) { + key = sourceKeys[index]; + if (!_hasOwnProperty.call(destination, key)) { + setProperty(destination, key, source[key]); + overridableKeys[key] = true; + } + } + } + function storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, startLine, startPos) { + var index, quantity; + if (Array.isArray(keyNode)) { + keyNode = Array.prototype.slice.call(keyNode); + for (index = 0, quantity = keyNode.length; index < quantity; index += 1) { + if (Array.isArray(keyNode[index])) { + throwError(state, "nested arrays are not supported inside keys"); + } + if (typeof keyNode === "object" && _class(keyNode[index]) === "[object Object]") { + keyNode[index] = "[object Object]"; + } + } + } + if (typeof keyNode === "object" && _class(keyNode) === "[object Object]") { + keyNode = "[object Object]"; + } + keyNode = String(keyNode); + if (_result === null) { + _result = {}; + } + if (keyTag === "tag:yaml.org,2002:merge") { + if (Array.isArray(valueNode)) { + for (index = 0, quantity = valueNode.length; index < quantity; index += 1) { + mergeMappings(state, _result, valueNode[index], overridableKeys); + } + } else { + mergeMappings(state, _result, valueNode, overridableKeys); + } + } else { + if (!state.json && !_hasOwnProperty.call(overridableKeys, keyNode) && _hasOwnProperty.call(_result, keyNode)) { + state.line = startLine || state.line; + state.position = startPos || state.position; + throwError(state, "duplicated mapping key"); + } + setProperty(_result, keyNode, valueNode); + delete overridableKeys[keyNode]; + } + return _result; + } + function readLineBreak(state) { + var ch; + ch = state.input.charCodeAt(state.position); + if (ch === 10) { + state.position++; + } else if (ch === 13) { + state.position++; + if (state.input.charCodeAt(state.position) === 10) { + state.position++; + } + } else { + throwError(state, "a line break is expected"); + } + state.line += 1; + state.lineStart = state.position; + } + function skipSeparationSpace(state, allowComments, checkIndent) { + var lineBreaks = 0, ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (allowComments && ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 10 && ch !== 13 && ch !== 0); + } + if (is_EOL(ch)) { + readLineBreak(state); + ch = state.input.charCodeAt(state.position); + lineBreaks++; + state.lineIndent = 0; + while (ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + } else { + break; + } + } + if (checkIndent !== -1 && lineBreaks !== 0 && state.lineIndent < checkIndent) { + throwWarning(state, "deficient indentation"); + } + return lineBreaks; + } + function testDocumentSeparator(state) { + var _position = state.position, ch; + ch = state.input.charCodeAt(_position); + if ((ch === 45 || ch === 46) && ch === state.input.charCodeAt(_position + 1) && ch === state.input.charCodeAt(_position + 2)) { + _position += 3; + ch = state.input.charCodeAt(_position); + if (ch === 0 || is_WS_OR_EOL(ch)) { + return true; + } + } + return false; + } + function writeFoldedLines(state, count) { + if (count === 1) { + state.result += " "; + } else if (count > 1) { + state.result += common.repeat("\n", count - 1); + } + } + function readPlainScalar(state, nodeIndent, withinFlowCollection) { + var preceding, following, captureStart, captureEnd, hasPendingContent, _line, _lineStart, _lineIndent, _kind = state.kind, _result = state.result, ch; + ch = state.input.charCodeAt(state.position); + if (is_WS_OR_EOL(ch) || is_FLOW_INDICATOR(ch) || ch === 35 || ch === 38 || ch === 42 || ch === 33 || ch === 124 || ch === 62 || ch === 39 || ch === 34 || ch === 37 || ch === 64 || ch === 96) { + return false; + } + if (ch === 63 || ch === 45) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following) || withinFlowCollection && is_FLOW_INDICATOR(following)) { + return false; + } + } + state.kind = "scalar"; + state.result = ""; + captureStart = captureEnd = state.position; + hasPendingContent = false; + while (ch !== 0) { + if (ch === 58) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following) || withinFlowCollection && is_FLOW_INDICATOR(following)) { + break; + } + } else if (ch === 35) { + preceding = state.input.charCodeAt(state.position - 1); + if (is_WS_OR_EOL(preceding)) { + break; + } + } else if (state.position === state.lineStart && testDocumentSeparator(state) || withinFlowCollection && is_FLOW_INDICATOR(ch)) { + break; + } else if (is_EOL(ch)) { + _line = state.line; + _lineStart = state.lineStart; + _lineIndent = state.lineIndent; + skipSeparationSpace(state, false, -1); + if (state.lineIndent >= nodeIndent) { + hasPendingContent = true; + ch = state.input.charCodeAt(state.position); + continue; + } else { + state.position = captureEnd; + state.line = _line; + state.lineStart = _lineStart; + state.lineIndent = _lineIndent; + break; + } + } + if (hasPendingContent) { + captureSegment(state, captureStart, captureEnd, false); + writeFoldedLines(state, state.line - _line); + captureStart = captureEnd = state.position; + hasPendingContent = false; + } + if (!is_WHITE_SPACE(ch)) { + captureEnd = state.position + 1; + } + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, captureEnd, false); + if (state.result) { + return true; + } + state.kind = _kind; + state.result = _result; + return false; + } + function readSingleQuotedScalar(state, nodeIndent) { + var ch, captureStart, captureEnd; + ch = state.input.charCodeAt(state.position); + if (ch !== 39) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 39) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (ch === 39) { + captureStart = state.position; + state.position++; + captureEnd = state.position; + } else { + return true; + } + } else if (is_EOL(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a single quoted scalar"); + } else { + state.position++; + captureEnd = state.position; + } + } + throwError(state, "unexpected end of the stream within a single quoted scalar"); + } + function readDoubleQuotedScalar(state, nodeIndent) { + var captureStart, captureEnd, hexLength, hexResult, tmp, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 34) { + return false; + } + state.kind = "scalar"; + state.result = ""; + state.position++; + captureStart = captureEnd = state.position; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + if (ch === 34) { + captureSegment(state, captureStart, state.position, true); + state.position++; + return true; + } else if (ch === 92) { + captureSegment(state, captureStart, state.position, true); + ch = state.input.charCodeAt(++state.position); + if (is_EOL(ch)) { + skipSeparationSpace(state, false, nodeIndent); + } else if (ch < 256 && simpleEscapeCheck[ch]) { + state.result += simpleEscapeMap[ch]; + state.position++; + } else if ((tmp = escapedHexLen(ch)) > 0) { + hexLength = tmp; + hexResult = 0; + for (; hexLength > 0; hexLength--) { + ch = state.input.charCodeAt(++state.position); + if ((tmp = fromHexCode(ch)) >= 0) { + hexResult = (hexResult << 4) + tmp; + } else { + throwError(state, "expected hexadecimal character"); + } + } + state.result += charFromCodepoint(hexResult); + state.position++; + } else { + throwError(state, "unknown escape sequence"); + } + captureStart = captureEnd = state.position; + } else if (is_EOL(ch)) { + captureSegment(state, captureStart, captureEnd, true); + writeFoldedLines(state, skipSeparationSpace(state, false, nodeIndent)); + captureStart = captureEnd = state.position; + } else if (state.position === state.lineStart && testDocumentSeparator(state)) { + throwError(state, "unexpected end of the document within a double quoted scalar"); + } else { + state.position++; + captureEnd = state.position; + } + } + throwError(state, "unexpected end of the stream within a double quoted scalar"); + } + function readFlowCollection(state, nodeIndent) { + var readNext = true, _line, _tag = state.tag, _result, _anchor = state.anchor, following, terminator, isPair, isExplicitPair, isMapping, overridableKeys = {}, keyNode, keyTag, valueNode, ch; + ch = state.input.charCodeAt(state.position); + if (ch === 91) { + terminator = 93; + isMapping = false; + _result = []; + } else if (ch === 123) { + terminator = 125; + isMapping = true; + _result = {}; + } else { + return false; + } + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(++state.position); + while (ch !== 0) { + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === terminator) { + state.position++; + state.tag = _tag; + state.anchor = _anchor; + state.kind = isMapping ? "mapping" : "sequence"; + state.result = _result; + return true; + } else if (!readNext) { + throwError(state, "missed comma between flow collection entries"); + } + keyTag = keyNode = valueNode = null; + isPair = isExplicitPair = false; + if (ch === 63) { + following = state.input.charCodeAt(state.position + 1); + if (is_WS_OR_EOL(following)) { + isPair = isExplicitPair = true; + state.position++; + skipSeparationSpace(state, true, nodeIndent); + } + } + _line = state.line; + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + keyTag = state.tag; + keyNode = state.result; + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if ((isExplicitPair || state.line === _line) && ch === 58) { + isPair = true; + ch = state.input.charCodeAt(++state.position); + skipSeparationSpace(state, true, nodeIndent); + composeNode(state, nodeIndent, CONTEXT_FLOW_IN, false, true); + valueNode = state.result; + } + if (isMapping) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode); + } else if (isPair) { + _result.push(storeMappingPair(state, null, overridableKeys, keyTag, keyNode, valueNode)); + } else { + _result.push(keyNode); + } + skipSeparationSpace(state, true, nodeIndent); + ch = state.input.charCodeAt(state.position); + if (ch === 44) { + readNext = true; + ch = state.input.charCodeAt(++state.position); + } else { + readNext = false; + } + } + throwError(state, "unexpected end of the stream within a flow collection"); + } + function readBlockScalar(state, nodeIndent) { + var captureStart, folding, chomping = CHOMPING_CLIP, didReadContent = false, detectedIndent = false, textIndent = nodeIndent, emptyLines = 0, atMoreIndented = false, tmp, ch; + ch = state.input.charCodeAt(state.position); + if (ch === 124) { + folding = false; + } else if (ch === 62) { + folding = true; + } else { + return false; + } + state.kind = "scalar"; + state.result = ""; + while (ch !== 0) { + ch = state.input.charCodeAt(++state.position); + if (ch === 43 || ch === 45) { + if (CHOMPING_CLIP === chomping) { + chomping = ch === 43 ? CHOMPING_KEEP : CHOMPING_STRIP; + } else { + throwError(state, "repeat of a chomping mode identifier"); + } + } else if ((tmp = fromDecimalCode(ch)) >= 0) { + if (tmp === 0) { + throwError(state, "bad explicit indentation width of a block scalar; it cannot be less than one"); + } else if (!detectedIndent) { + textIndent = nodeIndent + tmp - 1; + detectedIndent = true; + } else { + throwError(state, "repeat of an indentation width identifier"); + } + } else { + break; + } + } + if (is_WHITE_SPACE(ch)) { + do { + ch = state.input.charCodeAt(++state.position); + } while (is_WHITE_SPACE(ch)); + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (!is_EOL(ch) && ch !== 0); + } + } + while (ch !== 0) { + readLineBreak(state); + state.lineIndent = 0; + ch = state.input.charCodeAt(state.position); + while ((!detectedIndent || state.lineIndent < textIndent) && ch === 32) { + state.lineIndent++; + ch = state.input.charCodeAt(++state.position); + } + if (!detectedIndent && state.lineIndent > textIndent) { + textIndent = state.lineIndent; + } + if (is_EOL(ch)) { + emptyLines++; + continue; + } + if (state.lineIndent < textIndent) { + if (chomping === CHOMPING_KEEP) { + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (chomping === CHOMPING_CLIP) { + if (didReadContent) { + state.result += "\n"; + } + } + break; + } + if (folding) { + if (is_WHITE_SPACE(ch)) { + atMoreIndented = true; + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } else if (atMoreIndented) { + atMoreIndented = false; + state.result += common.repeat("\n", emptyLines + 1); + } else if (emptyLines === 0) { + if (didReadContent) { + state.result += " "; + } + } else { + state.result += common.repeat("\n", emptyLines); + } + } else { + state.result += common.repeat("\n", didReadContent ? 1 + emptyLines : emptyLines); + } + didReadContent = true; + detectedIndent = true; + emptyLines = 0; + captureStart = state.position; + while (!is_EOL(ch) && ch !== 0) { + ch = state.input.charCodeAt(++state.position); + } + captureSegment(state, captureStart, state.position, false); + } + return true; + } + function readBlockSequence(state, nodeIndent) { + var _line, _tag = state.tag, _anchor = state.anchor, _result = [], following, detected = false, ch; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + if (ch !== 45) { + break; + } + following = state.input.charCodeAt(state.position + 1); + if (!is_WS_OR_EOL(following)) { + break; + } + detected = true; + state.position++; + if (skipSeparationSpace(state, true, -1)) { + if (state.lineIndent <= nodeIndent) { + _result.push(null); + ch = state.input.charCodeAt(state.position); + continue; + } + } + _line = state.line; + composeNode(state, nodeIndent, CONTEXT_BLOCK_IN, false, true); + _result.push(state.result); + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if ((state.line === _line || state.lineIndent > nodeIndent) && ch !== 0) { + throwError(state, "bad indentation of a sequence entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "sequence"; + state.result = _result; + return true; + } + return false; + } + function readBlockMapping(state, nodeIndent, flowIndent) { + var following, allowCompact, _line, _pos, _tag = state.tag, _anchor = state.anchor, _result = {}, overridableKeys = {}, keyTag = null, keyNode = null, valueNode = null, atExplicitKey = false, detected = false, ch; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = _result; + } + ch = state.input.charCodeAt(state.position); + while (ch !== 0) { + following = state.input.charCodeAt(state.position + 1); + _line = state.line; + _pos = state.position; + if ((ch === 63 || ch === 58) && is_WS_OR_EOL(following)) { + if (ch === 63) { + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = true; + allowCompact = true; + } else if (atExplicitKey) { + atExplicitKey = false; + allowCompact = true; + } else { + throwError(state, "incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"); + } + state.position += 1; + ch = following; + } else if (composeNode(state, flowIndent, CONTEXT_FLOW_OUT, false, true)) { + if (state.line === _line) { + ch = state.input.charCodeAt(state.position); + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 58) { + ch = state.input.charCodeAt(++state.position); + if (!is_WS_OR_EOL(ch)) { + throwError(state, "a whitespace character is expected after the key-value separator within a block mapping"); + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + keyTag = keyNode = valueNode = null; + } + detected = true; + atExplicitKey = false; + allowCompact = false; + keyTag = state.tag; + keyNode = state.result; + } else if (detected) { + throwError(state, "can not read an implicit mapping pair; a colon is missed"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } else if (detected) { + throwError(state, "can not read a block mapping entry; a multiline key may not be an implicit key"); + } else { + state.tag = _tag; + state.anchor = _anchor; + return true; + } + } else { + break; + } + if (state.line === _line || state.lineIndent > nodeIndent) { + if (composeNode(state, nodeIndent, CONTEXT_BLOCK_OUT, true, allowCompact)) { + if (atExplicitKey) { + keyNode = state.result; + } else { + valueNode = state.result; + } + } + if (!atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, valueNode, _line, _pos); + keyTag = keyNode = valueNode = null; + } + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + } + if (state.lineIndent > nodeIndent && ch !== 0) { + throwError(state, "bad indentation of a mapping entry"); + } else if (state.lineIndent < nodeIndent) { + break; + } + } + if (atExplicitKey) { + storeMappingPair(state, _result, overridableKeys, keyTag, keyNode, null); + } + if (detected) { + state.tag = _tag; + state.anchor = _anchor; + state.kind = "mapping"; + state.result = _result; + } + return detected; + } + function readTagProperty(state) { + var _position, isVerbatim = false, isNamed = false, tagHandle, tagName, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 33) return false; + if (state.tag !== null) { + throwError(state, "duplication of a tag property"); + } + ch = state.input.charCodeAt(++state.position); + if (ch === 60) { + isVerbatim = true; + ch = state.input.charCodeAt(++state.position); + } else if (ch === 33) { + isNamed = true; + tagHandle = "!!"; + ch = state.input.charCodeAt(++state.position); + } else { + tagHandle = "!"; + } + _position = state.position; + if (isVerbatim) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && ch !== 62); + if (state.position < state.length) { + tagName = state.input.slice(_position, state.position); + ch = state.input.charCodeAt(++state.position); + } else { + throwError(state, "unexpected end of the stream within a verbatim tag"); + } + } else { + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + if (ch === 33) { + if (!isNamed) { + tagHandle = state.input.slice(_position - 1, state.position + 1); + if (!PATTERN_TAG_HANDLE.test(tagHandle)) { + throwError(state, "named tag handle cannot contain such characters"); + } + isNamed = true; + _position = state.position + 1; + } else { + throwError(state, "tag suffix cannot contain exclamation marks"); + } + } + ch = state.input.charCodeAt(++state.position); + } + tagName = state.input.slice(_position, state.position); + if (PATTERN_FLOW_INDICATORS.test(tagName)) { + throwError(state, "tag suffix cannot contain flow indicator characters"); + } + } + if (tagName && !PATTERN_TAG_URI.test(tagName)) { + throwError(state, "tag name cannot contain such characters: " + tagName); + } + if (isVerbatim) { + state.tag = tagName; + } else if (_hasOwnProperty.call(state.tagMap, tagHandle)) { + state.tag = state.tagMap[tagHandle] + tagName; + } else if (tagHandle === "!") { + state.tag = "!" + tagName; + } else if (tagHandle === "!!") { + state.tag = "tag:yaml.org,2002:" + tagName; + } else { + throwError(state, 'undeclared tag handle "' + tagHandle + '"'); + } + return true; + } + function readAnchorProperty(state) { + var _position, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 38) return false; + if (state.anchor !== null) { + throwError(state, "duplication of an anchor property"); + } + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch) && !is_FLOW_INDICATOR(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an anchor node must contain at least one character"); + } + state.anchor = state.input.slice(_position, state.position); + return true; + } + function readAlias(state) { + var _position, alias, ch; + ch = state.input.charCodeAt(state.position); + if (ch !== 42) return false; + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch) && !is_FLOW_INDICATOR(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (state.position === _position) { + throwError(state, "name of an alias node must contain at least one character"); + } + alias = state.input.slice(_position, state.position); + if (!_hasOwnProperty.call(state.anchorMap, alias)) { + throwError(state, 'unidentified alias "' + alias + '"'); + } + state.result = state.anchorMap[alias]; + skipSeparationSpace(state, true, -1); + return true; + } + function composeNode(state, parentIndent, nodeContext, allowToSeek, allowCompact) { + var allowBlockStyles, allowBlockScalars, allowBlockCollections, indentStatus = 1, atNewLine = false, hasContent = false, typeIndex, typeQuantity, type, flowIndent, blockIndent; + if (state.listener !== null) { + state.listener("open", state); + } + state.tag = null; + state.anchor = null; + state.kind = null; + state.result = null; + allowBlockStyles = allowBlockScalars = allowBlockCollections = CONTEXT_BLOCK_OUT === nodeContext || CONTEXT_BLOCK_IN === nodeContext; + if (allowToSeek) { + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } + } + if (indentStatus === 1) { + while (readTagProperty(state) || readAnchorProperty(state)) { + if (skipSeparationSpace(state, true, -1)) { + atNewLine = true; + allowBlockCollections = allowBlockStyles; + if (state.lineIndent > parentIndent) { + indentStatus = 1; + } else if (state.lineIndent === parentIndent) { + indentStatus = 0; + } else if (state.lineIndent < parentIndent) { + indentStatus = -1; + } + } else { + allowBlockCollections = false; + } + } + } + if (allowBlockCollections) { + allowBlockCollections = atNewLine || allowCompact; + } + if (indentStatus === 1 || CONTEXT_BLOCK_OUT === nodeContext) { + if (CONTEXT_FLOW_IN === nodeContext || CONTEXT_FLOW_OUT === nodeContext) { + flowIndent = parentIndent; + } else { + flowIndent = parentIndent + 1; + } + blockIndent = state.position - state.lineStart; + if (indentStatus === 1) { + if (allowBlockCollections && (readBlockSequence(state, blockIndent) || readBlockMapping(state, blockIndent, flowIndent)) || readFlowCollection(state, flowIndent)) { + hasContent = true; + } else { + if (allowBlockScalars && readBlockScalar(state, flowIndent) || readSingleQuotedScalar(state, flowIndent) || readDoubleQuotedScalar(state, flowIndent)) { + hasContent = true; + } else if (readAlias(state)) { + hasContent = true; + if (state.tag !== null || state.anchor !== null) { + throwError(state, "alias node should not have any properties"); + } + } else if (readPlainScalar(state, flowIndent, CONTEXT_FLOW_IN === nodeContext)) { + hasContent = true; + if (state.tag === null) { + state.tag = "?"; + } + } + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + } + } else if (indentStatus === 0) { + hasContent = allowBlockCollections && readBlockSequence(state, blockIndent); + } + } + if (state.tag !== null && state.tag !== "!") { + if (state.tag === "?") { + if (state.result !== null && state.kind !== "scalar") { + throwError(state, 'unacceptable node kind for ! tag; it should be "scalar", not "' + state.kind + '"'); + } + for (typeIndex = 0, typeQuantity = state.implicitTypes.length; typeIndex < typeQuantity; typeIndex += 1) { + type = state.implicitTypes[typeIndex]; + if (type.resolve(state.result)) { + state.result = type.construct(state.result); + state.tag = type.tag; + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + break; + } + } + } else if (_hasOwnProperty.call(state.typeMap[state.kind || "fallback"], state.tag)) { + type = state.typeMap[state.kind || "fallback"][state.tag]; + if (state.result !== null && type.kind !== state.kind) { + throwError(state, "unacceptable node kind for !<" + state.tag + '> tag; it should be "' + type.kind + '", not "' + state.kind + '"'); + } + if (!type.resolve(state.result)) { + throwError(state, "cannot resolve a node with !<" + state.tag + "> explicit tag"); + } else { + state.result = type.construct(state.result); + if (state.anchor !== null) { + state.anchorMap[state.anchor] = state.result; + } + } + } else { + throwError(state, "unknown tag !<" + state.tag + ">"); + } + } + if (state.listener !== null) { + state.listener("close", state); + } + return state.tag !== null || state.anchor !== null || hasContent; + } + function readDocument(state) { + var documentStart = state.position, _position, directiveName, directiveArgs, hasDirectives = false, ch; + state.version = null; + state.checkLineBreaks = state.legacy; + state.tagMap = {}; + state.anchorMap = {}; + while ((ch = state.input.charCodeAt(state.position)) !== 0) { + skipSeparationSpace(state, true, -1); + ch = state.input.charCodeAt(state.position); + if (state.lineIndent > 0 || ch !== 37) { + break; + } + hasDirectives = true; + ch = state.input.charCodeAt(++state.position); + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + ch = state.input.charCodeAt(++state.position); + } + directiveName = state.input.slice(_position, state.position); + directiveArgs = []; + if (directiveName.length < 1) { + throwError(state, "directive name must not be less than one character in length"); + } + while (ch !== 0) { + while (is_WHITE_SPACE(ch)) { + ch = state.input.charCodeAt(++state.position); + } + if (ch === 35) { + do { + ch = state.input.charCodeAt(++state.position); + } while (ch !== 0 && !is_EOL(ch)); + break; + } + if (is_EOL(ch)) break; + _position = state.position; + while (ch !== 0 && !is_WS_OR_EOL(ch)) { + ch = state.input.charCodeAt(++state.position); + } + directiveArgs.push(state.input.slice(_position, state.position)); + } + if (ch !== 0) readLineBreak(state); + if (_hasOwnProperty.call(directiveHandlers, directiveName)) { + directiveHandlers[directiveName](state, directiveName, directiveArgs); + } else { + throwWarning(state, 'unknown document directive "' + directiveName + '"'); + } + } + skipSeparationSpace(state, true, -1); + if (state.lineIndent === 0 && state.input.charCodeAt(state.position) === 45 && state.input.charCodeAt(state.position + 1) === 45 && state.input.charCodeAt(state.position + 2) === 45) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } else if (hasDirectives) { + throwError(state, "directives end mark is expected"); + } + composeNode(state, state.lineIndent - 1, CONTEXT_BLOCK_OUT, false, true); + skipSeparationSpace(state, true, -1); + if (state.checkLineBreaks && PATTERN_NON_ASCII_LINE_BREAKS.test(state.input.slice(documentStart, state.position))) { + throwWarning(state, "non-ASCII line breaks are interpreted as content"); + } + state.documents.push(state.result); + if (state.position === state.lineStart && testDocumentSeparator(state)) { + if (state.input.charCodeAt(state.position) === 46) { + state.position += 3; + skipSeparationSpace(state, true, -1); + } + return; + } + if (state.position < state.length - 1) { + throwError(state, "end of the stream or a document separator is expected"); + } else { + return; + } + } + function loadDocuments(input, options2) { + input = String(input); + options2 = options2 || {}; + if (input.length !== 0) { + if (input.charCodeAt(input.length - 1) !== 10 && input.charCodeAt(input.length - 1) !== 13) { + input += "\n"; + } + if (input.charCodeAt(0) === 65279) { + input = input.slice(1); + } + } + var state = new State(input, options2); + var nullpos = input.indexOf("\0"); + if (nullpos !== -1) { + state.position = nullpos; + throwError(state, "null byte is not allowed in input"); + } + state.input += "\0"; + while (state.input.charCodeAt(state.position) === 32) { + state.lineIndent += 1; + state.position += 1; + } + while (state.position < state.length - 1) { + readDocument(state); + } + return state.documents; + } + function loadAll(input, iterator, options2) { + if (iterator !== null && typeof iterator === "object" && typeof options2 === "undefined") { + options2 = iterator; + iterator = null; + } + var documents = loadDocuments(input, options2); + if (typeof iterator !== "function") { + return documents; + } + for (var index = 0, length = documents.length; index < length; index += 1) { + iterator(documents[index]); + } + } + function load(input, options2) { + var documents = loadDocuments(input, options2); + if (documents.length === 0) { + return void 0; + } else if (documents.length === 1) { + return documents[0]; + } + throw new YAMLException("expected a single document in the stream, but found more"); + } + function safeLoadAll(input, iterator, options2) { + if (typeof iterator === "object" && iterator !== null && typeof options2 === "undefined") { + options2 = iterator; + iterator = null; + } + return loadAll(input, iterator, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + function safeLoad(input, options2) { + return load(input, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + module2.exports.loadAll = loadAll; + module2.exports.load = load; + module2.exports.safeLoadAll = safeLoadAll; + module2.exports.safeLoad = safeLoad; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/dumper.js +var require_dumper = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml/dumper.js"(exports2, module2) { + "use strict"; + var common = require_common(); + var YAMLException = require_exception(); + var DEFAULT_FULL_SCHEMA = require_default_full(); + var DEFAULT_SAFE_SCHEMA = require_default_safe(); + var _toString = Object.prototype.toString; + var _hasOwnProperty = Object.prototype.hasOwnProperty; + var CHAR_TAB = 9; + var CHAR_LINE_FEED = 10; + var CHAR_CARRIAGE_RETURN = 13; + var CHAR_SPACE = 32; + var CHAR_EXCLAMATION = 33; + var CHAR_DOUBLE_QUOTE = 34; + var CHAR_SHARP = 35; + var CHAR_PERCENT = 37; + var CHAR_AMPERSAND = 38; + var CHAR_SINGLE_QUOTE = 39; + var CHAR_ASTERISK = 42; + var CHAR_COMMA = 44; + var CHAR_MINUS = 45; + var CHAR_COLON = 58; + var CHAR_EQUALS = 61; + var CHAR_GREATER_THAN = 62; + var CHAR_QUESTION = 63; + var CHAR_COMMERCIAL_AT = 64; + var CHAR_LEFT_SQUARE_BRACKET = 91; + var CHAR_RIGHT_SQUARE_BRACKET = 93; + var CHAR_GRAVE_ACCENT = 96; + var CHAR_LEFT_CURLY_BRACKET = 123; + var CHAR_VERTICAL_LINE = 124; + var CHAR_RIGHT_CURLY_BRACKET = 125; + var ESCAPE_SEQUENCES = {}; + ESCAPE_SEQUENCES[0] = "\\0"; + ESCAPE_SEQUENCES[7] = "\\a"; + ESCAPE_SEQUENCES[8] = "\\b"; + ESCAPE_SEQUENCES[9] = "\\t"; + ESCAPE_SEQUENCES[10] = "\\n"; + ESCAPE_SEQUENCES[11] = "\\v"; + ESCAPE_SEQUENCES[12] = "\\f"; + ESCAPE_SEQUENCES[13] = "\\r"; + ESCAPE_SEQUENCES[27] = "\\e"; + ESCAPE_SEQUENCES[34] = '\\"'; + ESCAPE_SEQUENCES[92] = "\\\\"; + ESCAPE_SEQUENCES[133] = "\\N"; + ESCAPE_SEQUENCES[160] = "\\_"; + ESCAPE_SEQUENCES[8232] = "\\L"; + ESCAPE_SEQUENCES[8233] = "\\P"; + var DEPRECATED_BOOLEANS_SYNTAX = [ + "y", + "Y", + "yes", + "Yes", + "YES", + "on", + "On", + "ON", + "n", + "N", + "no", + "No", + "NO", + "off", + "Off", + "OFF" + ]; + function compileStyleMap(schema, map) { + var result, keys, index, length, tag, style, type; + if (map === null) return {}; + result = {}; + keys = Object.keys(map); + for (index = 0, length = keys.length; index < length; index += 1) { + tag = keys[index]; + style = String(map[tag]); + if (tag.slice(0, 2) === "!!") { + tag = "tag:yaml.org,2002:" + tag.slice(2); + } + type = schema.compiledTypeMap["fallback"][tag]; + if (type && _hasOwnProperty.call(type.styleAliases, style)) { + style = type.styleAliases[style]; + } + result[tag] = style; + } + return result; + } + function encodeHex(character) { + var string, handle, length; + string = character.toString(16).toUpperCase(); + if (character <= 255) { + handle = "x"; + length = 2; + } else if (character <= 65535) { + handle = "u"; + length = 4; + } else if (character <= 4294967295) { + handle = "U"; + length = 8; + } else { + throw new YAMLException("code point within a string may not be greater than 0xFFFFFFFF"); + } + return "\\" + handle + common.repeat("0", length - string.length) + string; + } + function State(options2) { + this.schema = options2["schema"] || DEFAULT_FULL_SCHEMA; + this.indent = Math.max(1, options2["indent"] || 2); + this.noArrayIndent = options2["noArrayIndent"] || false; + this.skipInvalid = options2["skipInvalid"] || false; + this.flowLevel = common.isNothing(options2["flowLevel"]) ? -1 : options2["flowLevel"]; + this.styleMap = compileStyleMap(this.schema, options2["styles"] || null); + this.sortKeys = options2["sortKeys"] || false; + this.lineWidth = options2["lineWidth"] || 80; + this.noRefs = options2["noRefs"] || false; + this.noCompatMode = options2["noCompatMode"] || false; + this.condenseFlow = options2["condenseFlow"] || false; + this.implicitTypes = this.schema.compiledImplicit; + this.explicitTypes = this.schema.compiledExplicit; + this.tag = null; + this.result = ""; + this.duplicates = []; + this.usedDuplicates = null; + } + function indentString(string, spaces) { + var ind = common.repeat(" ", spaces), position = 0, next = -1, result = "", line, length = string.length; + while (position < length) { + next = string.indexOf("\n", position); + if (next === -1) { + line = string.slice(position); + position = length; + } else { + line = string.slice(position, next + 1); + position = next + 1; + } + if (line.length && line !== "\n") result += ind; + result += line; + } + return result; + } + function generateNextLine(state, level) { + return "\n" + common.repeat(" ", state.indent * level); + } + function testImplicitResolving(state, str2) { + var index, length, type; + for (index = 0, length = state.implicitTypes.length; index < length; index += 1) { + type = state.implicitTypes[index]; + if (type.resolve(str2)) { + return true; + } + } + return false; + } + function isWhitespace(c) { + return c === CHAR_SPACE || c === CHAR_TAB; + } + function isPrintable(c) { + return 32 <= c && c <= 126 || 161 <= c && c <= 55295 && c !== 8232 && c !== 8233 || 57344 <= c && c <= 65533 && c !== 65279 || 65536 <= c && c <= 1114111; + } + function isNsChar(c) { + return isPrintable(c) && !isWhitespace(c) && c !== 65279 && c !== CHAR_CARRIAGE_RETURN && c !== CHAR_LINE_FEED; + } + function isPlainSafe(c, prev) { + return isPrintable(c) && c !== 65279 && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET && c !== CHAR_COLON && (c !== CHAR_SHARP || prev && isNsChar(prev)); + } + function isPlainSafeFirst(c) { + return isPrintable(c) && c !== 65279 && !isWhitespace(c) && c !== CHAR_MINUS && c !== CHAR_QUESTION && c !== CHAR_COLON && c !== CHAR_COMMA && c !== CHAR_LEFT_SQUARE_BRACKET && c !== CHAR_RIGHT_SQUARE_BRACKET && c !== CHAR_LEFT_CURLY_BRACKET && c !== CHAR_RIGHT_CURLY_BRACKET && c !== CHAR_SHARP && c !== CHAR_AMPERSAND && c !== CHAR_ASTERISK && c !== CHAR_EXCLAMATION && c !== CHAR_VERTICAL_LINE && c !== CHAR_EQUALS && c !== CHAR_GREATER_THAN && c !== CHAR_SINGLE_QUOTE && c !== CHAR_DOUBLE_QUOTE && c !== CHAR_PERCENT && c !== CHAR_COMMERCIAL_AT && c !== CHAR_GRAVE_ACCENT; + } + function needIndentIndicator(string) { + var leadingSpaceRe = /^\n* /; + return leadingSpaceRe.test(string); + } + var STYLE_PLAIN = 1; + var STYLE_SINGLE = 2; + var STYLE_LITERAL = 3; + var STYLE_FOLDED = 4; + var STYLE_DOUBLE = 5; + function chooseScalarStyle(string, singleLineOnly, indentPerLevel, lineWidth, testAmbiguousType) { + var i; + var char, prev_char; + var hasLineBreak = false; + var hasFoldableLine = false; + var shouldTrackWidth = lineWidth !== -1; + var previousLineBreak = -1; + var plain = isPlainSafeFirst(string.charCodeAt(0)) && !isWhitespace(string.charCodeAt(string.length - 1)); + if (singleLineOnly) { + for (i = 0; i < string.length; i++) { + char = string.charCodeAt(i); + if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + prev_char = i > 0 ? string.charCodeAt(i - 1) : null; + plain = plain && isPlainSafe(char, prev_char); + } + } else { + for (i = 0; i < string.length; i++) { + char = string.charCodeAt(i); + if (char === CHAR_LINE_FEED) { + hasLineBreak = true; + if (shouldTrackWidth) { + hasFoldableLine = hasFoldableLine || // Foldable line = too long, and not more-indented. + i - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " "; + previousLineBreak = i; + } + } else if (!isPrintable(char)) { + return STYLE_DOUBLE; + } + prev_char = i > 0 ? string.charCodeAt(i - 1) : null; + plain = plain && isPlainSafe(char, prev_char); + } + hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " "); + } + if (!hasLineBreak && !hasFoldableLine) { + return plain && !testAmbiguousType(string) ? STYLE_PLAIN : STYLE_SINGLE; + } + if (indentPerLevel > 9 && needIndentIndicator(string)) { + return STYLE_DOUBLE; + } + return hasFoldableLine ? STYLE_FOLDED : STYLE_LITERAL; + } + function writeScalar(state, string, level, iskey) { + state.dump = (function() { + if (string.length === 0) { + return "''"; + } + if (!state.noCompatMode && DEPRECATED_BOOLEANS_SYNTAX.indexOf(string) !== -1) { + return "'" + string + "'"; + } + var indent = state.indent * Math.max(1, level); + var lineWidth = state.lineWidth === -1 ? -1 : Math.max(Math.min(state.lineWidth, 40), state.lineWidth - indent); + var singleLineOnly = iskey || state.flowLevel > -1 && level >= state.flowLevel; + function testAmbiguity(string2) { + return testImplicitResolving(state, string2); + } + switch (chooseScalarStyle(string, singleLineOnly, state.indent, lineWidth, testAmbiguity)) { + case STYLE_PLAIN: + return string; + case STYLE_SINGLE: + return "'" + string.replace(/'/g, "''") + "'"; + case STYLE_LITERAL: + return "|" + blockHeader(string, state.indent) + dropEndingNewline(indentString(string, indent)); + case STYLE_FOLDED: + return ">" + blockHeader(string, state.indent) + dropEndingNewline(indentString(foldString(string, lineWidth), indent)); + case STYLE_DOUBLE: + return '"' + escapeString(string, lineWidth) + '"'; + default: + throw new YAMLException("impossible error: invalid scalar style"); + } + })(); + } + function blockHeader(string, indentPerLevel) { + var indentIndicator = needIndentIndicator(string) ? String(indentPerLevel) : ""; + var clip = string[string.length - 1] === "\n"; + var keep = clip && (string[string.length - 2] === "\n" || string === "\n"); + var chomp = keep ? "+" : clip ? "" : "-"; + return indentIndicator + chomp + "\n"; + } + function dropEndingNewline(string) { + return string[string.length - 1] === "\n" ? string.slice(0, -1) : string; + } + function foldString(string, width) { + var lineRe = /(\n+)([^\n]*)/g; + var result = (function() { + var nextLF = string.indexOf("\n"); + nextLF = nextLF !== -1 ? nextLF : string.length; + lineRe.lastIndex = nextLF; + return foldLine(string.slice(0, nextLF), width); + })(); + var prevMoreIndented = string[0] === "\n" || string[0] === " "; + var moreIndented; + var match; + while (match = lineRe.exec(string)) { + var prefix = match[1], line = match[2]; + moreIndented = line[0] === " "; + result += prefix + (!prevMoreIndented && !moreIndented && line !== "" ? "\n" : "") + foldLine(line, width); + prevMoreIndented = moreIndented; + } + return result; + } + function foldLine(line, width) { + if (line === "" || line[0] === " ") return line; + var breakRe = / [^ ]/g; + var match; + var start = 0, end, curr = 0, next = 0; + var result = ""; + while (match = breakRe.exec(line)) { + next = match.index; + if (next - start > width) { + end = curr > start ? curr : next; + result += "\n" + line.slice(start, end); + start = end + 1; + } + curr = next; + } + result += "\n"; + if (line.length - start > width && curr > start) { + result += line.slice(start, curr) + "\n" + line.slice(curr + 1); + } else { + result += line.slice(start); + } + return result.slice(1); + } + function escapeString(string) { + var result = ""; + var char, nextChar; + var escapeSeq; + for (var i = 0; i < string.length; i++) { + char = string.charCodeAt(i); + if (char >= 55296 && char <= 56319) { + nextChar = string.charCodeAt(i + 1); + if (nextChar >= 56320 && nextChar <= 57343) { + result += encodeHex((char - 55296) * 1024 + nextChar - 56320 + 65536); + i++; + continue; + } + } + escapeSeq = ESCAPE_SEQUENCES[char]; + result += !escapeSeq && isPrintable(char) ? string[i] : escapeSeq || encodeHex(char); + } + return result; + } + function writeFlowSequence(state, level, object) { + var _result = "", _tag = state.tag, index, length; + for (index = 0, length = object.length; index < length; index += 1) { + if (writeNode(state, level, object[index], false, false)) { + if (index !== 0) _result += "," + (!state.condenseFlow ? " " : ""); + _result += state.dump; + } + } + state.tag = _tag; + state.dump = "[" + _result + "]"; + } + function writeBlockSequence(state, level, object, compact) { + var _result = "", _tag = state.tag, index, length; + for (index = 0, length = object.length; index < length; index += 1) { + if (writeNode(state, level + 1, object[index], true, true)) { + if (!compact || index !== 0) { + _result += generateNextLine(state, level); + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + _result += "-"; + } else { + _result += "- "; + } + _result += state.dump; + } + } + state.tag = _tag; + state.dump = _result || "[]"; + } + function writeFlowMapping(state, level, object) { + var _result = "", _tag = state.tag, objectKeyList = Object.keys(object), index, length, objectKey, objectValue, pairBuffer; + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + pairBuffer = ""; + if (index !== 0) pairBuffer += ", "; + if (state.condenseFlow) pairBuffer += '"'; + objectKey = objectKeyList[index]; + objectValue = object[objectKey]; + if (!writeNode(state, level, objectKey, false, false)) { + continue; + } + if (state.dump.length > 1024) pairBuffer += "? "; + pairBuffer += state.dump + (state.condenseFlow ? '"' : "") + ":" + (state.condenseFlow ? "" : " "); + if (!writeNode(state, level, objectValue, false, false)) { + continue; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = "{" + _result + "}"; + } + function writeBlockMapping(state, level, object, compact) { + var _result = "", _tag = state.tag, objectKeyList = Object.keys(object), index, length, objectKey, objectValue, explicitPair, pairBuffer; + if (state.sortKeys === true) { + objectKeyList.sort(); + } else if (typeof state.sortKeys === "function") { + objectKeyList.sort(state.sortKeys); + } else if (state.sortKeys) { + throw new YAMLException("sortKeys must be a boolean or a function"); + } + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + pairBuffer = ""; + if (!compact || index !== 0) { + pairBuffer += generateNextLine(state, level); + } + objectKey = objectKeyList[index]; + objectValue = object[objectKey]; + if (!writeNode(state, level + 1, objectKey, true, true, true)) { + continue; + } + explicitPair = state.tag !== null && state.tag !== "?" || state.dump && state.dump.length > 1024; + if (explicitPair) { + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += "?"; + } else { + pairBuffer += "? "; + } + } + pairBuffer += state.dump; + if (explicitPair) { + pairBuffer += generateNextLine(state, level); + } + if (!writeNode(state, level + 1, objectValue, true, explicitPair)) { + continue; + } + if (state.dump && CHAR_LINE_FEED === state.dump.charCodeAt(0)) { + pairBuffer += ":"; + } else { + pairBuffer += ": "; + } + pairBuffer += state.dump; + _result += pairBuffer; + } + state.tag = _tag; + state.dump = _result || "{}"; + } + function detectType(state, object, explicit) { + var _result, typeList, index, length, type, style; + typeList = explicit ? state.explicitTypes : state.implicitTypes; + for (index = 0, length = typeList.length; index < length; index += 1) { + type = typeList[index]; + if ((type.instanceOf || type.predicate) && (!type.instanceOf || typeof object === "object" && object instanceof type.instanceOf) && (!type.predicate || type.predicate(object))) { + state.tag = explicit ? type.tag : "?"; + if (type.represent) { + style = state.styleMap[type.tag] || type.defaultStyle; + if (_toString.call(type.represent) === "[object Function]") { + _result = type.represent(object, style); + } else if (_hasOwnProperty.call(type.represent, style)) { + _result = type.represent[style](object, style); + } else { + throw new YAMLException("!<" + type.tag + '> tag resolver accepts not "' + style + '" style'); + } + state.dump = _result; + } + return true; + } + } + return false; + } + function writeNode(state, level, object, block, compact, iskey) { + state.tag = null; + state.dump = object; + if (!detectType(state, object, false)) { + detectType(state, object, true); + } + var type = _toString.call(state.dump); + if (block) { + block = state.flowLevel < 0 || state.flowLevel > level; + } + var objectOrArray = type === "[object Object]" || type === "[object Array]", duplicateIndex, duplicate; + if (objectOrArray) { + duplicateIndex = state.duplicates.indexOf(object); + duplicate = duplicateIndex !== -1; + } + if (state.tag !== null && state.tag !== "?" || duplicate || state.indent !== 2 && level > 0) { + compact = false; + } + if (duplicate && state.usedDuplicates[duplicateIndex]) { + state.dump = "*ref_" + duplicateIndex; + } else { + if (objectOrArray && duplicate && !state.usedDuplicates[duplicateIndex]) { + state.usedDuplicates[duplicateIndex] = true; + } + if (type === "[object Object]") { + if (block && Object.keys(state.dump).length !== 0) { + writeBlockMapping(state, level, state.dump, compact); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowMapping(state, level, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type === "[object Array]") { + var arrayLevel = state.noArrayIndent && level > 0 ? level - 1 : level; + if (block && state.dump.length !== 0) { + writeBlockSequence(state, arrayLevel, state.dump, compact); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + state.dump; + } + } else { + writeFlowSequence(state, arrayLevel, state.dump); + if (duplicate) { + state.dump = "&ref_" + duplicateIndex + " " + state.dump; + } + } + } else if (type === "[object String]") { + if (state.tag !== "?") { + writeScalar(state, state.dump, level, iskey); + } + } else { + if (state.skipInvalid) return false; + throw new YAMLException("unacceptable kind of an object to dump " + type); + } + if (state.tag !== null && state.tag !== "?") { + state.dump = "!<" + state.tag + "> " + state.dump; + } + } + return true; + } + function getDuplicateReferences(object, state) { + var objects = [], duplicatesIndexes = [], index, length; + inspectNode(object, objects, duplicatesIndexes); + for (index = 0, length = duplicatesIndexes.length; index < length; index += 1) { + state.duplicates.push(objects[duplicatesIndexes[index]]); + } + state.usedDuplicates = new Array(length); + } + function inspectNode(object, objects, duplicatesIndexes) { + var objectKeyList, index, length; + if (object !== null && typeof object === "object") { + index = objects.indexOf(object); + if (index !== -1) { + if (duplicatesIndexes.indexOf(index) === -1) { + duplicatesIndexes.push(index); + } + } else { + objects.push(object); + if (Array.isArray(object)) { + for (index = 0, length = object.length; index < length; index += 1) { + inspectNode(object[index], objects, duplicatesIndexes); + } + } else { + objectKeyList = Object.keys(object); + for (index = 0, length = objectKeyList.length; index < length; index += 1) { + inspectNode(object[objectKeyList[index]], objects, duplicatesIndexes); + } + } + } + } + } + function dump(input, options2) { + options2 = options2 || {}; + var state = new State(options2); + if (!state.noRefs) getDuplicateReferences(input, state); + if (writeNode(state, 0, input, true, true)) return state.dump + "\n"; + return ""; + } + function safeDump(input, options2) { + return dump(input, common.extend({ schema: DEFAULT_SAFE_SCHEMA }, options2)); + } + module2.exports.dump = dump; + module2.exports.safeDump = safeDump; + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml.js +var require_js_yaml = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/lib/js-yaml.js"(exports2, module2) { + "use strict"; + var loader = require_loader(); + var dumper = require_dumper(); + function deprecated(name) { + return function() { + throw new Error("Function " + name + " is deprecated and cannot be used."); + }; + } + module2.exports.Type = require_type(); + module2.exports.Schema = require_schema(); + module2.exports.FAILSAFE_SCHEMA = require_failsafe(); + module2.exports.JSON_SCHEMA = require_json(); + module2.exports.CORE_SCHEMA = require_core(); + module2.exports.DEFAULT_SAFE_SCHEMA = require_default_safe(); + module2.exports.DEFAULT_FULL_SCHEMA = require_default_full(); + module2.exports.load = loader.load; + module2.exports.loadAll = loader.loadAll; + module2.exports.safeLoad = loader.safeLoad; + module2.exports.safeLoadAll = loader.safeLoadAll; + module2.exports.dump = dumper.dump; + module2.exports.safeDump = dumper.safeDump; + module2.exports.YAMLException = require_exception(); + module2.exports.MINIMAL_SCHEMA = require_failsafe(); + module2.exports.SAFE_SCHEMA = require_default_safe(); + module2.exports.DEFAULT_SCHEMA = require_default_full(); + module2.exports.scan = deprecated("scan"); + module2.exports.parse = deprecated("parse"); + module2.exports.compose = deprecated("compose"); + module2.exports.addConstructor = deprecated("addConstructor"); + } +}); + +// node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/index.js +var require_js_yaml2 = __commonJS({ + "node_modules/.pnpm/js-yaml@3.14.2/node_modules/js-yaml/index.js"(exports2, module2) { + "use strict"; + var yaml2 = require_js_yaml(); + module2.exports = yaml2; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engines.js +var require_engines = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engines.js"(exports, module) { + "use strict"; + var yaml = require_js_yaml2(); + var engines = exports = module.exports; + engines.yaml = { + parse: yaml.safeLoad.bind(yaml), + stringify: yaml.safeDump.bind(yaml) + }; + engines.json = { + parse: JSON.parse.bind(JSON), + stringify: function(obj, options2) { + const opts = Object.assign({ replacer: null, space: 2 }, options2); + return JSON.stringify(obj, opts.replacer, opts.space); + } + }; + engines.javascript = { + parse: function parse(str, options, wrap) { + try { + if (wrap !== false) { + str = "(function() {\nreturn " + str.trim() + ";\n}());"; + } + return eval(str) || {}; + } catch (err) { + if (wrap !== false && /(unexpected|identifier)/i.test(err.message)) { + return parse(str, options, false); + } + throw new SyntaxError(err); + } + }, + stringify: function() { + throw new Error("stringifying JavaScript is not supported"); + } + }; + } +}); + +// node_modules/.pnpm/strip-bom-string@1.0.0/node_modules/strip-bom-string/index.js +var require_strip_bom_string = __commonJS({ + "node_modules/.pnpm/strip-bom-string@1.0.0/node_modules/strip-bom-string/index.js"(exports2, module2) { + "use strict"; + module2.exports = function(str2) { + if (typeof str2 === "string" && str2.charAt(0) === "\uFEFF") { + return str2.slice(1); + } + return str2; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/utils.js +var require_utils = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/utils.js"(exports2) { + "use strict"; + var stripBom = require_strip_bom_string(); + var typeOf = require_kind_of(); + exports2.define = function(obj, key, val) { + Reflect.defineProperty(obj, key, { + enumerable: false, + configurable: true, + writable: true, + value: val + }); + }; + exports2.isBuffer = function(val) { + return typeOf(val) === "buffer"; + }; + exports2.isObject = function(val) { + return typeOf(val) === "object"; + }; + exports2.toBuffer = function(input) { + return typeof input === "string" ? Buffer.from(input) : input; + }; + exports2.toString = function(input) { + if (exports2.isBuffer(input)) return stripBom(String(input)); + if (typeof input !== "string") { + throw new TypeError("expected input to be a string or buffer"); + } + return stripBom(input); + }; + exports2.arrayify = function(val) { + return val ? Array.isArray(val) ? val : [val] : []; + }; + exports2.startsWith = function(str2, substr, len) { + if (typeof len !== "number") len = substr.length; + return str2.slice(0, len) === substr; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/defaults.js +var require_defaults = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/defaults.js"(exports2, module2) { + "use strict"; + var engines2 = require_engines(); + var utils = require_utils(); + module2.exports = function(options2) { + const opts = Object.assign({}, options2); + opts.delimiters = utils.arrayify(opts.delims || opts.delimiters || "---"); + if (opts.delimiters.length === 1) { + opts.delimiters.push(opts.delimiters[0]); + } + opts.language = (opts.language || opts.lang || "yaml").toLowerCase(); + opts.engines = Object.assign({}, engines2, opts.parsers, opts.engines); + return opts; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engine.js +var require_engine = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/engine.js"(exports2, module2) { + "use strict"; + module2.exports = function(name, options2) { + let engine = options2.engines[name] || options2.engines[aliase(name)]; + if (typeof engine === "undefined") { + throw new Error('gray-matter engine "' + name + '" is not registered'); + } + if (typeof engine === "function") { + engine = { parse: engine }; + } + return engine; + }; + function aliase(name) { + switch (name.toLowerCase()) { + case "js": + case "javascript": + return "javascript"; + case "coffee": + case "coffeescript": + case "cson": + return "coffee"; + case "yaml": + case "yml": + return "yaml"; + default: { + return name; + } + } + } + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/stringify.js +var require_stringify = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/stringify.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var getEngine = require_engine(); + var defaults = require_defaults(); + module2.exports = function(file, data, options2) { + if (data == null && options2 == null) { + switch (typeOf(file)) { + case "object": + data = file.data; + options2 = {}; + break; + case "string": + return file; + default: { + throw new TypeError("expected file to be a string or object"); + } + } + } + const str2 = file.content; + const opts = defaults(options2); + if (data == null) { + if (!opts.data) return file; + data = opts.data; + } + const language = file.language || opts.language; + const engine = getEngine(language, opts); + if (typeof engine.stringify !== "function") { + throw new TypeError('expected "' + language + '.stringify" to be a function'); + } + data = Object.assign({}, file.data, data); + const open = opts.delimiters[0]; + const close = opts.delimiters[1]; + const matter2 = engine.stringify(data, options2).trim(); + let buf = ""; + if (matter2 !== "{}") { + buf = newline(open) + newline(matter2) + newline(close); + } + if (typeof file.excerpt === "string" && file.excerpt !== "") { + if (str2.indexOf(file.excerpt.trim()) === -1) { + buf += newline(file.excerpt) + newline(close); + } + } + return buf + newline(str2); + }; + function newline(str2) { + return str2.slice(-1) !== "\n" ? str2 + "\n" : str2; + } + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/excerpt.js +var require_excerpt = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/excerpt.js"(exports2, module2) { + "use strict"; + var defaults = require_defaults(); + module2.exports = function(file, options2) { + const opts = defaults(options2); + if (file.data == null) { + file.data = {}; + } + if (typeof opts.excerpt === "function") { + return opts.excerpt(file, opts); + } + const sep = file.data.excerpt_separator || opts.excerpt_separator; + if (sep == null && (opts.excerpt === false || opts.excerpt == null)) { + return file; + } + const delimiter = typeof opts.excerpt === "string" ? opts.excerpt : sep || opts.delimiters[0]; + const idx = file.content.indexOf(delimiter); + if (idx !== -1) { + file.excerpt = file.content.slice(0, idx); + } + return file; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/to-file.js +var require_to_file = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/to-file.js"(exports2, module2) { + "use strict"; + var typeOf = require_kind_of(); + var stringify = require_stringify(); + var utils = require_utils(); + module2.exports = function(file) { + if (typeOf(file) !== "object") { + file = { content: file }; + } + if (typeOf(file.data) !== "object") { + file.data = {}; + } + if (file.contents && file.content == null) { + file.content = file.contents; + } + utils.define(file, "orig", utils.toBuffer(file.content)); + utils.define(file, "language", file.language || ""); + utils.define(file, "matter", file.matter || ""); + utils.define(file, "stringify", function(data, options2) { + if (options2 && options2.language) { + file.language = options2.language; + } + return stringify(file, data, options2); + }); + file.content = utils.toString(file.content); + file.isEmpty = false; + file.excerpt = ""; + return file; + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/parse.js +var require_parse = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/lib/parse.js"(exports2, module2) { + "use strict"; + var getEngine = require_engine(); + var defaults = require_defaults(); + module2.exports = function(language, str2, options2) { + const opts = defaults(options2); + const engine = getEngine(language, opts); + if (typeof engine.parse !== "function") { + throw new TypeError('expected "' + language + '.parse" to be a function'); + } + return engine.parse(str2, opts); + }; + } +}); + +// node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js +var require_gray_matter = __commonJS({ + "node_modules/.pnpm/gray-matter@4.0.3/node_modules/gray-matter/index.js"(exports2, module2) { + "use strict"; + var fs2 = require("fs"); + var sections = require_section_matter(); + var defaults = require_defaults(); + var stringify = require_stringify(); + var excerpt = require_excerpt(); + var engines2 = require_engines(); + var toFile = require_to_file(); + var parse2 = require_parse(); + var utils = require_utils(); + function matter2(input, options2) { + if (input === "") { + return { data: {}, content: input, excerpt: "", orig: input }; + } + let file = toFile(input); + const cached = matter2.cache[file.content]; + if (!options2) { + if (cached) { + file = Object.assign({}, cached); + file.orig = cached.orig; + return file; + } + matter2.cache[file.content] = file; + } + return parseMatter(file, options2); + } + function parseMatter(file, options2) { + const opts = defaults(options2); + const open = opts.delimiters[0]; + const close = "\n" + opts.delimiters[1]; + let str2 = file.content; + if (opts.language) { + file.language = opts.language; + } + const openLen = open.length; + if (!utils.startsWith(str2, open, openLen)) { + excerpt(file, opts); + return file; + } + if (str2.charAt(openLen) === open.slice(-1)) { + return file; + } + str2 = str2.slice(openLen); + const len = str2.length; + const language = matter2.language(str2, opts); + if (language.name) { + file.language = language.name; + str2 = str2.slice(language.raw.length); + } + let closeIndex = str2.indexOf(close); + if (closeIndex === -1) { + closeIndex = len; + } + file.matter = str2.slice(0, closeIndex); + const block = file.matter.replace(/^\s*#[^\n]+/gm, "").trim(); + if (block === "") { + file.isEmpty = true; + file.empty = file.content; + file.data = {}; + } else { + file.data = parse2(file.language, file.matter, opts); + } + if (closeIndex === len) { + file.content = ""; + } else { + file.content = str2.slice(closeIndex + close.length); + if (file.content[0] === "\r") { + file.content = file.content.slice(1); + } + if (file.content[0] === "\n") { + file.content = file.content.slice(1); + } + } + excerpt(file, opts); + if (opts.sections === true || typeof opts.section === "function") { + sections(file, opts.section); + } + return file; + } + matter2.engines = engines2; + matter2.stringify = function(file, data, options2) { + if (typeof file === "string") file = matter2(file, options2); + return stringify(file, data, options2); + }; + matter2.read = function(filepath, options2) { + const str2 = fs2.readFileSync(filepath, "utf8"); + const file = matter2(str2, options2); + file.path = filepath; + return file; + }; + matter2.test = function(str2, options2) { + return utils.startsWith(str2, defaults(options2).delimiters[0]); + }; + matter2.language = function(str2, options2) { + const opts = defaults(options2); + const open = opts.delimiters[0]; + if (matter2.test(str2)) { + str2 = str2.slice(open.length); + } + const language = str2.slice(0, str2.search(/\r?\n/)); + return { + raw: language, + name: language ? language.trim() : "" + }; + }; + matter2.cache = {}; + matter2.clearCache = function() { + matter2.cache = {}; + }; + module2.exports = matter2; + } +}); + +// mcp-server/ws-bridge.js +var ws_bridge_exports = {}; +__export(ws_bridge_exports, { + startBridge: () => startBridge, + startUiBridge: () => startUiBridge +}); +module.exports = __toCommonJS(ws_bridge_exports); +var import_node_http = require("node:http"); + +// node_modules/.pnpm/ws@8.19.0/node_modules/ws/wrapper.mjs +var import_stream = __toESM(require_stream(), 1); +var import_receiver = __toESM(require_receiver(), 1); +var import_sender = __toESM(require_sender(), 1); +var import_websocket = __toESM(require_websocket(), 1); +var import_websocket_server = __toESM(require_websocket_server(), 1); + +// mcp-server/vault.js +var import_promises = __toESM(require("node:fs/promises"), 1); +var import_node_path = __toESM(require("node:path"), 1); +var import_gray_matter = __toESM(require_gray_matter(), 1); +async function findMarkdownFiles(dir) { + const results = []; + const items = await import_promises.default.readdir(dir, { withFileTypes: true }); + for (const item of items) { + if (item.name.startsWith(".")) continue; + const full = import_node_path.default.join(dir, item.name); + if (item.isDirectory()) { + results.push(...await findMarkdownFiles(full)); + } else if (item.name.endsWith(".md")) { + results.push(full); + } + } + return results; +} +async function getNote(vaultPath, notePath) { + const absPath = import_node_path.default.isAbsolute(notePath) ? notePath : import_node_path.default.join(vaultPath, notePath); + const raw = await import_promises.default.readFile(absPath, "utf-8"); + const parsed = (0, import_gray_matter.default)(raw); + return { + path: import_node_path.default.relative(vaultPath, absPath), + frontmatter: parsed.data, + content: parsed.content.trim() + }; +} +async function searchNotes(vaultPath, query, limit = 10) { + const files = await findMarkdownFiles(vaultPath); + const q = query.toLowerCase(); + const results = []; + for (const filePath of files) { + if (results.length >= limit) break; + const content = await import_promises.default.readFile(filePath, "utf-8"); + const filename = import_node_path.default.basename(filePath, ".md"); + const titleMatch = extractTitle(content, filename); + const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q); + if (matches) { + const snippet = extractSnippet(content, q); + results.push({ + path: import_node_path.default.relative(vaultPath, filePath), + title: titleMatch, + snippet + }); + } + } + return results; +} +async function vaultContext(vaultPath) { + const files = await findMarkdownFiles(vaultPath); + const typesSet = /* @__PURE__ */ new Set(); + const foldersSet = /* @__PURE__ */ new Set(); + const notesWithMtime = []; + for (const filePath of files) { + const raw = await import_promises.default.readFile(filePath, "utf-8"); + const parsed = (0, import_gray_matter.default)(raw); + const type = parsed.data.type || parsed.data.is_a || null; + if (type) typesSet.add(type); + const rel = import_node_path.default.relative(vaultPath, filePath); + const topFolder = rel.split(import_node_path.default.sep)[0]; + if (topFolder !== rel) foldersSet.add(topFolder + "/"); + const stat = await import_promises.default.stat(filePath); + notesWithMtime.push({ + path: rel, + title: parsed.data.title || extractTitle(raw, import_node_path.default.basename(filePath, ".md")), + type, + mtime: stat.mtimeMs + }); + } + notesWithMtime.sort((a, b) => b.mtime - a.mtime); + const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest); + const configFiles = {}; + try { + const agentsPath = import_node_path.default.join(vaultPath, "config", "agents.md"); + const agentsContent = await import_promises.default.readFile(agentsPath, "utf-8"); + configFiles.agents = agentsContent; + } catch { + } + return { + types: [...typesSet].sort(), + noteCount: files.length, + folders: [...foldersSet].sort(), + recentNotes, + configFiles, + vaultPath + }; +} +function extractTitle(content, fallback) { + const h1Match = content.match(/^#\s+(.+)$/m); + if (h1Match) return h1Match[1].trim(); + const titleMatch = content.match(/^title:\s*(.+)$/m); + if (titleMatch) return titleMatch[1].trim(); + return fallback; +} +function extractSnippet(content, query) { + const body = content.replace(/^---[\s\S]*?---\n?/, "").trim(); + const idx = body.toLowerCase().indexOf(query); + if (idx === -1) return body.slice(0, 120); + const start = Math.max(0, idx - 40); + const end = Math.min(body.length, idx + query.length + 80); + return (start > 0 ? "..." : "") + body.slice(start, end) + (end < body.length ? "..." : ""); +} + +// mcp-server/ws-bridge.js +var VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + "/Laputa"; +var WS_PORT = parseInt(process.env.WS_PORT || "9710", 10); +var WS_UI_PORT = parseInt(process.env.WS_UI_PORT || "9711", 10); +var uiBridge = null; +function broadcastUiAction(action, payload) { + if (!uiBridge) return; + const msg = JSON.stringify({ type: "ui_action", action, ...payload }); + for (const client of uiBridge.clients) { + if (client.readyState === 1) client.send(msg); + } +} +var TOOL_HANDLERS = { + open_note: (args) => getNote(VAULT_PATH, args.path).then((note) => ({ content: note.content, frontmatter: note.frontmatter })), + read_note: (args) => getNote(VAULT_PATH, args.path).then((note) => ({ content: note.content, frontmatter: note.frontmatter })), + search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit), + vault_context: () => vaultContext(VAULT_PATH), + ui_open_note: (args) => { + broadcastUiAction("vault_changed", { path: args.path }); + broadcastUiAction("open_note", { path: args.path }); + return { ok: true }; + }, + ui_open_tab: (args) => { + broadcastUiAction("vault_changed", { path: args.path }); + broadcastUiAction("open_tab", { path: args.path }); + return { ok: true }; + }, + ui_highlight: (args) => { + broadcastUiAction("highlight", { element: args.element, path: args.path }); + return { ok: true }; + }, + ui_set_filter: (args) => { + broadcastUiAction("set_filter", { filterType: args.type }); + return { ok: true }; + }, + highlight_editor: (args) => { + broadcastUiAction("highlight", { element: args.element, path: args.path }); + return { ok: true }; + }, + refresh_vault: (args) => { + broadcastUiAction("vault_changed", { path: args?.path }); + return { ok: true }; + } +}; +async function handleMessage(data) { + const msg = JSON.parse(data); + const { id, tool, args } = msg; + const handler = TOOL_HANDLERS[tool]; + if (!handler) { + return { id, error: `Unknown tool: ${tool}` }; + } + try { + const result = await handler(args || {}); + return { id, result }; + } catch (err) { + return { id, error: err.message }; + } +} +function startUiBridge(port = WS_UI_PORT) { + return new Promise((resolve) => { + const httpServer = (0, import_node_http.createServer)(); + httpServer.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`); + } else { + console.error(`[ws-bridge] UI bridge error: ${err.message}`); + } + resolve(null); + }); + httpServer.listen(port, () => { + const wss = new import_websocket_server.default({ server: httpServer }); + wss.on("connection", (ws) => { + console.error(`[ws-bridge] UI client connected on port ${port}`); + ws.on("message", (raw) => { + for (const client of wss.clients) { + if (client !== ws && client.readyState === 1) client.send(raw.toString()); + } + }); + }); + uiBridge = wss; + console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`); + resolve(wss); + }); + }); +} +function startBridge(port = WS_PORT) { + const wss = new import_websocket_server.default({ port }); + wss.on("connection", (ws) => { + console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`); + ws.on("message", async (raw) => { + try { + const response = await handleMessage(raw.toString()); + ws.send(JSON.stringify(response)); + } catch (err) { + ws.send(JSON.stringify({ error: `Parse error: ${err.message}` })); + } + }); + ws.on("close", () => console.error("[ws-bridge] Client disconnected")); + }); + console.error(`[ws-bridge] Listening on ws://localhost:${port}`); + return wss; +} +var isMain = process.argv[1]?.endsWith("ws-bridge.js"); +if (isMain) { + startUiBridge().then(() => startBridge()); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + startBridge, + startUiBridge +}); +/*! Bundled license information: + +is-extendable/index.js: + (*! + * is-extendable + * + * Copyright (c) 2015, Jon Schlinkert. + * Licensed under the MIT License. + *) + +strip-bom-string/index.js: + (*! + * strip-bom-string + * + * Copyright (c) 2015, 2017, Jon Schlinkert. + * Released under the MIT License. + *) +*/ diff --git a/src-tauri/gen/apple/laputa.xcodeproj/project.pbxproj b/src-tauri/gen/apple/laputa.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d236f98 --- /dev/null +++ b/src-tauri/gen/apple/laputa.xcodeproj/project.pbxproj @@ -0,0 +1,566 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1662961784944D479C9E1B0D /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E3FC10F8A144BB158574C774 /* Metal.framework */; }; + 1C55EF711F7ACEAC173C344E /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CF0D094B920E757356FC3AD /* libapp.a */; }; + 4A94802D0295ECD102C21BA1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A34681D9D4CE45CDF83ABA59 /* UIKit.framework */; }; + 534929E6730B15780117F21F /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05C322F5E5E2ACB0CBC644EE /* MetalKit.framework */; }; + 5AAD7159BB4D20588044B3BC /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43C29783A8194F596483AD35 /* Security.framework */; }; + 74A9072960AF0CB774BEB749 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AB6F4BD2E768B4B6EDF7EA4 /* QuartzCore.framework */; }; + 799F0FF5DD8053B287C9CA46 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3D8616031E45547278A88B60 /* main.mm */; }; + 7EF9AA3C7F58D96F21743FB4 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27D33799362DF41B226D7635 /* WebKit.framework */; }; + 9BD440BEBCDBD633C9EE2C26 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A70EB7513BFD74428B2C6ED1 /* CoreGraphics.framework */; }; + A2279B6B796C960A2951E7C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F8E52CF938A81FD9ABC1B23F /* Assets.xcassets */; }; + DACF8A23100E74CEA0BE1ED9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9274F38B9AA0A9A44A3CBEAA /* LaunchScreen.storyboard */; }; + E0FB400669725FC03B8E9B39 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 1BC846C6372AE88FE28EA984 /* assets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 05C322F5E5E2ACB0CBC644EE /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + 077AD05C7FB4A784D360399B /* commands.rs */ = {isa = PBXFileReference; path = commands.rs; sourceTree = ""; }; + 0AE733EAA82134418C0AC89F /* api.rs */ = {isa = PBXFileReference; path = api.rs; sourceTree = ""; }; + 0CA7012668D8606FE9709D6D /* yaml.rs */ = {isa = PBXFileReference; path = yaml.rs; sourceTree = ""; }; + 0FB1053AF590466CC8971679 /* parsing.rs */ = {isa = PBXFileReference; path = parsing.rs; sourceTree = ""; }; + 16CCC72C07657AEBC320B099 /* auth.rs */ = {isa = PBXFileReference; path = auth.rs; sourceTree = ""; }; + 1BC846C6372AE88FE28EA984 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + 1D65CB9525835A85C38D3136 /* migration.rs */ = {isa = PBXFileReference; path = migration.rs; sourceTree = ""; }; + 27D33799362DF41B226D7635 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 2D31CB45BB367FD6A7FA0B67 /* rename.rs */ = {isa = PBXFileReference; path = rename.rs; sourceTree = ""; }; + 2D38A9417EA6B0A133D0D55F /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 34F1C40770F83FDC21036505 /* laputa_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = laputa_iOS.entitlements; sourceTree = ""; }; + 3AB6F4BD2E768B4B6EDF7EA4 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + 3CF0D094B920E757356FC3AD /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + 3D8616031E45547278A88B60 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + 3E23075B718E12551904D3B6 /* frontmatter.rs */ = {isa = PBXFileReference; path = frontmatter.rs; sourceTree = ""; }; + 43C29783A8194F596483AD35 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 4AAEE16E1AC0E472E9EAF907 /* conflict.rs */ = {isa = PBXFileReference; path = conflict.rs; sourceTree = ""; }; + 4F4C2FE8D3697D180E1D4304 /* entry.rs */ = {isa = PBXFileReference; path = entry.rs; sourceTree = ""; }; + 50B53E0FE0FC2A5515B48A28 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; + 517A653F5A674050430C1167 /* commit.rs */ = {isa = PBXFileReference; path = commit.rs; sourceTree = ""; }; + 5800BA1A3E5C03124E02E946 /* title_sync.rs */ = {isa = PBXFileReference; path = title_sync.rs; sourceTree = ""; }; + 5F28F393EB20BA7F8F2102EF /* config_seed.rs */ = {isa = PBXFileReference; path = config_seed.rs; sourceTree = ""; }; + 6288FD61B10C36115B12B4A4 /* cache.rs */ = {isa = PBXFileReference; path = cache.rs; sourceTree = ""; }; + 692CE75FC8E670478C4209C8 /* remote.rs */ = {isa = PBXFileReference; path = remote.rs; sourceTree = ""; }; + 6A5C3DA38846B440DD56E98A /* claude_cli.rs */ = {isa = PBXFileReference; path = claude_cli.rs; sourceTree = ""; }; + 6AC39E9370FCCB01E81EC824 /* ai_chat.rs */ = {isa = PBXFileReference; path = ai_chat.rs; sourceTree = ""; }; + 6F67F5CDD6BB137126C5AABB /* menu.rs */ = {isa = PBXFileReference; path = menu.rs; sourceTree = ""; }; + 71EDBD38A067DA56FD3E375F /* getting_started.rs */ = {isa = PBXFileReference; path = getting_started.rs; sourceTree = ""; }; + 7D94F0F22CD503903B63FFE7 /* file.rs */ = {isa = PBXFileReference; path = file.rs; sourceTree = ""; }; + 7FCDD5B9C3D021A45C402A43 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 81C8EBDD5CAD14B7F366AA54 /* trash.rs */ = {isa = PBXFileReference; path = trash.rs; sourceTree = ""; }; + 84ECD74B7841E52AD6549346 /* image.rs */ = {isa = PBXFileReference; path = image.rs; sourceTree = ""; }; + 87F0375DE367A8192708C2D8 /* pulse.rs */ = {isa = PBXFileReference; path = pulse.rs; sourceTree = ""; }; + 9274F38B9AA0A9A44A3CBEAA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 94B5B4BBA850E2BCAE3E4CC7 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + A17AA2469F233241B0DF8BE3 /* vault_list.rs */ = {isa = PBXFileReference; path = vault_list.rs; sourceTree = ""; }; + A34681D9D4CE45CDF83ABA59 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + A70EB7513BFD74428B2C6ED1 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + AA64014FA2D16950AD4DF619 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + ACCD8318FA6543BA013E37E4 /* ops.rs */ = {isa = PBXFileReference; path = ops.rs; sourceTree = ""; }; + BA9FA5D7A7A7574FF74589C8 /* telemetry.rs */ = {isa = PBXFileReference; path = telemetry.rs; sourceTree = ""; }; + BB4E3DD2D3BA34BB86C908B6 /* status.rs */ = {isa = PBXFileReference; path = status.rs; sourceTree = ""; }; + C39B460DE1A3B78A0937BFDD /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = ""; }; + C80E8FDDA822F4F167B601C1 /* mod_tests.rs */ = {isa = PBXFileReference; path = mod_tests.rs; sourceTree = ""; }; + CEDEEAE68B0DB4CD43ED89A8 /* laputa_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = laputa_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D0900BA51E68887E5DA395B7 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + DDC76F9C00A29C8C52345249 /* history.rs */ = {isa = PBXFileReference; path = history.rs; sourceTree = ""; }; + E1428C0A0CC22F741B7A4A0C /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; + E36BAA25D76F212157A06395 /* search.rs */ = {isa = PBXFileReference; path = search.rs; sourceTree = ""; }; + E3FC10F8A144BB158574C774 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + F87684B058BB66A215F98DCF /* mcp.rs */ = {isa = PBXFileReference; path = mcp.rs; sourceTree = ""; }; + F8E52CF938A81FD9ABC1B23F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F9FF9FD3CEB0EB658CA17305 /* clone.rs */ = {isa = PBXFileReference; path = clone.rs; sourceTree = ""; }; + FBB08655AD50E526ACEAE8A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 50978AC1976616D3342459ED /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1C55EF711F7ACEAC173C344E /* libapp.a in Frameworks */, + 9BD440BEBCDBD633C9EE2C26 /* CoreGraphics.framework in Frameworks */, + 1662961784944D479C9E1B0D /* Metal.framework in Frameworks */, + 534929E6730B15780117F21F /* MetalKit.framework in Frameworks */, + 74A9072960AF0CB774BEB749 /* QuartzCore.framework in Frameworks */, + 5AAD7159BB4D20588044B3BC /* Security.framework in Frameworks */, + 4A94802D0295ECD102C21BA1 /* UIKit.framework in Frameworks */, + 7EF9AA3C7F58D96F21743FB4 /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 021259EC384B5D897634CFF8 /* Products */ = { + isa = PBXGroup; + children = ( + CEDEEAE68B0DB4CD43ED89A8 /* laputa_iOS.app */, + ); + name = Products; + sourceTree = ""; + }; + 109126B51C7DDFCA7CD8B9EA /* Sources */ = { + isa = PBXGroup; + children = ( + DC6461BBBF0BDFBB28352CF9 /* laputa */, + ); + path = Sources; + sourceTree = ""; + }; + 1189728653A76416E01221D7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A70EB7513BFD74428B2C6ED1 /* CoreGraphics.framework */, + 3CF0D094B920E757356FC3AD /* libapp.a */, + E3FC10F8A144BB158574C774 /* Metal.framework */, + 05C322F5E5E2ACB0CBC644EE /* MetalKit.framework */, + 3AB6F4BD2E768B4B6EDF7EA4 /* QuartzCore.framework */, + 43C29783A8194F596483AD35 /* Security.framework */, + A34681D9D4CE45CDF83ABA59 /* UIKit.framework */, + 27D33799362DF41B226D7635 /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 314C2D9104FDC1DE81AC36AD = { + isa = PBXGroup; + children = ( + 1BC846C6372AE88FE28EA984 /* assets */, + F8E52CF938A81FD9ABC1B23F /* Assets.xcassets */, + 9274F38B9AA0A9A44A3CBEAA /* LaunchScreen.storyboard */, + FD3AA61436162714FAEC418D /* Externals */, + 56AC65AB30CBAEB08FD782A6 /* laputa_iOS */, + 109126B51C7DDFCA7CD8B9EA /* Sources */, + FA63A5561E1F084B5F13E265 /* src */, + 1189728653A76416E01221D7 /* Frameworks */, + 021259EC384B5D897634CFF8 /* Products */, + ); + sourceTree = ""; + }; + 56AC65AB30CBAEB08FD782A6 /* laputa_iOS */ = { + isa = PBXGroup; + children = ( + FBB08655AD50E526ACEAE8A2 /* Info.plist */, + 34F1C40770F83FDC21036505 /* laputa_iOS.entitlements */, + ); + path = laputa_iOS; + sourceTree = ""; + }; + 5D80057D94E84FA08C34A8B0 /* frontmatter */ = { + isa = PBXGroup; + children = ( + 94B5B4BBA850E2BCAE3E4CC7 /* mod.rs */, + ACCD8318FA6543BA013E37E4 /* ops.rs */, + 0CA7012668D8606FE9709D6D /* yaml.rs */, + ); + path = frontmatter; + sourceTree = ""; + }; + 6A109CE663DEBC9C99A47D0B /* github */ = { + isa = PBXGroup; + children = ( + 0AE733EAA82134418C0AC89F /* api.rs */, + 16CCC72C07657AEBC320B099 /* auth.rs */, + F9FF9FD3CEB0EB658CA17305 /* clone.rs */, + 2D38A9417EA6B0A133D0D55F /* mod.rs */, + ); + path = github; + sourceTree = ""; + }; + 85465A1346B035D3C3DA6236 /* git */ = { + isa = PBXGroup; + children = ( + 517A653F5A674050430C1167 /* commit.rs */, + 4AAEE16E1AC0E472E9EAF907 /* conflict.rs */, + DDC76F9C00A29C8C52345249 /* history.rs */, + 7FCDD5B9C3D021A45C402A43 /* mod.rs */, + 87F0375DE367A8192708C2D8 /* pulse.rs */, + 692CE75FC8E670478C4209C8 /* remote.rs */, + BB4E3DD2D3BA34BB86C908B6 /* status.rs */, + ); + path = git; + sourceTree = ""; + }; + A44D28635D2D14F8A9E644BC /* bindings */ = { + isa = PBXGroup; + children = ( + D0900BA51E68887E5DA395B7 /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + AE0DF532B1E0DADFEDFE383A /* vault */ = { + isa = PBXGroup; + children = ( + 6288FD61B10C36115B12B4A4 /* cache.rs */, + 5F28F393EB20BA7F8F2102EF /* config_seed.rs */, + 4F4C2FE8D3697D180E1D4304 /* entry.rs */, + 7D94F0F22CD503903B63FFE7 /* file.rs */, + 3E23075B718E12551904D3B6 /* frontmatter.rs */, + 71EDBD38A067DA56FD3E375F /* getting_started.rs */, + 84ECD74B7841E52AD6549346 /* image.rs */, + 1D65CB9525835A85C38D3136 /* migration.rs */, + C80E8FDDA822F4F167B601C1 /* mod_tests.rs */, + AA64014FA2D16950AD4DF619 /* mod.rs */, + 0FB1053AF590466CC8971679 /* parsing.rs */, + 2D31CB45BB367FD6A7FA0B67 /* rename.rs */, + 5800BA1A3E5C03124E02E946 /* title_sync.rs */, + 81C8EBDD5CAD14B7F366AA54 /* trash.rs */, + ); + path = vault; + sourceTree = ""; + }; + DC6461BBBF0BDFBB28352CF9 /* laputa */ = { + isa = PBXGroup; + children = ( + 3D8616031E45547278A88B60 /* main.mm */, + A44D28635D2D14F8A9E644BC /* bindings */, + ); + path = laputa; + sourceTree = ""; + }; + FA63A5561E1F084B5F13E265 /* src */ = { + isa = PBXGroup; + children = ( + 6AC39E9370FCCB01E81EC824 /* ai_chat.rs */, + 6A5C3DA38846B440DD56E98A /* claude_cli.rs */, + 077AD05C7FB4A784D360399B /* commands.rs */, + E1428C0A0CC22F741B7A4A0C /* lib.rs */, + 50B53E0FE0FC2A5515B48A28 /* main.rs */, + F87684B058BB66A215F98DCF /* mcp.rs */, + 6F67F5CDD6BB137126C5AABB /* menu.rs */, + E36BAA25D76F212157A06395 /* search.rs */, + C39B460DE1A3B78A0937BFDD /* settings.rs */, + BA9FA5D7A7A7574FF74589C8 /* telemetry.rs */, + A17AA2469F233241B0DF8BE3 /* vault_list.rs */, + 5D80057D94E84FA08C34A8B0 /* frontmatter */, + 85465A1346B035D3C3DA6236 /* git */, + 6A109CE663DEBC9C99A47D0B /* github */, + AE0DF532B1E0DADFEDFE383A /* vault */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; + FD3AA61436162714FAEC418D /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DAF25BBC5B5E1A3750AF7F8E /* laputa_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8ED295B21C1E1D8BE1CBAB2 /* Build configuration list for PBXNativeTarget "laputa_iOS" */; + buildPhases = ( + 72D053FB9C1C9BF219367CA2 /* Build Rust Code */, + 2FA764A26841E6D98B919C1A /* Sources */, + 46BE17F676877ABAEEFB1ACF /* Resources */, + 50978AC1976616D3342459ED /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = laputa_iOS; + packageProductDependencies = ( + ); + productName = laputa_iOS; + productReference = CEDEEAE68B0DB4CD43ED89A8 /* laputa_iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2E2C059AB505791AB84204D8 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = 02446155D1A36B8D10250861 /* Build configuration list for PBXProject "laputa" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 314C2D9104FDC1DE81AC36AD; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 021259EC384B5D897634CFF8 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DAF25BBC5B5E1A3750AF7F8E /* laputa_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 46BE17F676877ABAEEFB1ACF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A2279B6B796C960A2951E7C5 /* Assets.xcassets in Resources */, + DACF8A23100E74CEA0BE1ED9 /* LaunchScreen.storyboard in Resources */, + E0FB400669725FC03B8E9B39 /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 72D053FB9C1C9BF219367CA2 /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2FA764A26841E6D98B919C1A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 799F0FF5DD8053B287C9CA46 /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 3CC8721F138177C2FE029D95 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + 44D6538EB5E448ECCC49165C /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + 487BF5A97BEC469EBD248159 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = laputa_iOS/laputa_iOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = laputa_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria; + PRODUCT_NAME = Tolaria; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = debug; + }; + FE69C38FBEB0C14D7D5AC27E /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = laputa_iOS/laputa_iOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = laputa_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria; + PRODUCT_NAME = Tolaria; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 02446155D1A36B8D10250861 /* Build configuration list for PBXProject "laputa" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3CC8721F138177C2FE029D95 /* debug */, + 44D6538EB5E448ECCC49165C /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + F8ED295B21C1E1D8BE1CBAB2 /* Build configuration list for PBXNativeTarget "laputa_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 487BF5A97BEC469EBD248159 /* debug */, + FE69C38FBEB0C14D7D5AC27E /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2E2C059AB505791AB84204D8 /* Project object */; +} diff --git a/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..ac90d5a --- /dev/null +++ b/src-tauri/gen/apple/laputa.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + DisableBuildSystemDeprecationDiagnostic + + + diff --git a/src-tauri/gen/apple/laputa.xcodeproj/xcshareddata/xcschemes/laputa_iOS.xcscheme b/src-tauri/gen/apple/laputa.xcodeproj/xcshareddata/xcschemes/laputa_iOS.xcscheme new file mode 100644 index 0000000..086ea44 --- /dev/null +++ b/src-tauri/gen/apple/laputa.xcodeproj/xcshareddata/xcschemes/laputa_iOS.xcscheme @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/laputa_iOS/Info.plist b/src-tauri/gen/apple/laputa_iOS/Info.plist new file mode 100644 index 0000000..e0b1335 --- /dev/null +++ b/src-tauri/gen/apple/laputa_iOS/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + \ No newline at end of file diff --git a/src-tauri/gen/apple/laputa_iOS/laputa_iOS.entitlements b/src-tauri/gen/apple/laputa_iOS/laputa_iOS.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/src-tauri/gen/apple/laputa_iOS/laputa_iOS.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml new file mode 100644 index 0000000..aea6fa2 --- /dev/null +++ b/src-tauri/gen/apple/project.yml @@ -0,0 +1,88 @@ +name: Tolaria +options: + bundleIdPrefix: club.refactoring.tolaria + deploymentTarget: + iOS: 14.0 +fileGroups: [../../src] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: Tolaria + PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.tolaria +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + laputa_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Assets.xcassets + - path: Externals + - path: laputa_iOS + - path: assets + buildPhase: resources + type: folder + - path: LaunchScreen.storyboard + info: + path: laputa_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: 0.1.0 + CFBundleVersion: "0.1.0" + entitlements: + path: laputa_iOS/laputa_iOS.entitlements + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + base: + ENABLE_BITCODE: false + ARCHS: [arm64] + VALID_ARCHS: arm64 + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: libapp.a + embed: false + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + - sdk: WebKit.framework + preBuildScripts: + - script: npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..a2aaced Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..1724873 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/256x256.png b/src-tauri/icons/256x256.png new file mode 100644 index 0000000..9457a9b Binary files /dev/null and b/src-tauri/icons/256x256.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..1da9f3d Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/512x512-dark.png b/src-tauri/icons/512x512-dark.png new file mode 100644 index 0000000..d28d124 Binary files /dev/null and b/src-tauri/icons/512x512-dark.png differ diff --git a/src-tauri/icons/512x512.png b/src-tauri/icons/512x512.png new file mode 100644 index 0000000..55bab42 Binary files /dev/null and b/src-tauri/icons/512x512.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..a0be9ea Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..57509be Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..203cea0 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..ebd4655 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..fe77fd9 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..7467bc1 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..b1ecc35 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..024dc8f Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..af53ffe Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..0f48e9d Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..a2d6372 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..80a6041 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7ec8eb9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..aa6a35b Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..799efe2 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3354dbd Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ce006fe Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d3240cd Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8acf454 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a2018e9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b461399 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..62285c5 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..16317bf Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..d40311a Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9c0bd26 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b156fe5 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon-source-dark.svg b/src-tauri/icons/icon-source-dark.svg new file mode 100644 index 0000000..4953c93 --- /dev/null +++ b/src-tauri/icons/icon-source-dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/icon-source.png b/src-tauri/icons/icon-source.png new file mode 100644 index 0000000..2a87206 Binary files /dev/null and b/src-tauri/icons/icon-source.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..cec556b Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..64be380 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..3421d74 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..078c8dd Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..4e035d4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..4e035d4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..dc4f4f1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..d3f6c50 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..eb44cac Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..eb44cac Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..3a70c58 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..4e035d4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..26b3f05 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..26b3f05 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..b98bafc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..1315cf7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..b98bafc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..6c84aec Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..82e0095 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..3f939f1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..8113964 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/resources/agent-docs/AGENTS.md b/src-tauri/resources/agent-docs/AGENTS.md new file mode 100644 index 0000000..a090776 --- /dev/null +++ b/src-tauri/resources/agent-docs/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md - Tolaria Docs Bundle + +This folder contains local, generated Tolaria product docs for AI agents. + +Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions. + +Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read `pages/templates/portent.md` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views. + +Recommended lookup flow: + +1. Read the active vault's AGENTS.md for vault-specific conventions. +2. Read this folder's index.md for the docs map. +3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting. + +Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior. diff --git a/src-tauri/resources/agent-docs/all.md b/src-tauri/resources/agent-docs/all.md new file mode 100644 index 0000000..d8ee4db --- /dev/null +++ b/src-tauri/resources/agent-docs/all.md @@ -0,0 +1,2254 @@ +# Index + +Source: index.md +URL: / + + + +--- + +# First Launch + +Source: start/first-launch.md +URL: /start/first-launch + +# First Launch + +The first launch flow is designed to get you into a real vault quickly without hiding the local-first model. + +## What You Choose + +Tolaria asks whether you want to: + +- Create or clone the Getting Started vault. +- Open an existing local vault. +- Create a new empty vault. + +The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes. + +## What Tolaria Creates + +Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose. + +| Data | Stored in | +| --- | --- | +| Notes and attachments | Your vault folder | +| Type definitions and saved views | Your vault folder | +| Window size, zoom, recent vaults | Local app settings | +| Cache data | Rebuildable local cache | + +## First Commands To Try + +- `Cmd+K` / `Ctrl+K`: open the command palette. +- `New Note`: create a note in the current vault. +- `Open Getting Started Vault`: clone the public sample vault. +- `Reload Vault`: rescan files after external edits. + +## AI Setup Prompt + +Tolaria can show an optional AI agents prompt after a vault is open. It checks common local install locations for supported coding agents and gives you setup paths, but you can dismiss it and use Tolaria without AI. + +--- + +# Getting Started Vault + +Source: start/getting-started-vault.md +URL: /start/getting-started-vault + +# Getting Started Vault + +The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started). + +It exists to show Tolaria's conventions without requiring you to restructure your own notes first. + +## What It Demonstrates + +- Markdown notes with YAML frontmatter. +- Types such as Project, Person, Topic, and Procedure. +- Wikilinks in note bodies. +- Relationship fields in frontmatter. +- A local Git repository that can be connected to a remote later. +- Vault guidance files for AI agents. + +## Local-Only By Default + +When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later. + +To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette. + +Tolaria also repairs starter-vault guidance files when needed. `AGENTS.md` is the canonical guidance file, `CLAUDE.md` is kept as a compatibility shim, and `GEMINI.md` is only created when you explicitly restore Antigravity/Gemini guidance. + +## Use It Alongside Your Own Vaults + +You can keep the Getting Started vault open while working in your own notes. Enable `Settings` -> `Vaults` -> `Use multiple vaults at the same time`, then use the bottom-left vault menu to include both the sample vault and your real vault in the unified graph. + +This lets search, quick open, note lists, backlinks, and wikilink navigation span both vaults. Git actions still stay scoped to each vault's own repository, and new notes go to the default vault you choose in `Manage vaults`. + +## When To Move On + +After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start. You can remove the sample from Tolaria's vault list later without deleting its files from disk. + +--- + +# Install Tolaria + +Source: start/install.md +URL: /start/install + +# Install Tolaria + +Tolaria publishes desktop builds for macOS, Windows, and Linux. macOS is the primary day-to-day development target, with Windows and Linux builds supported through the release pipeline and fixed as platform issues are found. + +## Download + +Use the latest stable release unless you are intentionally testing pre-release builds: + +- Download the latest stable build +- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases) +- Read the release notes + +## Homebrew + +On macOS you can install the cask: + +```bash +brew install --cask tolaria +``` + +## Platform Status + +| Platform | Status | Notes | +| --- | --- | --- | +| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. | +| Windows | Supported, early | NSIS installers and updater bundles are Tauri-signed. Authenticode publisher signing will be added after Windows certificate provisioning; company-managed SmartScreen, Defender, or WDAC policies can still require IT approval before install. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. | + +See [Supported Platforms](/reference/supported-platforms) for the current support policy. + +## Managed Windows Devices + +Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, install Tolaria through your normal software approval path if policy blocks unsigned or unknown-publisher installers. After Authenticode provisioning is complete, validate that the downloaded installer has a valid Tolaria publisher signature before installing. + +## After Installing + +1. Open Tolaria. +2. Choose the Getting Started vault if you want a guided sample. +3. Or open an existing folder of Markdown files as a vault. +4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows. + +--- + +# Open Or Create A Vault + +Source: start/open-or-create-vault.md +URL: /start/open-or-create-vault + +# Open Or Create A Vault + +A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata. + +## Open An Existing Folder + +Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists. + +Good starting points: + +- A folder of plain Markdown files. +- An Obsidian-style vault. +- A Git repository containing notes. +- A copy of the Getting Started vault. + +## Create A New Vault + +Choose a new empty folder if you want Tolaria conventions from the start. New notes and optional type definitions are created as Markdown files. + +## Use More Than One Vault + +You do not have to merge everything into one folder. Register each local folder as its own vault, then turn on `Use multiple vaults at the same time` in `Settings` -> `Vaults`. + +Once enabled, the bottom-left vault menu lets you include vaults in the unified graph. Search, quick open, wikilinks, and note lists can span the included vaults, while Git sync and commits remain tied to each vault's own repository. + +## Git Is Recommended, Not Required + +Tolaria works well with a plain folder of Markdown files. You can open, edit, organize, and search notes without making the vault a Git repository. + +Git is recommended when you want local history, diff views, recovery, pull, push, and remote sync without a proprietary backend. If a vault is not already a repository, Tolaria can initialize one when you explicitly ask it to. + +--- + +# AI + +Source: concepts/ai.md +URL: /concepts/ai + +# AI + +Tolaria has two AI paths: coding agents that can use tools to inspect and edit a vault, and direct model targets that answer in chat mode from note context. + +## Coding Agents + +The AI panel can stream supported local CLI agents through Tolaria's normalized event layer. Current targets include Claude Code, Codex, OpenCode, Pi, and Antigravity CLI when they are installed on the machine. + +Coding agents can run in: + +- **Vault Safe** mode, limited to file, search, and edit tools. +- **Power User** mode, which can allow local shell commands scoped to the active vault for agents that support shell access. + +## Direct Models + +Direct model targets run in chat mode. They receive the active note, linked context, and conversation history, but they do not receive vault-write tools or shell access. + +Supported provider shapes include: + +- Local models through Ollama or LM Studio. +- Hosted providers such as OpenAI, Anthropic, Gemini, and OpenRouter. +- Custom OpenAI-compatible endpoints. + +## External MCP Setup + +Tolaria exposes an MCP server for external tools. The setup flow can write Tolaria's MCP entry into Claude Code, Antigravity CLI, Cursor, and a generic MCP config path, and it can also copy the exact JSON snippet for manual setup. + +MCP setup is explicit. Closing the dialog leaves third-party config files untouched. + +## Why Git Matters For AI + +AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work. + +--- + +# Editor + +Source: concepts/editor.md +URL: /concepts/editor + +# Editor + +Tolaria offers a rich editor for daily writing and a raw Markdown mode for exact file control. Both modes write back to the same Markdown file. + +## Rich Editing + +The rich editor supports blocks, slash commands, wikilinks, tables, code blocks, images, Mermaid diagrams, LaTeX-style math, and markdown-backed whiteboards. + +Use it when you want to write and reorganize quickly without thinking about Markdown syntax. + +## Raw Mode + +Raw mode shows the Markdown source directly. Use it when you need to edit YAML frontmatter, repair unusual Markdown, or make an exact text change. + +Toggle raw mode with `Cmd+\` on macOS or `Ctrl+\` on Windows and Linux. + +## Table Of Contents + +The table of contents panel builds an outline from headings in the current note. It is useful for long notes, procedures, research files, and generated documents. Toggle it with `Cmd+Shift+T` on macOS or `Ctrl+Shift+T` on Windows and Linux. + +## Width + +Notes can use normal or wide editor width. Set the default in Settings, or override an individual note from the editor toolbar. + +--- + +# Files And Media + +Source: concepts/files-and-media.md +URL: /concepts/files-and-media + +# Files And Media + +Tolaria starts with Markdown notes, but a vault can also contain images, PDFs, media files, whiteboards, and other local files. + +## Mermaid Diagrams + +Use Mermaid code blocks when a note needs a diagram that should stay plain text and versionable. + +````md +```mermaid +flowchart LR + Idea --> Draft --> Review --> Publish +``` +```` + +Tolaria renders Mermaid diagrams in the editor while keeping the source in Markdown. + +## Attachments + +Images pasted into the editor are saved into the vault as normal files. They remain portable and can be opened by other tools. + +## Previews + +Tolaria can preview common image files, PDFs, and supported media files in the app. Files without an in-app preview can still be opened in the default system app. + +Settings control whether PDFs, images, and unsupported files appear in All Notes. Folder browsing still shows files in their folders. + +## Whiteboards + +Whiteboards use tldraw in the editor, but their durable representation stays in Markdown. That keeps them inside the vault and versioned by Git with the rest of your notes. + +## Git Boundary + +If generated or local-only files are ignored by Git, Tolaria can hide them from notes, search, quick open, and folders. Use this when build artifacts or private local files should not behave like vault content. + +--- + +# Git + +Source: concepts/git.md +URL: /concepts/git + +# Git + +Git is Tolaria's recommended history and sync layer. Tolaria can work with plain Markdown folders, and Git unlocks local history, recovery, remote backup, and multi-device workflows when you want them. + +Tolaria acts as a lightweight Git client for your vault. You can review changes, commit, pull, push, and inspect history without leaving the app. + +## What Tolaria Uses Git For + +- Whole-vault commit history. +- Current diff for the vault. +- Per-note history. +- Current diff for an individual note. +- Pull and push. +- Conflict detection and resolution. +- Remote connection for local-only vaults. + +## History And Diffs + +Each note can show its own history and current diff, so you can understand how that file changed over time or what is unsaved relative to Git. + +Tolaria also shows a history of the whole vault. Use it when you want to review broader changes across multiple notes before committing or syncing. + +## Local Commits + +You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured. + +## Remotes + +Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work. + +--- + +# Inbox + +Source: concepts/inbox.md +URL: /concepts/inbox + +# Inbox + +The Inbox is for notes that have been captured but not yet organized. + +## Why It Exists + +Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later. + +The Inbox workflow is optional. Turn it off in Settings > Workflow if you prefer every note to appear organized by default. + +## Organizing Inbox Notes + +When reviewing the Inbox: + +1. Give the note a clear H1. +2. Set its `type`. +3. Add status, dates, or URL if useful. +4. Add relationships with wikilinks or frontmatter fields. +5. Move it into a folder only if the folder adds value. + +## Healthy Inbox Habit + +Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate. + +--- + +# Notes + +Source: concepts/notes.md +URL: /concepts/notes + +# Notes + +A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation. + +## Anatomy + +```md +--- +type: Project +status: Active +belongs_to: + - "[[workspace]]" +--- + +# Launch Documentation + +Draft the public Tolaria docs and keep them close to code changes. +``` + +## Titles + +The first H1 is the note title. Tolaria uses that title wherever the note is displayed: note lists, search results, wikilink suggestions, relationship pickers, tabs, and window titles. + +The title is separate from the filename. The filename stays visible in the breadcrumb so you can see the file on disk, and you can rename it independently when needed. + +Use the breadcrumb action to rename the file to match the title. New untitled notes can also auto-rename from the first H1 the first time they get a real title. Turn this behavior off in Settings > Vault Content > Titles & Filenames if you prefer filenames to stay unchanged until you rename them manually. + +## Body Links + +Use `[[wikilinks]]` to connect notes from the body. Tolaria shows autocomplete suggestions while you type, and links can resolve by filename or title. + +## Frontmatter + +Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body. + +Some notes can be displayed with specialized editors while keeping the same file-first model. A note with `_display: sheet` opens as a spreadsheet and stores its cells in a CSV-like body, while `type` remains available for organization. See [Spreadsheets](/concepts/spreadsheets). + +--- + +# Properties + +Source: concepts/properties.md +URL: /concepts/properties + +# Properties + +Properties are frontmatter fields that Tolaria can display, filter, and edit. + +## Suggested Properties + +Suggested properties are the fields Tolaria knows how to create quickly from the Properties panel. When a suggested property is missing, the panel shows a shortcut to add it with the right editor. + +| Field | Purpose | +| --- | --- | +| `type` | Groups the note into a type such as Project, Person, or Topic. | +| `status` | Tracks lifecycle state such as Active, Done, or Blocked. | +| `url` | Stores a canonical external link. | +| `date` | Represents a single date. | + +## System Properties + +Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing. + +Examples include `_icon`, `_color`, `_order`, `_sidebar_label`, `_width`, and `_pinned_properties` on type documents or notes. + +## Property Editing + +The Properties panel is the safest place to edit structured properties. Toggle it with `Cmd+Shift+I` on macOS or `Ctrl+Shift+I` on Windows and Linux. + +Date fields use Tolaria's picker, relationship fields can use wikilinks, and raw Markdown mode is available when you need direct control over YAML. + +--- + +# Relationships + +Source: concepts/relationships.md +URL: /concepts/relationships + +# Relationships + +Relationships make a vault feel like a graph instead of a pile of documents. + +## Relationship Fields + +Any frontmatter field containing wikilinks can become a relationship. Relationship fields can point to one note or to an array of notes. + +```yaml +belongs_to: + - "[[product-work]]" +related_to: + - "[[documentation]]" + - "[[editor-research]]" +blocked_by: + - "[[release-process]]" + - "[[sync-conflicts]]" +``` + +Tolaria supports default relationship fields out of the box: `belongs_to`, `has`, and `related_to`. It also detects custom relationship fields dynamically when they contain wikilinks. + +Default relationships have automatically computed inverses. If a note says it `belongs_to` a project, the project can show that note under its inverse `has` relationship without you writing the reverse link by hand. `related_to` works as a lateral relationship in both directions. + +These outgoing and inverse relationships appear in the Properties panel and in Neighborhood mode, where the note list becomes a graph view around the selected note. + +## Body Links Versus Relationship Fields + +Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, Neighborhood mode, or the Properties panel. + +## Backlinks + +Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context. + +--- + +# Spreadsheets + +Source: concepts/spreadsheets.md +URL: /concepts/spreadsheets + +# Spreadsheets + +Tolaria sheets are spreadsheet notes. They keep the same file-first model as other notes, but a note with `_display: sheet` opens in a spreadsheet editor instead of the rich text editor. The note's `type` remains available for organization. + +The durable file is still Markdown with YAML frontmatter. The body is CSV-like text containing cell inputs and formulas, and spreadsheet presentation state is stored as plain YAML under `_sheet`. + +## Read Next + +- [Use Spreadsheets](/guides/use-spreadsheets) for the editing workflow. +- [Spreadsheet File Format](/reference/spreadsheet-format) for the plain-text storage contract. +- [Spreadsheet Formulas](/reference/spreadsheet-functions) for formula syntax, autocomplete, and IronCalc function families. + +## Why Sheet Notes + +Sheets are useful when information is better modeled as rows, columns, and formulas than as prose. Examples include budgets, revenue models, inventories, editorial calendars, lightweight trackers, and analytical scratchpads. + +Tolaria does not store sheets as opaque workbook binaries. A sheet should remain: + +- readable in a text editor +- diffable in Git +- editable by humans and AI agents +- available offline +- connected to the rest of the vault through types, properties, relationships, and wikilinks + +## One Note, One Sheet + +A sheet note is a single sheet. Tolaria does not expose multiple tabs inside one note. + +When a model needs more than one table, create more than one sheet note and connect them with wikilinks or cross-sheet formulas. This keeps each file small, legible, and aligned with Tolaria's graph model. + +For example: + +- `newsletter-revenue.md` +- `sponsorship-pipeline.md` +- `refactoring-business-plan.md` + +Each can be a normal `_display: sheet` note, and formulas can reference cells in another sheet note with Tolaria's wikilink cell syntax. + +## Editing + +The interactive sheet editor is backed by IronCalc. Tolaria uses IronCalc for spreadsheet behavior and formula evaluation, then adapts the workbook back to Tolaria's plain-text note format. + +In the sheet editor: + +- cell inputs that start with `=` are formulas +- non-formula cells can contain normal text, numbers, dates, and `[[wikilinks]]` +- typing `[[` in a cell opens the same note autocomplete concept used elsewhere in Tolaria +- typing a formula function name opens inline formula autocomplete for the bundled IronCalc function catalog +- right-clicking a selection exposes core formatting controls such as number formats, decimal precision, bold, italic, and clear formatting + +Keyboard basics follow spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` with arrows extends the selection +- `Enter` starts editing the active cell +- `Escape` exits cell editing while keeping focus in the sheet +- `Delete` or `Backspace` clears the selected range +- copy and paste should preserve formulas, including Tolaria cross-sheet references + +## Wikilinks In Cells + +Wikilinks in non-formula cells are stored as normal Tolaria wikilinks: + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +The cell still behaves like a spreadsheet cell, but the value remains a vault link that Tolaria can understand. + +## Note Reference Formulas + +Tolaria adds a sheet-note reference syntax on top of IronCalc formulas: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=[[refactoring-business-plan]].$C$18 +``` + +The target before the dot is a normal Tolaria wikilink target. For another sheet note, the part after the dot is an A1-style cell address. + +Relative and absolute references work like spreadsheet references when copied: + +- `[[revenue]].B5` can shift when pasted to another cell +- `[[revenue]].$B$5` stays fixed +- `[[revenue]].B$5` fixes the row +- `[[revenue]].$B5` fixes the column + +This is not the same as an IronCalc workbook tab reference. It is Tolaria-specific syntax for referencing another sheet note in the vault. + +Current cross-sheet formulas resolve single cells. Ranges across sheet notes are not a stable file-format feature yet, so prefer composing them from explicit cell references or keeping range formulas inside the same sheet note. + +Sheet formulas can also read scalar frontmatter properties from a note: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +``` + +This keeps sheet models connected to ordinary Tolaria metadata without requiring a saved view or query. Unresolved, ambiguous, or non-scalar property references show spreadsheet errors. + +## Storage + +A minimal sheet note looks like this: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 + cells: + E6: + num_fmt: "0.00%" +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +Growth,,=(C5-B5)/B5,=(D5-C5)/C5,=(E5-B5)/B5 +``` + +Normal frontmatter stays normal Tolaria metadata. `_sheet` is system metadata for the spreadsheet editor and is hidden from normal property editing. + +For the full storage contract, see [Spreadsheet File Format](/reference/spreadsheet-format). + +## Formulas + +Tolaria delegates formula calculation to IronCalc. IronCalc aims for Excel-compatible formulas, while its project documentation still describes it as work in progress. For Tolaria-specific formula behavior and the autocomplete function catalog, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +--- + +# Types + +Source: concepts/types.md +URL: /concepts/types + +# Types + +Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create. + +## Type Field + +The `type:` field assigns a note to a type. + +```yaml +type: Project +``` + +Tolaria does not infer type from folder location. Moving a file into another folder does not change its type. + +## Prefer Types Over Folders + +Types are the preferred way to group notes in Tolaria. Folders are supported for existing vaults and fallback organization, but Tolaria is built around types and relationships because they carry stronger meaning than file paths. + +Use types for semantic groups such as Projects, People, Topics, Procedures, Events, and Essays. Use relationships to connect notes across those groups. This gives Tolaria better structure for navigation, filtering, properties, templates, and future automation than folder location alone. + +## Type Documents + +Type documents are Markdown notes with `type: Type` in frontmatter. They describe how a type should appear and what new notes of that type should start with. + +```yaml +--- +type: Type +_icon: folder +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +Type templates can live in the Type document's `template` frontmatter field. When a hand-edited Type body contains template-like structure after its own `# TypeName` heading, Tolaria also uses that body content as the new-note template. Plain descriptive body text stays documentation-only. + +## What Types Control + +- Sidebar grouping. +- Type icon and color. +- Sidebar order and label. +- Pinned properties. +- New-note templates. + +## New Note Defaults + +Type documents can define empty properties and relationships. When you create a new note of that type, Tolaria shows placeholders for those fields so you can fill them in from the Properties panel. + +If a type document gives a property a value, that value becomes the default for new notes of that type. For example, a Project type can define `status: Active` so every new project starts active until you change it. + +--- + +# Vaults + +Source: concepts/vaults.md +URL: /concepts/vaults + +# Vaults + +A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files. + +## Core Rules + +- Notes are Markdown files. +- YAML frontmatter provides structure. +- Attachments are normal files inside the vault. +- Type definitions and saved views are also files. +- Git can track history and support remote sync. + +## Why Local Files Matter + +Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git. + +Tolaria should never become the only way to read your data. + +## Git Is A Capability + +A plain folder of Markdown files can open as a vault. Git-backed vaults unlock history, changes, commits, pull, push, conflict handling, and remote setup. + +If a folder is not a Git repository, Tolaria can initialize Git when you explicitly ask it to. It avoids initializing broad personal folders such as Desktop, Documents, or Downloads unless they are clearly dedicated vault folders. + +## Multiple Vaults At The Same Time + +Tolaria can load multiple registered vaults into one unified graph. Enable this from `Settings` -> `Vaults` -> `Use multiple vaults at the same time`. + +After the option is enabled, open the bottom-left vault menu to include or exclude vaults from the graph. Included vaults appear together in note lists, search, quick open, backlinks, and wikilink navigation. Each note keeps a compact vault badge when Tolaria needs to disambiguate where it lives. + +The selected vault still matters. Git status, commits, sync, folder navigation, saved views, and vault repair actions stay scoped to the current repository. Use `Manage vaults` from the vault menu or the Vaults settings section to rename vaults, choose colors, and set the default destination for new notes. + +Cross-vault wikilinks use the target vault's stable alias when needed, for example `[[team/projects/alpha]]`. Links inside the same vault stay normal vault-relative links. + +## App State Versus Vault State + +Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation. + +| Vault state | App state | +| --- | --- | +| Type icons and colors | Editor zoom | +| Saved views | Window size | +| Pinned properties | Recent vault list | +| Relationship conventions | Local cache | +| Vault AI guidance files | AI target selection | + +--- + +# Build Custom Views + +Source: guides/build-custom-views.md +URL: /guides/build-custom-views + +# Build Custom Views + +Custom views are saved filters for recurring questions. + +## Good View Candidates + +- Active projects. +- People without a recent follow-up. +- Drafts ready for review. +- Notes changed this week. +- Events in a date range. + +## View Definition + +Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data. + +## Filters + +Custom views can use nested conditions, similar to Notion or Airtable filter groups. Combine `all` and `any` logic when a view needs to answer a more precise question than a single field filter can express. + +Date filters support dynamic natural-language values such as `today`, `yesterday`, or `one week ago`. Use these for views that should keep moving over time, such as recent work, stale follow-ups, or upcoming events. + +## Design The Question First + +Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens. + +--- + +# Capture A Note + +Source: guides/capture-a-note.md +URL: /guides/capture-a-note + +# Capture A Note + +Use capture when you need to get an idea into the vault before you know where it belongs. + +## Steps + +1. Press `Cmd+N` on macOS or `Ctrl+N` on Windows and Linux. +2. Write a clear H1. +3. Add the rough content. +4. Leave structure for later if you are still thinking. + +## Capture Well + +Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review. + +## When To Add Structure Immediately + +Add structure while capturing when the note's type or relationships are already obvious. Otherwise, capture the idea first and organize it later. + +--- + +# Manage Git Manually Or With AutoGit + +Source: guides/commit-and-push.md +URL: /guides/commit-and-push + +# Manage Git Manually Or With AutoGit + +Tolaria can act as a lightweight Git client for a Git-enabled vault. You can manage commits and pushes yourself, or enable AutoGit to create conservative checkpoints after editing pauses or when the app is no longer active. + +## Manual Git + +1. Open the Git or changes surface. +2. Review changed files. +3. Write a short commit message. +4. Commit locally. +5. Push when a remote is configured. + +If the remote has changed, pull first and resolve any conflicts. If the vault has no remote, manual commits still give you local history, diffs, and rollback. + +## AutoGit + +AutoGit is available in Settings for Git-enabled vaults. When enabled, Tolaria automatically commits and pushes saved local changes after an idle pause or after the app becomes inactive. + +Use AutoGit when you want the safety of regular checkpoints without interrupting capture or editing. You can still inspect each note's current diff, review note history, and browse the whole-vault history before making larger manual commits. + +## Use Small Commits + +Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits. + +--- + +# Configure AI Models + +Source: guides/configure-ai-models.md +URL: /guides/configure-ai-models + +# Configure AI Models + +Use model providers when you want chat over note context without giving an agent vault-write tools. + +## Local Models + +Local model targets are for tools such as Ollama and LM Studio. They usually need a base URL and model ID, and they usually do not need an API key. + +## API Models + +API model targets are for hosted providers such as OpenAI, Anthropic, Gemini, OpenRouter, or another OpenAI-compatible endpoint. + +Tolaria does not store provider API keys in vault settings. Choose one of the supported key paths: + +- Save the key locally on this device. +- Read the key from an environment variable. +- Use no key for local providers that do not require one. + +## Test The Connection + +After adding a provider, use the test action in Settings. A successful test means Tolaria reached the endpoint and the model replied. + +## Select The Target + +Once configured, choose the model from the AI target selector or set it as the default AI target in Settings. + +--- + +# Connect A Git Remote + +Source: guides/connect-a-git-remote.md +URL: /guides/connect-a-git-remote + +# Connect A Git Remote + +Connect a remote when you want backup or sync beyond the current machine. + +## Before You Start + +Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials. + +## Steps + +1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette. +2. Paste the remote URL. +3. Confirm the remote name. +4. Fetch or push according to the app prompt. + +## Recommended Auth + +- SSH keys. +- GitHub CLI authentication. +- Existing Git credential helpers. +- macOS Keychain credentials for HTTPS remotes on macOS. + +If authentication fails, see [Git Authentication](/troubleshooting/git-auth). + +--- + +# Create Types + +Source: guides/create-types.md +URL: /guides/create-types + +# Create Types + +Create a type when several notes share the same role in your system. + +## Steps + +1. Run `New Type` from the command palette, or click `+` in the Types header in the sidebar. +2. Give the type a clear name. +3. Add optional icon, color, sidebar order, sidebar label, pinned properties, suggested fields, default values, or a new-note template. + +You can also right-click a type in the sidebar to change its icon and color. + +```yaml +--- +type: Type +_icon: briefcase +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +## Use Types Sparingly + +A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead. + +## Templates + +Type documents can include a Markdown template for new notes of that type. Keep templates small and useful: a heading, a few expected fields, and the first checklist are usually enough. + +You can store the template in the Type document's `template` frontmatter field. When hand-editing the Type document body, content after the Type note's own `# TypeName` heading is also used as the new-note template if it looks like template structure such as field labels, secondary headings, or checklist starters. Plain descriptive body text is ignored. + +Type documents can also define fields for new notes. Empty properties and relationships become placeholders in new notes of that type. Properties with values become defaults for new notes of that type. + +--- + +# Manage Display Preferences + +Source: guides/manage-display-preferences.md +URL: /guides/manage-display-preferences + +# Manage Display Preferences + +Display preferences live in local app settings unless a setting is intentionally stored in the note or vault. + +## Theme + +Choose Light, Dark, or System in Settings. System follows the operating system appearance at runtime. + +You can also switch theme mode from the command palette. + +## Note Width + +Set the default rich-editor width in Settings: + +- **Normal** for focused writing. +- **Wide** for tables, diagrams, dense notes, and generated documents. + +An individual note can override the default width from the editor toolbar. That override is stored as `_width` in the note frontmatter. + +## Sidebar Labels + +Tolaria can pluralize type names in the sidebar. Turn this off in Settings if your type names should be shown exactly as written, or use `_sidebar_label` on a type document for an explicit label. + +## Vault Content + +Settings also control whether Gitignored files and non-Markdown file categories are visible in the app. Use these controls to keep generated or local-only files out of regular note workflows. + +--- + +# Organize The Inbox + +Source: guides/organize-inbox.md +URL: /guides/organize-inbox + +# Organize The Inbox + +Inbox review turns quick captures into usable knowledge. + +## Remove A Note From Inbox + +When a note is organized enough, mark it as organized. Use `Cmd+E` on macOS or `Ctrl+E` on Windows and Linux, or click the organize action in the breadcrumb bar. + +That action is what removes the note from Inbox. If auto-advance is enabled in Settings > Workflow, Tolaria opens the next Inbox item immediately after you mark the current note organized. + +## Review Checklist + +- Rename unclear notes. +- Add or correct the first H1. +- Set `type`. +- Add `status` for actionable notes. +- Add `belongs_to`, `related_to`, or other relationship fields when useful. +- Archive or delete notes that no longer matter. + +## Make Notes Navigable + +A note is organized when you can answer: + +- What kind of thing is this? +- What is it connected to? +- What is this useful for? +- What will I do with it? + +## Avoid Over-Structuring + +Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation. + +--- + +# Use The AI + +Source: guides/use-ai-panel.md +URL: /guides/use-ai-panel + +# Use The AI + +Tolaria gives you two ways to ask for AI help: open the AI panel for an ongoing conversation, or prompt directly from the editor with `Cmd+K` followed by a space. + +## Choose How To Prompt + +- **AI panel** is best for longer conversations, agent work, and requests that need visible back-and-forth. +- **Inline prompt** is best when you are already writing. Press `Cmd+K`, type a space, then write the prompt you want the AI to handle from the current note context. + +## Choose A Target + +Open Settings and choose the default AI target: + +- **Coding agent** for tool-backed vault editing through Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. +- **Local model** for Ollama or LM Studio chat over note context. +- **API model** for OpenAI, Anthropic, Gemini, OpenRouter, or an OpenAI-compatible endpoint. + +If a coding agent is missing, install it and reopen Tolaria or switch to another target. + +## Permission Mode + +Coding agents support per-vault permission modes: + +- **Vault Safe** keeps agents limited to file, search, and edit tools. +- **Power User** can allow shell commands for agents that support them. + +Direct model targets always stay in chat mode. They can use note context, but they cannot edit vault files through tools. + +## Good Requests + +- "Find notes related to this project." +- "Summarize what changed in this note." +- "Draft a weekly review from these linked notes." +- "Update this checklist based on the current project status." + +## Review Changes + +AI edits are file edits. Review them with Tolaria's diff and Git history before committing. + +--- + +# Use The Command Palette + +Source: guides/use-command-palette.md +URL: /guides/use-command-palette + +# Use The Command Palette + +The command palette is the fastest way to move around Tolaria. + +Open it with: + +- `Cmd+K` on macOS. +- `Ctrl+K` on Linux and Windows. + +## Common Commands + +- New Note. +- Search. +- Open Settings. +- Reload Vault. +- Add Remote. +- Open Getting Started Vault. +- Toggle Raw Mode. +- Toggle Table of Contents. +- Toggle AI Panel. +- Use Light, Dark, or System theme. +- Open in New Window. + +## Keyboard-First Workflow + +Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows. + +--- + +# Use Media Previews + +Source: guides/use-media-previews.md +URL: /guides/use-media-previews + +# Use Media Previews + +Media previews let you inspect vault files without leaving Tolaria. + +## Open A File + +Select an image, PDF, media file, or unsupported file from a folder or file list. Tolaria opens supported files in the app and offers an external-open action for files that should use the system default app. + +## All Notes Visibility + +Open Settings to choose whether non-Markdown files appear in All Notes: + +- PDFs. +- Images. +- Unsupported files. + +Folder browsing still shows files in their folders even when a category is hidden from All Notes. + +## Attachments + +When you paste or drop an image into a note, Tolaria copies it into the vault and references the copied file from Markdown. + +## Troubleshooting + +If a preview does not render, open the file in the default app to confirm the file is valid, then check whether the file is inside the active vault and not blocked by operating-system permissions. + +--- + +# Use Spreadsheets + +Source: guides/use-spreadsheets.md +URL: /guides/use-spreadsheets + +# Use Spreadsheets + +Tolaria spreadsheets are sheet notes: Markdown files with frontmatter and a CSV-like body that open in a spreadsheet editor when their `Display as` value is `Sheet`. + +Use a sheet note when a model needs rows, columns, calculations, or repeated numeric editing. Use a normal note when the main artifact is prose. + +## Create A Sheet + +Use the command palette action `New Sheet`, or create/open a note and set its `Display as` to `Sheet` from the Properties panel. `Type` remains separate and can still be `Note`, `Project`, `Responsibility`, or any other Tolaria type. + +When a note is a sheet: + +- the YAML frontmatter remains available for type, status, relationships, wikilinks, and custom properties +- `_display: sheet` tells Tolaria to display the note with the spreadsheet editor +- the body is the sheet itself +- there is no rich-text body around the table +- the editor switches from the text editor to the spreadsheet editor + +## Enter Values + +Click a cell and type a value. Non-formula values can be text, numbers, dates, or wikilinks. + +Press `Enter` on a selected cell to edit the cell. Press `Escape` while editing to leave cell editing and keep focus in the sheet. + +Use `Delete` or `Backspace` to clear the selected cell or range. + +## Enter Formulas + +Formulas start with `=`. + +```txt +=B2+B3-B4 +=SUM(B2:D2) +=ROUND(E6, 2) +=IF(E6>0, "Up", "Down") +``` + +Tolaria shows inline formula autocomplete while you type. The autocomplete list is built from the implemented function catalog in the bundled IronCalc engine; formula evaluation is still handled by IronCalc. + +See [Spreadsheet Formulas](/reference/spreadsheet-functions) for syntax, supported examples, and links to the full IronCalc formula reference. + +## Select And Edit Ranges + +The sheet editor follows spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` plus arrow keys extends the selection +- drag to select a range +- copy and paste preserves formulas where possible +- cut and paste moves formulas and shifts relative references +- right-click a selected cell or range to apply formatting + +Right-click actions apply to the current selection. Keep a multi-cell selection active before opening the context menu when you want to format several cells together. + +## Format Cells + +Use the context menu for common formatting: + +- number formats such as plain numbers, currency, and percentages +- decimal precision +- bold and italic text +- alignment and clearing formatting when available + +Formatting is stored as plain YAML under `_sheet`, not in an opaque workbook blob. For example, percentage formatting for `E6` is stored as: + +```yaml +_sheet: + cells: + E6: + num_fmt: "0.00%" +``` + +See [Spreadsheet File Format](/reference/spreadsheet-format) for the full storage model. + +## Add Wikilinks + +Type `[[` in a cell to open note autocomplete. + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +When the cell is not being edited, Tolaria renders the wikilink like other note links. When you edit the cell, the raw `[[wikilink]]` syntax is shown again. + +Command-click a wikilink in a sheet cell to open the linked note. + +## Reference Another Note + +Formulas can read a cell from another sheet note with Tolaria's wikilink cell syntax: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The part inside `[[...]]` resolves like a normal Tolaria wikilink. The part after the dot is an A1-style cell reference. + +Use absolute markers when copying formulas: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet references currently resolve single cells. Keep range formulas inside one sheet note. + +Formulas can read scalar frontmatter properties from a note with dot notation: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +Numbers, booleans, and text properties can be used in formulas. Missing or ambiguous note targets, missing properties, and non-scalar values such as lists or nested objects show as spreadsheet errors. + +## Work With The Raw File + +A sheet file remains readable text: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +``` + +When editing this file with scripts or AI agents, parse the body as CSV and preserve formulas as formulas. Do not replace formulas with displayed values. + +--- + +# Use The Table Of Contents + +Source: guides/use-table-of-contents.md +URL: /guides/use-table-of-contents + +# Use The Table Of Contents + +The table of contents panel helps you navigate long notes by heading. + +## Open It + +Use the editor toolbar, the command palette, or the shortcut: + +- `Cmd+Shift+T` on macOS. +- `Ctrl+Shift+T` on Windows and Linux. + +## How It Works + +Tolaria builds the outline from the current note's headings. The panel updates as the note changes and can jump to sections in the editor. + +## Good Uses + +- Long procedures. +- Meeting notes with many sections. +- Research notes. +- Generated documents that need review. + +If a note has no useful headings, add clear H2 and H3 sections rather than relying on a long uninterrupted document. + +--- + +# Use Wikilinks + +Source: guides/use-wikilinks.md +URL: /guides/use-wikilinks + +# Use Wikilinks + +Wikilinks connect notes by name. + +```md +This project belongs to [[content-systems]] and is related to [[git-workflows]]. +``` + +## Link From The Body + +Use body links when the connection is part of the sentence you are writing. + +## Link From Frontmatter + +Use frontmatter links when the relationship should become structured metadata. + +```yaml +related_to: + - "[[git-workflows]]" +``` + +## Keep Links Stable + +Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps you pick the right target while you type. + +--- + +# Portent + +Source: templates/portent.md +URL: /templates/portent + +# Portent + +[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases. + +It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first. + +## Core Questions + +Portent favors convention over configuration. Instead of asking "where should this go?", it asks: + +- What is this? +- What is it useful for? +- Is it captured, organized, or archived? + +Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views. + +## Types + +Portent defines eight default types. + +PORT types are actionable: + +- Project +- Operation +- Responsibility +- Task + +ENTP types are non-actionable knowledge records: + +- Event +- Note +- Topic +- Person + +These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first. + +## Relationships + +Portent models knowledge as a graph. The two default relationships are: + +- `belongs_to`: primary ownership, composition, or context. +- `related_to`: a looser semantic connection. + +In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app. + +## Lifecycle + +Portent separates capture from organization: + +1. Capture information quickly so it is not lost. +2. Organize it by assigning a type and useful relationships. +3. Archive it when it has served its purpose. + +Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available. + +## Why Use It + +A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately. + +Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals. + +## Start From The Template + +The fastest starting point is the Portent template vault: + +- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template) + +Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup. + +## Learn More + +Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes. + +--- + +# Contribute + +Source: reference/contribute.md +URL: /reference/contribute + +# Contribute + +Tolaria is free and open source, and any kind of help is useful. Pick the path that matches what you want to do. + +## Newsletter + +[Refactoring](https://refactoring.fm/) is Luca's newsletter and community for engineers building better teams and software with AI. Subscribing is the best way to support Tolaria. + +## Sponsors + +Tolaria is supported by a panel of tools Luca uses every day to keep the project healthy, tested, and ready for AI-assisted development: + +- [Codacy](https://www.codacy.com/) +- [CodeScene](https://codescene.com/) +- [CircleCI](https://circleci.com/) +- [Unblocked](https://getunblocked.com/) + +## Feature Requests + +Use the [product board](https://tolaria.canny.io/) for feature ideas. Search first, upvote existing ideas, and create a new post when the request is genuinely new. + +## Discussions + +Use [GitHub Discussions](https://github.com/refactoringhq/tolaria/discussions) for questions, conversations, show and tell, and broader community context. + +## Contribute Code + +Small, focused pull requests are welcome. Check the product board first so you build the right thing, then open a PR on [GitHub](https://github.com/refactoringhq/tolaria/pulls). The [contributing guide](https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md) explains the local workflow. + +## Report A Bug + +Use [GitHub Issues](https://github.com/refactoringhq/tolaria/issues) for bugs. Include what happened, what you expected, and clear reproduction steps. If you are reporting from inside Tolaria, use the Contribute panel to copy sanitized diagnostics and attach them to the issue. + +--- + +# Docs Maintenance + +Source: reference/docs-maintenance.md +URL: /reference/docs-maintenance + +# Docs Maintenance + +The public docs live in the app repo so documentation changes can ship with behavior changes. + +## Update Docs When You Change + +- A Tauri command. +- A new component or hook that changes user behavior. +- A data model or frontmatter convention. +- Git, AI, onboarding, or release behavior. +- Public release pages, download metadata, or updater channels. +- Platform support. +- Keyboard shortcuts. + +## Suggested Workflow + +1. Make the code change. +2. Update the matching concept, guide, or reference page. +3. Add a troubleshooting page if the change creates a new failure mode. +4. Run `pnpm docs:build`. +5. Check the home page, search, release/download links, and changed docs pages in a browser. + +## Page Types + +| Type | Purpose | +| --- | --- | +| Start | Helps a new user get into the app. | +| Concepts | Explains mental models. | +| Guides | Teaches workflows. | +| Reference | Gives stable facts and tables. | +| Troubleshooting | Starts from a symptom and ends with recovery. | + +## Review Checklist + +- Does the page describe current behavior? +- Does it mention macOS primary and Windows/Linux supported-early status when platform support matters? +- Are links relative and VitePress-compatible? +- Can a user discover the page with local search? + +--- + +# File Layout + +Source: reference/file-layout.md +URL: /reference/file-layout + +# File Layout + +Tolaria is not opinionated about folder structure. It finds notes recursively across the whole vault, stores new notes in the root by default, and uses types and relationships for real organization. + +```txt +my-vault/ + project-alpha.md + weekly-review.md + research/ + source-notes.md + attachments/ + diagram.png + source.pdf + project.md + person.md + views/ + active-projects.yml +``` + +## Root Notes + +Tolaria works well with a flat vault. Folders are optional and can be useful for compatibility with other tools, but they are not required for people, projects, topics, or any other note category. + +Type is not inferred from folder location. It comes from frontmatter, and relationships are expressed with wikilinks in fields. That is what Tolaria uses for the sidebar, Properties panel, search, custom views, and neighborhood navigation. + +## Special Folders + +| Folder | Purpose | +| --- | --- | +| `views/` | Saved custom views. | +| `attachments/` | Images and other attached files. | + +PDFs, images, and other non-Markdown files stay as normal files. Folder browsing can show them in place, and Settings controls whether PDFs, images, and unsupported files appear in All Notes. + +Whiteboards are Markdown files with durable tldraw data, so they belong with notes rather than in `attachments/`. + +Spreadsheets are also Markdown files. A note with `_display: sheet` stores ordinary frontmatter plus a CSV-like body and opens in the sheet editor. + +Type definitions are Markdown notes with `type: Type` in frontmatter. New type documents are normal notes, and existing type documents in older folders still work. + +## Git Files + +If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes. + +--- + +# Frontmatter Fields + +Source: reference/frontmatter-fields.md +URL: /reference/frontmatter-fields + +# Frontmatter Fields + +Tolaria uses conventions instead of a required schema. + +| Field | Meaning | +| --- | --- | +| `type` | The note's entity type. | +| `status` | Lifecycle state. | +| `icon` | Per-note icon. | +| `url` | External URL. | +| `date` | Single date. | +| `belongs_to` | Parent relationship. | +| `related_to` | Lateral relationship. | +| `has` | Contained relationship. | +| `_width` | Per-note editor width override. | +| `_display` | Display mode. Omit for text notes; use `sheet` for spreadsheet notes. | +| `_icon`, `_color` | Type or note appearance metadata. | +| `_sidebar_label`, `_order` | Type sidebar label and order. | +| `_pinned_properties` | Properties pinned for a type. | +| `_sheet` | Sheet-note presentation metadata such as grid settings, column widths, row heights, and cell formatting. | + +## Custom Fields + +You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship. + +## System Fields + +Fields starting with `_` are reserved for system behavior and hidden from standard property editing. They remain plain YAML, so they can still be inspected or changed in raw mode when needed. + +Nested keys under a system field are also system-owned. For example, `_sheet.cells.B6.num_fmt` belongs to the sheet editor and should not appear as a normal user property. + +--- + +# Keyboard Shortcuts + +Source: reference/keyboard-shortcuts.md +URL: /reference/keyboard-shortcuts + +# Keyboard Shortcuts + +| Shortcut | Action | +| --- | --- | +| `Cmd+K` / `Ctrl+K` | Open command palette. | +| `Cmd+P` / `Ctrl+P` | Quick open notes and files. | +| `Cmd+N` / `Ctrl+N` | Create a new note. | +| `Cmd+S` / `Ctrl+S` | Save current note. | +| `Cmd+F` / `Ctrl+F` | Find in the current note. | +| `Cmd+Shift+F` / `Ctrl+Shift+F` | Search the vault. | +| `Cmd+Shift+V` / `Ctrl+Shift+V` | Paste without formatting. | +| `Cmd+\` / `Ctrl+\` | Toggle raw Markdown mode. | +| `Cmd+Shift+T` / `Ctrl+Shift+T` | Toggle table of contents. | +| `Cmd+Shift+I` / `Ctrl+Shift+I` | Toggle Properties panel. | +| `Cmd+Shift+L` / `Ctrl+Shift+L` | Toggle AI panel. | +| `Cmd+[` / `Alt+Left` | Navigate back when available. | +| `Cmd+]` / `Alt+Right` | Navigate forward when available. | +| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. | +| `Cmd+D` / `Ctrl+D` | Toggle favorite for the current note. | +| `Cmd+E` / `Ctrl+E` | Mark the current Inbox note organized. | + +Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations. + +Use the command palette to discover the current command set. + +--- + +# Release Channels + +Source: reference/release-channels.md +URL: /reference/release-channels + +# Release Channels + +Tolaria publishes Stable and Alpha release metadata to GitHub Pages. + +## Stable + +Stable follows manually promoted releases. This is the right channel for normal use. + +The stable updater metadata lives at: + +```txt +/stable/latest.json +``` + +The public download page points at the latest stable release. + +## Alpha + +Alpha follows pushes to `main`. It receives fixes and features earlier, but it can be rougher than Stable. + +The alpha updater metadata lives at: + +```txt +/alpha/latest.json +``` + +Compatibility endpoints also point to the alpha metadata: + +```txt +/latest.json +/latest-canary.json +``` + +## Before Switching + +Commit or push important vault changes before changing release channel or installing an update. Your notes are local files, but a clean Git state makes recovery simpler. + +--- + +# Spreadsheet File Format + +Source: reference/spreadsheet-format.md +URL: /reference/spreadsheet-format + +# Spreadsheet File Format + +Sheet notes are Markdown files with YAML frontmatter and a CSV-like body. The note uses `_display: sheet` when it should open in the spreadsheet editor. The `type` field remains ordinary semantic metadata. + +For editing workflows, see [Use Spreadsheets](/guides/use-spreadsheets). For formula syntax and function references, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +## Structure + +```md +--- +type: Project +_display: sheet +tags: + - planning +_sheet: + show_grid_lines: true + frozen_rows: 1 + frozen_columns: 1 + columns: + A: + width: 180 + rows: + "1": + height: 32 + cells: + E6: + num_fmt: "0.00%" + bold: true +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Expenses,650,700,760,=SUM(B3:D3) +Net,=B2-B3,=C2-C3,=D2-D3,=SUM(B4:D4) +Growth,,=(C4-B4)/B4,=(D4-C4)/C4,=(E4-B4)/B4 +``` + +The frontmatter stores note metadata. The body stores rows and cells. There is no Markdown table wrapper, fenced code block, or embedded workbook blob. + +## Frontmatter + +All ordinary Tolaria fields remain available: + +- `type` +- `status` +- `date` +- `tags` +- `url` +- relationship fields such as `belongs_to`, `related_to`, and custom wikilink properties + +The `_display: sheet` field is the display-as marker. Omit it for ordinary text notes. + +The `_sheet` key is reserved for spreadsheet presentation metadata. It follows the same system-field convention as other underscore-prefixed Tolaria fields: hidden from normal property editing, but visible and editable in raw source. + +## Body + +The body is CSV-like text: + +- rows are separated by line breaks +- cells are separated by commas +- cells containing commas, quotes, or line breaks are quoted +- quotes inside quoted cells are escaped by doubling them +- empty trailing rows and columns may be omitted on save + +Any cell whose input starts with `=` is treated as a formula. Other cells are treated as literal values. + +## `_sheet` Metadata + +Tolaria stores spreadsheet presentation state in `_sheet` as plain YAML. + +| Field | Meaning | +| --- | --- | +| `show_grid_lines` | Whether grid lines are shown. | +| `frozen_rows` | Number of frozen rows from the top. | +| `frozen_columns` | Number of frozen columns from the left. | +| `columns..width` | Custom column width, keyed by column letter such as `A` or `BC`. | +| `rows."".height` | Custom row height, keyed by one-based row number. | +| `cells..num_fmt` | Number format code for a cell. | +| `cells..bold` | Bold text style. | +| `cells..italic` | Italic text style. | +| `cells..underline` | Underline text style. | +| `cells..strike` | Strikethrough text style. | +| `cells..font_size` | Font size. | +| `cells..font_color` | Text color. | +| `cells..fill_color` | Cell fill color. | +| `cells..horizontal_align` | Horizontal alignment. | +| `cells..vertical_align` | Vertical alignment. | +| `cells..wrap_text` | Text wrapping. | +| `cells..border_top` | Top border style. | +| `cells..border_right` | Right border style. | +| `cells..border_bottom` | Bottom border style. | +| `cells..border_left` | Left border style. | + +Cell metadata is keyed by A1-style cell addresses such as `A1`, `B12`, or `AA30`. + +Border values are stored as a style name with an optional color, for example: + +```yaml +border_bottom: "thin #d0d7de" +``` + +## Number Formats + +Number formats are stored in `num_fmt` using spreadsheet-style format codes. Common examples: + +| Format | Example output | +| --- | --- | +| `#,##0` | `1,250` | +| `#,##0.00` | `1,250.50` | +| `0.00%` | `12.35%` | +| `$#,##0.00` | `$1,250.50` | +| `yyyy-mm-dd` | `2026-06-15` | + +These formats affect presentation, not the underlying cell input in the CSV body. + +## Markdown Style Import + +When Tolaria imports a non-formula CSV cell, simple Markdown wrappers can seed initial styles: + +| Cell text | Stored value | Style | +| --- | --- | --- | +| `**Revenue**` | `Revenue` | bold | +| `_Estimate_` | `Estimate` | italic | +| `***Total***` | `Total` | bold and italic | +| `~~Removed~~` | `Removed` | strike | + +After save, the style belongs in `_sheet` metadata and the body keeps the unwrapped text. + +## Wikilinks + +Non-formula cells can store normal Tolaria wikilinks: + +```csv +Account,Source +Newsletter,[[newsletter-revenue]] +Sponsors,[[sponsorship-pipeline]] +``` + +Formula cells can reference another sheet note with Tolaria's cross-sheet syntax: + +```txt +=[[newsletter-revenue]].B5 +=ROUND([[business-plan]].$E$12, 2) +=[[device]].power.watts +``` + +Cross-sheet cell references resolve another sheet note by wikilink target, then read a single A1-style cell. Frontmatter references resolve one note by wikilink target, then read a scalar property path after the dot. Missing, ambiguous, circular, very deep, or non-scalar references are treated as unresolved and surface as spreadsheet errors. + +## Guidance For Agents And Scripts + +When editing a sheet note programmatically: + +- preserve the YAML frontmatter delimiter and ordinary Tolaria fields +- keep `_display: sheet` when the file should display as a spreadsheet +- keep spreadsheet presentation state under `_sheet` +- parse and serialize the body as CSV, not by splitting on every comma manually +- preserve formulas as formulas, including `[[sheet]].A1` and `[[note]].property.path` references +- avoid converting formulas to their displayed values +- quote CSV cells when they contain commas, quotes, or line breaks +- do not add workbook tabs inside one note; create another note with `_display: sheet` instead +- do not store opaque binary workbook state in the Markdown file + +If a script cannot safely preserve `_sheet`, it should leave that block untouched and edit only the CSV body cells it understands. + +--- + +# Spreadsheet Formulas + +Source: reference/spreadsheet-functions.md +URL: /reference/spreadsheet-functions + +# Spreadsheet Formulas + +Formula cells start with `=` and are evaluated by IronCalc through Tolaria's sheet editor. + +Tolaria adds vault-aware sheet references on top of the normal spreadsheet formula model. Everything else should be treated as IronCalc formula behavior. IronCalc aims for Excel-compatible formulas, but the upstream project is still evolving, so verify advanced formulas against the IronCalc docs when precision matters. + +## Basic Syntax + +| Syntax | Meaning | +| --- | --- | +| `=B2+B3-B4` | Arithmetic over cells. | +| `=SUM(B2:D2)` | Function call over a range. | +| `=ROUND(E6, 2)` | Function call with arguments. | +| `=IF(E6>0, "Up", "Down")` | Conditional expression. | +| `=$B$2` | Absolute column and row reference. | +| `=B$2` | Relative column, absolute row. | +| `=$B2` | Absolute column, relative row. | +| `=B2:D10` | A range in the current sheet note. | +| `="Q" & 1` | Text concatenation. | + +Use parentheses when a model depends on precedence: + +```txt +=(B2+B3-B4)/B5 +``` + +## Tolaria Note References + +Tolaria supports wikilink cell references for values that live in another sheet note: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The target inside `[[...]]` resolves like a normal Tolaria wikilink. The cell address after the dot uses A1 notation. + +Absolute markers follow spreadsheet copy behavior: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet cell references currently resolve single cells. Keep range formulas inside one sheet note until cross-note ranges are explicitly supported. + +Formulas can also read scalar frontmatter properties from a specific note: + +```txt +=[[device.md]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +The target resolves like a wikilink, and the dot path reads nested frontmatter keys. Numbers, booleans, and strings become formula literals. Missing notes, ambiguous note targets, missing properties, arrays, maps, and other non-scalar values resolve to `#N/A`. + +A first segment that looks like an A1 cell address, such as `B2`, is treated as a cross-sheet cell reference. Use property names that do not collide with A1 notation for frontmatter formulas. + +## Autocomplete Functions + +Tolaria's formula autocomplete exposes the implemented function catalog from the bundled IronCalc engine. The current catalog has 195 functions. + +The dropdown shows a small ranked set of matches while you type. Keep typing to narrow the result list. Function names with digits and dots, such as `BIN2DEC` and `ERFC.PRECISE`, are supported. + +### Logical + +`AND`, `FALSE`, `IF`, `IFERROR`, `IFNA`, `IFS`, `NOT`, `OR`, `SWITCH`, `TRUE`, `XOR` + +### Math and trigonometry + +`ABS`, `ACOS`, `ACOSH`, `ASIN`, `ASINH`, `ATAN`, `ATAN2`, `ATANH`, `COS`, `COSH`, `PI`, `POWER`, `PRODUCT`, `RAND`, `RANDBETWEEN`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`, `SIN`, `SINH`, `SQRT`, `SQRTPI`, `SUM`, `SUMIF`, `SUMIFS`, `TAN`, `TANH`, `SUBTOTAL` + +### Lookup and reference + +`CHOOSE`, `COLUMN`, `COLUMNS`, `HLOOKUP`, `INDEX`, `INDIRECT`, `LOOKUP`, `MATCH`, `OFFSET`, `ROW`, `ROWS`, `VLOOKUP`, `XLOOKUP` + +### Text + +`CONCAT`, `CONCATENATE`, `EXACT`, `FIND`, `LEFT`, `LEN`, `LOWER`, `MID`, `REPT`, `RIGHT`, `SEARCH`, `SUBSTITUTE`, `T`, `TEXT`, `TEXTAFTER`, `TEXTBEFORE`, `TEXTJOIN`, `TRIM`, `UNICODE`, `UPPER`, `VALUE`, `VALUETOTEXT` + +### Information + +`ERROR.TYPE`, `FORMULATEXT`, `ISBLANK`, `ISERR`, `ISERROR`, `ISEVEN`, `ISFORMULA`, `ISLOGICAL`, `ISNA`, `ISNONTEXT`, `ISNUMBER`, `ISODD`, `ISREF`, `ISTEXT`, `NA`, `SHEET`, `TYPE` + +### Statistical + +`AVERAGE`, `AVERAGEA`, `AVERAGEIF`, `AVERAGEIFS`, `COUNT`, `COUNTA`, `COUNTBLANK`, `COUNTIF`, `COUNTIFS`, `GEOMEAN`, `MAX`, `MAXIFS`, `MIN`, `MINIFS` + +### Date and time + +`DATE`, `DAY`, `EDATE`, `EOMONTH`, `MONTH`, `NOW`, `TODAY`, `YEAR` + +### Financial + +`CUMIPMT`, `CUMPRINC`, `DB`, `DDB`, `DOLLARDE`, `DOLLARFR`, `EFFECT`, `FV`, `IPMT`, `IRR`, `ISPMT`, `MIRR`, `NOMINAL`, `NPER`, `NPV`, `PDURATION`, `PMT`, `PPMT`, `PV`, `RATE`, `RRI`, `SLN`, `SYD`, `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD`, `XIRR`, `XNPV` + +### Engineering + +`BESSELI`, `BESSELJ`, `BESSELK`, `BESSELY`, `BIN2DEC`, `BIN2HEX`, `BIN2OCT`, `BITAND`, `BITLSHIFT`, `BITOR`, `BITRSHIFT`, `BITXOR`, `COMPLEX`, `CONVERT`, `DEC2BIN`, `DEC2HEX`, `DEC2OCT`, `DELTA`, `ERF`, `ERF.PRECISE`, `ERFC`, `ERFC.PRECISE`, `GESTEP`, `HEX2BIN`, `HEX2DEC`, `HEX2OCT`, `IMABS`, `IMAGINARY`, `IMARGUMENT`, `IMCONJUGATE`, `IMCOS`, `IMCOSH`, `IMCOT`, `IMCSC`, `IMCSCH`, `IMDIV`, `IMEXP`, `IMLN`, `IMLOG10`, `IMLOG2`, `IMPOWER`, `IMPRODUCT`, `IMREAL`, `IMSEC`, `IMSECH`, `IMSIN`, `IMSINH`, `IMSQRT`, `IMSUB`, `IMSUM`, `IMTAN`, `OCT2BIN`, `OCT2DEC`, `OCT2HEX` + +## Examples + +### Totals + +```txt +=SUM(B2:D2) +=B2+B3-B4 +=SUM(B2:D2)-SUM(B4:D4) +``` + +### Growth And Percentages + +```txt +=(C5-B5)/B5 +=IF(B5=0, 0, (C5-B5)/B5) +=ROUND((C5-B5)/B5, 4) +``` + +Format the result as a percentage with a cell `num_fmt` such as `0.00%`. + +### Conditional Logic + +```txt +=IF(E5>10000, "On track", "Review") +=IFS(E5>10000, "High", E5>5000, "Medium", TRUE, "Low") +=IFERROR(B5/B4, 0) +``` + +### Dates + +```txt +=TODAY() +=DATE(2026, 6, 15) +=YEAR(TODAY()) +=MONTH(TODAY()) +``` + +### Text + +```txt +=CONCAT(A2, " - ", B2) +=UPPER(A2) +=TRIM(A2) +=TEXT(B2, "$#,##0.00") +``` + +### Lookup + +```txt +=INDEX(B2:E10, 3, 2) +=MATCH("Revenue", A2:A20, 0) +=VLOOKUP("Revenue", A2:E20, 5, FALSE) +=XLOOKUP("Revenue", A2:A20, E2:E20) +``` + +### Cross-Sheet Model + +```txt +=[[newsletter-revenue]].E5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=IF([[business-plan]].$E$12>0, [[business-plan]].$E$12, 0) +``` + +## IronCalc Function Families + +IronCalc documents formulas by category. Use these upstream pages for detailed syntax and examples. The upstream documentation may include newer functions that are not yet present in Tolaria's bundled IronCalc version. + +| Family | Link | +| --- | --- | +| Lookup and reference | [IronCalc lookup and reference](https://docs.ironcalc.com/functions/lookup-and-reference.html) | +| Financial | [IronCalc financial](https://docs.ironcalc.com/functions/financial.html) | +| Engineering | [IronCalc engineering](https://docs.ironcalc.com/functions/engineering.html) | +| Database | [IronCalc database](https://docs.ironcalc.com/functions/database.html) | +| Statistical | [IronCalc statistical](https://docs.ironcalc.com/functions/statistical.html) | +| Text | [IronCalc text](https://docs.ironcalc.com/functions/text.html) | +| Math and trigonometry | [IronCalc math and trigonometry](https://docs.ironcalc.com/functions/math-and-trigonometry.html) | +| Logical | [IronCalc logical](https://docs.ironcalc.com/functions/logical.html) | +| Date and time | [IronCalc date and time](https://docs.ironcalc.com/functions/date-and-time.html) | +| Information | [IronCalc information](https://docs.ironcalc.com/functions/information.html) | + +IronCalc also documents [value types](https://docs.ironcalc.com/features/value-types.html), [error types](https://docs.ironcalc.com/features/error-types.html), [optional arguments](https://docs.ironcalc.com/features/optional-arguments.html), and [formatting values](https://docs.ironcalc.com/features/formatting-values.html). + +For current upstream gaps, see IronCalc's [unsupported features](https://docs.ironcalc.com/features/unsupported-features.html). + +--- + +# Supported Platforms + +Source: reference/supported-platforms.md +URL: /reference/supported-platforms + +# Supported Platforms + +Tolaria is a desktop app built with Tauri. Releases currently target macOS, Windows, and Linux. + +| Platform | Current support | Notes | +| --- | --- | --- | +| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. | +| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. | + +## Support Policy + +Primary support means the platform is part of normal development and release validation. Supported, early means release artifacts exist and the app is expected to work, but platform-specific bugs can take longer to diagnose than macOS issues. + +## Reporting Platform Bugs + +Include: + +- Tolaria version. +- Operating system and version. +- CPU architecture. +- Whether the vault is local-only or connected to a remote. +- Steps to reproduce. + +--- + +# View Filters + +Source: reference/view-filters.md +URL: /reference/view-filters + +# View Filters + +View filters define saved lists of notes. + +## Common Filter Ideas + +| Goal | Filter direction | +| --- | --- | +| Active projects | `type` is Project and `status` is Active | +| Drafts | `type` is Article and `status` is Draft | +| People follow-up | `type` is Person and date is before today | +| Recent work | modified date is within a recent range | + +## Sorting + +Useful sorts include: + +- Recently modified first. +- Title ascending. +- Status ascending. +- A custom property ascending or descending. + +## Operators + +Saved views can combine filters for text, dates, relationship fields, and frontmatter values. Relative date expressions are useful for views such as notes changed this week or people that need follow-up. + +Regex filters are available for power-user cases. Keep them narrow and test them on a small view first. + +## Keep Views Focused + +A view should answer one recurring question. If it becomes too broad, split it into two views. + +You can also customize view appearance with the same kind of icon and color controls used by types. + +--- + +# AI Agent Not Found + +Source: troubleshooting/ai-agent-not-found.md +URL: /troubleshooting/ai-agent-not-found + +# AI Agent Not Found + +Tolaria can only launch local CLI agents that are installed and discoverable. + +## Symptoms + +- The AI panel says no supported agent is available. +- Claude Code or another agent works in one shell but not in Tolaria. + +## Checks + +Open a terminal and run the agent command directly. For Claude Code: + +```bash +claude --version +``` + +If the command fails, install or repair the agent first. + +## Path Issues + +Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell. + +--- + +# Git Authentication + +Source: troubleshooting/git-auth.md +URL: /troubleshooting/git-auth + +# Git Authentication + +Tolaria uses system Git authentication. It does not manage provider passwords directly. + +## Symptoms + +- Push fails. +- Pull asks for credentials repeatedly. +- Remote fetch works in one terminal but not in Tolaria. + +## Checks + +1. Open a terminal. +2. `cd` into the vault. +3. Run `git remote -v`. +4. Run `git fetch`. + +If `git fetch` fails in the terminal, fix system Git auth first. + +## Common Fixes + +- Sign in with GitHub CLI. +- Configure SSH keys. +- Update the remote URL. +- Check your credential helper. + +--- + +# Model Provider Connection + +Source: troubleshooting/model-provider-connection.md +URL: /troubleshooting/model-provider-connection + +# Model Provider Connection + +Use this checklist when a local or API model provider does not connect. + +## Local Providers + +For Ollama or LM Studio: + +1. Start the local model server. +2. Confirm the base URL in Tolaria matches the server. +3. Confirm the model ID is installed and loaded by the provider. +4. Use the Settings test action again. + +## API Providers + +For hosted providers: + +1. Confirm the provider kind and endpoint. +2. Confirm the model ID exists for your account. +3. Confirm the API key is saved locally or available in the configured environment variable. +4. Avoid storing secrets in the vault. + +## Chat Mode Boundary + +Direct model targets run in chat mode. If you need file-editing tools, use a coding agent target such as Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. + +--- + +# Sync Conflicts + +Source: troubleshooting/sync-conflicts.md +URL: /troubleshooting/sync-conflicts + +# Sync Conflicts + +Sync conflicts happen when local and remote changes touch the same content. + +## What To Do + +1. Stop editing the conflicted note. +2. Open the conflict resolver if Tolaria presents it. +3. Review both sides. +4. Choose the correct content or merge manually. +5. Commit the resolved file. +6. Push again. + +## Prevent Conflicts + +- Pull before starting work on another device. +- Push after meaningful sessions. +- Keep AI-generated edits in small commits. +- Avoid editing the same note on multiple devices at the same time. + +--- + +# Vault Not Loading + +Source: troubleshooting/vault-not-loading.md +URL: /troubleshooting/vault-not-loading + +# Vault Not Loading + +Use this checklist when Tolaria cannot open or refresh a vault. + +## Check The Folder + +- Confirm the folder exists. +- Confirm the folder contains readable files. +- Confirm Tolaria has permission to access the folder. +- Try opening a smaller test vault to isolate the issue. + +## Check Git + +If the vault is a Git repository, verify it is not in a broken state: + +```bash +git status +``` + +Resolve interrupted merges or corrupted repository state before retrying. + +## Reload + +Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/concepts.md b/src-tauri/resources/agent-docs/concepts.md new file mode 100644 index 0000000..34ba557 --- /dev/null +++ b/src-tauri/resources/agent-docs/concepts.md @@ -0,0 +1,554 @@ +# AI + +Source: concepts/ai.md +URL: /concepts/ai + +# AI + +Tolaria has two AI paths: coding agents that can use tools to inspect and edit a vault, and direct model targets that answer in chat mode from note context. + +## Coding Agents + +The AI panel can stream supported local CLI agents through Tolaria's normalized event layer. Current targets include Claude Code, Codex, OpenCode, Pi, and Antigravity CLI when they are installed on the machine. + +Coding agents can run in: + +- **Vault Safe** mode, limited to file, search, and edit tools. +- **Power User** mode, which can allow local shell commands scoped to the active vault for agents that support shell access. + +## Direct Models + +Direct model targets run in chat mode. They receive the active note, linked context, and conversation history, but they do not receive vault-write tools or shell access. + +Supported provider shapes include: + +- Local models through Ollama or LM Studio. +- Hosted providers such as OpenAI, Anthropic, Gemini, and OpenRouter. +- Custom OpenAI-compatible endpoints. + +## External MCP Setup + +Tolaria exposes an MCP server for external tools. The setup flow can write Tolaria's MCP entry into Claude Code, Antigravity CLI, Cursor, and a generic MCP config path, and it can also copy the exact JSON snippet for manual setup. + +MCP setup is explicit. Closing the dialog leaves third-party config files untouched. + +## Why Git Matters For AI + +AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work. + +--- + +# Editor + +Source: concepts/editor.md +URL: /concepts/editor + +# Editor + +Tolaria offers a rich editor for daily writing and a raw Markdown mode for exact file control. Both modes write back to the same Markdown file. + +## Rich Editing + +The rich editor supports blocks, slash commands, wikilinks, tables, code blocks, images, Mermaid diagrams, LaTeX-style math, and markdown-backed whiteboards. + +Use it when you want to write and reorganize quickly without thinking about Markdown syntax. + +## Raw Mode + +Raw mode shows the Markdown source directly. Use it when you need to edit YAML frontmatter, repair unusual Markdown, or make an exact text change. + +Toggle raw mode with `Cmd+\` on macOS or `Ctrl+\` on Windows and Linux. + +## Table Of Contents + +The table of contents panel builds an outline from headings in the current note. It is useful for long notes, procedures, research files, and generated documents. Toggle it with `Cmd+Shift+T` on macOS or `Ctrl+Shift+T` on Windows and Linux. + +## Width + +Notes can use normal or wide editor width. Set the default in Settings, or override an individual note from the editor toolbar. + +--- + +# Files And Media + +Source: concepts/files-and-media.md +URL: /concepts/files-and-media + +# Files And Media + +Tolaria starts with Markdown notes, but a vault can also contain images, PDFs, media files, whiteboards, and other local files. + +## Mermaid Diagrams + +Use Mermaid code blocks when a note needs a diagram that should stay plain text and versionable. + +````md +```mermaid +flowchart LR + Idea --> Draft --> Review --> Publish +``` +```` + +Tolaria renders Mermaid diagrams in the editor while keeping the source in Markdown. + +## Attachments + +Images pasted into the editor are saved into the vault as normal files. They remain portable and can be opened by other tools. + +## Previews + +Tolaria can preview common image files, PDFs, and supported media files in the app. Files without an in-app preview can still be opened in the default system app. + +Settings control whether PDFs, images, and unsupported files appear in All Notes. Folder browsing still shows files in their folders. + +## Whiteboards + +Whiteboards use tldraw in the editor, but their durable representation stays in Markdown. That keeps them inside the vault and versioned by Git with the rest of your notes. + +## Git Boundary + +If generated or local-only files are ignored by Git, Tolaria can hide them from notes, search, quick open, and folders. Use this when build artifacts or private local files should not behave like vault content. + +--- + +# Git + +Source: concepts/git.md +URL: /concepts/git + +# Git + +Git is Tolaria's recommended history and sync layer. Tolaria can work with plain Markdown folders, and Git unlocks local history, recovery, remote backup, and multi-device workflows when you want them. + +Tolaria acts as a lightweight Git client for your vault. You can review changes, commit, pull, push, and inspect history without leaving the app. + +## What Tolaria Uses Git For + +- Whole-vault commit history. +- Current diff for the vault. +- Per-note history. +- Current diff for an individual note. +- Pull and push. +- Conflict detection and resolution. +- Remote connection for local-only vaults. + +## History And Diffs + +Each note can show its own history and current diff, so you can understand how that file changed over time or what is unsaved relative to Git. + +Tolaria also shows a history of the whole vault. Use it when you want to review broader changes across multiple notes before committing or syncing. + +## Local Commits + +You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured. + +## Remotes + +Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work. + +--- + +# Inbox + +Source: concepts/inbox.md +URL: /concepts/inbox + +# Inbox + +The Inbox is for notes that have been captured but not yet organized. + +## Why It Exists + +Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later. + +The Inbox workflow is optional. Turn it off in Settings > Workflow if you prefer every note to appear organized by default. + +## Organizing Inbox Notes + +When reviewing the Inbox: + +1. Give the note a clear H1. +2. Set its `type`. +3. Add status, dates, or URL if useful. +4. Add relationships with wikilinks or frontmatter fields. +5. Move it into a folder only if the folder adds value. + +## Healthy Inbox Habit + +Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate. + +--- + +# Notes + +Source: concepts/notes.md +URL: /concepts/notes + +# Notes + +A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation. + +## Anatomy + +```md +--- +type: Project +status: Active +belongs_to: + - "[[workspace]]" +--- + +# Launch Documentation + +Draft the public Tolaria docs and keep them close to code changes. +``` + +## Titles + +The first H1 is the note title. Tolaria uses that title wherever the note is displayed: note lists, search results, wikilink suggestions, relationship pickers, tabs, and window titles. + +The title is separate from the filename. The filename stays visible in the breadcrumb so you can see the file on disk, and you can rename it independently when needed. + +Use the breadcrumb action to rename the file to match the title. New untitled notes can also auto-rename from the first H1 the first time they get a real title. Turn this behavior off in Settings > Vault Content > Titles & Filenames if you prefer filenames to stay unchanged until you rename them manually. + +## Body Links + +Use `[[wikilinks]]` to connect notes from the body. Tolaria shows autocomplete suggestions while you type, and links can resolve by filename or title. + +## Frontmatter + +Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body. + +Some notes can be displayed with specialized editors while keeping the same file-first model. A note with `_display: sheet` opens as a spreadsheet and stores its cells in a CSV-like body, while `type` remains available for organization. See [Spreadsheets](/concepts/spreadsheets). + +--- + +# Properties + +Source: concepts/properties.md +URL: /concepts/properties + +# Properties + +Properties are frontmatter fields that Tolaria can display, filter, and edit. + +## Suggested Properties + +Suggested properties are the fields Tolaria knows how to create quickly from the Properties panel. When a suggested property is missing, the panel shows a shortcut to add it with the right editor. + +| Field | Purpose | +| --- | --- | +| `type` | Groups the note into a type such as Project, Person, or Topic. | +| `status` | Tracks lifecycle state such as Active, Done, or Blocked. | +| `url` | Stores a canonical external link. | +| `date` | Represents a single date. | + +## System Properties + +Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing. + +Examples include `_icon`, `_color`, `_order`, `_sidebar_label`, `_width`, and `_pinned_properties` on type documents or notes. + +## Property Editing + +The Properties panel is the safest place to edit structured properties. Toggle it with `Cmd+Shift+I` on macOS or `Ctrl+Shift+I` on Windows and Linux. + +Date fields use Tolaria's picker, relationship fields can use wikilinks, and raw Markdown mode is available when you need direct control over YAML. + +--- + +# Relationships + +Source: concepts/relationships.md +URL: /concepts/relationships + +# Relationships + +Relationships make a vault feel like a graph instead of a pile of documents. + +## Relationship Fields + +Any frontmatter field containing wikilinks can become a relationship. Relationship fields can point to one note or to an array of notes. + +```yaml +belongs_to: + - "[[product-work]]" +related_to: + - "[[documentation]]" + - "[[editor-research]]" +blocked_by: + - "[[release-process]]" + - "[[sync-conflicts]]" +``` + +Tolaria supports default relationship fields out of the box: `belongs_to`, `has`, and `related_to`. It also detects custom relationship fields dynamically when they contain wikilinks. + +Default relationships have automatically computed inverses. If a note says it `belongs_to` a project, the project can show that note under its inverse `has` relationship without you writing the reverse link by hand. `related_to` works as a lateral relationship in both directions. + +These outgoing and inverse relationships appear in the Properties panel and in Neighborhood mode, where the note list becomes a graph view around the selected note. + +## Body Links Versus Relationship Fields + +Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, Neighborhood mode, or the Properties panel. + +## Backlinks + +Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context. + +--- + +# Spreadsheets + +Source: concepts/spreadsheets.md +URL: /concepts/spreadsheets + +# Spreadsheets + +Tolaria sheets are spreadsheet notes. They keep the same file-first model as other notes, but a note with `_display: sheet` opens in a spreadsheet editor instead of the rich text editor. The note's `type` remains available for organization. + +The durable file is still Markdown with YAML frontmatter. The body is CSV-like text containing cell inputs and formulas, and spreadsheet presentation state is stored as plain YAML under `_sheet`. + +## Read Next + +- [Use Spreadsheets](/guides/use-spreadsheets) for the editing workflow. +- [Spreadsheet File Format](/reference/spreadsheet-format) for the plain-text storage contract. +- [Spreadsheet Formulas](/reference/spreadsheet-functions) for formula syntax, autocomplete, and IronCalc function families. + +## Why Sheet Notes + +Sheets are useful when information is better modeled as rows, columns, and formulas than as prose. Examples include budgets, revenue models, inventories, editorial calendars, lightweight trackers, and analytical scratchpads. + +Tolaria does not store sheets as opaque workbook binaries. A sheet should remain: + +- readable in a text editor +- diffable in Git +- editable by humans and AI agents +- available offline +- connected to the rest of the vault through types, properties, relationships, and wikilinks + +## One Note, One Sheet + +A sheet note is a single sheet. Tolaria does not expose multiple tabs inside one note. + +When a model needs more than one table, create more than one sheet note and connect them with wikilinks or cross-sheet formulas. This keeps each file small, legible, and aligned with Tolaria's graph model. + +For example: + +- `newsletter-revenue.md` +- `sponsorship-pipeline.md` +- `refactoring-business-plan.md` + +Each can be a normal `_display: sheet` note, and formulas can reference cells in another sheet note with Tolaria's wikilink cell syntax. + +## Editing + +The interactive sheet editor is backed by IronCalc. Tolaria uses IronCalc for spreadsheet behavior and formula evaluation, then adapts the workbook back to Tolaria's plain-text note format. + +In the sheet editor: + +- cell inputs that start with `=` are formulas +- non-formula cells can contain normal text, numbers, dates, and `[[wikilinks]]` +- typing `[[` in a cell opens the same note autocomplete concept used elsewhere in Tolaria +- typing a formula function name opens inline formula autocomplete for the bundled IronCalc function catalog +- right-clicking a selection exposes core formatting controls such as number formats, decimal precision, bold, italic, and clear formatting + +Keyboard basics follow spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` with arrows extends the selection +- `Enter` starts editing the active cell +- `Escape` exits cell editing while keeping focus in the sheet +- `Delete` or `Backspace` clears the selected range +- copy and paste should preserve formulas, including Tolaria cross-sheet references + +## Wikilinks In Cells + +Wikilinks in non-formula cells are stored as normal Tolaria wikilinks: + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +The cell still behaves like a spreadsheet cell, but the value remains a vault link that Tolaria can understand. + +## Note Reference Formulas + +Tolaria adds a sheet-note reference syntax on top of IronCalc formulas: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=[[refactoring-business-plan]].$C$18 +``` + +The target before the dot is a normal Tolaria wikilink target. For another sheet note, the part after the dot is an A1-style cell address. + +Relative and absolute references work like spreadsheet references when copied: + +- `[[revenue]].B5` can shift when pasted to another cell +- `[[revenue]].$B$5` stays fixed +- `[[revenue]].B$5` fixes the row +- `[[revenue]].$B5` fixes the column + +This is not the same as an IronCalc workbook tab reference. It is Tolaria-specific syntax for referencing another sheet note in the vault. + +Current cross-sheet formulas resolve single cells. Ranges across sheet notes are not a stable file-format feature yet, so prefer composing them from explicit cell references or keeping range formulas inside the same sheet note. + +Sheet formulas can also read scalar frontmatter properties from a note: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +``` + +This keeps sheet models connected to ordinary Tolaria metadata without requiring a saved view or query. Unresolved, ambiguous, or non-scalar property references show spreadsheet errors. + +## Storage + +A minimal sheet note looks like this: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 + cells: + E6: + num_fmt: "0.00%" +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +Growth,,=(C5-B5)/B5,=(D5-C5)/C5,=(E5-B5)/B5 +``` + +Normal frontmatter stays normal Tolaria metadata. `_sheet` is system metadata for the spreadsheet editor and is hidden from normal property editing. + +For the full storage contract, see [Spreadsheet File Format](/reference/spreadsheet-format). + +## Formulas + +Tolaria delegates formula calculation to IronCalc. IronCalc aims for Excel-compatible formulas, while its project documentation still describes it as work in progress. For Tolaria-specific formula behavior and the autocomplete function catalog, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +--- + +# Types + +Source: concepts/types.md +URL: /concepts/types + +# Types + +Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create. + +## Type Field + +The `type:` field assigns a note to a type. + +```yaml +type: Project +``` + +Tolaria does not infer type from folder location. Moving a file into another folder does not change its type. + +## Prefer Types Over Folders + +Types are the preferred way to group notes in Tolaria. Folders are supported for existing vaults and fallback organization, but Tolaria is built around types and relationships because they carry stronger meaning than file paths. + +Use types for semantic groups such as Projects, People, Topics, Procedures, Events, and Essays. Use relationships to connect notes across those groups. This gives Tolaria better structure for navigation, filtering, properties, templates, and future automation than folder location alone. + +## Type Documents + +Type documents are Markdown notes with `type: Type` in frontmatter. They describe how a type should appear and what new notes of that type should start with. + +```yaml +--- +type: Type +_icon: folder +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +Type templates can live in the Type document's `template` frontmatter field. When a hand-edited Type body contains template-like structure after its own `# TypeName` heading, Tolaria also uses that body content as the new-note template. Plain descriptive body text stays documentation-only. + +## What Types Control + +- Sidebar grouping. +- Type icon and color. +- Sidebar order and label. +- Pinned properties. +- New-note templates. + +## New Note Defaults + +Type documents can define empty properties and relationships. When you create a new note of that type, Tolaria shows placeholders for those fields so you can fill them in from the Properties panel. + +If a type document gives a property a value, that value becomes the default for new notes of that type. For example, a Project type can define `status: Active` so every new project starts active until you change it. + +--- + +# Vaults + +Source: concepts/vaults.md +URL: /concepts/vaults + +# Vaults + +A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files. + +## Core Rules + +- Notes are Markdown files. +- YAML frontmatter provides structure. +- Attachments are normal files inside the vault. +- Type definitions and saved views are also files. +- Git can track history and support remote sync. + +## Why Local Files Matter + +Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git. + +Tolaria should never become the only way to read your data. + +## Git Is A Capability + +A plain folder of Markdown files can open as a vault. Git-backed vaults unlock history, changes, commits, pull, push, conflict handling, and remote setup. + +If a folder is not a Git repository, Tolaria can initialize Git when you explicitly ask it to. It avoids initializing broad personal folders such as Desktop, Documents, or Downloads unless they are clearly dedicated vault folders. + +## Multiple Vaults At The Same Time + +Tolaria can load multiple registered vaults into one unified graph. Enable this from `Settings` -> `Vaults` -> `Use multiple vaults at the same time`. + +After the option is enabled, open the bottom-left vault menu to include or exclude vaults from the graph. Included vaults appear together in note lists, search, quick open, backlinks, and wikilink navigation. Each note keeps a compact vault badge when Tolaria needs to disambiguate where it lives. + +The selected vault still matters. Git status, commits, sync, folder navigation, saved views, and vault repair actions stay scoped to the current repository. Use `Manage vaults` from the vault menu or the Vaults settings section to rename vaults, choose colors, and set the default destination for new notes. + +Cross-vault wikilinks use the target vault's stable alias when needed, for example `[[team/projects/alpha]]`. Links inside the same vault stay normal vault-relative links. + +## App State Versus Vault State + +Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation. + +| Vault state | App state | +| --- | --- | +| Type icons and colors | Editor zoom | +| Saved views | Window size | +| Pinned properties | Recent vault list | +| Relationship conventions | Local cache | +| Vault AI guidance files | AI target selection | \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/guides.md b/src-tauri/resources/agent-docs/guides.md new file mode 100644 index 0000000..d041041 --- /dev/null +++ b/src-tauri/resources/agent-docs/guides.md @@ -0,0 +1,597 @@ +# Build Custom Views + +Source: guides/build-custom-views.md +URL: /guides/build-custom-views + +# Build Custom Views + +Custom views are saved filters for recurring questions. + +## Good View Candidates + +- Active projects. +- People without a recent follow-up. +- Drafts ready for review. +- Notes changed this week. +- Events in a date range. + +## View Definition + +Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data. + +## Filters + +Custom views can use nested conditions, similar to Notion or Airtable filter groups. Combine `all` and `any` logic when a view needs to answer a more precise question than a single field filter can express. + +Date filters support dynamic natural-language values such as `today`, `yesterday`, or `one week ago`. Use these for views that should keep moving over time, such as recent work, stale follow-ups, or upcoming events. + +## Design The Question First + +Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens. + +--- + +# Capture A Note + +Source: guides/capture-a-note.md +URL: /guides/capture-a-note + +# Capture A Note + +Use capture when you need to get an idea into the vault before you know where it belongs. + +## Steps + +1. Press `Cmd+N` on macOS or `Ctrl+N` on Windows and Linux. +2. Write a clear H1. +3. Add the rough content. +4. Leave structure for later if you are still thinking. + +## Capture Well + +Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review. + +## When To Add Structure Immediately + +Add structure while capturing when the note's type or relationships are already obvious. Otherwise, capture the idea first and organize it later. + +--- + +# Manage Git Manually Or With AutoGit + +Source: guides/commit-and-push.md +URL: /guides/commit-and-push + +# Manage Git Manually Or With AutoGit + +Tolaria can act as a lightweight Git client for a Git-enabled vault. You can manage commits and pushes yourself, or enable AutoGit to create conservative checkpoints after editing pauses or when the app is no longer active. + +## Manual Git + +1. Open the Git or changes surface. +2. Review changed files. +3. Write a short commit message. +4. Commit locally. +5. Push when a remote is configured. + +If the remote has changed, pull first and resolve any conflicts. If the vault has no remote, manual commits still give you local history, diffs, and rollback. + +## AutoGit + +AutoGit is available in Settings for Git-enabled vaults. When enabled, Tolaria automatically commits and pushes saved local changes after an idle pause or after the app becomes inactive. + +Use AutoGit when you want the safety of regular checkpoints without interrupting capture or editing. You can still inspect each note's current diff, review note history, and browse the whole-vault history before making larger manual commits. + +## Use Small Commits + +Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits. + +--- + +# Configure AI Models + +Source: guides/configure-ai-models.md +URL: /guides/configure-ai-models + +# Configure AI Models + +Use model providers when you want chat over note context without giving an agent vault-write tools. + +## Local Models + +Local model targets are for tools such as Ollama and LM Studio. They usually need a base URL and model ID, and they usually do not need an API key. + +## API Models + +API model targets are for hosted providers such as OpenAI, Anthropic, Gemini, OpenRouter, or another OpenAI-compatible endpoint. + +Tolaria does not store provider API keys in vault settings. Choose one of the supported key paths: + +- Save the key locally on this device. +- Read the key from an environment variable. +- Use no key for local providers that do not require one. + +## Test The Connection + +After adding a provider, use the test action in Settings. A successful test means Tolaria reached the endpoint and the model replied. + +## Select The Target + +Once configured, choose the model from the AI target selector or set it as the default AI target in Settings. + +--- + +# Connect A Git Remote + +Source: guides/connect-a-git-remote.md +URL: /guides/connect-a-git-remote + +# Connect A Git Remote + +Connect a remote when you want backup or sync beyond the current machine. + +## Before You Start + +Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials. + +## Steps + +1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette. +2. Paste the remote URL. +3. Confirm the remote name. +4. Fetch or push according to the app prompt. + +## Recommended Auth + +- SSH keys. +- GitHub CLI authentication. +- Existing Git credential helpers. +- macOS Keychain credentials for HTTPS remotes on macOS. + +If authentication fails, see [Git Authentication](/troubleshooting/git-auth). + +--- + +# Create Types + +Source: guides/create-types.md +URL: /guides/create-types + +# Create Types + +Create a type when several notes share the same role in your system. + +## Steps + +1. Run `New Type` from the command palette, or click `+` in the Types header in the sidebar. +2. Give the type a clear name. +3. Add optional icon, color, sidebar order, sidebar label, pinned properties, suggested fields, default values, or a new-note template. + +You can also right-click a type in the sidebar to change its icon and color. + +```yaml +--- +type: Type +_icon: briefcase +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +## Use Types Sparingly + +A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead. + +## Templates + +Type documents can include a Markdown template for new notes of that type. Keep templates small and useful: a heading, a few expected fields, and the first checklist are usually enough. + +You can store the template in the Type document's `template` frontmatter field. When hand-editing the Type document body, content after the Type note's own `# TypeName` heading is also used as the new-note template if it looks like template structure such as field labels, secondary headings, or checklist starters. Plain descriptive body text is ignored. + +Type documents can also define fields for new notes. Empty properties and relationships become placeholders in new notes of that type. Properties with values become defaults for new notes of that type. + +--- + +# Manage Display Preferences + +Source: guides/manage-display-preferences.md +URL: /guides/manage-display-preferences + +# Manage Display Preferences + +Display preferences live in local app settings unless a setting is intentionally stored in the note or vault. + +## Theme + +Choose Light, Dark, or System in Settings. System follows the operating system appearance at runtime. + +You can also switch theme mode from the command palette. + +## Note Width + +Set the default rich-editor width in Settings: + +- **Normal** for focused writing. +- **Wide** for tables, diagrams, dense notes, and generated documents. + +An individual note can override the default width from the editor toolbar. That override is stored as `_width` in the note frontmatter. + +## Sidebar Labels + +Tolaria can pluralize type names in the sidebar. Turn this off in Settings if your type names should be shown exactly as written, or use `_sidebar_label` on a type document for an explicit label. + +## Vault Content + +Settings also control whether Gitignored files and non-Markdown file categories are visible in the app. Use these controls to keep generated or local-only files out of regular note workflows. + +--- + +# Organize The Inbox + +Source: guides/organize-inbox.md +URL: /guides/organize-inbox + +# Organize The Inbox + +Inbox review turns quick captures into usable knowledge. + +## Remove A Note From Inbox + +When a note is organized enough, mark it as organized. Use `Cmd+E` on macOS or `Ctrl+E` on Windows and Linux, or click the organize action in the breadcrumb bar. + +That action is what removes the note from Inbox. If auto-advance is enabled in Settings > Workflow, Tolaria opens the next Inbox item immediately after you mark the current note organized. + +## Review Checklist + +- Rename unclear notes. +- Add or correct the first H1. +- Set `type`. +- Add `status` for actionable notes. +- Add `belongs_to`, `related_to`, or other relationship fields when useful. +- Archive or delete notes that no longer matter. + +## Make Notes Navigable + +A note is organized when you can answer: + +- What kind of thing is this? +- What is it connected to? +- What is this useful for? +- What will I do with it? + +## Avoid Over-Structuring + +Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation. + +--- + +# Use The AI + +Source: guides/use-ai-panel.md +URL: /guides/use-ai-panel + +# Use The AI + +Tolaria gives you two ways to ask for AI help: open the AI panel for an ongoing conversation, or prompt directly from the editor with `Cmd+K` followed by a space. + +## Choose How To Prompt + +- **AI panel** is best for longer conversations, agent work, and requests that need visible back-and-forth. +- **Inline prompt** is best when you are already writing. Press `Cmd+K`, type a space, then write the prompt you want the AI to handle from the current note context. + +## Choose A Target + +Open Settings and choose the default AI target: + +- **Coding agent** for tool-backed vault editing through Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. +- **Local model** for Ollama or LM Studio chat over note context. +- **API model** for OpenAI, Anthropic, Gemini, OpenRouter, or an OpenAI-compatible endpoint. + +If a coding agent is missing, install it and reopen Tolaria or switch to another target. + +## Permission Mode + +Coding agents support per-vault permission modes: + +- **Vault Safe** keeps agents limited to file, search, and edit tools. +- **Power User** can allow shell commands for agents that support them. + +Direct model targets always stay in chat mode. They can use note context, but they cannot edit vault files through tools. + +## Good Requests + +- "Find notes related to this project." +- "Summarize what changed in this note." +- "Draft a weekly review from these linked notes." +- "Update this checklist based on the current project status." + +## Review Changes + +AI edits are file edits. Review them with Tolaria's diff and Git history before committing. + +--- + +# Use The Command Palette + +Source: guides/use-command-palette.md +URL: /guides/use-command-palette + +# Use The Command Palette + +The command palette is the fastest way to move around Tolaria. + +Open it with: + +- `Cmd+K` on macOS. +- `Ctrl+K` on Linux and Windows. + +## Common Commands + +- New Note. +- Search. +- Open Settings. +- Reload Vault. +- Add Remote. +- Open Getting Started Vault. +- Toggle Raw Mode. +- Toggle Table of Contents. +- Toggle AI Panel. +- Use Light, Dark, or System theme. +- Open in New Window. + +## Keyboard-First Workflow + +Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows. + +--- + +# Use Media Previews + +Source: guides/use-media-previews.md +URL: /guides/use-media-previews + +# Use Media Previews + +Media previews let you inspect vault files without leaving Tolaria. + +## Open A File + +Select an image, PDF, media file, or unsupported file from a folder or file list. Tolaria opens supported files in the app and offers an external-open action for files that should use the system default app. + +## All Notes Visibility + +Open Settings to choose whether non-Markdown files appear in All Notes: + +- PDFs. +- Images. +- Unsupported files. + +Folder browsing still shows files in their folders even when a category is hidden from All Notes. + +## Attachments + +When you paste or drop an image into a note, Tolaria copies it into the vault and references the copied file from Markdown. + +## Troubleshooting + +If a preview does not render, open the file in the default app to confirm the file is valid, then check whether the file is inside the active vault and not blocked by operating-system permissions. + +--- + +# Use Spreadsheets + +Source: guides/use-spreadsheets.md +URL: /guides/use-spreadsheets + +# Use Spreadsheets + +Tolaria spreadsheets are sheet notes: Markdown files with frontmatter and a CSV-like body that open in a spreadsheet editor when their `Display as` value is `Sheet`. + +Use a sheet note when a model needs rows, columns, calculations, or repeated numeric editing. Use a normal note when the main artifact is prose. + +## Create A Sheet + +Use the command palette action `New Sheet`, or create/open a note and set its `Display as` to `Sheet` from the Properties panel. `Type` remains separate and can still be `Note`, `Project`, `Responsibility`, or any other Tolaria type. + +When a note is a sheet: + +- the YAML frontmatter remains available for type, status, relationships, wikilinks, and custom properties +- `_display: sheet` tells Tolaria to display the note with the spreadsheet editor +- the body is the sheet itself +- there is no rich-text body around the table +- the editor switches from the text editor to the spreadsheet editor + +## Enter Values + +Click a cell and type a value. Non-formula values can be text, numbers, dates, or wikilinks. + +Press `Enter` on a selected cell to edit the cell. Press `Escape` while editing to leave cell editing and keep focus in the sheet. + +Use `Delete` or `Backspace` to clear the selected cell or range. + +## Enter Formulas + +Formulas start with `=`. + +```txt +=B2+B3-B4 +=SUM(B2:D2) +=ROUND(E6, 2) +=IF(E6>0, "Up", "Down") +``` + +Tolaria shows inline formula autocomplete while you type. The autocomplete list is built from the implemented function catalog in the bundled IronCalc engine; formula evaluation is still handled by IronCalc. + +See [Spreadsheet Formulas](/reference/spreadsheet-functions) for syntax, supported examples, and links to the full IronCalc formula reference. + +## Select And Edit Ranges + +The sheet editor follows spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` plus arrow keys extends the selection +- drag to select a range +- copy and paste preserves formulas where possible +- cut and paste moves formulas and shifts relative references +- right-click a selected cell or range to apply formatting + +Right-click actions apply to the current selection. Keep a multi-cell selection active before opening the context menu when you want to format several cells together. + +## Format Cells + +Use the context menu for common formatting: + +- number formats such as plain numbers, currency, and percentages +- decimal precision +- bold and italic text +- alignment and clearing formatting when available + +Formatting is stored as plain YAML under `_sheet`, not in an opaque workbook blob. For example, percentage formatting for `E6` is stored as: + +```yaml +_sheet: + cells: + E6: + num_fmt: "0.00%" +``` + +See [Spreadsheet File Format](/reference/spreadsheet-format) for the full storage model. + +## Add Wikilinks + +Type `[[` in a cell to open note autocomplete. + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +When the cell is not being edited, Tolaria renders the wikilink like other note links. When you edit the cell, the raw `[[wikilink]]` syntax is shown again. + +Command-click a wikilink in a sheet cell to open the linked note. + +## Reference Another Note + +Formulas can read a cell from another sheet note with Tolaria's wikilink cell syntax: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The part inside `[[...]]` resolves like a normal Tolaria wikilink. The part after the dot is an A1-style cell reference. + +Use absolute markers when copying formulas: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet references currently resolve single cells. Keep range formulas inside one sheet note. + +Formulas can read scalar frontmatter properties from a note with dot notation: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +Numbers, booleans, and text properties can be used in formulas. Missing or ambiguous note targets, missing properties, and non-scalar values such as lists or nested objects show as spreadsheet errors. + +## Work With The Raw File + +A sheet file remains readable text: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +``` + +When editing this file with scripts or AI agents, parse the body as CSV and preserve formulas as formulas. Do not replace formulas with displayed values. + +--- + +# Use The Table Of Contents + +Source: guides/use-table-of-contents.md +URL: /guides/use-table-of-contents + +# Use The Table Of Contents + +The table of contents panel helps you navigate long notes by heading. + +## Open It + +Use the editor toolbar, the command palette, or the shortcut: + +- `Cmd+Shift+T` on macOS. +- `Ctrl+Shift+T` on Windows and Linux. + +## How It Works + +Tolaria builds the outline from the current note's headings. The panel updates as the note changes and can jump to sections in the editor. + +## Good Uses + +- Long procedures. +- Meeting notes with many sections. +- Research notes. +- Generated documents that need review. + +If a note has no useful headings, add clear H2 and H3 sections rather than relying on a long uninterrupted document. + +--- + +# Use Wikilinks + +Source: guides/use-wikilinks.md +URL: /guides/use-wikilinks + +# Use Wikilinks + +Wikilinks connect notes by name. + +```md +This project belongs to [[content-systems]] and is related to [[git-workflows]]. +``` + +## Link From The Body + +Use body links when the connection is part of the sentence you are writing. + +## Link From Frontmatter + +Use frontmatter links when the relationship should become structured metadata. + +```yaml +related_to: + - "[[git-workflows]]" +``` + +## Keep Links Stable + +Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps you pick the right target while you type. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/home.md b/src-tauri/resources/agent-docs/home.md new file mode 100644 index 0000000..a326166 --- /dev/null +++ b/src-tauri/resources/agent-docs/home.md @@ -0,0 +1,6 @@ +# Index + +Source: index.md +URL: / + + \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/index.md b/src-tauri/resources/agent-docs/index.md new file mode 100644 index 0000000..2ad8798 --- /dev/null +++ b/src-tauri/resources/agent-docs/index.md @@ -0,0 +1,81 @@ +# Tolaria Agent Docs + +These docs are generated from the public Tolaria documentation for local AI agent lookup. + +Start here, then use `rg` over this folder for specific Tolaria concepts and workflows. + +When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria's default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views. + +## Home + +- [Index](pages/index.md) + +## Start + +- [First Launch](pages/start/first-launch.md) +- [Getting Started Vault](pages/start/getting-started-vault.md) +- [Install Tolaria](pages/start/install.md) +- [Open Or Create A Vault](pages/start/open-or-create-vault.md) + +## Concepts + +- [AI](pages/concepts/ai.md) +- [Editor](pages/concepts/editor.md) +- [Files And Media](pages/concepts/files-and-media.md) +- [Git](pages/concepts/git.md) +- [Inbox](pages/concepts/inbox.md) +- [Notes](pages/concepts/notes.md) +- [Properties](pages/concepts/properties.md) +- [Relationships](pages/concepts/relationships.md) +- [Spreadsheets](pages/concepts/spreadsheets.md) +- [Types](pages/concepts/types.md) +- [Vaults](pages/concepts/vaults.md) + +## Guides + +- [Build Custom Views](pages/guides/build-custom-views.md) +- [Capture A Note](pages/guides/capture-a-note.md) +- [Manage Git Manually Or With AutoGit](pages/guides/commit-and-push.md) +- [Configure AI Models](pages/guides/configure-ai-models.md) +- [Connect A Git Remote](pages/guides/connect-a-git-remote.md) +- [Create Types](pages/guides/create-types.md) +- [Manage Display Preferences](pages/guides/manage-display-preferences.md) +- [Organize The Inbox](pages/guides/organize-inbox.md) +- [Use The AI](pages/guides/use-ai-panel.md) +- [Use The Command Palette](pages/guides/use-command-palette.md) +- [Use Media Previews](pages/guides/use-media-previews.md) +- [Use Spreadsheets](pages/guides/use-spreadsheets.md) +- [Use The Table Of Contents](pages/guides/use-table-of-contents.md) +- [Use Wikilinks](pages/guides/use-wikilinks.md) + +## Templates + +- [Portent](pages/templates/portent.md) + +## Reference + +- [Contribute](pages/reference/contribute.md) +- [Docs Maintenance](pages/reference/docs-maintenance.md) +- [File Layout](pages/reference/file-layout.md) +- [Frontmatter Fields](pages/reference/frontmatter-fields.md) +- [Keyboard Shortcuts](pages/reference/keyboard-shortcuts.md) +- [Release Channels](pages/reference/release-channels.md) +- [Spreadsheet File Format](pages/reference/spreadsheet-format.md) +- [Spreadsheet Formulas](pages/reference/spreadsheet-functions.md) +- [Supported Platforms](pages/reference/supported-platforms.md) +- [View Filters](pages/reference/view-filters.md) + +## Troubleshooting + +- [AI Agent Not Found](pages/troubleshooting/ai-agent-not-found.md) +- [Git Authentication](pages/troubleshooting/git-auth.md) +- [Model Provider Connection](pages/troubleshooting/model-provider-connection.md) +- [Sync Conflicts](pages/troubleshooting/sync-conflicts.md) +- [Vault Not Loading](pages/troubleshooting/vault-not-loading.md) + +## Generated Files + +- `all.md`: all public docs concatenated for fast full-context reads. +- `search-index.json`: title, heading, section, path, and URL metadata for quick routing. +- `
      .md`: one compact bundle per docs section. +- `pages/`: one generated Markdown file per public docs page. diff --git a/src-tauri/resources/agent-docs/pages/concepts/ai.md b/src-tauri/resources/agent-docs/pages/concepts/ai.md new file mode 100644 index 0000000..28953a3 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/ai.md @@ -0,0 +1,37 @@ +# AI + +Source: concepts/ai.md +URL: /concepts/ai + +# AI + +Tolaria has two AI paths: coding agents that can use tools to inspect and edit a vault, and direct model targets that answer in chat mode from note context. + +## Coding Agents + +The AI panel can stream supported local CLI agents through Tolaria's normalized event layer. Current targets include Claude Code, Codex, OpenCode, Pi, and Antigravity CLI when they are installed on the machine. + +Coding agents can run in: + +- **Vault Safe** mode, limited to file, search, and edit tools. +- **Power User** mode, which can allow local shell commands scoped to the active vault for agents that support shell access. + +## Direct Models + +Direct model targets run in chat mode. They receive the active note, linked context, and conversation history, but they do not receive vault-write tools or shell access. + +Supported provider shapes include: + +- Local models through Ollama or LM Studio. +- Hosted providers such as OpenAI, Anthropic, Gemini, and OpenRouter. +- Custom OpenAI-compatible endpoints. + +## External MCP Setup + +Tolaria exposes an MCP server for external tools. The setup flow can write Tolaria's MCP entry into Claude Code, Antigravity CLI, Cursor, and a generic MCP config path, and it can also copy the exact JSON snippet for manual setup. + +MCP setup is explicit. Closing the dialog leaves third-party config files untouched. + +## Why Git Matters For AI + +AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/editor.md b/src-tauri/resources/agent-docs/pages/concepts/editor.md new file mode 100644 index 0000000..e3e1b5a --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/editor.md @@ -0,0 +1,28 @@ +# Editor + +Source: concepts/editor.md +URL: /concepts/editor + +# Editor + +Tolaria offers a rich editor for daily writing and a raw Markdown mode for exact file control. Both modes write back to the same Markdown file. + +## Rich Editing + +The rich editor supports blocks, slash commands, wikilinks, tables, code blocks, images, Mermaid diagrams, LaTeX-style math, and markdown-backed whiteboards. + +Use it when you want to write and reorganize quickly without thinking about Markdown syntax. + +## Raw Mode + +Raw mode shows the Markdown source directly. Use it when you need to edit YAML frontmatter, repair unusual Markdown, or make an exact text change. + +Toggle raw mode with `Cmd+\` on macOS or `Ctrl+\` on Windows and Linux. + +## Table Of Contents + +The table of contents panel builds an outline from headings in the current note. It is useful for long notes, procedures, research files, and generated documents. Toggle it with `Cmd+Shift+T` on macOS or `Ctrl+Shift+T` on Windows and Linux. + +## Width + +Notes can use normal or wide editor width. Set the default in Settings, or override an individual note from the editor toolbar. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/files-and-media.md b/src-tauri/resources/agent-docs/pages/concepts/files-and-media.md new file mode 100644 index 0000000..67e5491 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/files-and-media.md @@ -0,0 +1,39 @@ +# Files And Media + +Source: concepts/files-and-media.md +URL: /concepts/files-and-media + +# Files And Media + +Tolaria starts with Markdown notes, but a vault can also contain images, PDFs, media files, whiteboards, and other local files. + +## Mermaid Diagrams + +Use Mermaid code blocks when a note needs a diagram that should stay plain text and versionable. + +````md +```mermaid +flowchart LR + Idea --> Draft --> Review --> Publish +``` +```` + +Tolaria renders Mermaid diagrams in the editor while keeping the source in Markdown. + +## Attachments + +Images pasted into the editor are saved into the vault as normal files. They remain portable and can be opened by other tools. + +## Previews + +Tolaria can preview common image files, PDFs, and supported media files in the app. Files without an in-app preview can still be opened in the default system app. + +Settings control whether PDFs, images, and unsupported files appear in All Notes. Folder browsing still shows files in their folders. + +## Whiteboards + +Whiteboards use tldraw in the editor, but their durable representation stays in Markdown. That keeps them inside the vault and versioned by Git with the rest of your notes. + +## Git Boundary + +If generated or local-only files are ignored by Git, Tolaria can hide them from notes, search, quick open, and folders. Use this when build artifacts or private local files should not behave like vault content. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/git.md b/src-tauri/resources/agent-docs/pages/concepts/git.md new file mode 100644 index 0000000..f31d363 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/git.md @@ -0,0 +1,34 @@ +# Git + +Source: concepts/git.md +URL: /concepts/git + +# Git + +Git is Tolaria's recommended history and sync layer. Tolaria can work with plain Markdown folders, and Git unlocks local history, recovery, remote backup, and multi-device workflows when you want them. + +Tolaria acts as a lightweight Git client for your vault. You can review changes, commit, pull, push, and inspect history without leaving the app. + +## What Tolaria Uses Git For + +- Whole-vault commit history. +- Current diff for the vault. +- Per-note history. +- Current diff for an individual note. +- Pull and push. +- Conflict detection and resolution. +- Remote connection for local-only vaults. + +## History And Diffs + +Each note can show its own history and current diff, so you can understand how that file changed over time or what is unsaved relative to Git. + +Tolaria also shows a history of the whole vault. Use it when you want to review broader changes across multiple notes before committing or syncing. + +## Local Commits + +You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured. + +## Remotes + +Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/inbox.md b/src-tauri/resources/agent-docs/pages/concepts/inbox.md new file mode 100644 index 0000000..d6e69a9 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/inbox.md @@ -0,0 +1,28 @@ +# Inbox + +Source: concepts/inbox.md +URL: /concepts/inbox + +# Inbox + +The Inbox is for notes that have been captured but not yet organized. + +## Why It Exists + +Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later. + +The Inbox workflow is optional. Turn it off in Settings > Workflow if you prefer every note to appear organized by default. + +## Organizing Inbox Notes + +When reviewing the Inbox: + +1. Give the note a clear H1. +2. Set its `type`. +3. Add status, dates, or URL if useful. +4. Add relationships with wikilinks or frontmatter fields. +5. Move it into a folder only if the folder adds value. + +## Healthy Inbox Habit + +Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/notes.md b/src-tauri/resources/agent-docs/pages/concepts/notes.md new file mode 100644 index 0000000..f0e9295 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/notes.md @@ -0,0 +1,41 @@ +# Notes + +Source: concepts/notes.md +URL: /concepts/notes + +# Notes + +A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation. + +## Anatomy + +```md +--- +type: Project +status: Active +belongs_to: + - "[[workspace]]" +--- + +# Launch Documentation + +Draft the public Tolaria docs and keep them close to code changes. +``` + +## Titles + +The first H1 is the note title. Tolaria uses that title wherever the note is displayed: note lists, search results, wikilink suggestions, relationship pickers, tabs, and window titles. + +The title is separate from the filename. The filename stays visible in the breadcrumb so you can see the file on disk, and you can rename it independently when needed. + +Use the breadcrumb action to rename the file to match the title. New untitled notes can also auto-rename from the first H1 the first time they get a real title. Turn this behavior off in Settings > Vault Content > Titles & Filenames if you prefer filenames to stay unchanged until you rename them manually. + +## Body Links + +Use `[[wikilinks]]` to connect notes from the body. Tolaria shows autocomplete suggestions while you type, and links can resolve by filename or title. + +## Frontmatter + +Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body. + +Some notes can be displayed with specialized editors while keeping the same file-first model. A note with `_display: sheet` opens as a spreadsheet and stores its cells in a CSV-like body, while `type` remains available for organization. See [Spreadsheets](/concepts/spreadsheets). \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/properties.md b/src-tauri/resources/agent-docs/pages/concepts/properties.md new file mode 100644 index 0000000..3805a36 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/properties.md @@ -0,0 +1,31 @@ +# Properties + +Source: concepts/properties.md +URL: /concepts/properties + +# Properties + +Properties are frontmatter fields that Tolaria can display, filter, and edit. + +## Suggested Properties + +Suggested properties are the fields Tolaria knows how to create quickly from the Properties panel. When a suggested property is missing, the panel shows a shortcut to add it with the right editor. + +| Field | Purpose | +| --- | --- | +| `type` | Groups the note into a type such as Project, Person, or Topic. | +| `status` | Tracks lifecycle state such as Active, Done, or Blocked. | +| `url` | Stores a canonical external link. | +| `date` | Represents a single date. | + +## System Properties + +Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing. + +Examples include `_icon`, `_color`, `_order`, `_sidebar_label`, `_width`, and `_pinned_properties` on type documents or notes. + +## Property Editing + +The Properties panel is the safest place to edit structured properties. Toggle it with `Cmd+Shift+I` on macOS or `Ctrl+Shift+I` on Windows and Linux. + +Date fields use Tolaria's picker, relationship fields can use wikilinks, and raw Markdown mode is available when you need direct control over YAML. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/relationships.md b/src-tauri/resources/agent-docs/pages/concepts/relationships.md new file mode 100644 index 0000000..309a0b0 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/relationships.md @@ -0,0 +1,37 @@ +# Relationships + +Source: concepts/relationships.md +URL: /concepts/relationships + +# Relationships + +Relationships make a vault feel like a graph instead of a pile of documents. + +## Relationship Fields + +Any frontmatter field containing wikilinks can become a relationship. Relationship fields can point to one note or to an array of notes. + +```yaml +belongs_to: + - "[[product-work]]" +related_to: + - "[[documentation]]" + - "[[editor-research]]" +blocked_by: + - "[[release-process]]" + - "[[sync-conflicts]]" +``` + +Tolaria supports default relationship fields out of the box: `belongs_to`, `has`, and `related_to`. It also detects custom relationship fields dynamically when they contain wikilinks. + +Default relationships have automatically computed inverses. If a note says it `belongs_to` a project, the project can show that note under its inverse `has` relationship without you writing the reverse link by hand. `related_to` works as a lateral relationship in both directions. + +These outgoing and inverse relationships appear in the Properties panel and in Neighborhood mode, where the note list becomes a graph view around the selected note. + +## Body Links Versus Relationship Fields + +Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, Neighborhood mode, or the Properties panel. + +## Backlinks + +Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/spreadsheets.md b/src-tauri/resources/agent-docs/pages/concepts/spreadsheets.md new file mode 100644 index 0000000..87fcba2 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/spreadsheets.md @@ -0,0 +1,143 @@ +# Spreadsheets + +Source: concepts/spreadsheets.md +URL: /concepts/spreadsheets + +# Spreadsheets + +Tolaria sheets are spreadsheet notes. They keep the same file-first model as other notes, but a note with `_display: sheet` opens in a spreadsheet editor instead of the rich text editor. The note's `type` remains available for organization. + +The durable file is still Markdown with YAML frontmatter. The body is CSV-like text containing cell inputs and formulas, and spreadsheet presentation state is stored as plain YAML under `_sheet`. + +## Read Next + +- [Use Spreadsheets](/guides/use-spreadsheets) for the editing workflow. +- [Spreadsheet File Format](/reference/spreadsheet-format) for the plain-text storage contract. +- [Spreadsheet Formulas](/reference/spreadsheet-functions) for formula syntax, autocomplete, and IronCalc function families. + +## Why Sheet Notes + +Sheets are useful when information is better modeled as rows, columns, and formulas than as prose. Examples include budgets, revenue models, inventories, editorial calendars, lightweight trackers, and analytical scratchpads. + +Tolaria does not store sheets as opaque workbook binaries. A sheet should remain: + +- readable in a text editor +- diffable in Git +- editable by humans and AI agents +- available offline +- connected to the rest of the vault through types, properties, relationships, and wikilinks + +## One Note, One Sheet + +A sheet note is a single sheet. Tolaria does not expose multiple tabs inside one note. + +When a model needs more than one table, create more than one sheet note and connect them with wikilinks or cross-sheet formulas. This keeps each file small, legible, and aligned with Tolaria's graph model. + +For example: + +- `newsletter-revenue.md` +- `sponsorship-pipeline.md` +- `refactoring-business-plan.md` + +Each can be a normal `_display: sheet` note, and formulas can reference cells in another sheet note with Tolaria's wikilink cell syntax. + +## Editing + +The interactive sheet editor is backed by IronCalc. Tolaria uses IronCalc for spreadsheet behavior and formula evaluation, then adapts the workbook back to Tolaria's plain-text note format. + +In the sheet editor: + +- cell inputs that start with `=` are formulas +- non-formula cells can contain normal text, numbers, dates, and `[[wikilinks]]` +- typing `[[` in a cell opens the same note autocomplete concept used elsewhere in Tolaria +- typing a formula function name opens inline formula autocomplete for the bundled IronCalc function catalog +- right-clicking a selection exposes core formatting controls such as number formats, decimal precision, bold, italic, and clear formatting + +Keyboard basics follow spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` with arrows extends the selection +- `Enter` starts editing the active cell +- `Escape` exits cell editing while keeping focus in the sheet +- `Delete` or `Backspace` clears the selected range +- copy and paste should preserve formulas, including Tolaria cross-sheet references + +## Wikilinks In Cells + +Wikilinks in non-formula cells are stored as normal Tolaria wikilinks: + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +The cell still behaves like a spreadsheet cell, but the value remains a vault link that Tolaria can understand. + +## Note Reference Formulas + +Tolaria adds a sheet-note reference syntax on top of IronCalc formulas: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=[[refactoring-business-plan]].$C$18 +``` + +The target before the dot is a normal Tolaria wikilink target. For another sheet note, the part after the dot is an A1-style cell address. + +Relative and absolute references work like spreadsheet references when copied: + +- `[[revenue]].B5` can shift when pasted to another cell +- `[[revenue]].$B$5` stays fixed +- `[[revenue]].B$5` fixes the row +- `[[revenue]].$B5` fixes the column + +This is not the same as an IronCalc workbook tab reference. It is Tolaria-specific syntax for referencing another sheet note in the vault. + +Current cross-sheet formulas resolve single cells. Ranges across sheet notes are not a stable file-format feature yet, so prefer composing them from explicit cell references or keeping range formulas inside the same sheet note. + +Sheet formulas can also read scalar frontmatter properties from a note: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +``` + +This keeps sheet models connected to ordinary Tolaria metadata without requiring a saved view or query. Unresolved, ambiguous, or non-scalar property references show spreadsheet errors. + +## Storage + +A minimal sheet note looks like this: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 + cells: + E6: + num_fmt: "0.00%" +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +Growth,,=(C5-B5)/B5,=(D5-C5)/C5,=(E5-B5)/B5 +``` + +Normal frontmatter stays normal Tolaria metadata. `_sheet` is system metadata for the spreadsheet editor and is hidden from normal property editing. + +For the full storage contract, see [Spreadsheet File Format](/reference/spreadsheet-format). + +## Formulas + +Tolaria delegates formula calculation to IronCalc. IronCalc aims for Excel-compatible formulas, while its project documentation still describes it as work in progress. For Tolaria-specific formula behavior and the autocomplete function catalog, see [Spreadsheet Formulas](/reference/spreadsheet-functions). \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/types.md b/src-tauri/resources/agent-docs/pages/concepts/types.md new file mode 100644 index 0000000..5ffa716 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/types.md @@ -0,0 +1,56 @@ +# Types + +Source: concepts/types.md +URL: /concepts/types + +# Types + +Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create. + +## Type Field + +The `type:` field assigns a note to a type. + +```yaml +type: Project +``` + +Tolaria does not infer type from folder location. Moving a file into another folder does not change its type. + +## Prefer Types Over Folders + +Types are the preferred way to group notes in Tolaria. Folders are supported for existing vaults and fallback organization, but Tolaria is built around types and relationships because they carry stronger meaning than file paths. + +Use types for semantic groups such as Projects, People, Topics, Procedures, Events, and Essays. Use relationships to connect notes across those groups. This gives Tolaria better structure for navigation, filtering, properties, templates, and future automation than folder location alone. + +## Type Documents + +Type documents are Markdown notes with `type: Type` in frontmatter. They describe how a type should appear and what new notes of that type should start with. + +```yaml +--- +type: Type +_icon: folder +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +Type templates can live in the Type document's `template` frontmatter field. When a hand-edited Type body contains template-like structure after its own `# TypeName` heading, Tolaria also uses that body content as the new-note template. Plain descriptive body text stays documentation-only. + +## What Types Control + +- Sidebar grouping. +- Type icon and color. +- Sidebar order and label. +- Pinned properties. +- New-note templates. + +## New Note Defaults + +Type documents can define empty properties and relationships. When you create a new note of that type, Tolaria shows placeholders for those fields so you can fill them in from the Properties panel. + +If a type document gives a property a value, that value becomes the default for new notes of that type. For example, a Project type can define `status: Active` so every new project starts active until you change it. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/concepts/vaults.md b/src-tauri/resources/agent-docs/pages/concepts/vaults.md new file mode 100644 index 0000000..fb28941 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/concepts/vaults.md @@ -0,0 +1,50 @@ +# Vaults + +Source: concepts/vaults.md +URL: /concepts/vaults + +# Vaults + +A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files. + +## Core Rules + +- Notes are Markdown files. +- YAML frontmatter provides structure. +- Attachments are normal files inside the vault. +- Type definitions and saved views are also files. +- Git can track history and support remote sync. + +## Why Local Files Matter + +Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git. + +Tolaria should never become the only way to read your data. + +## Git Is A Capability + +A plain folder of Markdown files can open as a vault. Git-backed vaults unlock history, changes, commits, pull, push, conflict handling, and remote setup. + +If a folder is not a Git repository, Tolaria can initialize Git when you explicitly ask it to. It avoids initializing broad personal folders such as Desktop, Documents, or Downloads unless they are clearly dedicated vault folders. + +## Multiple Vaults At The Same Time + +Tolaria can load multiple registered vaults into one unified graph. Enable this from `Settings` -> `Vaults` -> `Use multiple vaults at the same time`. + +After the option is enabled, open the bottom-left vault menu to include or exclude vaults from the graph. Included vaults appear together in note lists, search, quick open, backlinks, and wikilink navigation. Each note keeps a compact vault badge when Tolaria needs to disambiguate where it lives. + +The selected vault still matters. Git status, commits, sync, folder navigation, saved views, and vault repair actions stay scoped to the current repository. Use `Manage vaults` from the vault menu or the Vaults settings section to rename vaults, choose colors, and set the default destination for new notes. + +Cross-vault wikilinks use the target vault's stable alias when needed, for example `[[team/projects/alpha]]`. Links inside the same vault stay normal vault-relative links. + +## App State Versus Vault State + +Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation. + +| Vault state | App state | +| --- | --- | +| Type icons and colors | Editor zoom | +| Saved views | Window size | +| Pinned properties | Recent vault list | +| Relationship conventions | Local cache | +| Vault AI guidance files | AI target selection | \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/build-custom-views.md b/src-tauri/resources/agent-docs/pages/guides/build-custom-views.md new file mode 100644 index 0000000..487adb3 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/build-custom-views.md @@ -0,0 +1,30 @@ +# Build Custom Views + +Source: guides/build-custom-views.md +URL: /guides/build-custom-views + +# Build Custom Views + +Custom views are saved filters for recurring questions. + +## Good View Candidates + +- Active projects. +- People without a recent follow-up. +- Drafts ready for review. +- Notes changed this week. +- Events in a date range. + +## View Definition + +Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data. + +## Filters + +Custom views can use nested conditions, similar to Notion or Airtable filter groups. Combine `all` and `any` logic when a view needs to answer a more precise question than a single field filter can express. + +Date filters support dynamic natural-language values such as `today`, `yesterday`, or `one week ago`. Use these for views that should keep moving over time, such as recent work, stale follow-ups, or upcoming events. + +## Design The Question First + +Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/capture-a-note.md b/src-tauri/resources/agent-docs/pages/guides/capture-a-note.md new file mode 100644 index 0000000..f858218 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/capture-a-note.md @@ -0,0 +1,23 @@ +# Capture A Note + +Source: guides/capture-a-note.md +URL: /guides/capture-a-note + +# Capture A Note + +Use capture when you need to get an idea into the vault before you know where it belongs. + +## Steps + +1. Press `Cmd+N` on macOS or `Ctrl+N` on Windows and Linux. +2. Write a clear H1. +3. Add the rough content. +4. Leave structure for later if you are still thinking. + +## Capture Well + +Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review. + +## When To Add Structure Immediately + +Add structure while capturing when the note's type or relationships are already obvious. Otherwise, capture the idea first and organize it later. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/commit-and-push.md b/src-tauri/resources/agent-docs/pages/guides/commit-and-push.md new file mode 100644 index 0000000..8778dc0 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/commit-and-push.md @@ -0,0 +1,28 @@ +# Manage Git Manually Or With AutoGit + +Source: guides/commit-and-push.md +URL: /guides/commit-and-push + +# Manage Git Manually Or With AutoGit + +Tolaria can act as a lightweight Git client for a Git-enabled vault. You can manage commits and pushes yourself, or enable AutoGit to create conservative checkpoints after editing pauses or when the app is no longer active. + +## Manual Git + +1. Open the Git or changes surface. +2. Review changed files. +3. Write a short commit message. +4. Commit locally. +5. Push when a remote is configured. + +If the remote has changed, pull first and resolve any conflicts. If the vault has no remote, manual commits still give you local history, diffs, and rollback. + +## AutoGit + +AutoGit is available in Settings for Git-enabled vaults. When enabled, Tolaria automatically commits and pushes saved local changes after an idle pause or after the app becomes inactive. + +Use AutoGit when you want the safety of regular checkpoints without interrupting capture or editing. You can still inspect each note's current diff, review note history, and browse the whole-vault history before making larger manual commits. + +## Use Small Commits + +Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/configure-ai-models.md b/src-tauri/resources/agent-docs/pages/guides/configure-ai-models.md new file mode 100644 index 0000000..c6a87e5 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/configure-ai-models.md @@ -0,0 +1,30 @@ +# Configure AI Models + +Source: guides/configure-ai-models.md +URL: /guides/configure-ai-models + +# Configure AI Models + +Use model providers when you want chat over note context without giving an agent vault-write tools. + +## Local Models + +Local model targets are for tools such as Ollama and LM Studio. They usually need a base URL and model ID, and they usually do not need an API key. + +## API Models + +API model targets are for hosted providers such as OpenAI, Anthropic, Gemini, OpenRouter, or another OpenAI-compatible endpoint. + +Tolaria does not store provider API keys in vault settings. Choose one of the supported key paths: + +- Save the key locally on this device. +- Read the key from an environment variable. +- Use no key for local providers that do not require one. + +## Test The Connection + +After adding a provider, use the test action in Settings. A successful test means Tolaria reached the endpoint and the model replied. + +## Select The Target + +Once configured, choose the model from the AI target selector or set it as the default AI target in Settings. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/connect-a-git-remote.md b/src-tauri/resources/agent-docs/pages/guides/connect-a-git-remote.md new file mode 100644 index 0000000..84ec167 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/connect-a-git-remote.md @@ -0,0 +1,28 @@ +# Connect A Git Remote + +Source: guides/connect-a-git-remote.md +URL: /guides/connect-a-git-remote + +# Connect A Git Remote + +Connect a remote when you want backup or sync beyond the current machine. + +## Before You Start + +Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials. + +## Steps + +1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette. +2. Paste the remote URL. +3. Confirm the remote name. +4. Fetch or push according to the app prompt. + +## Recommended Auth + +- SSH keys. +- GitHub CLI authentication. +- Existing Git credential helpers. +- macOS Keychain credentials for HTTPS remotes on macOS. + +If authentication fails, see [Git Authentication](/troubleshooting/git-auth). \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/create-types.md b/src-tauri/resources/agent-docs/pages/guides/create-types.md new file mode 100644 index 0000000..22c93af --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/create-types.md @@ -0,0 +1,40 @@ +# Create Types + +Source: guides/create-types.md +URL: /guides/create-types + +# Create Types + +Create a type when several notes share the same role in your system. + +## Steps + +1. Run `New Type` from the command palette, or click `+` in the Types header in the sidebar. +2. Give the type a clear name. +3. Add optional icon, color, sidebar order, sidebar label, pinned properties, suggested fields, default values, or a new-note template. + +You can also right-click a type in the sidebar to change its icon and color. + +```yaml +--- +type: Type +_icon: briefcase +_color: blue +_sidebar_label: Projects +_order: 10 +--- + +# Project +``` + +## Use Types Sparingly + +A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead. + +## Templates + +Type documents can include a Markdown template for new notes of that type. Keep templates small and useful: a heading, a few expected fields, and the first checklist are usually enough. + +You can store the template in the Type document's `template` frontmatter field. When hand-editing the Type document body, content after the Type note's own `# TypeName` heading is also used as the new-note template if it looks like template structure such as field labels, secondary headings, or checklist starters. Plain descriptive body text is ignored. + +Type documents can also define fields for new notes. Empty properties and relationships become placeholders in new notes of that type. Properties with values become defaults for new notes of that type. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/manage-display-preferences.md b/src-tauri/resources/agent-docs/pages/guides/manage-display-preferences.md new file mode 100644 index 0000000..6423910 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/manage-display-preferences.md @@ -0,0 +1,31 @@ +# Manage Display Preferences + +Source: guides/manage-display-preferences.md +URL: /guides/manage-display-preferences + +# Manage Display Preferences + +Display preferences live in local app settings unless a setting is intentionally stored in the note or vault. + +## Theme + +Choose Light, Dark, or System in Settings. System follows the operating system appearance at runtime. + +You can also switch theme mode from the command palette. + +## Note Width + +Set the default rich-editor width in Settings: + +- **Normal** for focused writing. +- **Wide** for tables, diagrams, dense notes, and generated documents. + +An individual note can override the default width from the editor toolbar. That override is stored as `_width` in the note frontmatter. + +## Sidebar Labels + +Tolaria can pluralize type names in the sidebar. Turn this off in Settings if your type names should be shown exactly as written, or use `_sidebar_label` on a type document for an explicit label. + +## Vault Content + +Settings also control whether Gitignored files and non-Markdown file categories are visible in the app. Use these controls to keep generated or local-only files out of regular note workflows. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/organize-inbox.md b/src-tauri/resources/agent-docs/pages/guides/organize-inbox.md new file mode 100644 index 0000000..8cabe51 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/organize-inbox.md @@ -0,0 +1,36 @@ +# Organize The Inbox + +Source: guides/organize-inbox.md +URL: /guides/organize-inbox + +# Organize The Inbox + +Inbox review turns quick captures into usable knowledge. + +## Remove A Note From Inbox + +When a note is organized enough, mark it as organized. Use `Cmd+E` on macOS or `Ctrl+E` on Windows and Linux, or click the organize action in the breadcrumb bar. + +That action is what removes the note from Inbox. If auto-advance is enabled in Settings > Workflow, Tolaria opens the next Inbox item immediately after you mark the current note organized. + +## Review Checklist + +- Rename unclear notes. +- Add or correct the first H1. +- Set `type`. +- Add `status` for actionable notes. +- Add `belongs_to`, `related_to`, or other relationship fields when useful. +- Archive or delete notes that no longer matter. + +## Make Notes Navigable + +A note is organized when you can answer: + +- What kind of thing is this? +- What is it connected to? +- What is this useful for? +- What will I do with it? + +## Avoid Over-Structuring + +Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-ai-panel.md b/src-tauri/resources/agent-docs/pages/guides/use-ai-panel.md new file mode 100644 index 0000000..e28777c --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-ai-panel.md @@ -0,0 +1,43 @@ +# Use The AI + +Source: guides/use-ai-panel.md +URL: /guides/use-ai-panel + +# Use The AI + +Tolaria gives you two ways to ask for AI help: open the AI panel for an ongoing conversation, or prompt directly from the editor with `Cmd+K` followed by a space. + +## Choose How To Prompt + +- **AI panel** is best for longer conversations, agent work, and requests that need visible back-and-forth. +- **Inline prompt** is best when you are already writing. Press `Cmd+K`, type a space, then write the prompt you want the AI to handle from the current note context. + +## Choose A Target + +Open Settings and choose the default AI target: + +- **Coding agent** for tool-backed vault editing through Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. +- **Local model** for Ollama or LM Studio chat over note context. +- **API model** for OpenAI, Anthropic, Gemini, OpenRouter, or an OpenAI-compatible endpoint. + +If a coding agent is missing, install it and reopen Tolaria or switch to another target. + +## Permission Mode + +Coding agents support per-vault permission modes: + +- **Vault Safe** keeps agents limited to file, search, and edit tools. +- **Power User** can allow shell commands for agents that support them. + +Direct model targets always stay in chat mode. They can use note context, but they cannot edit vault files through tools. + +## Good Requests + +- "Find notes related to this project." +- "Summarize what changed in this note." +- "Draft a weekly review from these linked notes." +- "Update this checklist based on the current project status." + +## Review Changes + +AI edits are file edits. Review them with Tolaria's diff and Git history before committing. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-command-palette.md b/src-tauri/resources/agent-docs/pages/guides/use-command-palette.md new file mode 100644 index 0000000..3792eef --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-command-palette.md @@ -0,0 +1,31 @@ +# Use The Command Palette + +Source: guides/use-command-palette.md +URL: /guides/use-command-palette + +# Use The Command Palette + +The command palette is the fastest way to move around Tolaria. + +Open it with: + +- `Cmd+K` on macOS. +- `Ctrl+K` on Linux and Windows. + +## Common Commands + +- New Note. +- Search. +- Open Settings. +- Reload Vault. +- Add Remote. +- Open Getting Started Vault. +- Toggle Raw Mode. +- Toggle Table of Contents. +- Toggle AI Panel. +- Use Light, Dark, or System theme. +- Open in New Window. + +## Keyboard-First Workflow + +Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-media-previews.md b/src-tauri/resources/agent-docs/pages/guides/use-media-previews.md new file mode 100644 index 0000000..768ad34 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-media-previews.md @@ -0,0 +1,30 @@ +# Use Media Previews + +Source: guides/use-media-previews.md +URL: /guides/use-media-previews + +# Use Media Previews + +Media previews let you inspect vault files without leaving Tolaria. + +## Open A File + +Select an image, PDF, media file, or unsupported file from a folder or file list. Tolaria opens supported files in the app and offers an external-open action for files that should use the system default app. + +## All Notes Visibility + +Open Settings to choose whether non-Markdown files appear in All Notes: + +- PDFs. +- Images. +- Unsupported files. + +Folder browsing still shows files in their folders even when a category is hidden from All Notes. + +## Attachments + +When you paste or drop an image into a note, Tolaria copies it into the vault and references the copied file from Markdown. + +## Troubleshooting + +If a preview does not render, open the file in the default app to confirm the file is valid, then check whether the file is inside the active vault and not blocked by operating-system permissions. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-spreadsheets.md b/src-tauri/resources/agent-docs/pages/guides/use-spreadsheets.md new file mode 100644 index 0000000..07d5553 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-spreadsheets.md @@ -0,0 +1,151 @@ +# Use Spreadsheets + +Source: guides/use-spreadsheets.md +URL: /guides/use-spreadsheets + +# Use Spreadsheets + +Tolaria spreadsheets are sheet notes: Markdown files with frontmatter and a CSV-like body that open in a spreadsheet editor when their `Display as` value is `Sheet`. + +Use a sheet note when a model needs rows, columns, calculations, or repeated numeric editing. Use a normal note when the main artifact is prose. + +## Create A Sheet + +Use the command palette action `New Sheet`, or create/open a note and set its `Display as` to `Sheet` from the Properties panel. `Type` remains separate and can still be `Note`, `Project`, `Responsibility`, or any other Tolaria type. + +When a note is a sheet: + +- the YAML frontmatter remains available for type, status, relationships, wikilinks, and custom properties +- `_display: sheet` tells Tolaria to display the note with the spreadsheet editor +- the body is the sheet itself +- there is no rich-text body around the table +- the editor switches from the text editor to the spreadsheet editor + +## Enter Values + +Click a cell and type a value. Non-formula values can be text, numbers, dates, or wikilinks. + +Press `Enter` on a selected cell to edit the cell. Press `Escape` while editing to leave cell editing and keep focus in the sheet. + +Use `Delete` or `Backspace` to clear the selected cell or range. + +## Enter Formulas + +Formulas start with `=`. + +```txt +=B2+B3-B4 +=SUM(B2:D2) +=ROUND(E6, 2) +=IF(E6>0, "Up", "Down") +``` + +Tolaria shows inline formula autocomplete while you type. The autocomplete list is built from the implemented function catalog in the bundled IronCalc engine; formula evaluation is still handled by IronCalc. + +See [Spreadsheet Formulas](/reference/spreadsheet-functions) for syntax, supported examples, and links to the full IronCalc formula reference. + +## Select And Edit Ranges + +The sheet editor follows spreadsheet conventions: + +- arrow keys move the active cell +- `Shift` plus arrow keys extends the selection +- drag to select a range +- copy and paste preserves formulas where possible +- cut and paste moves formulas and shifts relative references +- right-click a selected cell or range to apply formatting + +Right-click actions apply to the current selection. Keep a multi-cell selection active before opening the context menu when you want to format several cells together. + +## Format Cells + +Use the context menu for common formatting: + +- number formats such as plain numbers, currency, and percentages +- decimal precision +- bold and italic text +- alignment and clearing formatting when available + +Formatting is stored as plain YAML under `_sheet`, not in an opaque workbook blob. For example, percentage formatting for `E6` is stored as: + +```yaml +_sheet: + cells: + E6: + num_fmt: "0.00%" +``` + +See [Spreadsheet File Format](/reference/spreadsheet-format) for the full storage model. + +## Add Wikilinks + +Type `[[` in a cell to open note autocomplete. + +```csv +Project,Owner,Status +[[website-redesign]],[[person/alice]],Active +[[sponsorship-pipeline]],[[person/matteo]],Review +``` + +When the cell is not being edited, Tolaria renders the wikilink like other note links. When you edit the cell, the raw `[[wikilink]]` syntax is shown again. + +Command-click a wikilink in a sheet cell to open the linked note. + +## Reference Another Note + +Formulas can read a cell from another sheet note with Tolaria's wikilink cell syntax: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The part inside `[[...]]` resolves like a normal Tolaria wikilink. The part after the dot is an A1-style cell reference. + +Use absolute markers when copying formulas: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet references currently resolve single cells. Keep range formulas inside one sheet note. + +Formulas can read scalar frontmatter properties from a note with dot notation: + +```txt +=[[device]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +Numbers, booleans, and text properties can be used in formulas. Missing or ambiguous note targets, missing properties, and non-scalar values such as lists or nested objects show as spreadsheet errors. + +## Work With The Raw File + +A sheet file remains readable text: + +```md +--- +type: Project +_display: sheet +status: Draft +belongs_to: + - "[[business-plan]]" +_sheet: + frozen_rows: 1 + columns: + A: + width: 180 +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Services,800,900,750,=SUM(B3:D3) +Expenses,650,700,760,=SUM(B4:D4) +Net,=B2+B3-B4,=C2+C3-C4,=D2+D3-D4,=SUM(B5:D5) +``` + +When editing this file with scripts or AI agents, parse the body as CSV and preserve formulas as formulas. Do not replace formulas with displayed values. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-table-of-contents.md b/src-tauri/resources/agent-docs/pages/guides/use-table-of-contents.md new file mode 100644 index 0000000..f978c0e --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-table-of-contents.md @@ -0,0 +1,28 @@ +# Use The Table Of Contents + +Source: guides/use-table-of-contents.md +URL: /guides/use-table-of-contents + +# Use The Table Of Contents + +The table of contents panel helps you navigate long notes by heading. + +## Open It + +Use the editor toolbar, the command palette, or the shortcut: + +- `Cmd+Shift+T` on macOS. +- `Ctrl+Shift+T` on Windows and Linux. + +## How It Works + +Tolaria builds the outline from the current note's headings. The panel updates as the note changes and can jump to sections in the editor. + +## Good Uses + +- Long procedures. +- Meeting notes with many sections. +- Research notes. +- Generated documents that need review. + +If a note has no useful headings, add clear H2 and H3 sections rather than relying on a long uninterrupted document. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/guides/use-wikilinks.md b/src-tauri/resources/agent-docs/pages/guides/use-wikilinks.md new file mode 100644 index 0000000..b4e00ca --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/guides/use-wikilinks.md @@ -0,0 +1,29 @@ +# Use Wikilinks + +Source: guides/use-wikilinks.md +URL: /guides/use-wikilinks + +# Use Wikilinks + +Wikilinks connect notes by name. + +```md +This project belongs to [[content-systems]] and is related to [[git-workflows]]. +``` + +## Link From The Body + +Use body links when the connection is part of the sentence you are writing. + +## Link From Frontmatter + +Use frontmatter links when the relationship should become structured metadata. + +```yaml +related_to: + - "[[git-workflows]]" +``` + +## Keep Links Stable + +Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps you pick the right target while you type. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/index.md b/src-tauri/resources/agent-docs/pages/index.md new file mode 100644 index 0000000..a326166 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/index.md @@ -0,0 +1,6 @@ +# Index + +Source: index.md +URL: / + + \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/contribute.md b/src-tauri/resources/agent-docs/pages/reference/contribute.md new file mode 100644 index 0000000..4fe2672 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/contribute.md @@ -0,0 +1,37 @@ +# Contribute + +Source: reference/contribute.md +URL: /reference/contribute + +# Contribute + +Tolaria is free and open source, and any kind of help is useful. Pick the path that matches what you want to do. + +## Newsletter + +[Refactoring](https://refactoring.fm/) is Luca's newsletter and community for engineers building better teams and software with AI. Subscribing is the best way to support Tolaria. + +## Sponsors + +Tolaria is supported by a panel of tools Luca uses every day to keep the project healthy, tested, and ready for AI-assisted development: + +- [Codacy](https://www.codacy.com/) +- [CodeScene](https://codescene.com/) +- [CircleCI](https://circleci.com/) +- [Unblocked](https://getunblocked.com/) + +## Feature Requests + +Use the [product board](https://tolaria.canny.io/) for feature ideas. Search first, upvote existing ideas, and create a new post when the request is genuinely new. + +## Discussions + +Use [GitHub Discussions](https://github.com/refactoringhq/tolaria/discussions) for questions, conversations, show and tell, and broader community context. + +## Contribute Code + +Small, focused pull requests are welcome. Check the product board first so you build the right thing, then open a PR on [GitHub](https://github.com/refactoringhq/tolaria/pulls). The [contributing guide](https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md) explains the local workflow. + +## Report A Bug + +Use [GitHub Issues](https://github.com/refactoringhq/tolaria/issues) for bugs. Include what happened, what you expected, and clear reproduction steps. If you are reporting from inside Tolaria, use the Contribute panel to copy sanitized diagnostics and attach them to the issue. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/docs-maintenance.md b/src-tauri/resources/agent-docs/pages/reference/docs-maintenance.md new file mode 100644 index 0000000..b15cc24 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/docs-maintenance.md @@ -0,0 +1,43 @@ +# Docs Maintenance + +Source: reference/docs-maintenance.md +URL: /reference/docs-maintenance + +# Docs Maintenance + +The public docs live in the app repo so documentation changes can ship with behavior changes. + +## Update Docs When You Change + +- A Tauri command. +- A new component or hook that changes user behavior. +- A data model or frontmatter convention. +- Git, AI, onboarding, or release behavior. +- Public release pages, download metadata, or updater channels. +- Platform support. +- Keyboard shortcuts. + +## Suggested Workflow + +1. Make the code change. +2. Update the matching concept, guide, or reference page. +3. Add a troubleshooting page if the change creates a new failure mode. +4. Run `pnpm docs:build`. +5. Check the home page, search, release/download links, and changed docs pages in a browser. + +## Page Types + +| Type | Purpose | +| --- | --- | +| Start | Helps a new user get into the app. | +| Concepts | Explains mental models. | +| Guides | Teaches workflows. | +| Reference | Gives stable facts and tables. | +| Troubleshooting | Starts from a symptom and ends with recovery. | + +## Review Checklist + +- Does the page describe current behavior? +- Does it mention macOS primary and Windows/Linux supported-early status when platform support matters? +- Are links relative and VitePress-compatible? +- Can a user discover the page with local search? \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/file-layout.md b/src-tauri/resources/agent-docs/pages/reference/file-layout.md new file mode 100644 index 0000000..51d84c7 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/file-layout.md @@ -0,0 +1,48 @@ +# File Layout + +Source: reference/file-layout.md +URL: /reference/file-layout + +# File Layout + +Tolaria is not opinionated about folder structure. It finds notes recursively across the whole vault, stores new notes in the root by default, and uses types and relationships for real organization. + +```txt +my-vault/ + project-alpha.md + weekly-review.md + research/ + source-notes.md + attachments/ + diagram.png + source.pdf + project.md + person.md + views/ + active-projects.yml +``` + +## Root Notes + +Tolaria works well with a flat vault. Folders are optional and can be useful for compatibility with other tools, but they are not required for people, projects, topics, or any other note category. + +Type is not inferred from folder location. It comes from frontmatter, and relationships are expressed with wikilinks in fields. That is what Tolaria uses for the sidebar, Properties panel, search, custom views, and neighborhood navigation. + +## Special Folders + +| Folder | Purpose | +| --- | --- | +| `views/` | Saved custom views. | +| `attachments/` | Images and other attached files. | + +PDFs, images, and other non-Markdown files stay as normal files. Folder browsing can show them in place, and Settings controls whether PDFs, images, and unsupported files appear in All Notes. + +Whiteboards are Markdown files with durable tldraw data, so they belong with notes rather than in `attachments/`. + +Spreadsheets are also Markdown files. A note with `_display: sheet` stores ordinary frontmatter plus a CSV-like body and opens in the sheet editor. + +Type definitions are Markdown notes with `type: Type` in frontmatter. New type documents are normal notes, and existing type documents in older folders still work. + +## Git Files + +If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/frontmatter-fields.md b/src-tauri/resources/agent-docs/pages/reference/frontmatter-fields.md new file mode 100644 index 0000000..6977e34 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/frontmatter-fields.md @@ -0,0 +1,35 @@ +# Frontmatter Fields + +Source: reference/frontmatter-fields.md +URL: /reference/frontmatter-fields + +# Frontmatter Fields + +Tolaria uses conventions instead of a required schema. + +| Field | Meaning | +| --- | --- | +| `type` | The note's entity type. | +| `status` | Lifecycle state. | +| `icon` | Per-note icon. | +| `url` | External URL. | +| `date` | Single date. | +| `belongs_to` | Parent relationship. | +| `related_to` | Lateral relationship. | +| `has` | Contained relationship. | +| `_width` | Per-note editor width override. | +| `_display` | Display mode. Omit for text notes; use `sheet` for spreadsheet notes. | +| `_icon`, `_color` | Type or note appearance metadata. | +| `_sidebar_label`, `_order` | Type sidebar label and order. | +| `_pinned_properties` | Properties pinned for a type. | +| `_sheet` | Sheet-note presentation metadata such as grid settings, column widths, row heights, and cell formatting. | + +## Custom Fields + +You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship. + +## System Fields + +Fields starting with `_` are reserved for system behavior and hidden from standard property editing. They remain plain YAML, so they can still be inspected or changed in raw mode when needed. + +Nested keys under a system field are also system-owned. For example, `_sheet.cells.B6.num_fmt` belongs to the sheet editor and should not appear as a normal user property. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/keyboard-shortcuts.md b/src-tauri/resources/agent-docs/pages/reference/keyboard-shortcuts.md new file mode 100644 index 0000000..0ea896e --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/keyboard-shortcuts.md @@ -0,0 +1,29 @@ +# Keyboard Shortcuts + +Source: reference/keyboard-shortcuts.md +URL: /reference/keyboard-shortcuts + +# Keyboard Shortcuts + +| Shortcut | Action | +| --- | --- | +| `Cmd+K` / `Ctrl+K` | Open command palette. | +| `Cmd+P` / `Ctrl+P` | Quick open notes and files. | +| `Cmd+N` / `Ctrl+N` | Create a new note. | +| `Cmd+S` / `Ctrl+S` | Save current note. | +| `Cmd+F` / `Ctrl+F` | Find in the current note. | +| `Cmd+Shift+F` / `Ctrl+Shift+F` | Search the vault. | +| `Cmd+Shift+V` / `Ctrl+Shift+V` | Paste without formatting. | +| `Cmd+\` / `Ctrl+\` | Toggle raw Markdown mode. | +| `Cmd+Shift+T` / `Ctrl+Shift+T` | Toggle table of contents. | +| `Cmd+Shift+I` / `Ctrl+Shift+I` | Toggle Properties panel. | +| `Cmd+Shift+L` / `Ctrl+Shift+L` | Toggle AI panel. | +| `Cmd+[` / `Alt+Left` | Navigate back when available. | +| `Cmd+]` / `Alt+Right` | Navigate forward when available. | +| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. | +| `Cmd+D` / `Ctrl+D` | Toggle favorite for the current note. | +| `Cmd+E` / `Ctrl+E` | Mark the current Inbox note organized. | + +Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations. + +Use the command palette to discover the current command set. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/release-channels.md b/src-tauri/resources/agent-docs/pages/reference/release-channels.md new file mode 100644 index 0000000..1ae915d --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/release-channels.md @@ -0,0 +1,41 @@ +# Release Channels + +Source: reference/release-channels.md +URL: /reference/release-channels + +# Release Channels + +Tolaria publishes Stable and Alpha release metadata to GitHub Pages. + +## Stable + +Stable follows manually promoted releases. This is the right channel for normal use. + +The stable updater metadata lives at: + +```txt +/stable/latest.json +``` + +The public download page points at the latest stable release. + +## Alpha + +Alpha follows pushes to `main`. It receives fixes and features earlier, but it can be rougher than Stable. + +The alpha updater metadata lives at: + +```txt +/alpha/latest.json +``` + +Compatibility endpoints also point to the alpha metadata: + +```txt +/latest.json +/latest-canary.json +``` + +## Before Switching + +Commit or push important vault changes before changing release channel or installing an update. Your notes are local files, but a clean Git state makes recovery simpler. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/spreadsheet-format.md b/src-tauri/resources/agent-docs/pages/reference/spreadsheet-format.md new file mode 100644 index 0000000..8b61593 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/spreadsheet-format.md @@ -0,0 +1,167 @@ +# Spreadsheet File Format + +Source: reference/spreadsheet-format.md +URL: /reference/spreadsheet-format + +# Spreadsheet File Format + +Sheet notes are Markdown files with YAML frontmatter and a CSV-like body. The note uses `_display: sheet` when it should open in the spreadsheet editor. The `type` field remains ordinary semantic metadata. + +For editing workflows, see [Use Spreadsheets](/guides/use-spreadsheets). For formula syntax and function references, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +## Structure + +```md +--- +type: Project +_display: sheet +tags: + - planning +_sheet: + show_grid_lines: true + frozen_rows: 1 + frozen_columns: 1 + columns: + A: + width: 180 + rows: + "1": + height: 32 + cells: + E6: + num_fmt: "0.00%" + bold: true +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Expenses,650,700,760,=SUM(B3:D3) +Net,=B2-B3,=C2-C3,=D2-D3,=SUM(B4:D4) +Growth,,=(C4-B4)/B4,=(D4-C4)/C4,=(E4-B4)/B4 +``` + +The frontmatter stores note metadata. The body stores rows and cells. There is no Markdown table wrapper, fenced code block, or embedded workbook blob. + +## Frontmatter + +All ordinary Tolaria fields remain available: + +- `type` +- `status` +- `date` +- `tags` +- `url` +- relationship fields such as `belongs_to`, `related_to`, and custom wikilink properties + +The `_display: sheet` field is the display-as marker. Omit it for ordinary text notes. + +The `_sheet` key is reserved for spreadsheet presentation metadata. It follows the same system-field convention as other underscore-prefixed Tolaria fields: hidden from normal property editing, but visible and editable in raw source. + +## Body + +The body is CSV-like text: + +- rows are separated by line breaks +- cells are separated by commas +- cells containing commas, quotes, or line breaks are quoted +- quotes inside quoted cells are escaped by doubling them +- empty trailing rows and columns may be omitted on save + +Any cell whose input starts with `=` is treated as a formula. Other cells are treated as literal values. + +## `_sheet` Metadata + +Tolaria stores spreadsheet presentation state in `_sheet` as plain YAML. + +| Field | Meaning | +| --- | --- | +| `show_grid_lines` | Whether grid lines are shown. | +| `frozen_rows` | Number of frozen rows from the top. | +| `frozen_columns` | Number of frozen columns from the left. | +| `columns..width` | Custom column width, keyed by column letter such as `A` or `BC`. | +| `rows."".height` | Custom row height, keyed by one-based row number. | +| `cells..num_fmt` | Number format code for a cell. | +| `cells..bold` | Bold text style. | +| `cells..italic` | Italic text style. | +| `cells..underline` | Underline text style. | +| `cells..strike` | Strikethrough text style. | +| `cells..font_size` | Font size. | +| `cells..font_color` | Text color. | +| `cells..fill_color` | Cell fill color. | +| `cells..horizontal_align` | Horizontal alignment. | +| `cells..vertical_align` | Vertical alignment. | +| `cells..wrap_text` | Text wrapping. | +| `cells..border_top` | Top border style. | +| `cells..border_right` | Right border style. | +| `cells..border_bottom` | Bottom border style. | +| `cells..border_left` | Left border style. | + +Cell metadata is keyed by A1-style cell addresses such as `A1`, `B12`, or `AA30`. + +Border values are stored as a style name with an optional color, for example: + +```yaml +border_bottom: "thin #d0d7de" +``` + +## Number Formats + +Number formats are stored in `num_fmt` using spreadsheet-style format codes. Common examples: + +| Format | Example output | +| --- | --- | +| `#,##0` | `1,250` | +| `#,##0.00` | `1,250.50` | +| `0.00%` | `12.35%` | +| `$#,##0.00` | `$1,250.50` | +| `yyyy-mm-dd` | `2026-06-15` | + +These formats affect presentation, not the underlying cell input in the CSV body. + +## Markdown Style Import + +When Tolaria imports a non-formula CSV cell, simple Markdown wrappers can seed initial styles: + +| Cell text | Stored value | Style | +| --- | --- | --- | +| `**Revenue**` | `Revenue` | bold | +| `_Estimate_` | `Estimate` | italic | +| `***Total***` | `Total` | bold and italic | +| `~~Removed~~` | `Removed` | strike | + +After save, the style belongs in `_sheet` metadata and the body keeps the unwrapped text. + +## Wikilinks + +Non-formula cells can store normal Tolaria wikilinks: + +```csv +Account,Source +Newsletter,[[newsletter-revenue]] +Sponsors,[[sponsorship-pipeline]] +``` + +Formula cells can reference another sheet note with Tolaria's cross-sheet syntax: + +```txt +=[[newsletter-revenue]].B5 +=ROUND([[business-plan]].$E$12, 2) +=[[device]].power.watts +``` + +Cross-sheet cell references resolve another sheet note by wikilink target, then read a single A1-style cell. Frontmatter references resolve one note by wikilink target, then read a scalar property path after the dot. Missing, ambiguous, circular, very deep, or non-scalar references are treated as unresolved and surface as spreadsheet errors. + +## Guidance For Agents And Scripts + +When editing a sheet note programmatically: + +- preserve the YAML frontmatter delimiter and ordinary Tolaria fields +- keep `_display: sheet` when the file should display as a spreadsheet +- keep spreadsheet presentation state under `_sheet` +- parse and serialize the body as CSV, not by splitting on every comma manually +- preserve formulas as formulas, including `[[sheet]].A1` and `[[note]].property.path` references +- avoid converting formulas to their displayed values +- quote CSV cells when they contain commas, quotes, or line breaks +- do not add workbook tabs inside one note; create another note with `_display: sheet` instead +- do not store opaque binary workbook state in the Markdown file + +If a script cannot safely preserve `_sheet`, it should leave that block untouched and edit only the CSV body cells it understands. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/spreadsheet-functions.md b/src-tauri/resources/agent-docs/pages/reference/spreadsheet-functions.md new file mode 100644 index 0000000..fadd2a9 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/spreadsheet-functions.md @@ -0,0 +1,191 @@ +# Spreadsheet Formulas + +Source: reference/spreadsheet-functions.md +URL: /reference/spreadsheet-functions + +# Spreadsheet Formulas + +Formula cells start with `=` and are evaluated by IronCalc through Tolaria's sheet editor. + +Tolaria adds vault-aware sheet references on top of the normal spreadsheet formula model. Everything else should be treated as IronCalc formula behavior. IronCalc aims for Excel-compatible formulas, but the upstream project is still evolving, so verify advanced formulas against the IronCalc docs when precision matters. + +## Basic Syntax + +| Syntax | Meaning | +| --- | --- | +| `=B2+B3-B4` | Arithmetic over cells. | +| `=SUM(B2:D2)` | Function call over a range. | +| `=ROUND(E6, 2)` | Function call with arguments. | +| `=IF(E6>0, "Up", "Down")` | Conditional expression. | +| `=$B$2` | Absolute column and row reference. | +| `=B$2` | Relative column, absolute row. | +| `=$B2` | Absolute column, relative row. | +| `=B2:D10` | A range in the current sheet note. | +| `="Q" & 1` | Text concatenation. | + +Use parentheses when a model depends on precedence: + +```txt +=(B2+B3-B4)/B5 +``` + +## Tolaria Note References + +Tolaria supports wikilink cell references for values that live in another sheet note: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The target inside `[[...]]` resolves like a normal Tolaria wikilink. The cell address after the dot uses A1 notation. + +Absolute markers follow spreadsheet copy behavior: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet cell references currently resolve single cells. Keep range formulas inside one sheet note until cross-note ranges are explicitly supported. + +Formulas can also read scalar frontmatter properties from a specific note: + +```txt +=[[device.md]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +The target resolves like a wikilink, and the dot path reads nested frontmatter keys. Numbers, booleans, and strings become formula literals. Missing notes, ambiguous note targets, missing properties, arrays, maps, and other non-scalar values resolve to `#N/A`. + +A first segment that looks like an A1 cell address, such as `B2`, is treated as a cross-sheet cell reference. Use property names that do not collide with A1 notation for frontmatter formulas. + +## Autocomplete Functions + +Tolaria's formula autocomplete exposes the implemented function catalog from the bundled IronCalc engine. The current catalog has 195 functions. + +The dropdown shows a small ranked set of matches while you type. Keep typing to narrow the result list. Function names with digits and dots, such as `BIN2DEC` and `ERFC.PRECISE`, are supported. + +### Logical + +`AND`, `FALSE`, `IF`, `IFERROR`, `IFNA`, `IFS`, `NOT`, `OR`, `SWITCH`, `TRUE`, `XOR` + +### Math and trigonometry + +`ABS`, `ACOS`, `ACOSH`, `ASIN`, `ASINH`, `ATAN`, `ATAN2`, `ATANH`, `COS`, `COSH`, `PI`, `POWER`, `PRODUCT`, `RAND`, `RANDBETWEEN`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`, `SIN`, `SINH`, `SQRT`, `SQRTPI`, `SUM`, `SUMIF`, `SUMIFS`, `TAN`, `TANH`, `SUBTOTAL` + +### Lookup and reference + +`CHOOSE`, `COLUMN`, `COLUMNS`, `HLOOKUP`, `INDEX`, `INDIRECT`, `LOOKUP`, `MATCH`, `OFFSET`, `ROW`, `ROWS`, `VLOOKUP`, `XLOOKUP` + +### Text + +`CONCAT`, `CONCATENATE`, `EXACT`, `FIND`, `LEFT`, `LEN`, `LOWER`, `MID`, `REPT`, `RIGHT`, `SEARCH`, `SUBSTITUTE`, `T`, `TEXT`, `TEXTAFTER`, `TEXTBEFORE`, `TEXTJOIN`, `TRIM`, `UNICODE`, `UPPER`, `VALUE`, `VALUETOTEXT` + +### Information + +`ERROR.TYPE`, `FORMULATEXT`, `ISBLANK`, `ISERR`, `ISERROR`, `ISEVEN`, `ISFORMULA`, `ISLOGICAL`, `ISNA`, `ISNONTEXT`, `ISNUMBER`, `ISODD`, `ISREF`, `ISTEXT`, `NA`, `SHEET`, `TYPE` + +### Statistical + +`AVERAGE`, `AVERAGEA`, `AVERAGEIF`, `AVERAGEIFS`, `COUNT`, `COUNTA`, `COUNTBLANK`, `COUNTIF`, `COUNTIFS`, `GEOMEAN`, `MAX`, `MAXIFS`, `MIN`, `MINIFS` + +### Date and time + +`DATE`, `DAY`, `EDATE`, `EOMONTH`, `MONTH`, `NOW`, `TODAY`, `YEAR` + +### Financial + +`CUMIPMT`, `CUMPRINC`, `DB`, `DDB`, `DOLLARDE`, `DOLLARFR`, `EFFECT`, `FV`, `IPMT`, `IRR`, `ISPMT`, `MIRR`, `NOMINAL`, `NPER`, `NPV`, `PDURATION`, `PMT`, `PPMT`, `PV`, `RATE`, `RRI`, `SLN`, `SYD`, `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD`, `XIRR`, `XNPV` + +### Engineering + +`BESSELI`, `BESSELJ`, `BESSELK`, `BESSELY`, `BIN2DEC`, `BIN2HEX`, `BIN2OCT`, `BITAND`, `BITLSHIFT`, `BITOR`, `BITRSHIFT`, `BITXOR`, `COMPLEX`, `CONVERT`, `DEC2BIN`, `DEC2HEX`, `DEC2OCT`, `DELTA`, `ERF`, `ERF.PRECISE`, `ERFC`, `ERFC.PRECISE`, `GESTEP`, `HEX2BIN`, `HEX2DEC`, `HEX2OCT`, `IMABS`, `IMAGINARY`, `IMARGUMENT`, `IMCONJUGATE`, `IMCOS`, `IMCOSH`, `IMCOT`, `IMCSC`, `IMCSCH`, `IMDIV`, `IMEXP`, `IMLN`, `IMLOG10`, `IMLOG2`, `IMPOWER`, `IMPRODUCT`, `IMREAL`, `IMSEC`, `IMSECH`, `IMSIN`, `IMSINH`, `IMSQRT`, `IMSUB`, `IMSUM`, `IMTAN`, `OCT2BIN`, `OCT2DEC`, `OCT2HEX` + +## Examples + +### Totals + +```txt +=SUM(B2:D2) +=B2+B3-B4 +=SUM(B2:D2)-SUM(B4:D4) +``` + +### Growth And Percentages + +```txt +=(C5-B5)/B5 +=IF(B5=0, 0, (C5-B5)/B5) +=ROUND((C5-B5)/B5, 4) +``` + +Format the result as a percentage with a cell `num_fmt` such as `0.00%`. + +### Conditional Logic + +```txt +=IF(E5>10000, "On track", "Review") +=IFS(E5>10000, "High", E5>5000, "Medium", TRUE, "Low") +=IFERROR(B5/B4, 0) +``` + +### Dates + +```txt +=TODAY() +=DATE(2026, 6, 15) +=YEAR(TODAY()) +=MONTH(TODAY()) +``` + +### Text + +```txt +=CONCAT(A2, " - ", B2) +=UPPER(A2) +=TRIM(A2) +=TEXT(B2, "$#,##0.00") +``` + +### Lookup + +```txt +=INDEX(B2:E10, 3, 2) +=MATCH("Revenue", A2:A20, 0) +=VLOOKUP("Revenue", A2:E20, 5, FALSE) +=XLOOKUP("Revenue", A2:A20, E2:E20) +``` + +### Cross-Sheet Model + +```txt +=[[newsletter-revenue]].E5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=IF([[business-plan]].$E$12>0, [[business-plan]].$E$12, 0) +``` + +## IronCalc Function Families + +IronCalc documents formulas by category. Use these upstream pages for detailed syntax and examples. The upstream documentation may include newer functions that are not yet present in Tolaria's bundled IronCalc version. + +| Family | Link | +| --- | --- | +| Lookup and reference | [IronCalc lookup and reference](https://docs.ironcalc.com/functions/lookup-and-reference.html) | +| Financial | [IronCalc financial](https://docs.ironcalc.com/functions/financial.html) | +| Engineering | [IronCalc engineering](https://docs.ironcalc.com/functions/engineering.html) | +| Database | [IronCalc database](https://docs.ironcalc.com/functions/database.html) | +| Statistical | [IronCalc statistical](https://docs.ironcalc.com/functions/statistical.html) | +| Text | [IronCalc text](https://docs.ironcalc.com/functions/text.html) | +| Math and trigonometry | [IronCalc math and trigonometry](https://docs.ironcalc.com/functions/math-and-trigonometry.html) | +| Logical | [IronCalc logical](https://docs.ironcalc.com/functions/logical.html) | +| Date and time | [IronCalc date and time](https://docs.ironcalc.com/functions/date-and-time.html) | +| Information | [IronCalc information](https://docs.ironcalc.com/functions/information.html) | + +IronCalc also documents [value types](https://docs.ironcalc.com/features/value-types.html), [error types](https://docs.ironcalc.com/features/error-types.html), [optional arguments](https://docs.ironcalc.com/features/optional-arguments.html), and [formatting values](https://docs.ironcalc.com/features/formatting-values.html). + +For current upstream gaps, see IronCalc's [unsupported features](https://docs.ironcalc.com/features/unsupported-features.html). \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/supported-platforms.md b/src-tauri/resources/agent-docs/pages/reference/supported-platforms.md new file mode 100644 index 0000000..9eaf074 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/supported-platforms.md @@ -0,0 +1,28 @@ +# Supported Platforms + +Source: reference/supported-platforms.md +URL: /reference/supported-platforms + +# Supported Platforms + +Tolaria is a desktop app built with Tauri. Releases currently target macOS, Windows, and Linux. + +| Platform | Current support | Notes | +| --- | --- | --- | +| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. | +| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. | + +## Support Policy + +Primary support means the platform is part of normal development and release validation. Supported, early means release artifacts exist and the app is expected to work, but platform-specific bugs can take longer to diagnose than macOS issues. + +## Reporting Platform Bugs + +Include: + +- Tolaria version. +- Operating system and version. +- CPU architecture. +- Whether the vault is local-only or connected to a remote. +- Steps to reproduce. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/reference/view-filters.md b/src-tauri/resources/agent-docs/pages/reference/view-filters.md new file mode 100644 index 0000000..533498e --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/reference/view-filters.md @@ -0,0 +1,38 @@ +# View Filters + +Source: reference/view-filters.md +URL: /reference/view-filters + +# View Filters + +View filters define saved lists of notes. + +## Common Filter Ideas + +| Goal | Filter direction | +| --- | --- | +| Active projects | `type` is Project and `status` is Active | +| Drafts | `type` is Article and `status` is Draft | +| People follow-up | `type` is Person and date is before today | +| Recent work | modified date is within a recent range | + +## Sorting + +Useful sorts include: + +- Recently modified first. +- Title ascending. +- Status ascending. +- A custom property ascending or descending. + +## Operators + +Saved views can combine filters for text, dates, relationship fields, and frontmatter values. Relative date expressions are useful for views such as notes changed this week or people that need follow-up. + +Regex filters are available for power-user cases. Keep them narrow and test them on a small view first. + +## Keep Views Focused + +A view should answer one recurring question. If it becomes too broad, split it into two views. + +You can also customize view appearance with the same kind of icon and color controls used by types. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/start/first-launch.md b/src-tauri/resources/agent-docs/pages/start/first-launch.md new file mode 100644 index 0000000..dde0c9c --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/start/first-launch.md @@ -0,0 +1,40 @@ +# First Launch + +Source: start/first-launch.md +URL: /start/first-launch + +# First Launch + +The first launch flow is designed to get you into a real vault quickly without hiding the local-first model. + +## What You Choose + +Tolaria asks whether you want to: + +- Create or clone the Getting Started vault. +- Open an existing local vault. +- Create a new empty vault. + +The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes. + +## What Tolaria Creates + +Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose. + +| Data | Stored in | +| --- | --- | +| Notes and attachments | Your vault folder | +| Type definitions and saved views | Your vault folder | +| Window size, zoom, recent vaults | Local app settings | +| Cache data | Rebuildable local cache | + +## First Commands To Try + +- `Cmd+K` / `Ctrl+K`: open the command palette. +- `New Note`: create a note in the current vault. +- `Open Getting Started Vault`: clone the public sample vault. +- `Reload Vault`: rescan files after external edits. + +## AI Setup Prompt + +Tolaria can show an optional AI agents prompt after a vault is open. It checks common local install locations for supported coding agents and gives you setup paths, but you can dismiss it and use Tolaria without AI. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/start/getting-started-vault.md b/src-tauri/resources/agent-docs/pages/start/getting-started-vault.md new file mode 100644 index 0000000..314c1b2 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/start/getting-started-vault.md @@ -0,0 +1,37 @@ +# Getting Started Vault + +Source: start/getting-started-vault.md +URL: /start/getting-started-vault + +# Getting Started Vault + +The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started). + +It exists to show Tolaria's conventions without requiring you to restructure your own notes first. + +## What It Demonstrates + +- Markdown notes with YAML frontmatter. +- Types such as Project, Person, Topic, and Procedure. +- Wikilinks in note bodies. +- Relationship fields in frontmatter. +- A local Git repository that can be connected to a remote later. +- Vault guidance files for AI agents. + +## Local-Only By Default + +When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later. + +To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette. + +Tolaria also repairs starter-vault guidance files when needed. `AGENTS.md` is the canonical guidance file, `CLAUDE.md` is kept as a compatibility shim, and `GEMINI.md` is only created when you explicitly restore Antigravity/Gemini guidance. + +## Use It Alongside Your Own Vaults + +You can keep the Getting Started vault open while working in your own notes. Enable `Settings` -> `Vaults` -> `Use multiple vaults at the same time`, then use the bottom-left vault menu to include both the sample vault and your real vault in the unified graph. + +This lets search, quick open, note lists, backlinks, and wikilink navigation span both vaults. Git actions still stay scoped to each vault's own repository, and new notes go to the default vault you choose in `Manage vaults`. + +## When To Move On + +After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start. You can remove the sample from Tolaria's vault list later without deleting its files from disk. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/start/install.md b/src-tauri/resources/agent-docs/pages/start/install.md new file mode 100644 index 0000000..1932c14 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/start/install.md @@ -0,0 +1,45 @@ +# Install Tolaria + +Source: start/install.md +URL: /start/install + +# Install Tolaria + +Tolaria publishes desktop builds for macOS, Windows, and Linux. macOS is the primary day-to-day development target, with Windows and Linux builds supported through the release pipeline and fixed as platform issues are found. + +## Download + +Use the latest stable release unless you are intentionally testing pre-release builds: + +- Download the latest stable build +- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases) +- Read the release notes + +## Homebrew + +On macOS you can install the cask: + +```bash +brew install --cask tolaria +``` + +## Platform Status + +| Platform | Status | Notes | +| --- | --- | --- | +| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. | +| Windows | Supported, early | NSIS installers and updater bundles are Tauri-signed. Authenticode publisher signing will be added after Windows certificate provisioning; company-managed SmartScreen, Defender, or WDAC policies can still require IT approval before install. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. | + +See [Supported Platforms](/reference/supported-platforms) for the current support policy. + +## Managed Windows Devices + +Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, install Tolaria through your normal software approval path if policy blocks unsigned or unknown-publisher installers. After Authenticode provisioning is complete, validate that the downloaded installer has a valid Tolaria publisher signature before installing. + +## After Installing + +1. Open Tolaria. +2. Choose the Getting Started vault if you want a guided sample. +3. Or open an existing folder of Markdown files as a vault. +4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/start/open-or-create-vault.md b/src-tauri/resources/agent-docs/pages/start/open-or-create-vault.md new file mode 100644 index 0000000..3c51751 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/start/open-or-create-vault.md @@ -0,0 +1,35 @@ +# Open Or Create A Vault + +Source: start/open-or-create-vault.md +URL: /start/open-or-create-vault + +# Open Or Create A Vault + +A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata. + +## Open An Existing Folder + +Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists. + +Good starting points: + +- A folder of plain Markdown files. +- An Obsidian-style vault. +- A Git repository containing notes. +- A copy of the Getting Started vault. + +## Create A New Vault + +Choose a new empty folder if you want Tolaria conventions from the start. New notes and optional type definitions are created as Markdown files. + +## Use More Than One Vault + +You do not have to merge everything into one folder. Register each local folder as its own vault, then turn on `Use multiple vaults at the same time` in `Settings` -> `Vaults`. + +Once enabled, the bottom-left vault menu lets you include vaults in the unified graph. Search, quick open, wikilinks, and note lists can span the included vaults, while Git sync and commits remain tied to each vault's own repository. + +## Git Is Recommended, Not Required + +Tolaria works well with a plain folder of Markdown files. You can open, edit, organize, and search notes without making the vault a Git repository. + +Git is recommended when you want local history, diff views, recovery, pull, push, and remote sync without a proprietary backend. If a vault is not already a repository, Tolaria can initialize one when you explicitly ask it to. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/templates/portent.md b/src-tauri/resources/agent-docs/pages/templates/portent.md new file mode 100644 index 0000000..e07e136 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/templates/portent.md @@ -0,0 +1,77 @@ +# Portent + +Source: templates/portent.md +URL: /templates/portent + +# Portent + +[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases. + +It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first. + +## Core Questions + +Portent favors convention over configuration. Instead of asking "where should this go?", it asks: + +- What is this? +- What is it useful for? +- Is it captured, organized, or archived? + +Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views. + +## Types + +Portent defines eight default types. + +PORT types are actionable: + +- Project +- Operation +- Responsibility +- Task + +ENTP types are non-actionable knowledge records: + +- Event +- Note +- Topic +- Person + +These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first. + +## Relationships + +Portent models knowledge as a graph. The two default relationships are: + +- `belongs_to`: primary ownership, composition, or context. +- `related_to`: a looser semantic connection. + +In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app. + +## Lifecycle + +Portent separates capture from organization: + +1. Capture information quickly so it is not lost. +2. Organize it by assigning a type and useful relationships. +3. Archive it when it has served its purpose. + +Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available. + +## Why Use It + +A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately. + +Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals. + +## Start From The Template + +The fastest starting point is the Portent template vault: + +- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template) + +Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup. + +## Learn More + +Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/troubleshooting/ai-agent-not-found.md b/src-tauri/resources/agent-docs/pages/troubleshooting/ai-agent-not-found.md new file mode 100644 index 0000000..613407e --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/troubleshooting/ai-agent-not-found.md @@ -0,0 +1,27 @@ +# AI Agent Not Found + +Source: troubleshooting/ai-agent-not-found.md +URL: /troubleshooting/ai-agent-not-found + +# AI Agent Not Found + +Tolaria can only launch local CLI agents that are installed and discoverable. + +## Symptoms + +- The AI panel says no supported agent is available. +- Claude Code or another agent works in one shell but not in Tolaria. + +## Checks + +Open a terminal and run the agent command directly. For Claude Code: + +```bash +claude --version +``` + +If the command fails, install or repair the agent first. + +## Path Issues + +Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/troubleshooting/git-auth.md b/src-tauri/resources/agent-docs/pages/troubleshooting/git-auth.md new file mode 100644 index 0000000..f4cd498 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/troubleshooting/git-auth.md @@ -0,0 +1,30 @@ +# Git Authentication + +Source: troubleshooting/git-auth.md +URL: /troubleshooting/git-auth + +# Git Authentication + +Tolaria uses system Git authentication. It does not manage provider passwords directly. + +## Symptoms + +- Push fails. +- Pull asks for credentials repeatedly. +- Remote fetch works in one terminal but not in Tolaria. + +## Checks + +1. Open a terminal. +2. `cd` into the vault. +3. Run `git remote -v`. +4. Run `git fetch`. + +If `git fetch` fails in the terminal, fix system Git auth first. + +## Common Fixes + +- Sign in with GitHub CLI. +- Configure SSH keys. +- Update the remote URL. +- Check your credential helper. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/troubleshooting/model-provider-connection.md b/src-tauri/resources/agent-docs/pages/troubleshooting/model-provider-connection.md new file mode 100644 index 0000000..50c0840 --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/troubleshooting/model-provider-connection.md @@ -0,0 +1,30 @@ +# Model Provider Connection + +Source: troubleshooting/model-provider-connection.md +URL: /troubleshooting/model-provider-connection + +# Model Provider Connection + +Use this checklist when a local or API model provider does not connect. + +## Local Providers + +For Ollama or LM Studio: + +1. Start the local model server. +2. Confirm the base URL in Tolaria matches the server. +3. Confirm the model ID is installed and loaded by the provider. +4. Use the Settings test action again. + +## API Providers + +For hosted providers: + +1. Confirm the provider kind and endpoint. +2. Confirm the model ID exists for your account. +3. Confirm the API key is saved locally or available in the configured environment variable. +4. Avoid storing secrets in the vault. + +## Chat Mode Boundary + +Direct model targets run in chat mode. If you need file-editing tools, use a coding agent target such as Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/troubleshooting/sync-conflicts.md b/src-tauri/resources/agent-docs/pages/troubleshooting/sync-conflicts.md new file mode 100644 index 0000000..a122c9e --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/troubleshooting/sync-conflicts.md @@ -0,0 +1,24 @@ +# Sync Conflicts + +Source: troubleshooting/sync-conflicts.md +URL: /troubleshooting/sync-conflicts + +# Sync Conflicts + +Sync conflicts happen when local and remote changes touch the same content. + +## What To Do + +1. Stop editing the conflicted note. +2. Open the conflict resolver if Tolaria presents it. +3. Review both sides. +4. Choose the correct content or merge manually. +5. Commit the resolved file. +6. Push again. + +## Prevent Conflicts + +- Pull before starting work on another device. +- Push after meaningful sessions. +- Keep AI-generated edits in small commits. +- Avoid editing the same note on multiple devices at the same time. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/pages/troubleshooting/vault-not-loading.md b/src-tauri/resources/agent-docs/pages/troubleshooting/vault-not-loading.md new file mode 100644 index 0000000..de2fa5c --- /dev/null +++ b/src-tauri/resources/agent-docs/pages/troubleshooting/vault-not-loading.md @@ -0,0 +1,29 @@ +# Vault Not Loading + +Source: troubleshooting/vault-not-loading.md +URL: /troubleshooting/vault-not-loading + +# Vault Not Loading + +Use this checklist when Tolaria cannot open or refresh a vault. + +## Check The Folder + +- Confirm the folder exists. +- Confirm the folder contains readable files. +- Confirm Tolaria has permission to access the folder. +- Try opening a smaller test vault to isolate the issue. + +## Check Git + +If the vault is a Git repository, verify it is not in a broken state: + +```bash +git status +``` + +Resolve interrupted merges or corrupted repository state before retrying. + +## Reload + +Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/reference.md b/src-tauri/resources/agent-docs/reference.md new file mode 100644 index 0000000..b604115 --- /dev/null +++ b/src-tauri/resources/agent-docs/reference.md @@ -0,0 +1,684 @@ +# Contribute + +Source: reference/contribute.md +URL: /reference/contribute + +# Contribute + +Tolaria is free and open source, and any kind of help is useful. Pick the path that matches what you want to do. + +## Newsletter + +[Refactoring](https://refactoring.fm/) is Luca's newsletter and community for engineers building better teams and software with AI. Subscribing is the best way to support Tolaria. + +## Sponsors + +Tolaria is supported by a panel of tools Luca uses every day to keep the project healthy, tested, and ready for AI-assisted development: + +- [Codacy](https://www.codacy.com/) +- [CodeScene](https://codescene.com/) +- [CircleCI](https://circleci.com/) +- [Unblocked](https://getunblocked.com/) + +## Feature Requests + +Use the [product board](https://tolaria.canny.io/) for feature ideas. Search first, upvote existing ideas, and create a new post when the request is genuinely new. + +## Discussions + +Use [GitHub Discussions](https://github.com/refactoringhq/tolaria/discussions) for questions, conversations, show and tell, and broader community context. + +## Contribute Code + +Small, focused pull requests are welcome. Check the product board first so you build the right thing, then open a PR on [GitHub](https://github.com/refactoringhq/tolaria/pulls). The [contributing guide](https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md) explains the local workflow. + +## Report A Bug + +Use [GitHub Issues](https://github.com/refactoringhq/tolaria/issues) for bugs. Include what happened, what you expected, and clear reproduction steps. If you are reporting from inside Tolaria, use the Contribute panel to copy sanitized diagnostics and attach them to the issue. + +--- + +# Docs Maintenance + +Source: reference/docs-maintenance.md +URL: /reference/docs-maintenance + +# Docs Maintenance + +The public docs live in the app repo so documentation changes can ship with behavior changes. + +## Update Docs When You Change + +- A Tauri command. +- A new component or hook that changes user behavior. +- A data model or frontmatter convention. +- Git, AI, onboarding, or release behavior. +- Public release pages, download metadata, or updater channels. +- Platform support. +- Keyboard shortcuts. + +## Suggested Workflow + +1. Make the code change. +2. Update the matching concept, guide, or reference page. +3. Add a troubleshooting page if the change creates a new failure mode. +4. Run `pnpm docs:build`. +5. Check the home page, search, release/download links, and changed docs pages in a browser. + +## Page Types + +| Type | Purpose | +| --- | --- | +| Start | Helps a new user get into the app. | +| Concepts | Explains mental models. | +| Guides | Teaches workflows. | +| Reference | Gives stable facts and tables. | +| Troubleshooting | Starts from a symptom and ends with recovery. | + +## Review Checklist + +- Does the page describe current behavior? +- Does it mention macOS primary and Windows/Linux supported-early status when platform support matters? +- Are links relative and VitePress-compatible? +- Can a user discover the page with local search? + +--- + +# File Layout + +Source: reference/file-layout.md +URL: /reference/file-layout + +# File Layout + +Tolaria is not opinionated about folder structure. It finds notes recursively across the whole vault, stores new notes in the root by default, and uses types and relationships for real organization. + +```txt +my-vault/ + project-alpha.md + weekly-review.md + research/ + source-notes.md + attachments/ + diagram.png + source.pdf + project.md + person.md + views/ + active-projects.yml +``` + +## Root Notes + +Tolaria works well with a flat vault. Folders are optional and can be useful for compatibility with other tools, but they are not required for people, projects, topics, or any other note category. + +Type is not inferred from folder location. It comes from frontmatter, and relationships are expressed with wikilinks in fields. That is what Tolaria uses for the sidebar, Properties panel, search, custom views, and neighborhood navigation. + +## Special Folders + +| Folder | Purpose | +| --- | --- | +| `views/` | Saved custom views. | +| `attachments/` | Images and other attached files. | + +PDFs, images, and other non-Markdown files stay as normal files. Folder browsing can show them in place, and Settings controls whether PDFs, images, and unsupported files appear in All Notes. + +Whiteboards are Markdown files with durable tldraw data, so they belong with notes rather than in `attachments/`. + +Spreadsheets are also Markdown files. A note with `_display: sheet` stores ordinary frontmatter plus a CSV-like body and opens in the sheet editor. + +Type definitions are Markdown notes with `type: Type` in frontmatter. New type documents are normal notes, and existing type documents in older folders still work. + +## Git Files + +If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes. + +--- + +# Frontmatter Fields + +Source: reference/frontmatter-fields.md +URL: /reference/frontmatter-fields + +# Frontmatter Fields + +Tolaria uses conventions instead of a required schema. + +| Field | Meaning | +| --- | --- | +| `type` | The note's entity type. | +| `status` | Lifecycle state. | +| `icon` | Per-note icon. | +| `url` | External URL. | +| `date` | Single date. | +| `belongs_to` | Parent relationship. | +| `related_to` | Lateral relationship. | +| `has` | Contained relationship. | +| `_width` | Per-note editor width override. | +| `_display` | Display mode. Omit for text notes; use `sheet` for spreadsheet notes. | +| `_icon`, `_color` | Type or note appearance metadata. | +| `_sidebar_label`, `_order` | Type sidebar label and order. | +| `_pinned_properties` | Properties pinned for a type. | +| `_sheet` | Sheet-note presentation metadata such as grid settings, column widths, row heights, and cell formatting. | + +## Custom Fields + +You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship. + +## System Fields + +Fields starting with `_` are reserved for system behavior and hidden from standard property editing. They remain plain YAML, so they can still be inspected or changed in raw mode when needed. + +Nested keys under a system field are also system-owned. For example, `_sheet.cells.B6.num_fmt` belongs to the sheet editor and should not appear as a normal user property. + +--- + +# Keyboard Shortcuts + +Source: reference/keyboard-shortcuts.md +URL: /reference/keyboard-shortcuts + +# Keyboard Shortcuts + +| Shortcut | Action | +| --- | --- | +| `Cmd+K` / `Ctrl+K` | Open command palette. | +| `Cmd+P` / `Ctrl+P` | Quick open notes and files. | +| `Cmd+N` / `Ctrl+N` | Create a new note. | +| `Cmd+S` / `Ctrl+S` | Save current note. | +| `Cmd+F` / `Ctrl+F` | Find in the current note. | +| `Cmd+Shift+F` / `Ctrl+Shift+F` | Search the vault. | +| `Cmd+Shift+V` / `Ctrl+Shift+V` | Paste without formatting. | +| `Cmd+\` / `Ctrl+\` | Toggle raw Markdown mode. | +| `Cmd+Shift+T` / `Ctrl+Shift+T` | Toggle table of contents. | +| `Cmd+Shift+I` / `Ctrl+Shift+I` | Toggle Properties panel. | +| `Cmd+Shift+L` / `Ctrl+Shift+L` | Toggle AI panel. | +| `Cmd+[` / `Alt+Left` | Navigate back when available. | +| `Cmd+]` / `Alt+Right` | Navigate forward when available. | +| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. | +| `Cmd+D` / `Ctrl+D` | Toggle favorite for the current note. | +| `Cmd+E` / `Ctrl+E` | Mark the current Inbox note organized. | + +Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations. + +Use the command palette to discover the current command set. + +--- + +# Release Channels + +Source: reference/release-channels.md +URL: /reference/release-channels + +# Release Channels + +Tolaria publishes Stable and Alpha release metadata to GitHub Pages. + +## Stable + +Stable follows manually promoted releases. This is the right channel for normal use. + +The stable updater metadata lives at: + +```txt +/stable/latest.json +``` + +The public download page points at the latest stable release. + +## Alpha + +Alpha follows pushes to `main`. It receives fixes and features earlier, but it can be rougher than Stable. + +The alpha updater metadata lives at: + +```txt +/alpha/latest.json +``` + +Compatibility endpoints also point to the alpha metadata: + +```txt +/latest.json +/latest-canary.json +``` + +## Before Switching + +Commit or push important vault changes before changing release channel or installing an update. Your notes are local files, but a clean Git state makes recovery simpler. + +--- + +# Spreadsheet File Format + +Source: reference/spreadsheet-format.md +URL: /reference/spreadsheet-format + +# Spreadsheet File Format + +Sheet notes are Markdown files with YAML frontmatter and a CSV-like body. The note uses `_display: sheet` when it should open in the spreadsheet editor. The `type` field remains ordinary semantic metadata. + +For editing workflows, see [Use Spreadsheets](/guides/use-spreadsheets). For formula syntax and function references, see [Spreadsheet Formulas](/reference/spreadsheet-functions). + +## Structure + +```md +--- +type: Project +_display: sheet +tags: + - planning +_sheet: + show_grid_lines: true + frozen_rows: 1 + frozen_columns: 1 + columns: + A: + width: 180 + rows: + "1": + height: 32 + cells: + E6: + num_fmt: "0.00%" + bold: true +--- +Metric,January,February,March,Q1 Total +Subscriptions,1200,1350,1500,=SUM(B2:D2) +Expenses,650,700,760,=SUM(B3:D3) +Net,=B2-B3,=C2-C3,=D2-D3,=SUM(B4:D4) +Growth,,=(C4-B4)/B4,=(D4-C4)/C4,=(E4-B4)/B4 +``` + +The frontmatter stores note metadata. The body stores rows and cells. There is no Markdown table wrapper, fenced code block, or embedded workbook blob. + +## Frontmatter + +All ordinary Tolaria fields remain available: + +- `type` +- `status` +- `date` +- `tags` +- `url` +- relationship fields such as `belongs_to`, `related_to`, and custom wikilink properties + +The `_display: sheet` field is the display-as marker. Omit it for ordinary text notes. + +The `_sheet` key is reserved for spreadsheet presentation metadata. It follows the same system-field convention as other underscore-prefixed Tolaria fields: hidden from normal property editing, but visible and editable in raw source. + +## Body + +The body is CSV-like text: + +- rows are separated by line breaks +- cells are separated by commas +- cells containing commas, quotes, or line breaks are quoted +- quotes inside quoted cells are escaped by doubling them +- empty trailing rows and columns may be omitted on save + +Any cell whose input starts with `=` is treated as a formula. Other cells are treated as literal values. + +## `_sheet` Metadata + +Tolaria stores spreadsheet presentation state in `_sheet` as plain YAML. + +| Field | Meaning | +| --- | --- | +| `show_grid_lines` | Whether grid lines are shown. | +| `frozen_rows` | Number of frozen rows from the top. | +| `frozen_columns` | Number of frozen columns from the left. | +| `columns..width` | Custom column width, keyed by column letter such as `A` or `BC`. | +| `rows."".height` | Custom row height, keyed by one-based row number. | +| `cells..num_fmt` | Number format code for a cell. | +| `cells..bold` | Bold text style. | +| `cells..italic` | Italic text style. | +| `cells..underline` | Underline text style. | +| `cells..strike` | Strikethrough text style. | +| `cells..font_size` | Font size. | +| `cells..font_color` | Text color. | +| `cells..fill_color` | Cell fill color. | +| `cells..horizontal_align` | Horizontal alignment. | +| `cells..vertical_align` | Vertical alignment. | +| `cells..wrap_text` | Text wrapping. | +| `cells..border_top` | Top border style. | +| `cells..border_right` | Right border style. | +| `cells..border_bottom` | Bottom border style. | +| `cells..border_left` | Left border style. | + +Cell metadata is keyed by A1-style cell addresses such as `A1`, `B12`, or `AA30`. + +Border values are stored as a style name with an optional color, for example: + +```yaml +border_bottom: "thin #d0d7de" +``` + +## Number Formats + +Number formats are stored in `num_fmt` using spreadsheet-style format codes. Common examples: + +| Format | Example output | +| --- | --- | +| `#,##0` | `1,250` | +| `#,##0.00` | `1,250.50` | +| `0.00%` | `12.35%` | +| `$#,##0.00` | `$1,250.50` | +| `yyyy-mm-dd` | `2026-06-15` | + +These formats affect presentation, not the underlying cell input in the CSV body. + +## Markdown Style Import + +When Tolaria imports a non-formula CSV cell, simple Markdown wrappers can seed initial styles: + +| Cell text | Stored value | Style | +| --- | --- | --- | +| `**Revenue**` | `Revenue` | bold | +| `_Estimate_` | `Estimate` | italic | +| `***Total***` | `Total` | bold and italic | +| `~~Removed~~` | `Removed` | strike | + +After save, the style belongs in `_sheet` metadata and the body keeps the unwrapped text. + +## Wikilinks + +Non-formula cells can store normal Tolaria wikilinks: + +```csv +Account,Source +Newsletter,[[newsletter-revenue]] +Sponsors,[[sponsorship-pipeline]] +``` + +Formula cells can reference another sheet note with Tolaria's cross-sheet syntax: + +```txt +=[[newsletter-revenue]].B5 +=ROUND([[business-plan]].$E$12, 2) +=[[device]].power.watts +``` + +Cross-sheet cell references resolve another sheet note by wikilink target, then read a single A1-style cell. Frontmatter references resolve one note by wikilink target, then read a scalar property path after the dot. Missing, ambiguous, circular, very deep, or non-scalar references are treated as unresolved and surface as spreadsheet errors. + +## Guidance For Agents And Scripts + +When editing a sheet note programmatically: + +- preserve the YAML frontmatter delimiter and ordinary Tolaria fields +- keep `_display: sheet` when the file should display as a spreadsheet +- keep spreadsheet presentation state under `_sheet` +- parse and serialize the body as CSV, not by splitting on every comma manually +- preserve formulas as formulas, including `[[sheet]].A1` and `[[note]].property.path` references +- avoid converting formulas to their displayed values +- quote CSV cells when they contain commas, quotes, or line breaks +- do not add workbook tabs inside one note; create another note with `_display: sheet` instead +- do not store opaque binary workbook state in the Markdown file + +If a script cannot safely preserve `_sheet`, it should leave that block untouched and edit only the CSV body cells it understands. + +--- + +# Spreadsheet Formulas + +Source: reference/spreadsheet-functions.md +URL: /reference/spreadsheet-functions + +# Spreadsheet Formulas + +Formula cells start with `=` and are evaluated by IronCalc through Tolaria's sheet editor. + +Tolaria adds vault-aware sheet references on top of the normal spreadsheet formula model. Everything else should be treated as IronCalc formula behavior. IronCalc aims for Excel-compatible formulas, but the upstream project is still evolving, so verify advanced formulas against the IronCalc docs when precision matters. + +## Basic Syntax + +| Syntax | Meaning | +| --- | --- | +| `=B2+B3-B4` | Arithmetic over cells. | +| `=SUM(B2:D2)` | Function call over a range. | +| `=ROUND(E6, 2)` | Function call with arguments. | +| `=IF(E6>0, "Up", "Down")` | Conditional expression. | +| `=$B$2` | Absolute column and row reference. | +| `=B$2` | Relative column, absolute row. | +| `=$B2` | Absolute column, relative row. | +| `=B2:D10` | A range in the current sheet note. | +| `="Q" & 1` | Text concatenation. | + +Use parentheses when a model depends on precedence: + +```txt +=(B2+B3-B4)/B5 +``` + +## Tolaria Note References + +Tolaria supports wikilink cell references for values that live in another sheet note: + +```txt +=[[newsletter-revenue]].B5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=ROUND([[business-plan]].$E$12, 2) +``` + +The target inside `[[...]]` resolves like a normal Tolaria wikilink. The cell address after the dot uses A1 notation. + +Absolute markers follow spreadsheet copy behavior: + +| Reference | Copy behavior | +| --- | --- | +| `[[revenue]].B5` | row and column can shift | +| `[[revenue]].$B$5` | row and column stay fixed | +| `[[revenue]].B$5` | row fixed, column can shift | +| `[[revenue]].$B5` | column fixed, row can shift | + +Cross-sheet cell references currently resolve single cells. Keep range formulas inside one sheet note until cross-note ranges are explicitly supported. + +Formulas can also read scalar frontmatter properties from a specific note: + +```txt +=[[device.md]].power.watts +=[[project-alpha]].status +=[[book-notes/the-design-of-everyday-things.md]].rating +``` + +The target resolves like a wikilink, and the dot path reads nested frontmatter keys. Numbers, booleans, and strings become formula literals. Missing notes, ambiguous note targets, missing properties, arrays, maps, and other non-scalar values resolve to `#N/A`. + +A first segment that looks like an A1 cell address, such as `B2`, is treated as a cross-sheet cell reference. Use property names that do not collide with A1 notation for frontmatter formulas. + +## Autocomplete Functions + +Tolaria's formula autocomplete exposes the implemented function catalog from the bundled IronCalc engine. The current catalog has 195 functions. + +The dropdown shows a small ranked set of matches while you type. Keep typing to narrow the result list. Function names with digits and dots, such as `BIN2DEC` and `ERFC.PRECISE`, are supported. + +### Logical + +`AND`, `FALSE`, `IF`, `IFERROR`, `IFNA`, `IFS`, `NOT`, `OR`, `SWITCH`, `TRUE`, `XOR` + +### Math and trigonometry + +`ABS`, `ACOS`, `ACOSH`, `ASIN`, `ASINH`, `ATAN`, `ATAN2`, `ATANH`, `COS`, `COSH`, `PI`, `POWER`, `PRODUCT`, `RAND`, `RANDBETWEEN`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`, `SIN`, `SINH`, `SQRT`, `SQRTPI`, `SUM`, `SUMIF`, `SUMIFS`, `TAN`, `TANH`, `SUBTOTAL` + +### Lookup and reference + +`CHOOSE`, `COLUMN`, `COLUMNS`, `HLOOKUP`, `INDEX`, `INDIRECT`, `LOOKUP`, `MATCH`, `OFFSET`, `ROW`, `ROWS`, `VLOOKUP`, `XLOOKUP` + +### Text + +`CONCAT`, `CONCATENATE`, `EXACT`, `FIND`, `LEFT`, `LEN`, `LOWER`, `MID`, `REPT`, `RIGHT`, `SEARCH`, `SUBSTITUTE`, `T`, `TEXT`, `TEXTAFTER`, `TEXTBEFORE`, `TEXTJOIN`, `TRIM`, `UNICODE`, `UPPER`, `VALUE`, `VALUETOTEXT` + +### Information + +`ERROR.TYPE`, `FORMULATEXT`, `ISBLANK`, `ISERR`, `ISERROR`, `ISEVEN`, `ISFORMULA`, `ISLOGICAL`, `ISNA`, `ISNONTEXT`, `ISNUMBER`, `ISODD`, `ISREF`, `ISTEXT`, `NA`, `SHEET`, `TYPE` + +### Statistical + +`AVERAGE`, `AVERAGEA`, `AVERAGEIF`, `AVERAGEIFS`, `COUNT`, `COUNTA`, `COUNTBLANK`, `COUNTIF`, `COUNTIFS`, `GEOMEAN`, `MAX`, `MAXIFS`, `MIN`, `MINIFS` + +### Date and time + +`DATE`, `DAY`, `EDATE`, `EOMONTH`, `MONTH`, `NOW`, `TODAY`, `YEAR` + +### Financial + +`CUMIPMT`, `CUMPRINC`, `DB`, `DDB`, `DOLLARDE`, `DOLLARFR`, `EFFECT`, `FV`, `IPMT`, `IRR`, `ISPMT`, `MIRR`, `NOMINAL`, `NPER`, `NPV`, `PDURATION`, `PMT`, `PPMT`, `PV`, `RATE`, `RRI`, `SLN`, `SYD`, `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD`, `XIRR`, `XNPV` + +### Engineering + +`BESSELI`, `BESSELJ`, `BESSELK`, `BESSELY`, `BIN2DEC`, `BIN2HEX`, `BIN2OCT`, `BITAND`, `BITLSHIFT`, `BITOR`, `BITRSHIFT`, `BITXOR`, `COMPLEX`, `CONVERT`, `DEC2BIN`, `DEC2HEX`, `DEC2OCT`, `DELTA`, `ERF`, `ERF.PRECISE`, `ERFC`, `ERFC.PRECISE`, `GESTEP`, `HEX2BIN`, `HEX2DEC`, `HEX2OCT`, `IMABS`, `IMAGINARY`, `IMARGUMENT`, `IMCONJUGATE`, `IMCOS`, `IMCOSH`, `IMCOT`, `IMCSC`, `IMCSCH`, `IMDIV`, `IMEXP`, `IMLN`, `IMLOG10`, `IMLOG2`, `IMPOWER`, `IMPRODUCT`, `IMREAL`, `IMSEC`, `IMSECH`, `IMSIN`, `IMSINH`, `IMSQRT`, `IMSUB`, `IMSUM`, `IMTAN`, `OCT2BIN`, `OCT2DEC`, `OCT2HEX` + +## Examples + +### Totals + +```txt +=SUM(B2:D2) +=B2+B3-B4 +=SUM(B2:D2)-SUM(B4:D4) +``` + +### Growth And Percentages + +```txt +=(C5-B5)/B5 +=IF(B5=0, 0, (C5-B5)/B5) +=ROUND((C5-B5)/B5, 4) +``` + +Format the result as a percentage with a cell `num_fmt` such as `0.00%`. + +### Conditional Logic + +```txt +=IF(E5>10000, "On track", "Review") +=IFS(E5>10000, "High", E5>5000, "Medium", TRUE, "Low") +=IFERROR(B5/B4, 0) +``` + +### Dates + +```txt +=TODAY() +=DATE(2026, 6, 15) +=YEAR(TODAY()) +=MONTH(TODAY()) +``` + +### Text + +```txt +=CONCAT(A2, " - ", B2) +=UPPER(A2) +=TRIM(A2) +=TEXT(B2, "$#,##0.00") +``` + +### Lookup + +```txt +=INDEX(B2:E10, 3, 2) +=MATCH("Revenue", A2:A20, 0) +=VLOOKUP("Revenue", A2:E20, 5, FALSE) +=XLOOKUP("Revenue", A2:A20, E2:E20) +``` + +### Cross-Sheet Model + +```txt +=[[newsletter-revenue]].E5 +=SUM(B2:D2)+[[sponsorship-pipeline]].E12 +=IF([[business-plan]].$E$12>0, [[business-plan]].$E$12, 0) +``` + +## IronCalc Function Families + +IronCalc documents formulas by category. Use these upstream pages for detailed syntax and examples. The upstream documentation may include newer functions that are not yet present in Tolaria's bundled IronCalc version. + +| Family | Link | +| --- | --- | +| Lookup and reference | [IronCalc lookup and reference](https://docs.ironcalc.com/functions/lookup-and-reference.html) | +| Financial | [IronCalc financial](https://docs.ironcalc.com/functions/financial.html) | +| Engineering | [IronCalc engineering](https://docs.ironcalc.com/functions/engineering.html) | +| Database | [IronCalc database](https://docs.ironcalc.com/functions/database.html) | +| Statistical | [IronCalc statistical](https://docs.ironcalc.com/functions/statistical.html) | +| Text | [IronCalc text](https://docs.ironcalc.com/functions/text.html) | +| Math and trigonometry | [IronCalc math and trigonometry](https://docs.ironcalc.com/functions/math-and-trigonometry.html) | +| Logical | [IronCalc logical](https://docs.ironcalc.com/functions/logical.html) | +| Date and time | [IronCalc date and time](https://docs.ironcalc.com/functions/date-and-time.html) | +| Information | [IronCalc information](https://docs.ironcalc.com/functions/information.html) | + +IronCalc also documents [value types](https://docs.ironcalc.com/features/value-types.html), [error types](https://docs.ironcalc.com/features/error-types.html), [optional arguments](https://docs.ironcalc.com/features/optional-arguments.html), and [formatting values](https://docs.ironcalc.com/features/formatting-values.html). + +For current upstream gaps, see IronCalc's [unsupported features](https://docs.ironcalc.com/features/unsupported-features.html). + +--- + +# Supported Platforms + +Source: reference/supported-platforms.md +URL: /reference/supported-platforms + +# Supported Platforms + +Tolaria is a desktop app built with Tauri. Releases currently target macOS, Windows, and Linux. + +| Platform | Current support | Notes | +| --- | --- | --- | +| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. | +| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. | + +## Support Policy + +Primary support means the platform is part of normal development and release validation. Supported, early means release artifacts exist and the app is expected to work, but platform-specific bugs can take longer to diagnose than macOS issues. + +## Reporting Platform Bugs + +Include: + +- Tolaria version. +- Operating system and version. +- CPU architecture. +- Whether the vault is local-only or connected to a remote. +- Steps to reproduce. + +--- + +# View Filters + +Source: reference/view-filters.md +URL: /reference/view-filters + +# View Filters + +View filters define saved lists of notes. + +## Common Filter Ideas + +| Goal | Filter direction | +| --- | --- | +| Active projects | `type` is Project and `status` is Active | +| Drafts | `type` is Article and `status` is Draft | +| People follow-up | `type` is Person and date is before today | +| Recent work | modified date is within a recent range | + +## Sorting + +Useful sorts include: + +- Recently modified first. +- Title ascending. +- Status ascending. +- A custom property ascending or descending. + +## Operators + +Saved views can combine filters for text, dates, relationship fields, and frontmatter values. Relative date expressions are useful for views such as notes changed this week or people that need follow-up. + +Regex filters are available for power-user cases. Keep them narrow and test them on a small view first. + +## Keep Views Focused + +A view should answer one recurring question. If it becomes too broad, split it into two views. + +You can also customize view appearance with the same kind of icon and color controls used by types. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/search-index.json b/src-tauri/resources/agent-docs/search-index.json new file mode 100644 index 0000000..9cd4598 --- /dev/null +++ b/src-tauri/resources/agent-docs/search-index.json @@ -0,0 +1,560 @@ +[ + { + "title": "Index", + "path": "pages/index.md", + "url": "/", + "section": "home", + "headings": [] + }, + { + "title": "First Launch", + "path": "pages/start/first-launch.md", + "url": "/start/first-launch", + "section": "start", + "headings": [ + "What You Choose", + "What Tolaria Creates", + "First Commands To Try", + "AI Setup Prompt" + ] + }, + { + "title": "Getting Started Vault", + "path": "pages/start/getting-started-vault.md", + "url": "/start/getting-started-vault", + "section": "start", + "headings": [ + "What It Demonstrates", + "Local-Only By Default", + "Use It Alongside Your Own Vaults", + "When To Move On" + ] + }, + { + "title": "Install Tolaria", + "path": "pages/start/install.md", + "url": "/start/install", + "section": "start", + "headings": [ + "Download", + "Homebrew", + "Platform Status", + "Managed Windows Devices", + "After Installing" + ] + }, + { + "title": "Open Or Create A Vault", + "path": "pages/start/open-or-create-vault.md", + "url": "/start/open-or-create-vault", + "section": "start", + "headings": [ + "Open An Existing Folder", + "Create A New Vault", + "Use More Than One Vault", + "Git Is Recommended, Not Required" + ] + }, + { + "title": "AI", + "path": "pages/concepts/ai.md", + "url": "/concepts/ai", + "section": "concepts", + "headings": [ + "Coding Agents", + "Direct Models", + "External MCP Setup", + "Why Git Matters For AI" + ] + }, + { + "title": "Editor", + "path": "pages/concepts/editor.md", + "url": "/concepts/editor", + "section": "concepts", + "headings": [ + "Rich Editing", + "Raw Mode", + "Table Of Contents", + "Width" + ] + }, + { + "title": "Files And Media", + "path": "pages/concepts/files-and-media.md", + "url": "/concepts/files-and-media", + "section": "concepts", + "headings": [ + "Mermaid Diagrams", + "Attachments", + "Previews", + "Whiteboards", + "Git Boundary" + ] + }, + { + "title": "Git", + "path": "pages/concepts/git.md", + "url": "/concepts/git", + "section": "concepts", + "headings": [ + "What Tolaria Uses Git For", + "History And Diffs", + "Local Commits", + "Remotes" + ] + }, + { + "title": "Inbox", + "path": "pages/concepts/inbox.md", + "url": "/concepts/inbox", + "section": "concepts", + "headings": [ + "Why It Exists", + "Organizing Inbox Notes", + "Healthy Inbox Habit" + ] + }, + { + "title": "Notes", + "path": "pages/concepts/notes.md", + "url": "/concepts/notes", + "section": "concepts", + "headings": [ + "Anatomy", + "Titles", + "Body Links", + "Frontmatter" + ] + }, + { + "title": "Properties", + "path": "pages/concepts/properties.md", + "url": "/concepts/properties", + "section": "concepts", + "headings": [ + "Suggested Properties", + "System Properties", + "Property Editing" + ] + }, + { + "title": "Relationships", + "path": "pages/concepts/relationships.md", + "url": "/concepts/relationships", + "section": "concepts", + "headings": [ + "Relationship Fields", + "Body Links Versus Relationship Fields", + "Backlinks" + ] + }, + { + "title": "Spreadsheets", + "path": "pages/concepts/spreadsheets.md", + "url": "/concepts/spreadsheets", + "section": "concepts", + "headings": [ + "Read Next", + "Why Sheet Notes", + "One Note, One Sheet", + "Editing", + "Wikilinks In Cells", + "Note Reference Formulas", + "Storage", + "Formulas" + ] + }, + { + "title": "Types", + "path": "pages/concepts/types.md", + "url": "/concepts/types", + "section": "concepts", + "headings": [ + "Type Field", + "Prefer Types Over Folders", + "Type Documents", + "What Types Control", + "New Note Defaults" + ] + }, + { + "title": "Vaults", + "path": "pages/concepts/vaults.md", + "url": "/concepts/vaults", + "section": "concepts", + "headings": [ + "Core Rules", + "Why Local Files Matter", + "Git Is A Capability", + "Multiple Vaults At The Same Time", + "App State Versus Vault State" + ] + }, + { + "title": "Build Custom Views", + "path": "pages/guides/build-custom-views.md", + "url": "/guides/build-custom-views", + "section": "guides", + "headings": [ + "Good View Candidates", + "View Definition", + "Filters", + "Design The Question First" + ] + }, + { + "title": "Capture A Note", + "path": "pages/guides/capture-a-note.md", + "url": "/guides/capture-a-note", + "section": "guides", + "headings": [ + "Steps", + "Capture Well", + "When To Add Structure Immediately" + ] + }, + { + "title": "Manage Git Manually Or With AutoGit", + "path": "pages/guides/commit-and-push.md", + "url": "/guides/commit-and-push", + "section": "guides", + "headings": [ + "Manual Git", + "AutoGit", + "Use Small Commits" + ] + }, + { + "title": "Configure AI Models", + "path": "pages/guides/configure-ai-models.md", + "url": "/guides/configure-ai-models", + "section": "guides", + "headings": [ + "Local Models", + "API Models", + "Test The Connection", + "Select The Target" + ] + }, + { + "title": "Connect A Git Remote", + "path": "pages/guides/connect-a-git-remote.md", + "url": "/guides/connect-a-git-remote", + "section": "guides", + "headings": [ + "Before You Start", + "Steps", + "Recommended Auth" + ] + }, + { + "title": "Create Types", + "path": "pages/guides/create-types.md", + "url": "/guides/create-types", + "section": "guides", + "headings": [ + "Steps", + "Use Types Sparingly", + "Templates" + ] + }, + { + "title": "Manage Display Preferences", + "path": "pages/guides/manage-display-preferences.md", + "url": "/guides/manage-display-preferences", + "section": "guides", + "headings": [ + "Theme", + "Note Width", + "Sidebar Labels", + "Vault Content" + ] + }, + { + "title": "Organize The Inbox", + "path": "pages/guides/organize-inbox.md", + "url": "/guides/organize-inbox", + "section": "guides", + "headings": [ + "Remove A Note From Inbox", + "Review Checklist", + "Make Notes Navigable", + "Avoid Over-Structuring" + ] + }, + { + "title": "Use The AI", + "path": "pages/guides/use-ai-panel.md", + "url": "/guides/use-ai-panel", + "section": "guides", + "headings": [ + "Choose How To Prompt", + "Choose A Target", + "Permission Mode", + "Good Requests", + "Review Changes" + ] + }, + { + "title": "Use The Command Palette", + "path": "pages/guides/use-command-palette.md", + "url": "/guides/use-command-palette", + "section": "guides", + "headings": [ + "Common Commands", + "Keyboard-First Workflow" + ] + }, + { + "title": "Use Media Previews", + "path": "pages/guides/use-media-previews.md", + "url": "/guides/use-media-previews", + "section": "guides", + "headings": [ + "Open A File", + "All Notes Visibility", + "Attachments", + "Troubleshooting" + ] + }, + { + "title": "Use Spreadsheets", + "path": "pages/guides/use-spreadsheets.md", + "url": "/guides/use-spreadsheets", + "section": "guides", + "headings": [ + "Create A Sheet", + "Enter Values", + "Enter Formulas", + "Select And Edit Ranges", + "Format Cells", + "Add Wikilinks", + "Reference Another Note", + "Work With The Raw File" + ] + }, + { + "title": "Use The Table Of Contents", + "path": "pages/guides/use-table-of-contents.md", + "url": "/guides/use-table-of-contents", + "section": "guides", + "headings": [ + "Open It", + "How It Works", + "Good Uses" + ] + }, + { + "title": "Use Wikilinks", + "path": "pages/guides/use-wikilinks.md", + "url": "/guides/use-wikilinks", + "section": "guides", + "headings": [ + "Link From The Body", + "Link From Frontmatter", + "Keep Links Stable" + ] + }, + { + "title": "Portent", + "path": "pages/templates/portent.md", + "url": "/templates/portent", + "section": "templates", + "headings": [ + "Core Questions", + "Types", + "Relationships", + "Lifecycle", + "Why Use It", + "Start From The Template", + "Learn More" + ] + }, + { + "title": "Contribute", + "path": "pages/reference/contribute.md", + "url": "/reference/contribute", + "section": "reference", + "headings": [ + "Newsletter", + "Sponsors", + "Feature Requests", + "Discussions", + "Contribute Code", + "Report A Bug" + ] + }, + { + "title": "Docs Maintenance", + "path": "pages/reference/docs-maintenance.md", + "url": "/reference/docs-maintenance", + "section": "reference", + "headings": [ + "Update Docs When You Change", + "Suggested Workflow", + "Page Types", + "Review Checklist" + ] + }, + { + "title": "File Layout", + "path": "pages/reference/file-layout.md", + "url": "/reference/file-layout", + "section": "reference", + "headings": [ + "Root Notes", + "Special Folders", + "Git Files" + ] + }, + { + "title": "Frontmatter Fields", + "path": "pages/reference/frontmatter-fields.md", + "url": "/reference/frontmatter-fields", + "section": "reference", + "headings": [ + "Custom Fields", + "System Fields" + ] + }, + { + "title": "Keyboard Shortcuts", + "path": "pages/reference/keyboard-shortcuts.md", + "url": "/reference/keyboard-shortcuts", + "section": "reference", + "headings": [] + }, + { + "title": "Release Channels", + "path": "pages/reference/release-channels.md", + "url": "/reference/release-channels", + "section": "reference", + "headings": [ + "Stable", + "Alpha", + "Before Switching" + ] + }, + { + "title": "Spreadsheet File Format", + "path": "pages/reference/spreadsheet-format.md", + "url": "/reference/spreadsheet-format", + "section": "reference", + "headings": [ + "Structure", + "Frontmatter", + "Body", + "`_sheet` Metadata", + "Number Formats", + "Markdown Style Import", + "Wikilinks", + "Guidance For Agents And Scripts" + ] + }, + { + "title": "Spreadsheet Formulas", + "path": "pages/reference/spreadsheet-functions.md", + "url": "/reference/spreadsheet-functions", + "section": "reference", + "headings": [ + "Basic Syntax", + "Tolaria Note References", + "Autocomplete Functions", + "Logical", + "Math and trigonometry", + "Lookup and reference", + "Text", + "Information", + "Statistical", + "Date and time", + "Financial", + "Engineering", + "Examples", + "Totals", + "Growth And Percentages", + "Conditional Logic", + "Dates", + "Text", + "Lookup", + "Cross-Sheet Model", + "IronCalc Function Families" + ] + }, + { + "title": "Supported Platforms", + "path": "pages/reference/supported-platforms.md", + "url": "/reference/supported-platforms", + "section": "reference", + "headings": [ + "Support Policy", + "Reporting Platform Bugs" + ] + }, + { + "title": "View Filters", + "path": "pages/reference/view-filters.md", + "url": "/reference/view-filters", + "section": "reference", + "headings": [ + "Common Filter Ideas", + "Sorting", + "Operators", + "Keep Views Focused" + ] + }, + { + "title": "AI Agent Not Found", + "path": "pages/troubleshooting/ai-agent-not-found.md", + "url": "/troubleshooting/ai-agent-not-found", + "section": "troubleshooting", + "headings": [ + "Symptoms", + "Checks", + "Path Issues" + ] + }, + { + "title": "Git Authentication", + "path": "pages/troubleshooting/git-auth.md", + "url": "/troubleshooting/git-auth", + "section": "troubleshooting", + "headings": [ + "Symptoms", + "Checks", + "Common Fixes" + ] + }, + { + "title": "Model Provider Connection", + "path": "pages/troubleshooting/model-provider-connection.md", + "url": "/troubleshooting/model-provider-connection", + "section": "troubleshooting", + "headings": [ + "Local Providers", + "API Providers", + "Chat Mode Boundary" + ] + }, + { + "title": "Sync Conflicts", + "path": "pages/troubleshooting/sync-conflicts.md", + "url": "/troubleshooting/sync-conflicts", + "section": "troubleshooting", + "headings": [ + "What To Do", + "Prevent Conflicts" + ] + }, + { + "title": "Vault Not Loading", + "path": "pages/troubleshooting/vault-not-loading.md", + "url": "/troubleshooting/vault-not-loading", + "section": "troubleshooting", + "headings": [ + "Check The Folder", + "Check Git", + "Reload" + ] + } +] diff --git a/src-tauri/resources/agent-docs/start.md b/src-tauri/resources/agent-docs/start.md new file mode 100644 index 0000000..4151092 --- /dev/null +++ b/src-tauri/resources/agent-docs/start.md @@ -0,0 +1,166 @@ +# First Launch + +Source: start/first-launch.md +URL: /start/first-launch + +# First Launch + +The first launch flow is designed to get you into a real vault quickly without hiding the local-first model. + +## What You Choose + +Tolaria asks whether you want to: + +- Create or clone the Getting Started vault. +- Open an existing local vault. +- Create a new empty vault. + +The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes. + +## What Tolaria Creates + +Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose. + +| Data | Stored in | +| --- | --- | +| Notes and attachments | Your vault folder | +| Type definitions and saved views | Your vault folder | +| Window size, zoom, recent vaults | Local app settings | +| Cache data | Rebuildable local cache | + +## First Commands To Try + +- `Cmd+K` / `Ctrl+K`: open the command palette. +- `New Note`: create a note in the current vault. +- `Open Getting Started Vault`: clone the public sample vault. +- `Reload Vault`: rescan files after external edits. + +## AI Setup Prompt + +Tolaria can show an optional AI agents prompt after a vault is open. It checks common local install locations for supported coding agents and gives you setup paths, but you can dismiss it and use Tolaria without AI. + +--- + +# Getting Started Vault + +Source: start/getting-started-vault.md +URL: /start/getting-started-vault + +# Getting Started Vault + +The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started). + +It exists to show Tolaria's conventions without requiring you to restructure your own notes first. + +## What It Demonstrates + +- Markdown notes with YAML frontmatter. +- Types such as Project, Person, Topic, and Procedure. +- Wikilinks in note bodies. +- Relationship fields in frontmatter. +- A local Git repository that can be connected to a remote later. +- Vault guidance files for AI agents. + +## Local-Only By Default + +When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later. + +To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette. + +Tolaria also repairs starter-vault guidance files when needed. `AGENTS.md` is the canonical guidance file, `CLAUDE.md` is kept as a compatibility shim, and `GEMINI.md` is only created when you explicitly restore Antigravity/Gemini guidance. + +## Use It Alongside Your Own Vaults + +You can keep the Getting Started vault open while working in your own notes. Enable `Settings` -> `Vaults` -> `Use multiple vaults at the same time`, then use the bottom-left vault menu to include both the sample vault and your real vault in the unified graph. + +This lets search, quick open, note lists, backlinks, and wikilink navigation span both vaults. Git actions still stay scoped to each vault's own repository, and new notes go to the default vault you choose in `Manage vaults`. + +## When To Move On + +After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start. You can remove the sample from Tolaria's vault list later without deleting its files from disk. + +--- + +# Install Tolaria + +Source: start/install.md +URL: /start/install + +# Install Tolaria + +Tolaria publishes desktop builds for macOS, Windows, and Linux. macOS is the primary day-to-day development target, with Windows and Linux builds supported through the release pipeline and fixed as platform issues are found. + +## Download + +Use the latest stable release unless you are intentionally testing pre-release builds: + +- Download the latest stable build +- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases) +- Read the release notes + +## Homebrew + +On macOS you can install the cask: + +```bash +brew install --cask tolaria +``` + +## Platform Status + +| Platform | Status | Notes | +| --- | --- | --- | +| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. | +| Windows | Supported, early | NSIS installers and updater bundles are Tauri-signed. Authenticode publisher signing will be added after Windows certificate provisioning; company-managed SmartScreen, Defender, or WDAC policies can still require IT approval before install. | +| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. | + +See [Supported Platforms](/reference/supported-platforms) for the current support policy. + +## Managed Windows Devices + +Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, install Tolaria through your normal software approval path if policy blocks unsigned or unknown-publisher installers. After Authenticode provisioning is complete, validate that the downloaded installer has a valid Tolaria publisher signature before installing. + +## After Installing + +1. Open Tolaria. +2. Choose the Getting Started vault if you want a guided sample. +3. Or open an existing folder of Markdown files as a vault. +4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows. + +--- + +# Open Or Create A Vault + +Source: start/open-or-create-vault.md +URL: /start/open-or-create-vault + +# Open Or Create A Vault + +A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata. + +## Open An Existing Folder + +Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists. + +Good starting points: + +- A folder of plain Markdown files. +- An Obsidian-style vault. +- A Git repository containing notes. +- A copy of the Getting Started vault. + +## Create A New Vault + +Choose a new empty folder if you want Tolaria conventions from the start. New notes and optional type definitions are created as Markdown files. + +## Use More Than One Vault + +You do not have to merge everything into one folder. Register each local folder as its own vault, then turn on `Use multiple vaults at the same time` in `Settings` -> `Vaults`. + +Once enabled, the bottom-left vault menu lets you include vaults in the unified graph. Search, quick open, wikilinks, and note lists can span the included vaults, while Git sync and commits remain tied to each vault's own repository. + +## Git Is Recommended, Not Required + +Tolaria works well with a plain folder of Markdown files. You can open, edit, organize, and search notes without making the vault a Git repository. + +Git is recommended when you want local history, diff views, recovery, pull, push, and remote sync without a proprietary backend. If a vault is not already a repository, Tolaria can initialize one when you explicitly ask it to. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/templates.md b/src-tauri/resources/agent-docs/templates.md new file mode 100644 index 0000000..e07e136 --- /dev/null +++ b/src-tauri/resources/agent-docs/templates.md @@ -0,0 +1,77 @@ +# Portent + +Source: templates/portent.md +URL: /templates/portent + +# Portent + +[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases. + +It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first. + +## Core Questions + +Portent favors convention over configuration. Instead of asking "where should this go?", it asks: + +- What is this? +- What is it useful for? +- Is it captured, organized, or archived? + +Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views. + +## Types + +Portent defines eight default types. + +PORT types are actionable: + +- Project +- Operation +- Responsibility +- Task + +ENTP types are non-actionable knowledge records: + +- Event +- Note +- Topic +- Person + +These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first. + +## Relationships + +Portent models knowledge as a graph. The two default relationships are: + +- `belongs_to`: primary ownership, composition, or context. +- `related_to`: a looser semantic connection. + +In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app. + +## Lifecycle + +Portent separates capture from organization: + +1. Capture information quickly so it is not lost. +2. Organize it by assigning a type and useful relationships. +3. Archive it when it has served its purpose. + +Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available. + +## Why Use It + +A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately. + +Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals. + +## Start From The Template + +The fastest starting point is the Portent template vault: + +- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template) + +Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup. + +## Learn More + +Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes. \ No newline at end of file diff --git a/src-tauri/resources/agent-docs/troubleshooting.md b/src-tauri/resources/agent-docs/troubleshooting.md new file mode 100644 index 0000000..1e27b9a --- /dev/null +++ b/src-tauri/resources/agent-docs/troubleshooting.md @@ -0,0 +1,152 @@ +# AI Agent Not Found + +Source: troubleshooting/ai-agent-not-found.md +URL: /troubleshooting/ai-agent-not-found + +# AI Agent Not Found + +Tolaria can only launch local CLI agents that are installed and discoverable. + +## Symptoms + +- The AI panel says no supported agent is available. +- Claude Code or another agent works in one shell but not in Tolaria. + +## Checks + +Open a terminal and run the agent command directly. For Claude Code: + +```bash +claude --version +``` + +If the command fails, install or repair the agent first. + +## Path Issues + +Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell. + +--- + +# Git Authentication + +Source: troubleshooting/git-auth.md +URL: /troubleshooting/git-auth + +# Git Authentication + +Tolaria uses system Git authentication. It does not manage provider passwords directly. + +## Symptoms + +- Push fails. +- Pull asks for credentials repeatedly. +- Remote fetch works in one terminal but not in Tolaria. + +## Checks + +1. Open a terminal. +2. `cd` into the vault. +3. Run `git remote -v`. +4. Run `git fetch`. + +If `git fetch` fails in the terminal, fix system Git auth first. + +## Common Fixes + +- Sign in with GitHub CLI. +- Configure SSH keys. +- Update the remote URL. +- Check your credential helper. + +--- + +# Model Provider Connection + +Source: troubleshooting/model-provider-connection.md +URL: /troubleshooting/model-provider-connection + +# Model Provider Connection + +Use this checklist when a local or API model provider does not connect. + +## Local Providers + +For Ollama or LM Studio: + +1. Start the local model server. +2. Confirm the base URL in Tolaria matches the server. +3. Confirm the model ID is installed and loaded by the provider. +4. Use the Settings test action again. + +## API Providers + +For hosted providers: + +1. Confirm the provider kind and endpoint. +2. Confirm the model ID exists for your account. +3. Confirm the API key is saved locally or available in the configured environment variable. +4. Avoid storing secrets in the vault. + +## Chat Mode Boundary + +Direct model targets run in chat mode. If you need file-editing tools, use a coding agent target such as Claude Code, Codex, OpenCode, Pi, or Antigravity CLI. + +--- + +# Sync Conflicts + +Source: troubleshooting/sync-conflicts.md +URL: /troubleshooting/sync-conflicts + +# Sync Conflicts + +Sync conflicts happen when local and remote changes touch the same content. + +## What To Do + +1. Stop editing the conflicted note. +2. Open the conflict resolver if Tolaria presents it. +3. Review both sides. +4. Choose the correct content or merge manually. +5. Commit the resolved file. +6. Push again. + +## Prevent Conflicts + +- Pull before starting work on another device. +- Push after meaningful sessions. +- Keep AI-generated edits in small commits. +- Avoid editing the same note on multiple devices at the same time. + +--- + +# Vault Not Loading + +Source: troubleshooting/vault-not-loading.md +URL: /troubleshooting/vault-not-loading + +# Vault Not Loading + +Use this checklist when Tolaria cannot open or refresh a vault. + +## Check The Folder + +- Confirm the folder exists. +- Confirm the folder contains readable files. +- Confirm Tolaria has permission to access the folder. +- Try opening a smaller test vault to isolate the issue. + +## Check Git + +If the vault is a Git repository, verify it is not in a broken state: + +```bash +git status +``` + +Resolve interrupted merges or corrupted repository state before retrying. + +## Reload + +Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem. \ No newline at end of file diff --git a/src-tauri/src/ai_agent_processes.rs b/src-tauri/src/ai_agent_processes.rs new file mode 100644 index 0000000..9180c3c --- /dev/null +++ b/src-tauri/src/ai_agent_processes.rs @@ -0,0 +1,140 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::process::{Child, ExitStatus}; +use std::sync::{Arc, Mutex, OnceLock}; + +type SharedChild = Arc>; + +thread_local! { + static CURRENT_STREAM_ID: RefCell> = const { RefCell::new(None) }; +} + +static ACTIVE_CHILDREN: OnceLock>> = OnceLock::new(); + +pub(crate) struct RegisteredAiAgentChild { + stream_id: Option, + child: SharedChild, +} + +struct StreamIdGuard { + previous: Option, +} + +impl Drop for StreamIdGuard { + fn drop(&mut self) { + CURRENT_STREAM_ID.with(|cell| { + cell.replace(self.previous.take()); + }); + } +} + +impl Drop for RegisteredAiAgentChild { + fn drop(&mut self) { + let Some(stream_id) = self.stream_id.as_deref() else { + return; + }; + + if let Ok(mut children) = active_children().lock() { + if children + .get(stream_id) + .is_some_and(|child| Arc::ptr_eq(child, &self.child)) + { + children.remove(stream_id); + } + } + } +} + +impl RegisteredAiAgentChild { + pub(crate) fn wait(&self) -> Result { + let mut child = self + .child + .lock() + .map_err(|_| "AI agent process lock was poisoned".to_string())?; + child + .wait() + .map_err(|error| format!("Wait failed: {error}")) + } +} + +pub(crate) fn with_stream_id(stream_id: String, run: impl FnOnce() -> T) -> T { + let guard = CURRENT_STREAM_ID.with(|cell| StreamIdGuard { + previous: cell.replace(Some(stream_id)), + }); + let result = run(); + drop(guard); + result +} + +pub(crate) fn register_current_stream_child(child: Child) -> RegisteredAiAgentChild { + let stream_id = current_stream_id(); + let child = Arc::new(Mutex::new(child)); + + if let Some(stream_id) = stream_id.as_deref() { + if let Ok(mut children) = active_children().lock() { + children.insert(stream_id.to_string(), child.clone()); + } + } + + RegisteredAiAgentChild { stream_id, child } +} + +pub(crate) fn abort_stream(stream_id: &str) -> Result { + let child = active_children() + .lock() + .map_err(|_| "AI agent process registry lock was poisoned".to_string())? + .get(stream_id) + .cloned(); + + let Some(child) = child else { + return Ok(false); + }; + + let mut child = child + .lock() + .map_err(|_| "AI agent process lock was poisoned".to_string())?; + if child + .try_wait() + .map_err(|error| format!("Failed to inspect AI agent process: {error}"))? + .is_some() + { + return Ok(false); + } + + child + .kill() + .map_err(|error| format!("Failed to stop AI agent process: {error}"))?; + Ok(true) +} + +fn active_children() -> &'static Mutex> { + ACTIVE_CHILDREN.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn current_stream_id() -> Option { + CURRENT_STREAM_ID.with(|cell| cell.borrow().clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn abort_stream_kills_registered_child() { + let child = std::process::Command::new("sh") + .arg("-c") + .arg("sleep 30") + .spawn() + .unwrap(); + + with_stream_id("ai-agent-stream-test".into(), || { + let registered = register_current_stream_child(child); + + assert!(abort_stream("ai-agent-stream-test").unwrap()); + let status = registered.wait().unwrap(); + + assert!(!status.success()); + }); + } +} diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs new file mode 100644 index 0000000..4cd74be --- /dev/null +++ b/src-tauri/src/ai_agents.rs @@ -0,0 +1,448 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::task::JoinHandle; + +const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiAgentId { + ClaudeCode, + Codex, + Copilot, + Opencode, + Pi, + #[serde(alias = "gemini")] + Antigravity, + Kiro, + Hermes, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AiAgentPermissionMode { + #[default] + Safe, + PowerUser, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AiAgentAvailability { + pub installed: bool, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AiAgentsStatus { + pub claude_code: AiAgentAvailability, + pub codex: AiAgentAvailability, + pub copilot: AiAgentAvailability, + pub opencode: AiAgentAvailability, + pub pi: AiAgentAvailability, + pub antigravity: AiAgentAvailability, + pub kiro: AiAgentAvailability, + pub hermes: AiAgentAvailability, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind")] +pub enum AiAgentStreamEvent { + Init { + session_id: String, + }, + TextDelta { + text: String, + }, + ThinkingDelta { + text: String, + }, + ToolStart { + tool_name: String, + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option, + }, + ToolDone { + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + }, + Error { + message: String, + }, + Done, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AiAgentStreamRequest { + pub agent: AiAgentId, + pub message: String, + pub system_prompt: Option, + pub vault_path: String, + #[serde(default)] + pub vault_paths: Vec, + pub permission_mode: Option, + #[serde(default)] + pub event_name: Option, +} + +impl AiAgentStreamRequest { + fn permission_mode(&self) -> AiAgentPermissionMode { + self.permission_mode.unwrap_or_default() + } +} + +/// Probe every supported AI-agent CLI in parallel. +/// +/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s +/// when the binary is missing and we fall through to the login-shell +/// fallback (`/bin/zsh -lc 'command -v '` etc., evaluating the full +/// shell startup). Running them sequentially used to add ~5 s to cold start +/// when no agents are installed. Fan them out across Tokio's blocking pool +/// so the user-perceived wall time is the slowest single probe rather than +/// the sum of all supported probes. +/// +/// A panicking probe is mapped to `installed: false` so the IPC handler +/// always returns a fully populated `AiAgentsStatus` and the frontend can +/// keep rendering. +pub async fn get_ai_agents_status() -> AiAgentsStatus { + let claude = tokio::task::spawn_blocking(availability_from_claude); + let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli); + let copilot = tokio::task::spawn_blocking(crate::copilot_cli::check_cli); + let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli); + let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli); + let antigravity = tokio::task::spawn_blocking(crate::antigravity_cli::check_cli); + let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli); + let hermes = tokio::task::spawn_blocking(crate::hermes_cli::check_cli); + + let (claude, codex, copilot, opencode, pi, antigravity, kiro, hermes) = tokio::join!( + availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(copilot, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(antigravity, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(hermes, AI_AGENT_STATUS_PROBE_TIMEOUT) + ); + + AiAgentsStatus { + claude_code: claude, + codex, + copilot, + opencode, + pi, + antigravity, + kiro, + hermes, + } +} + +async fn availability_or_missing( + probe: JoinHandle, + timeout: Duration, +) -> AiAgentAvailability { + match tokio::time::timeout(timeout, probe).await { + Ok(Ok(availability)) => availability, + Ok(Err(_)) | Err(_) => missing_availability(), + } +} + +fn missing_availability() -> AiAgentAvailability { + AiAgentAvailability { + installed: false, + version: None, + } +} + +pub fn run_ai_agent_stream(request: AiAgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let permission_mode = request.permission_mode(); + match request.agent { + AiAgentId::ClaudeCode => run_claude_agent_stream(request, permission_mode, emit), + AiAgentId::Codex => run_shared_agent_stream( + request, + permission_mode, + crate::codex_cli::run_agent_stream, + emit, + ), + AiAgentId::Copilot => run_shared_agent_stream( + request, + permission_mode, + crate::copilot_cli::run_agent_stream, + emit, + ), + AiAgentId::Opencode => run_shared_agent_stream( + request, + permission_mode, + crate::opencode_cli::run_agent_stream, + emit, + ), + AiAgentId::Pi => run_shared_agent_stream( + request, + permission_mode, + crate::pi_cli::run_agent_stream, + emit, + ), + AiAgentId::Antigravity => run_shared_agent_stream( + request, + permission_mode, + crate::antigravity_cli::run_agent_stream, + emit, + ), + AiAgentId::Kiro => run_shared_agent_stream( + request, + permission_mode, + crate::kiro_cli::run_agent_stream, + emit, + ), + AiAgentId::Hermes => run_shared_agent_stream( + request, + permission_mode, + crate::hermes_cli::run_agent_stream, + emit, + ), + } +} + +fn run_claude_agent_stream( + request: AiAgentStreamRequest, + permission_mode: AiAgentPermissionMode, + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let mapped = crate::claude_cli::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + vault_paths: request.vault_paths, + permission_mode, + }; + crate::claude_cli::run_agent_stream(mapped, |event| { + if let Some(mapped_event) = map_claude_event(event) { + emit(mapped_event); + } + }) +} + +fn run_shared_agent_stream( + request: AiAgentStreamRequest, + permission_mode: AiAgentPermissionMode, + runner: R, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), + R: FnOnce(crate::cli_agent_runtime::AgentStreamRequest, F) -> Result, +{ + let mapped = crate::cli_agent_runtime::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + vault_paths: request.vault_paths, + permission_mode, + }; + runner(mapped, emit) +} + +fn availability_from_claude() -> AiAgentAvailability { + let status = crate::claude_cli::check_cli(); + AiAgentAvailability { + installed: status.installed, + version: status.version, + } +} + +fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option { + match event { + crate::claude_cli::ClaudeStreamEvent::Init { session_id } => { + Some(AiAgentStreamEvent::Init { session_id }) + } + crate::claude_cli::ClaudeStreamEvent::TextDelta { text } => { + Some(AiAgentStreamEvent::TextDelta { text }) + } + crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text } => { + Some(AiAgentStreamEvent::ThinkingDelta { text }) + } + crate::claude_cli::ClaudeStreamEvent::ToolStart { + tool_name, + tool_id, + input, + } => Some(AiAgentStreamEvent::ToolStart { + tool_name, + tool_id, + input, + }), + crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id, output } => { + Some(AiAgentStreamEvent::ToolDone { tool_id, output }) + } + crate::claude_cli::ClaudeStreamEvent::Error { message } => { + Some(AiAgentStreamEvent::Error { message }) + } + crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done), + crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => { + Some(AiAgentStreamEvent::TextDelta { text }) + } + crate::claude_cli::ClaudeStreamEvent::Result { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request_with_permission( + permission_mode: Option, + ) -> AiAgentStreamRequest { + AiAgentStreamRequest { + agent: AiAgentId::Codex, + message: "Summarize this vault".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode, + event_name: None, + } + } + + #[test] + fn stream_request_uses_default_or_explicit_permission_mode() { + assert_eq!( + request_with_permission(None).permission_mode(), + AiAgentPermissionMode::Safe + ); + assert_eq!( + request_with_permission(Some(AiAgentPermissionMode::PowerUser)).permission_mode(), + AiAgentPermissionMode::PowerUser + ); + } + + #[tokio::test] + async fn normalize_status_contains_all_agents() { + let status = get_ai_agents_status().await; + let install_flags = [ + status.claude_code.installed, + status.codex.installed, + status.copilot.installed, + status.opencode.installed, + status.pi.installed, + status.antigravity.installed, + status.kiro.installed, + status.hermes.installed, + ]; + + assert!(install_flags + .iter() + .all(|installed| matches!(installed, true | false))); + } + + #[tokio::test] + async fn availability_probe_timeout_returns_missing_status() { + let handle = tokio::task::spawn_blocking(|| { + std::thread::sleep(std::time::Duration::from_millis(50)); + AiAgentAvailability { + installed: true, + version: Some("late".into()), + } + }); + + let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await; + + assert!(!status.installed); + assert_eq!(status.version, None); + } + + #[test] + fn map_claude_done_event_preserves_completion_signal() { + let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done); + + assert!(matches!(mapped, Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn map_claude_text_events_preserve_stream_data() { + assert!(matches!( + map_claude_event(crate::claude_cli::ClaudeStreamEvent::Init { + session_id: "session-1".into(), + }), + Some(AiAgentStreamEvent::Init { session_id }) if session_id == "session-1" + )); + assert!(matches!( + map_claude_event(crate::claude_cli::ClaudeStreamEvent::TextDelta { + text: "visible output".into(), + }), + Some(AiAgentStreamEvent::TextDelta { text }) if text == "visible output" + )); + assert!(matches!( + map_claude_event(crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { + text: "thinking".into(), + }), + Some(AiAgentStreamEvent::ThinkingDelta { text }) if text == "thinking" + )); + } + + #[test] + fn map_claude_tool_events_preserve_stream_data() { + let started = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolStart { + tool_name: "Read".into(), + tool_id: "tool-1".into(), + input: Some("{\"file\":\"note.md\"}".into()), + }); + let finished = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolDone { + tool_id: "tool-1".into(), + output: Some("done".into()), + }); + + assert!(matches!( + started, + Some(AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }) + if tool_name == "Read" + && tool_id == "tool-1" + && input.as_deref() == Some("{\"file\":\"note.md\"}") + )); + assert!(matches!( + finished, + Some(AiAgentStreamEvent::ToolDone { tool_id, output }) + if tool_id == "tool-1" && output.as_deref() == Some("done") + )); + } + + #[test] + fn map_claude_error_event_preserves_message() { + let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Error { + message: "missing auth".into(), + }); + + assert!(matches!( + mapped, + Some(AiAgentStreamEvent::Error { message }) if message == "missing auth" + )); + } + + #[test] + fn map_claude_result_event_preserves_final_text() { + let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result { + text: "Final answer from Claude".into(), + session_id: "session-1".into(), + }); + + assert!(matches!( + mapped, + Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude" + )); + } + + #[test] + fn map_claude_empty_result_event_is_ignored() { + let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result { + text: String::new(), + session_id: "session-1".into(), + }); + + assert!(mapped.is_none()); + } +} diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs new file mode 100644 index 0000000..97192fa --- /dev/null +++ b/src-tauri/src/ai_model_tools.rs @@ -0,0 +1,623 @@ +use crate::ai_agents::AiAgentStreamEvent; +use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest}; +use std::path::{Path, PathBuf}; + +const CREATE_NOTE_TOOL_NAME: &str = "create_note"; +const CREATE_NOTE_TOOL_JSON: &str = r#"{ + "type": "function", + "function": { + "name": "create_note", + "description": "Create a new markdown note inside the active Tolaria vault without overwriting existing files.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path inside the vault, or an absolute path inside the active vault. Must end in .md." + }, + "content": { + "type": "string", + "description": "Full markdown note content, including YAML frontmatter and H1 when needed." + }, + "title": { + "type": "string", + "description": "Optional title used only when content is omitted." + }, + "type": { + "type": "string", + "description": "Optional note type used only when content is omitted." + }, + "is_a": { + "type": "string", + "description": "Legacy alias for type, used only when content is omitted." + }, + "vaultPath": { + "type": "string", + "description": "Optional target vault root when multiple vaults are active." + } + }, + "required": ["path"], + "additionalProperties": false + } + } +}"#; + +struct OpenAiToolCall { + id: String, + name: String, + arguments: serde_json::Value, + raw_arguments: String, +} + +struct CreatedNoteToolResult { + summary: String, + output: String, +} + +pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value { + let mut payload = serde_json::json!({ + "model": request.model_id, + "messages": openai_chat_messages(request), + "stream": false + }); + if should_offer_openai_tools(request) { + payload["tools"] = serde_json::Value::Array(vec![openai_create_note_tool()]); + payload["tool_choice"] = serde_json::Value::String("auto".into()); + } + payload +} + +pub(crate) fn execute_openai_tool_calls( + request: &AiModelStreamRequest, + json: &serde_json::Value, + emit: F, +) -> Result, String> +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_calls = openai_tool_calls(json)?; + if tool_calls.is_empty() { + return Ok(None); + } + run_openai_tool_calls(request, &tool_calls, emit).map(Some) +} + +fn openai_chat_messages(request: &AiModelStreamRequest) -> Vec { + let mut messages = Vec::new(); + if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) { + messages.push(serde_json::json!({ "role": "system", "content": system_prompt })); + } + messages.push(serde_json::json!({ "role": "user", "content": request.message })); + messages +} + +fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool { + let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some(); + has_active_vault + && (request.provider.kind == AiModelProviderKind::OpenAi + || selected_model_supports_tools(request)) +} + +fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool { + request + .provider + .models + .iter() + .find(|model| model.id == request.model_id) + .is_some_and(|model| model.capabilities.tools) +} + +fn openai_create_note_tool() -> serde_json::Value { + serde_json::from_str(CREATE_NOTE_TOOL_JSON).expect("create_note tool schema must be valid JSON") +} + +fn run_openai_tool_calls( + request: &AiModelStreamRequest, + tool_calls: &[OpenAiToolCall], + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let mut summaries = Vec::new(); + for tool_call in tool_calls { + summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?); + } + Ok(summaries.join("\n")) +} + +fn execute_openai_tool_call( + request: &AiModelStreamRequest, + tool_call: &OpenAiToolCall, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + if tool_call.name != CREATE_NOTE_TOOL_NAME { + return Err(format!( + "AI provider requested unsupported tool: {}", + tool_call.name + )); + } + + emit(AiAgentStreamEvent::ToolStart { + tool_name: CREATE_NOTE_TOOL_NAME.into(), + tool_id: tool_call.id.clone(), + input: Some(tool_call.raw_arguments.clone()), + }); + + match create_note_from_tool_args(request, &tool_call.arguments) { + Ok(result) => { + emit(AiAgentStreamEvent::ToolDone { + tool_id: tool_call.id.clone(), + output: Some(result.output), + }); + Ok(result.summary) + } + Err(error) => { + emit(AiAgentStreamEvent::ToolDone { + tool_id: tool_call.id.clone(), + output: Some(format!("Error: {error}")), + }); + Err(error) + } + } +} + +fn openai_tool_calls(json: &serde_json::Value) -> Result, String> { + let Some(calls) = json["choices"][0]["message"]["tool_calls"].as_array() else { + return Ok(Vec::new()); + }; + + calls + .iter() + .enumerate() + .map(|(index, call)| openai_tool_call(index, call)) + .collect() +} + +fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result { + let function = &call["function"]; + let name = function["name"] + .as_str() + .ok_or_else(|| "AI provider tool call did not include a function name.".to_string())? + .to_string(); + let (arguments, raw_arguments) = parse_tool_arguments(&function["arguments"])?; + let id = call["id"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| format!("tool_call_{index}")); + Ok(OpenAiToolCall { + id, + name, + arguments, + raw_arguments, + }) +} + +fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value, String), String> { + if let Some(raw) = value.as_str() { + let parsed = serde_json::from_str(raw) + .map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?; + return Ok((parsed, raw.to_string())); + } + if value.is_object() { + let raw = serde_json::to_string(value) + .map_err(|error| format!("Failed to serialize AI tool arguments: {error}"))?; + return Ok((value.clone(), raw)); + } + Ok((serde_json::json!({}), "{}".into())) +} + +fn create_note_from_tool_args( + request: &AiModelStreamRequest, + args: &serde_json::Value, +) -> Result { + let note_path = required_tool_string(args, "path")?; + let content = create_note_tool_content(args, note_path); + let vault_path = tool_vault_path(request, args)?; + crate::commands::create_note_content( + PathBuf::from(note_path), + content, + Some(PathBuf::from(vault_path)), + )?; + let output = serde_json::json!({ + "path": note_path, + "vaultPath": vault_path, + }) + .to_string(); + Ok(CreatedNoteToolResult { + summary: format!("Created note: {note_path}"), + output, + }) +} + +fn tool_vault_path<'a>( + request: &'a AiModelStreamRequest, + args: &'a serde_json::Value, +) -> Result<&'a str, String> { + if let Some(vault_path) = string_arg(args, "vaultPath") { + return active_tool_vault_path(request, vault_path); + } + request + .vault_path + .as_deref() + .and_then(non_empty_str) + .ok_or_else(|| "No active vault is available for create_note.".to_string()) +} + +fn active_tool_vault_path<'a>( + request: &'a AiModelStreamRequest, + vault_path: &'a str, +) -> Result<&'a str, String> { + if active_vault_paths(request).any(|active| active == vault_path) { + Ok(vault_path) + } else { + Err(format!("Vault is not active in Tolaria: {vault_path}")) + } +} + +fn active_vault_paths(request: &AiModelStreamRequest) -> impl Iterator { + request + .vault_path + .as_deref() + .into_iter() + .chain(request.vault_paths.iter().map(String::as_str)) + .filter_map(non_empty_str) +} + +fn create_note_tool_content(args: &serde_json::Value, note_path: &str) -> String { + if let Some(content) = content_arg(args, "content") { + return content.to_string(); + } + let title = string_arg(args, "title") + .map(str::to_string) + .unwrap_or_else(|| fallback_note_title(note_path)); + let note_type = string_arg(args, "type") + .or_else(|| string_arg(args, "is_a")) + .unwrap_or("Note"); + let note_type_yaml = serde_json::to_string(note_type).unwrap_or_else(|_| "\"Note\"".into()); + format!("---\ntype: {note_type_yaml}\n---\n\n# {title}\n") +} + +fn fallback_note_title(note_path: &str) -> String { + let normalized = note_path.replace('\\', "/"); + Path::new(&normalized) + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|title| !title.trim().is_empty()) + .unwrap_or("Untitled") + .to_string() +} + +fn required_tool_string<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> { + string_arg(args, key).ok_or_else(|| format!("create_note requires {key}.")) +} + +fn string_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args[key].as_str().and_then(non_empty_str) +} + +fn content_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args[key] + .as_str() + .filter(|content| !content.trim().is_empty()) +} + +fn non_empty_option(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn non_empty_str(value: &str) -> Option<&str> { + non_empty_option(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_models::{ + AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider, + }; + use serde_json::json; + use std::fs; + + const CREATED_NOTE_PATH: &str = "nota-longa-teste-gerada-2.md"; + const CREATED_NOTE_CONTENT: &str = "---\ntype: Note\n---\n\n# Nota longa de teste - gerada 2\n"; + + fn request(vault_path: String) -> AiModelStreamRequest { + request_with_provider(provider(), Some(vault_path), Vec::new()) + } + + fn request_with_provider( + provider: AiModelProvider, + vault_path: Option, + vault_paths: Vec, + ) -> AiModelStreamRequest { + AiModelStreamRequest { + provider, + model_id: "gpt-5-nano".into(), + message: "Create the note".into(), + system_prompt: Some("Use create_note for new notes.".into()), + vault_path, + vault_paths, + api_key_override: None, + event_name: None, + } + } + + fn provider() -> AiModelProvider { + AiModelProvider { + id: "openai".into(), + name: "OpenAI".into(), + kind: AiModelProviderKind::OpenAi, + base_url: Some("https://api.openai.com/v1".into()), + api_key_storage: Some(AiModelApiKeyStorage::LocalFile), + api_key_env_var: None, + headers: None, + models: vec![AiModelDefinition { + id: "gpt-5-nano".into(), + display_name: None, + context_window: None, + max_output_tokens: None, + capabilities: AiModelCapabilities { + streaming: false, + tools: false, + vision: false, + json_mode: false, + reasoning: false, + }, + }], + } + } + + fn create_note_response() -> serde_json::Value { + tool_call_response(json!({ + "id": "call_create", + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": CREATED_NOTE_PATH, + "content": CREATED_NOTE_CONTENT + })).unwrap() + } + })) + } + + fn tool_call_response(tool_call: serde_json::Value) -> serde_json::Value { + json!({ + "choices": [{ + "message": { + "tool_calls": [tool_call] + } + }] + }) + } + + fn create_note_response_with_args(arguments: serde_json::Value) -> serde_json::Value { + tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": arguments, + } + })) + } + + fn create_note_error(arguments: serde_json::Value) -> String { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + + execute_openai_tool_calls(&request, &create_note_response_with_args(arguments), |_| {}) + .unwrap_err() + } + + fn assert_note_created(vault_path: &Path) { + let actual = fs::read_to_string(vault_path.join(CREATED_NOTE_PATH)).unwrap(); + assert_eq!(actual, CREATED_NOTE_CONTENT); + } + + fn assert_summary(summary: Option) { + assert_eq!( + summary.as_deref(), + Some("Created note: nota-longa-teste-gerada-2.md"), + ); + } + + fn assert_tool_events(events: &[AiAgentStreamEvent]) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input: Some(input) } + if tool_name == CREATE_NOTE_TOOL_NAME && tool_id == "call_create" && input.contains(CREATED_NOTE_PATH) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output: Some(output) } + if tool_id == "call_create" && output.contains(CREATED_NOTE_PATH) + )); + } + + #[test] + fn openai_payload_offers_create_note_when_active_vault_is_loaded() { + let dir = tempfile::tempdir().unwrap(); + let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned())); + + assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME); + } + + #[test] + fn openai_payload_offers_create_note_for_tool_capable_custom_models() { + let dir = tempfile::tempdir().unwrap(); + let mut provider = provider(); + provider.kind = AiModelProviderKind::OpenAiCompatible; + provider.models[0].capabilities.tools = true; + let request = request_with_provider( + provider, + Some(dir.path().to_string_lossy().into_owned()), + vec![], + ); + + let payload = openai_chat_payload(&request); + + assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME); + } + + #[test] + fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() { + let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new())); + + assert!(payload.get("tools").is_none()); + assert!(payload.get("tool_choice").is_none()); + } + + #[test] + fn execute_openai_tool_calls_returns_none_without_tool_calls() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + + let summary = execute_openai_tool_calls( + &request, + &json!({ "choices": [{ "message": { "content": "No tools" } }] }), + |_| {}, + ) + .unwrap(); + + assert_eq!(summary, None); + } + + #[test] + fn executes_openai_create_note_tool_call_inside_active_vault() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let mut events = Vec::new(); + + let summary = execute_openai_tool_calls(&request, &create_note_response(), |event| { + events.push(event) + }) + .unwrap(); + + assert_note_created(dir.path()); + assert_summary(summary); + assert_tool_events(&events); + } + + #[test] + fn executes_openai_create_note_with_object_arguments_and_selected_vault() { + let primary = tempfile::tempdir().unwrap(); + let secondary = tempfile::tempdir().unwrap(); + let secondary_path = secondary.path().to_string_lossy().into_owned(); + let request = request_with_provider( + provider(), + Some(primary.path().to_string_lossy().into_owned()), + vec![secondary_path.clone()], + ); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": { + "path": "Generated/fallback-note.md", + "is_a": "Project", + "vaultPath": secondary_path, + } + } + })); + + let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap(); + + assert_eq!( + fs::read_to_string(secondary.path().join("Generated/fallback-note.md")).unwrap(), + "---\ntype: \"Project\"\n---\n\n# fallback-note\n", + ); + assert_eq!( + summary.as_deref(), + Some("Created note: Generated/fallback-note.md") + ); + assert!(!primary.path().join("Generated/fallback-note.md").exists()); + } + + #[test] + fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "id": "call_delete", + "function": { + "name": "delete_note", + "arguments": "{}", + } + })); + let mut events = Vec::new(); + + let error = + execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err(); + + assert_eq!(error, "AI provider requested unsupported tool: delete_note"); + assert!(events.is_empty()); + } + + #[test] + fn execute_openai_tool_calls_rejects_malformed_arguments() { + let error = create_note_error(json!("{not-json")); + + assert!(error.contains("Failed to parse AI tool arguments")); + } + + #[test] + fn execute_openai_tool_calls_treats_non_object_arguments_as_empty() { + let error = create_note_error(json!([])); + + assert_eq!(error, "create_note requires path."); + } + + #[test] + fn execute_openai_tool_calls_rejects_existing_note_without_overwriting() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + fs::write(dir.path().join("existing.md"), "# Existing\n").unwrap(); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": "existing.md", + "content": "# Replacement\n", + })).unwrap(), + } + })); + + let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err(); + + assert!(error.contains("already exists")); + assert_eq!( + fs::read_to_string(dir.path().join("existing.md")).unwrap(), + "# Existing\n", + ); + } + + #[test] + fn execute_openai_tool_calls_requires_path() { + let error = create_note_error(json!("{}")); + + assert_eq!(error, "create_note requires path."); + } + + #[test] + fn execute_openai_tool_calls_rejects_inactive_explicit_vault() { + let active = tempfile::tempdir().unwrap(); + let inactive = tempfile::tempdir().unwrap(); + let request = request(active.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": "inactive.md", + "content": "# Inactive\n", + "vaultPath": inactive.path().to_string_lossy(), + })).unwrap(), + } + })); + + let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err(); + + assert!(error.starts_with("Vault is not active in Tolaria:")); + assert!(!inactive.path().join("inactive.md").exists()); + } +} diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs new file mode 100644 index 0000000..b7f27fd --- /dev/null +++ b/src-tauri/src/ai_models.rs @@ -0,0 +1,788 @@ +use crate::ai_agents::AiAgentStreamEvent; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiModelProviderKind { + OpenAi, + Anthropic, + OpenAiCompatible, + Ollama, + LmStudio, + OpenRouter, + Gemini, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiModelApiKeyStorage { + None, + Env, + LocalFile, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct AiModelCapabilities { + pub streaming: bool, + pub tools: bool, + pub vision: bool, + pub json_mode: bool, + pub reasoning: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct AiModelDefinition { + pub id: String, + pub display_name: Option, + pub context_window: Option, + pub max_output_tokens: Option, + pub capabilities: AiModelCapabilities, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct AiModelProvider { + pub id: String, + pub name: String, + pub kind: AiModelProviderKind, + pub base_url: Option, + pub api_key_storage: Option, + pub api_key_env_var: Option, + pub headers: Option>, + pub models: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AiModelStreamRequest { + pub provider: AiModelProvider, + pub model_id: String, + pub message: String, + pub system_prompt: Option, + pub vault_path: Option, + #[serde(default)] + pub vault_paths: Vec, + pub api_key_override: Option, + #[serde(default)] + pub event_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AiModelProviderTestRequest { + pub provider: AiModelProvider, + pub model_id: String, + pub api_key_override: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct AiModelProviderCatalogEntry { + kind: AiModelProviderKind, + runtime_base_url: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +struct AiProviderSecrets { + provider_api_keys: BTreeMap, +} + +static AI_MODEL_PROVIDER_CATALOG: OnceLock> = OnceLock::new(); + +fn provider_catalog() -> &'static [AiModelProviderCatalogEntry] { + AI_MODEL_PROVIDER_CATALOG + .get_or_init(|| { + serde_json::from_str(include_str!("../../src/shared/aiModelProviderCatalog.json")) + .expect("bundled AI model provider catalog must be valid JSON") + }) + .as_slice() +} + +fn provider_default_base_url(kind: &AiModelProviderKind) -> Option<&'static str> { + provider_catalog() + .iter() + .find(|entry| entry.kind == *kind) + .and_then(|entry| entry.runtime_base_url.as_deref()) +} + +pub fn normalize_ai_model_providers( + providers: Option>, +) -> Option> { + let normalized = providers? + .into_iter() + .filter_map(normalize_ai_model_provider) + .collect::>(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn normalize_ai_model_provider(mut provider: AiModelProvider) -> Option { + provider.id = provider.id.trim().to_ascii_lowercase(); + provider.name = provider.name.trim().to_string(); + provider.base_url = normalize_optional_string(provider.base_url); + provider.api_key_env_var = normalize_optional_string(provider.api_key_env_var); + provider.api_key_storage = normalize_api_key_storage(&provider); + provider.models = normalized_models(provider.models); + + is_valid_provider(&provider).then_some(provider) +} + +fn normalize_api_key_storage(provider: &AiModelProvider) -> Option { + match provider.api_key_storage { + Some(AiModelApiKeyStorage::LocalFile) => Some(AiModelApiKeyStorage::LocalFile), + Some(AiModelApiKeyStorage::Env) | None if provider.api_key_env_var.is_some() => { + Some(AiModelApiKeyStorage::Env) + } + _ => Some(AiModelApiKeyStorage::None), + } +} + +fn normalized_models(models: Vec) -> Vec { + models + .into_iter() + .filter_map(|mut model| { + model.id = model.id.trim().to_string(); + model.display_name = normalize_optional_string(model.display_name); + (!model.id.is_empty()).then_some(model) + }) + .collect() +} + +fn is_valid_provider(provider: &AiModelProvider) -> bool { + !provider.id.is_empty() && !provider.name.is_empty() && !provider.models.is_empty() +} + +fn normalize_optional_string(value: Option) -> Option { + value + .map(|candidate| candidate.trim().to_string()) + .filter(|candidate| !candidate.is_empty()) +} + +pub fn run_ai_model_stream(request: AiModelStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + emit(AiAgentStreamEvent::Init { + session_id: format!("api-{}", uuid::Uuid::new_v4()), + }); + + let text = send_model_message(&request, &mut emit)?; + + emit(AiAgentStreamEvent::TextDelta { text }); + emit(AiAgentStreamEvent::Done); + Ok(String::new()) +} + +pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result { + let request = AiModelStreamRequest { + provider: request.provider, + model_id: request.model_id, + message: "Reply with exactly OK.".into(), + system_prompt: Some( + "You are testing whether this model endpoint is reachable. Reply with exactly OK." + .into(), + ), + vault_path: None, + vault_paths: Vec::new(), + api_key_override: normalize_optional_string(request.api_key_override), + event_name: None, + }; + send_model_message(&request, &mut |_| {}) +} + +fn send_model_message(request: &AiModelStreamRequest, emit: &mut F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + match request.provider.kind { + AiModelProviderKind::Anthropic => send_anthropic_message(request), + _ => send_openai_compatible_message(request, emit), + } +} + +fn send_openai_compatible_message( + request: &AiModelStreamRequest, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let endpoint = format!("{}/chat/completions", normalized_base_url(request)?); + let payload = crate::ai_model_tools::openai_chat_payload(request); + let json = send_json_request(request, endpoint, payload)?; + if let Some(tool_summary) = + crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)? + { + return Ok(tool_summary); + } + extract_openai_text(&json) +} + +fn send_anthropic_message(request: &AiModelStreamRequest) -> Result { + let endpoint = format!("{}/messages", normalized_base_url(request)?); + let mut payload = serde_json::json!({ + "model": request.model_id, + "max_tokens": selected_max_tokens(request), + "messages": [{ "role": "user", "content": request.message }] + }); + + if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) { + payload["system"] = serde_json::Value::String(system_prompt.to_string()); + } + + let json = send_json_request(request, endpoint, payload)?; + extract_anthropic_text(&json) +} + +fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 { + request + .provider + .models + .iter() + .find(|model| model.id == request.model_id) + .and_then(|model| model.max_output_tokens) + .unwrap_or(4096) +} + +fn normalized_base_url(request: &AiModelStreamRequest) -> Result { + let fallback = provider_default_base_url(&request.provider.kind).unwrap_or(""); + let base = request + .provider + .base_url + .as_deref() + .and_then(non_empty_str) + .unwrap_or(fallback) + .trim_end_matches('/'); + if base.is_empty() { + return Err("Custom API providers need a base URL.".into()); + } + Ok(base.to_string()) +} + +fn send_json_request( + request: &AiModelStreamRequest, + endpoint: String, + payload: serde_json::Value, +) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|error| format!("Failed to create HTTP client: {error}"))?; + let builder = client.post(endpoint).json(&payload); + let builder = apply_auth_headers(builder, request)?; + let builder = apply_provider_headers(builder, request); + let response = send_provider_request(builder)?; + let status = response.status(); + let text = response + .text() + .map_err(|error| format!("Failed to read AI provider response: {error}"))?; + if !status.is_success() { + return Err(format!( + "AI provider returned {status}: {}", + truncate_error(&text) + )); + } + serde_json::from_str(&text) + .map_err(|error| format!("Failed to parse AI provider response: {error}")) +} + +fn apply_auth_headers( + builder: reqwest::blocking::RequestBuilder, + request: &AiModelStreamRequest, +) -> Result { + let Some(api_key) = api_key_from_provider(request)? else { + return Ok(builder); + }; + + Ok(match request.provider.kind { + AiModelProviderKind::Anthropic => builder.header("x-api-key", api_key), + _ => builder.bearer_auth(api_key), + }) +} + +fn apply_provider_headers( + mut builder: reqwest::blocking::RequestBuilder, + request: &AiModelStreamRequest, +) -> reqwest::blocking::RequestBuilder { + if matches!(request.provider.kind, AiModelProviderKind::Anthropic) { + builder = builder.header("anthropic-version", "2023-06-01"); + } + for (key, value) in safe_custom_headers(request) { + builder = builder.header(key, value); + } + builder +} + +fn safe_custom_headers(request: &AiModelStreamRequest) -> Vec<(&String, &String)> { + request + .provider + .headers + .as_ref() + .into_iter() + .flat_map(|headers| headers.iter()) + .filter(|(key, value)| { + !key.eq_ignore_ascii_case("authorization") && non_empty_option(Some(value)).is_some() + }) + .collect() +} + +fn send_provider_request( + builder: reqwest::blocking::RequestBuilder, +) -> Result { + builder + .send() + .map_err(|error| format!("AI provider request failed: {error}")) +} + +pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> { + let provider_id = normalize_secret_provider_id(&provider_id)?; + let api_key = api_key.trim().to_string(); + if api_key.is_empty() { + return Err("API key cannot be empty.".into()); + } + let path = secrets_path()?; + let mut secrets = read_secrets_at(&path)?; + secrets.provider_api_keys.insert(provider_id, api_key); + write_secrets_at(&path, &secrets) +} + +pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> { + let provider_id = normalize_secret_provider_id(&provider_id)?; + let path = secrets_path()?; + let mut secrets = read_secrets_at(&path)?; + secrets.provider_api_keys.remove(&provider_id); + write_secrets_at(&path, &secrets) +} + +fn normalize_secret_provider_id(provider_id: &str) -> Result { + let provider_id = provider_id.trim().to_ascii_lowercase(); + if provider_id.is_empty() { + Err("Provider ID cannot be empty.".into()) + } else { + Ok(provider_id) + } +} + +fn secrets_path() -> Result { + crate::settings::preferred_app_config_path("ai-provider-secrets.json") +} + +fn read_secrets_at(path: &Path) -> Result { + if !path.exists() { + return Ok(AiProviderSecrets::default()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("Failed to read AI provider secrets: {error}"))?; + serde_json::from_str(&content) + .map_err(|error| format!("Failed to parse AI provider secrets: {error}")) +} + +fn write_secrets_at(path: &Path, secrets: &AiProviderSecrets) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create AI provider secrets directory: {error}"))?; + } + let json = serde_json::to_string_pretty(secrets) + .map_err(|error| format!("Failed to serialize AI provider secrets: {error}"))?; + write_secret_file(path, json) +} + +#[cfg(unix)] +fn write_secret_file(path: &Path, content: String) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::PermissionsExt; + + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|error| format!("Failed to open AI provider secrets file: {error}"))?; + file.write_all(content.as_bytes()) + .map_err(|error| format!("Failed to write AI provider secrets: {error}"))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("Failed to secure AI provider secrets file: {error}")) +} + +#[cfg(not(unix))] +fn write_secret_file(path: &Path, content: String) -> Result<(), String> { + fs::write(path, content) + .map_err(|error| format!("Failed to write AI provider secrets: {error}")) +} + +fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result, String> { + let secrets = read_secrets_at(&secrets_path()?)?; + let api_key = secrets + .provider_api_keys + .get(&request.provider.id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if api_key.is_none() { + return Err(format!( + "No local API key is saved for {}.", + request.provider.name + )); + } + Ok(api_key) +} + +fn api_key_from_env(request: &AiModelStreamRequest) -> Result, String> { + api_key_from_env_with_lookup( + request, + crate::cli_agent_runtime::env_value_from_process_or_user_shell, + ) +} + +fn api_key_from_env_with_lookup( + request: &AiModelStreamRequest, + lookup: impl Fn(crate::cli_agent_runtime::EnvName<'_>) -> Option, +) -> Result, String> { + let Some(name) = request + .provider + .api_key_env_var + .as_deref() + .and_then(non_empty_str) + .and_then(crate::cli_agent_runtime::EnvName::new) + else { + return Ok(None); + }; + + lookup(name).map(Some).ok_or_else(|| { + format!( + "Environment variable {} is not set for this AI provider.", + name.as_str() + ) + }) +} + +fn api_key_from_provider(request: &AiModelStreamRequest) -> Result, String> { + if let Some(api_key) = request + .api_key_override + .as_deref() + .and_then(non_empty_str) + .map(str::to_string) + { + return Ok(Some(api_key)); + } + + match request.provider.api_key_storage { + Some(AiModelApiKeyStorage::LocalFile) => api_key_from_local_file(request), + Some(AiModelApiKeyStorage::Env) => api_key_from_env(request), + _ => Ok(None), + } +} + +fn extract_openai_text(json: &serde_json::Value) -> Result { + json["choices"][0]["message"]["content"] + .as_str() + .map(str::to_string) + .filter(|text| !text.trim().is_empty()) + .ok_or_else(|| "AI provider response did not include assistant text.".to_string()) +} + +fn extract_anthropic_text(json: &serde_json::Value) -> Result { + let text = json["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join(""); + if text.trim().is_empty() { + Err("Anthropic response did not include assistant text.".into()) + } else { + Ok(text) + } +} + +fn non_empty_option(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn non_empty_str(value: &str) -> Option<&str> { + non_empty_option(Some(value)) +} + +fn truncate_error(value: &str) -> String { + const MAX_ERROR_LENGTH: usize = 600; + if value.len() <= MAX_ERROR_LENGTH { + return value.to_string(); + } + format!("{}...", &value[..MAX_ERROR_LENGTH]) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn capabilities() -> AiModelCapabilities { + AiModelCapabilities { + streaming: true, + tools: false, + vision: false, + json_mode: true, + reasoning: false, + } + } + + fn model(id: &str) -> AiModelDefinition { + AiModelDefinition { + id: id.into(), + display_name: Some(" Demo Model ".into()), + context_window: Some(128_000), + max_output_tokens: Some(8192), + capabilities: capabilities(), + } + } + + fn provider(kind: AiModelProviderKind) -> AiModelProvider { + AiModelProvider { + id: " Demo ".into(), + name: " Demo Provider ".into(), + kind, + base_url: Some(" https://example.com/v1/ ".into()), + api_key_storage: None, + api_key_env_var: Some(" DEMO_API_KEY ".into()), + headers: None, + models: vec![model(" demo-model "), model(" ")], + } + } + + fn request(provider: AiModelProvider) -> AiModelStreamRequest { + AiModelStreamRequest { + provider, + model_id: "demo-model".into(), + message: "Hello".into(), + system_prompt: Some(" Be concise. ".into()), + vault_path: None, + vault_paths: Vec::new(), + api_key_override: None, + event_name: None, + } + } + + #[test] + fn normalize_providers_trims_values_and_filters_invalid_entries() { + let invalid = AiModelProvider { + id: " ".into(), + name: "Missing".into(), + kind: AiModelProviderKind::OpenAiCompatible, + base_url: None, + api_key_storage: None, + api_key_env_var: None, + headers: None, + models: vec![model("ignored")], + }; + + let providers = normalize_ai_model_providers(Some(vec![ + invalid, + provider(AiModelProviderKind::OpenAiCompatible), + ])) + .expect("valid provider should remain"); + + assert_eq!(providers.len(), 1); + let normalized = &providers[0]; + assert_eq!(normalized.id, "demo"); + assert_eq!(normalized.name, "Demo Provider"); + assert_eq!( + normalized.base_url.as_deref(), + Some("https://example.com/v1/") + ); + assert_eq!(normalized.api_key_env_var.as_deref(), Some("DEMO_API_KEY")); + assert_eq!(normalized.api_key_storage, Some(AiModelApiKeyStorage::Env)); + assert_eq!(normalized.models.len(), 1); + assert_eq!(normalized.models[0].id, "demo-model"); + assert_eq!( + normalized.models[0].display_name.as_deref(), + Some("Demo Model") + ); + } + + #[test] + fn normalize_providers_returns_none_for_missing_or_empty_sets() { + assert_eq!(normalize_ai_model_providers(None), None); + assert_eq!(normalize_ai_model_providers(Some(Vec::new())), None); + } + + #[test] + fn model_request_helpers_resolve_defaults_and_safe_headers() { + let mut provider = provider(AiModelProviderKind::Anthropic); + provider.base_url = None; + provider.headers = Some(BTreeMap::from([ + ("Authorization".into(), "ignored".into()), + ("X-Demo".into(), "demo".into()), + ("X-Blank".into(), " ".into()), + ])); + provider.models = vec![model("demo-model")]; + let request = request(provider); + let headers = safe_custom_headers(&request) + .into_iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect::>(); + + assert_eq!( + normalized_base_url(&request).unwrap(), + "https://api.anthropic.com/v1" + ); + assert_eq!(selected_max_tokens(&request), 8192); + assert_eq!(headers, vec![("X-Demo", "demo")]); + } + + #[test] + fn request_builders_apply_auth_and_provider_headers() { + let client = reqwest::blocking::Client::new(); + let mut anthropic_provider = provider(AiModelProviderKind::Anthropic); + anthropic_provider.api_key_env_var = None; + anthropic_provider.headers = Some(BTreeMap::from([ + ("Authorization".into(), "ignored".into()), + ("X-Demo".into(), "demo".into()), + ])); + let mut anthropic_request = request(anthropic_provider); + anthropic_request.api_key_override = Some(" secret ".into()); + + let built = apply_provider_headers( + apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(), + &anthropic_request, + ) + .build() + .unwrap(); + + let headers = built.headers(); + assert_eq!( + ( + headers["x-api-key"].to_str().unwrap(), + headers["anthropic-version"].to_str().unwrap(), + headers["X-Demo"].to_str().unwrap(), + headers.get("authorization").is_none(), + ), + ("secret", "2023-06-01", "demo", true), + ); + + let mut openai_provider = provider(AiModelProviderKind::OpenAi); + openai_provider.api_key_env_var = None; + let mut openai_request = request(openai_provider); + openai_request.api_key_override = Some(" openai-secret ".into()); + let built = apply_auth_headers(client.post("https://example.test"), &openai_request) + .unwrap() + .build() + .unwrap(); + + assert_eq!( + built.headers()["authorization"].to_str().unwrap(), + "Bearer openai-secret" + ); + } + + #[test] + fn shared_provider_catalog_supplies_runtime_base_urls() { + assert_eq!( + provider_default_base_url(&AiModelProviderKind::OpenAi), + Some("https://api.openai.com/v1") + ); + assert_eq!( + provider_default_base_url(&AiModelProviderKind::Ollama), + Some("http://localhost:11434/v1") + ); + assert_eq!( + provider_default_base_url(&AiModelProviderKind::OpenAiCompatible), + None + ); + } + + #[test] + fn custom_provider_requires_base_url() { + let mut provider = provider(AiModelProviderKind::OpenAiCompatible); + provider.base_url = Some(" ".into()); + + assert_eq!( + normalized_base_url(&request(provider)).unwrap_err(), + "Custom API providers need a base URL.", + ); + } + + #[test] + fn env_api_key_uses_shell_lookup_when_process_env_is_missing() { + let mut provider = provider(AiModelProviderKind::Anthropic); + provider.api_key_storage = Some(AiModelApiKeyStorage::Env); + provider.api_key_env_var = Some("ANTHROPIC_API_KEY".into()); + let request = request(provider); + + let api_key = + api_key_from_env_with_lookup(&request, |_| Some("shell-secret".to_string())).unwrap(); + + assert_eq!(api_key.as_deref(), Some("shell-secret")); + } + + #[test] + fn extracts_provider_text_payloads_and_reports_empty_responses() { + let openai = json!({ + "choices": [{ "message": { "content": "Hello from OpenAI" } }] + }); + let anthropic = json!({ + "content": [ + { "text": "Hello " }, + { "text": "from Anthropic" } + ] + }); + + assert_eq!(extract_openai_text(&openai).unwrap(), "Hello from OpenAI"); + assert_eq!( + extract_anthropic_text(&anthropic).unwrap(), + "Hello from Anthropic" + ); + assert_eq!( + extract_openai_text(&json!({ "choices": [] })).unwrap_err(), + "AI provider response did not include assistant text.", + ); + assert_eq!( + extract_anthropic_text(&json!({ "content": [{ "type": "thinking" }] })).unwrap_err(), + "Anthropic response did not include assistant text.", + ); + } + + #[test] + fn saves_reads_and_validates_local_provider_secrets() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/secrets.json"); + let secrets = AiProviderSecrets { + provider_api_keys: BTreeMap::from([("demo".into(), "secret".into())]), + }; + + write_secrets_at(&path, &secrets).unwrap(); + + assert_eq!(read_secrets_at(&path).unwrap(), secrets); + assert_eq!( + read_secrets_at(&dir.path().join("missing.json")).unwrap(), + AiProviderSecrets::default() + ); + assert_eq!(normalize_secret_provider_id(" Demo ").unwrap(), "demo"); + assert_eq!( + normalize_secret_provider_id(" ").unwrap_err(), + "Provider ID cannot be empty.", + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn truncates_long_provider_errors_without_touching_short_errors() { + let short = "provider unavailable"; + let long = "x".repeat(605); + + assert_eq!(truncate_error(short), short); + assert_eq!(truncate_error(&long).len(), 603); + assert!(truncate_error(&long).ends_with("...")); + } +} diff --git a/src-tauri/src/antigravity_cli.rs b/src-tauri/src/antigravity_cli.rs new file mode 100644 index 0000000..4ac2c72 --- /dev/null +++ b/src-tauri/src/antigravity_cli.rs @@ -0,0 +1,195 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::cli_agent_runtime::{AgentStreamRequest, LineStreamProcess}; +use std::path::Path; + +pub fn check_cli() -> AiAgentAvailability { + crate::antigravity_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::antigravity_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let command = crate::antigravity_config::build_command(binary, &request)?; + crate::cli_agent_runtime::run_ai_agent_line_stream( + LineStreamProcess::new(command, "agy", "antigravity"), + emit, + format_antigravity_error, + ) +} + +fn format_antigravity_error(stderr_output: &str, status: &str) -> String { + if is_auth_or_setup_error(stderr_output) { + return "Antigravity CLI is not ready. Run `agy` in your terminal to finish install and sign-in, then retry in Tolaria.".into(); + } + + let stderr = stderr_output.trim(); + if stderr.is_empty() { + format!("agy exited with status {status}") + } else { + stderr.lines().take(3).collect::>().join("\n") + } +} + +fn is_auth_or_setup_error(stderr_output: &str) -> bool { + let lower = stderr_output.to_ascii_lowercase(); + [ + "auth", + "api key", + "keyring", + "login", + "oauth", + "sign in", + "token", + "unauthorized", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + use std::path::PathBuf; + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: Some("Use Tolaria conventions".into()), + vault_path, + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("agy"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_stdout_and_writes_workspace_mcp_config() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'Hello from Antigravity' +printf '%s\n' 'Second line' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert!(session_id.starts_with("antigravity-")); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id.starts_with("antigravity-") + )); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Hello from Antigravity\n" + ))); + assert!(vault + .path() + .join(".agents") + .join("mcp_config.json") + .exists()); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_antigravity_auth_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'oauth login required' >&2 +exit 3 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert!(session_id.starts_with("antigravity-")); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not ready") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_uses_supported_antigravity_workspace_flag() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"seen_add_dir=false +while [ "$#" -gt 0 ]; do + if [ "$1" = "--cwd" ]; then + printf '%s\n' 'flags provided but not defined: -cwd' >&2 + exit 2 + fi + if [ "$1" = "--add-dir" ]; then + seen_add_dir=true + shift + fi + shift +done +if [ "$seen_add_dir" != "true" ]; then + printf '%s\n' 'missing --add-dir workspace argument' >&2 + exit 3 +fi +printf '%s\n' 'Antigravity accepted workspace' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert!(session_id.starts_with("antigravity-")); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Antigravity accepted workspace\n" + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/antigravity_config.rs b/src-tauri/src/antigravity_config.rs new file mode 100644 index 0000000..d73582b --- /dev/null +++ b/src-tauri/src/antigravity_config.rs @@ -0,0 +1,208 @@ +use crate::ai_agents::AiAgentPermissionMode; +use crate::cli_agent_runtime::AgentStreamRequest; +use std::path::Path; +use std::process::Stdio; + +pub(crate) fn build_command( + binary: &Path, + request: &AgentStreamRequest, +) -> Result { + ensure_workspace_mcp_config(request)?; + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .arg("-p") + .arg(build_prompt(request)) + .arg("--add-dir") + .arg(&request.vault_path) + .arg(format!( + "--sandbox={}", + sandbox_enabled(request.permission_mode) + )) + .arg(format!( + "--toolPermission={}", + tool_permission(request.permission_mode) + )) + .env("NO_COLOR", "1") + .current_dir(&request.vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn build_prompt(request: &AgentStreamRequest) -> String { + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) +} + +fn sandbox_enabled(permission_mode: AiAgentPermissionMode) -> bool { + permission_mode == AiAgentPermissionMode::Safe +} + +fn tool_permission(permission_mode: AiAgentPermissionMode) -> &'static str { + match permission_mode { + AiAgentPermissionMode::Safe => "proceed-in-sandbox", + AiAgentPermissionMode::PowerUser => "always-proceed", + } +} + +fn ensure_workspace_mcp_config(request: &AgentStreamRequest) -> Result<(), String> { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + write_workspace_mcp_config(&request.vault_path, &request.vault_paths, &mcp_server_path) +} + +fn write_workspace_mcp_config( + vault_path: &str, + vault_paths: &[String], + mcp_server_path: &str, +) -> Result<(), String> { + let config_dir = Path::new(vault_path).join(".agents"); + std::fs::create_dir_all(&config_dir) + .map_err(|e| format!("Failed to create .agents directory: {e}"))?; + + let config_path = config_dir.join("mcp_config.json"); + let mut config = read_json_object(&config_path)?; + let servers_value = config + .as_object_mut() + .ok_or("Invalid mcp_config.json: not an object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + let servers = servers_value + .as_object_mut() + .ok_or("Invalid mcp_config.json: mcpServers is not an object")?; + servers.insert( + "tolaria".to_string(), + crate::cli_agent_runtime::tolaria_node_mcp_server( + mcp_server_path, + vault_path, + vault_paths, + true, + ), + ); + + std::fs::write( + &config_path, + serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize Antigravity MCP config: {e}"))?, + ) + .map_err(|e| format!("Failed to write .agents/mcp_config.json: {e}"))?; + + Ok(()) +} + +fn read_json_object(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(serde_json::json!({})); + } + + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Failed to read .agents/mcp_config.json: {e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("Invalid .agents/mcp_config.json: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::path::PathBuf; + + fn request(vault_path: String, permission_mode: AiAgentPermissionMode) -> AgentStreamRequest { + AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path, + vault_paths: Vec::new(), + permission_mode, + } + } + + #[test] + fn command_uses_one_shot_prompt_and_workspace_mcp_config() { + let vault = tempfile::tempdir().unwrap(); + let request = request( + vault.path().to_string_lossy().into_owned(), + AiAgentPermissionMode::Safe, + ); + let command = build_command(&PathBuf::from("agy"), &request).unwrap(); + let args = command.get_args().collect::>(); + + assert_eq!(command.get_program(), OsStr::new("agy")); + assert!(args + .windows(2) + .any(|pair| pair == [OsStr::new("-p"), OsStr::new("Rename the note")])); + assert!(args + .windows(2) + .any(|pair| pair == [OsStr::new("--add-dir"), vault.path().as_os_str()])); + assert!(!args.contains(&OsStr::new("--cwd"))); + assert!(args.contains(&OsStr::new("--sandbox=true"))); + assert!(args.contains(&OsStr::new("--toolPermission=proceed-in-sandbox"))); + assert_eq!(command.get_current_dir(), Some(vault.path())); + assert!(vault + .path() + .join(".agents") + .join("mcp_config.json") + .exists()); + } + + #[test] + fn power_user_command_allows_non_sandboxed_autonomy_without_skip_flag() { + let vault = tempfile::tempdir().unwrap(); + let request = request( + vault.path().to_string_lossy().into_owned(), + AiAgentPermissionMode::PowerUser, + ); + let command = build_command(&PathBuf::from("agy"), &request).unwrap(); + let args = command.get_args().collect::>(); + + assert!(args.contains(&OsStr::new("--sandbox=false"))); + assert!(args.contains(&OsStr::new("--toolPermission=always-proceed"))); + assert!(!args.contains(&OsStr::new("--dangerously-skip-permissions"))); + } + + #[test] + fn workspace_mcp_config_preserves_other_servers() { + let vault = tempfile::tempdir().unwrap(); + let config_path = vault.path().join(".agents").join("mcp_config.json"); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + serde_json::to_string(&serde_json::json!({ + "mcpServers": { + "other": { "command": "example" } + } + })) + .unwrap(), + ) + .unwrap(); + + write_workspace_mcp_config(vault.path().to_str().unwrap(), &[], "/mcp/index.js").unwrap(); + let config: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap(); + + assert_eq!(config["mcpServers"]["other"]["command"], "example"); + assert_eq!(config["mcpServers"]["tolaria"]["command"], "node"); + assert_eq!(config["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711"); + } + + #[test] + fn workspace_mcp_config_rejects_non_object_servers() { + let vault = tempfile::tempdir().unwrap(); + let config_path = vault.path().join(".agents").join("mcp_config.json"); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + serde_json::to_string(&serde_json::json!({ "mcpServers": [] })).unwrap(), + ) + .unwrap(); + + let error = + write_workspace_mcp_config(vault.path().to_str().unwrap(), &[], "/mcp/index.js") + .unwrap_err(); + + assert!(error.contains("mcpServers is not an object")); + } +} diff --git a/src-tauri/src/antigravity_discovery.rs b/src-tauri/src/antigravity_discovery.rs new file mode 100644 index 0000000..9c0fbe0 --- /dev/null +++ b/src-tauri/src/antigravity_discovery.rs @@ -0,0 +1,175 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + match find_binary() { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +pub(crate) fn find_binary() -> Result { + if let Some(binary) = find_binary_on_path() { + return Ok(binary); + } + if let Some(binary) = find_binary_in_user_shell() { + return Ok(binary); + } + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + antigravity_binary_candidates(), + "Antigravity CLI", + )? { + return Ok(binary); + } + + Err("Antigravity CLI not found. Install it: https://antigravity.google/docs/cli/install".into()) +} + +fn find_binary_on_path() -> Option { + crate::hidden_command(path_lookup_command()) + .arg("agy") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + +fn find_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, "agy")) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + first_existing_path_for_platform(stdout, cfg!(windows)) +} + +fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { + let mut paths = stdout.lines().filter_map(existing_path); + if windows { + return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path)); + } + + paths.next() +} + +fn existing_path(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) +} + +fn antigravity_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| antigravity_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn antigravity_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".local/bin/agy"), + home.join(".local/bin/agy.exe"), + home.join("AppData/Local/agy/bin/agy.exe"), + PathBuf::from("/usr/local/bin/agy"), + PathBuf::from("/opt/homebrew/bin/agy"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/agy"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = antigravity_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/agy"), + home.join("AppData/Local/agy/bin/agy.exe"), + PathBuf::from("/opt/homebrew/bin/agy"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-agy"); + let agy = dir.path().join("agy"); + std::fs::write(&agy, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), agy.display()); + + assert_eq!(first_existing_path(&stdout), Some(agy)); + } + + #[test] + fn windows_path_lookup_prefers_cmd_shim_over_extensionless_script() { + let dir = tempfile::tempdir().unwrap(); + let shell_script = dir.path().join("agy"); + let cmd_shim = dir.path().join("agy.cmd"); + std::fs::write(&shell_script, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd_shim, "@ECHO off\n").unwrap(); + + let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display()); + + assert_eq!( + first_existing_path_for_platform(&stdout, true), + Some(cmd_shim) + ); + } +} diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs new file mode 100644 index 0000000..b2d1992 --- /dev/null +++ b/src-tauri/src/app_config.rs @@ -0,0 +1,295 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +const APP_CONFIG_POLICY_JSON: &str = include_str!("../../mcp-server/app-config-policy.json"); + +#[derive(Debug, Deserialize)] +struct AppConfigPolicy { + current_namespace: String, + legacy_namespace: String, + namespace_read_order: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AppConfigNamespace { + Current, + Legacy, +} + +impl AppConfigPolicy { + fn current_namespace(&self) -> &str { + &self.current_namespace + } + + fn namespace_read_order(&self) -> &[AppConfigNamespace] { + &self.namespace_read_order + } + + fn namespace_dir(&self, namespace: &AppConfigNamespace) -> &str { + match namespace { + AppConfigNamespace::Current => &self.current_namespace, + AppConfigNamespace::Legacy => &self.legacy_namespace, + } + } +} + +fn app_config_policy() -> &'static AppConfigPolicy { + static POLICY: OnceLock = OnceLock::new(); + POLICY.get_or_init(|| { + serde_json::from_str(APP_CONFIG_POLICY_JSON) + .expect("mcp-server/app-config-policy.json must be valid") + }) +} + +fn app_config_dir() -> Result { + primary_config_dir().ok_or_else(|| "Could not determine config directory".to_string()) +} + +fn primary_config_dir() -> Option { + primary_config_dir_from_sources( + explicit_xdg_config_home(), + dirs::home_dir(), + dirs::config_dir(), + ) +} + +fn primary_config_dir_from_sources( + explicit_xdg: Option, + home: Option, + platform: Option, +) -> Option { + explicit_xdg + .and_then(absolute_path) + .or_else(|| default_xdg_config_home(home)) + .or(platform) +} + +fn explicit_xdg_config_home() -> Option { + Some(PathBuf::from(std::env::var_os("XDG_CONFIG_HOME")?)) +} + +fn default_xdg_config_home(home: Option) -> Option { + if cfg!(windows) { + None + } else { + home.and_then(absolute_path) + .map(|path| path.join(".config")) + } +} + +fn absolute_path(path: PathBuf) -> Option { + if path.is_absolute() { + Some(path) + } else { + None + } +} + +fn preferred_path_in(config_dir: &Path, file_name: &str) -> PathBuf { + config_dir + .join(app_config_policy().current_namespace()) + .join(file_name) +} + +fn existing_or_preferred_path_in_dirs(config_dirs: &[PathBuf], file_name: &str) -> PathBuf { + let policy = app_config_policy(); + for config_dir in config_dirs { + for namespace in policy.namespace_read_order() { + let candidate = config_dir + .join(policy.namespace_dir(namespace)) + .join(file_name); + if candidate.exists() { + return candidate; + } + } + } + + preferred_path_in(&config_dirs[0], file_name) +} + +fn app_config_read_dirs() -> Result, String> { + let primary = app_config_dir()?; + let mut dirs = vec![primary.clone()]; + if let Some(platform) = dirs::config_dir() { + if platform != primary { + dirs.push(platform); + } + } + Ok(dirs) +} + +pub(crate) fn preferred_app_config_path(file_name: &str) -> Result { + Ok(preferred_path_in(&app_config_dir()?, file_name)) +} + +pub(crate) fn resolve_existing_or_preferred_app_config_path( + file_name: &str, +) -> Result { + Ok(existing_or_preferred_path_in_dirs( + &app_config_read_dirs()?, + file_name, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn absolute_temp_dir(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(name) + } + + #[test] + fn absolute_xdg_config_home_is_accepted() { + let path = absolute_temp_dir("tolaria-xdg-config"); + assert_eq!(absolute_path(path.clone()), Some(path)); + } + + #[test] + fn relative_xdg_config_home_is_ignored() { + assert!(absolute_path(PathBuf::from("relative-config")).is_none()); + } + + #[cfg(not(windows))] + #[test] + fn default_unix_config_home_uses_home_dot_config() { + let home = absolute_temp_dir("tolaria-home"); + let platform = absolute_temp_dir("tolaria-platform-config"); + + assert_eq!( + primary_config_dir_from_sources(None, Some(home.clone()), Some(platform)), + Some(home.join(".config")) + ); + } + + #[test] + fn explicit_xdg_config_home_wins_over_default_and_platform_paths() { + let explicit = absolute_temp_dir("tolaria-explicit-xdg"); + let home = absolute_temp_dir("tolaria-home"); + let platform = absolute_temp_dir("tolaria-platform-config"); + + assert_eq!( + primary_config_dir_from_sources(Some(explicit.clone()), Some(home), Some(platform)), + Some(explicit) + ); + } + + #[test] + fn relative_xdg_config_home_falls_back_to_platform_when_no_home_is_available() { + let platform = absolute_temp_dir("tolaria-platform-config"); + + assert_eq!( + primary_config_dir_from_sources( + Some(PathBuf::from("relative-config")), + None, + Some(platform.clone()) + ), + Some(platform) + ); + } + + #[test] + fn preferred_path_uses_tolaria_namespace() { + let config_dir = absolute_temp_dir("tolaria-config-root"); + let path = preferred_path_in(&config_dir, "settings.json"); + assert_eq!( + path, + config_dir.join("com.tolaria.app").join("settings.json") + ); + } + + #[test] + fn existing_preferred_path_wins_over_legacy_path() { + let dir = tempfile::TempDir::new().unwrap(); + let preferred = dir + .path() + .join(app_config_policy().current_namespace()) + .join("settings.json"); + let legacy = dir + .path() + .join(app_config_policy().legacy_namespace.as_str()) + .join("settings.json"); + std::fs::create_dir_all(preferred.parent().unwrap()).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&preferred, "{}").unwrap(); + std::fs::write(&legacy, "{}").unwrap(); + + assert_eq!( + existing_or_preferred_path_in_dirs(&[dir.path().to_path_buf()], "settings.json"), + preferred + ); + } + + #[test] + fn legacy_path_is_read_when_preferred_path_is_absent() { + let dir = tempfile::TempDir::new().unwrap(); + let legacy = dir + .path() + .join(app_config_policy().legacy_namespace.as_str()) + .join("vaults.json"); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, r#"{"vaults":[]}"#).unwrap(); + + assert_eq!( + existing_or_preferred_path_in_dirs(&[dir.path().to_path_buf()], "vaults.json"), + legacy + ); + } + + #[test] + fn settings_and_vault_registry_share_namespace_fallback_order() { + let dir = tempfile::TempDir::new().unwrap(); + + for file_name in ["settings.json", "vaults.json"] { + let legacy = dir + .path() + .join(app_config_policy().legacy_namespace.as_str()) + .join(file_name); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, "{}").unwrap(); + + assert_eq!( + existing_or_preferred_path_in_dirs(&[dir.path().to_path_buf()], file_name), + legacy + ); + } + } + + #[test] + fn previous_platform_config_dir_is_read_when_primary_dir_is_empty() { + let primary = tempfile::TempDir::new().unwrap(); + let platform = tempfile::TempDir::new().unwrap(); + let existing = platform + .path() + .join(app_config_policy().current_namespace()) + .join("settings.json"); + std::fs::create_dir_all(existing.parent().unwrap()).unwrap(); + std::fs::write(&existing, "{}").unwrap(); + + assert_eq!( + existing_or_preferred_path_in_dirs( + &[primary.path().to_path_buf(), platform.path().to_path_buf()], + "settings.json" + ), + existing + ); + } + + #[test] + fn missing_files_use_preferred_path() { + let dir = tempfile::TempDir::new().unwrap(); + let expected = dir + .path() + .join(app_config_policy().current_namespace()) + .join("last-vault.txt"); + + assert_eq!( + existing_or_preferred_path_in_dirs(&[dir.path().to_path_buf()], "last-vault.txt"), + expected + ); + } +} diff --git a/src-tauri/src/app_icon.rs b/src-tauri/src/app_icon.rs new file mode 100644 index 0000000..b1254b5 --- /dev/null +++ b/src-tauri/src/app_icon.rs @@ -0,0 +1,59 @@ +use tauri::Manager; + +const LIGHT_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512.png"); +const DARK_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512-dark.png"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppIconMode { + Light, + Dark, +} + +impl AppIconMode { + fn parse(value: &str) -> Result { + match value { + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + _ => Err(format!("Unsupported app icon theme mode: {value}")), + } + } + + fn png_bytes(self) -> &'static [u8] { + match self { + Self::Light => LIGHT_ICON_BYTES, + Self::Dark => DARK_ICON_BYTES, + } + } +} + +pub fn update_app_icon_for_theme( + app_handle: &tauri::AppHandle, + theme_mode: &str, +) -> Result<(), String> { + let icon_bytes = AppIconMode::parse(theme_mode)?.png_bytes(); + let image = tauri::image::Image::from_bytes(icon_bytes) + .map_err(|err| format!("Failed to decode app icon: {err}"))?; + + for window in app_handle.webview_windows().into_values() { + window + .set_icon(image.clone()) + .map_err(|err| format!("Failed to update window icon: {err}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::AppIconMode; + + #[test] + fn parses_supported_icon_modes() { + assert_eq!(AppIconMode::parse("light"), Ok(AppIconMode::Light)); + assert_eq!(AppIconMode::parse("dark"), Ok(AppIconMode::Dark)); + } + + #[test] + fn rejects_unknown_icon_modes() { + assert!(AppIconMode::parse("system").is_err()); + } +} diff --git a/src-tauri/src/app_updater.rs b/src-tauri/src/app_updater.rs new file mode 100644 index 0000000..6058143 --- /dev/null +++ b/src-tauri/src/app_updater.rs @@ -0,0 +1,426 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tauri::{ipc::Channel, AppHandle, Runtime, Url}; +use tauri_plugin_updater::UpdaterExt; + +const ALPHA_METADATA_ASSET_NAME: &str = "alpha-latest.json"; +const GITHUB_RELEASES_API_URL: &str = + "https://api.github.com/repos/refactoringhq/tolaria/releases?per_page=100"; +const RELEASES_BASE_URL: &str = "https://refactoringhq.github.io/tolaria"; +const UPDATER_HTTP_TIMEOUT: Duration = Duration::from_secs(5); +const UPDATER_USER_AGENT: &str = concat!("Tolaria/", env!("CARGO_PKG_VERSION")); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppUpdateMetadata { + pub current_version: String, + pub version: String, + pub date: Option, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", content = "data")] +pub enum AppUpdateDownloadEvent { + #[serde(rename_all = "camelCase")] + Started { + content_length: Option, + }, + #[serde(rename_all = "camelCase")] + Progress { + chunk_length: usize, + }, + Finished, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleaseChannel { + Alpha, + Stable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct AlphaReleaseVersion { + year: i32, + month: u32, + day: u32, + sequence: u32, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubRelease { + tag_name: String, + draft: bool, + assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +impl ReleaseChannel { + fn from_settings_value(value: Option<&str>) -> Self { + match crate::settings::effective_release_channel(value) { + "alpha" => Self::Alpha, + _ => Self::Stable, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Alpha => "alpha", + Self::Stable => "stable", + } + } + + fn updater_endpoint(self) -> Result { + let endpoint = format!("{}/{}/latest.json", RELEASES_BASE_URL, self.as_str()); + Url::parse(&endpoint).map_err(|e| format!("Invalid updater endpoint: {e}")) + } +} + +impl AlphaReleaseVersion { + fn parse_tag(tag_name: &str) -> Option { + let release = tag_name.strip_prefix("alpha-v")?; + let (date, sequence) = release.split_once("-alpha.")?; + let sequence = sequence.parse().ok()?; + let (year, month, day) = parse_calendar_date(date)?; + chrono::NaiveDate::from_ymd_opt(year, month, day)?; + + Some(Self { + year, + month, + day, + sequence, + }) + } +} + +fn parse_calendar_date(value: &str) -> Option<(i32, u32, u32)> { + let mut parts = value.split('.'); + let year = parts.next()?.parse().ok()?; + let month = parts.next()?.parse().ok()?; + let day = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + + Some((year, month, day)) +} + +fn latest_alpha_release_metadata_url(releases: &[GitHubRelease]) -> Option { + releases + .iter() + .filter(|release| !release.draft) + .filter_map(alpha_release_metadata_candidate) + .max_by_key(|(version, _)| *version) + .map(|(_, url)| url) +} + +fn alpha_release_metadata_candidate(release: &GitHubRelease) -> Option<(AlphaReleaseVersion, Url)> { + let version = AlphaReleaseVersion::parse_tag(&release.tag_name)?; + let asset = release + .assets + .iter() + .find(|asset| asset.name == ALPHA_METADATA_ASSET_NAME)?; + let url = Url::parse(&asset.browser_download_url).ok()?; + + Some((version, url)) +} + +async fn alpha_release_metadata_endpoint() -> Result { + let client = reqwest::Client::builder() + .timeout(UPDATER_HTTP_TIMEOUT) + .user_agent(UPDATER_USER_AGENT) + .build() + .map_err(|e| format!("Failed to create updater metadata client: {e}"))?; + + let releases = client + .get(GITHUB_RELEASES_API_URL) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("Failed to fetch GitHub releases: {e}"))? + .error_for_status() + .map_err(|e| format!("GitHub releases request failed: {e}"))? + .json::>() + .await + .map_err(|e| format!("Failed to parse GitHub releases: {e}"))?; + + latest_alpha_release_metadata_url(&releases) + .ok_or_else(|| "No alpha updater metadata asset found in GitHub releases".to_string()) +} + +async fn updater_endpoint(release_channel: ReleaseChannel) -> Result { + match release_channel { + ReleaseChannel::Stable => release_channel.updater_endpoint(), + ReleaseChannel::Alpha => alpha_release_metadata_endpoint() + .await + .or_else(|_| release_channel.updater_endpoint()), + } +} + +fn build_updater( + app_handle: &AppHandle, + endpoint: Url, +) -> Result { + app_handle + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|e| format!("Failed to configure updater endpoint: {e}"))? + .build() + .map_err(|e| format!("Failed to build updater: {e}")) +} + +fn to_update_metadata(update: tauri_plugin_updater::Update) -> AppUpdateMetadata { + AppUpdateMetadata { + current_version: update.current_version, + version: update.version, + date: update.date.map(|value| value.to_string()), + body: update.body, + } +} + +fn ensure_expected_update_version( + update: &tauri_plugin_updater::Update, + expected_version: &str, +) -> Result<(), String> { + if update.version == expected_version { + return Ok(()); + } + + Err(format!( + "Expected update version {}, found {}", + expected_version, update.version + )) +} + +async fn install_update( + update: tauri_plugin_updater::Update, + on_event: Channel, +) -> Result<(), String> { + let mut started = false; + update + .download_and_install( + |chunk_length, content_length| { + if !started { + started = true; + let _ = on_event.send(AppUpdateDownloadEvent::Started { content_length }); + } + + let _ = on_event.send(AppUpdateDownloadEvent::Progress { chunk_length }); + }, + || { + let _ = on_event.send(AppUpdateDownloadEvent::Finished); + }, + ) + .await + .map_err(|e| format!("Failed to download and install update: {e}")) +} + +pub async fn check_for_app_update( + app_handle: AppHandle, + release_channel: Option, +) -> Result, String> { + let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); + let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?; + let update = updater + .check() + .await + .map_err(|e| format!("Failed to check for updates: {e}"))?; + + Ok(update.map(to_update_metadata)) +} + +pub async fn download_and_install_app_update( + app_handle: AppHandle, + release_channel: Option, + expected_version: String, + on_event: Channel, +) -> Result<(), String> { + let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); + let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?; + let update = updater + .check() + .await + .map_err(|e| format!("Failed to refresh update metadata: {e}"))? + .ok_or_else(|| "No update is currently available".to_string())?; + + ensure_expected_update_version(&update, &expected_version)?; + + install_update(update, on_event).await +} + +#[cfg(test)] +mod tests { + use super::{ + latest_alpha_release_metadata_url, AppUpdateDownloadEvent, AppUpdateMetadata, GitHubAsset, + GitHubRelease, ReleaseChannel, + }; + use serde_json::json; + + #[test] + fn release_channel_defaults_to_stable() { + assert_eq!( + ReleaseChannel::from_settings_value(None), + ReleaseChannel::Stable + ); + assert_eq!( + ReleaseChannel::from_settings_value(Some("stable")), + ReleaseChannel::Stable + ); + assert_eq!( + ReleaseChannel::from_settings_value(Some("beta")), + ReleaseChannel::Stable + ); + assert_eq!( + ReleaseChannel::from_settings_value(Some("invalid")), + ReleaseChannel::Stable + ); + } + + #[test] + fn release_channel_accepts_alpha() { + assert_eq!( + ReleaseChannel::from_settings_value(Some("alpha")), + ReleaseChannel::Alpha + ); + assert_eq!( + ReleaseChannel::from_settings_value(Some(" alpha ")), + ReleaseChannel::Alpha + ); + } + + #[test] + fn release_channel_endpoints_match_expected_paths() { + assert_eq!( + ReleaseChannel::Alpha.updater_endpoint().unwrap().as_str(), + "https://refactoringhq.github.io/tolaria/alpha/latest.json" + ); + assert_eq!( + ReleaseChannel::Stable.updater_endpoint().unwrap().as_str(), + "https://refactoringhq.github.io/tolaria/stable/latest.json" + ); + } + + #[test] + fn alpha_release_metadata_url_uses_highest_calendar_sequence_tag() { + let releases = vec![ + github_alpha_release( + "alpha-v2026.5.8-alpha.0007", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0007/alpha-latest.json", + ), + github_alpha_release( + "alpha-v2026.5.8-alpha.0017", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json", + ), + github_alpha_release( + "alpha-v2026.5.7-alpha.0099", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.7-alpha.0099/alpha-latest.json", + ), + ]; + + assert_eq!( + latest_alpha_release_metadata_url(&releases) + .unwrap() + .as_str(), + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json" + ); + } + + #[test] + fn alpha_release_metadata_url_ignores_drafts_and_non_alpha_assets() { + let releases = vec![ + GitHubRelease { + tag_name: "alpha-v2026.5.8-alpha.0018".into(), + draft: true, + assets: vec![GitHubAsset { + name: "alpha-latest.json".into(), + browser_download_url: "https://example.com/draft.json".into(), + }], + }, + GitHubRelease { + tag_name: "stable-v2026.5.8".into(), + draft: false, + assets: vec![GitHubAsset { + name: "stable-latest.json".into(), + browser_download_url: "https://example.com/stable.json".into(), + }], + }, + github_alpha_release( + "alpha-v2026.5.8-alpha.0017", + "https://example.com/alpha-latest.json", + ), + ]; + + assert_eq!( + latest_alpha_release_metadata_url(&releases) + .unwrap() + .as_str(), + "https://example.com/alpha-latest.json" + ); + } + + #[test] + fn update_metadata_serializes_for_frontend_consumers() { + let metadata = AppUpdateMetadata { + current_version: "2026.4.1".into(), + version: "2026.4.2".into(), + date: Some("2026-04-30T12:00:00Z".into()), + body: Some("Bug fixes".into()), + }; + + assert_eq!( + serde_json::to_value(metadata).unwrap(), + json!({ + "currentVersion": "2026.4.1", + "version": "2026.4.2", + "date": "2026-04-30T12:00:00Z", + "body": "Bug fixes" + }) + ); + } + + #[test] + fn download_events_serialize_as_tagged_frontend_events() { + let events = [ + ( + AppUpdateDownloadEvent::Started { + content_length: Some(4096), + }, + json!({ + "event": "Started", + "data": { "contentLength": 4096 } + }), + ), + ( + AppUpdateDownloadEvent::Progress { chunk_length: 512 }, + json!({ + "event": "Progress", + "data": { "chunkLength": 512 } + }), + ), + ( + AppUpdateDownloadEvent::Finished, + json!({ "event": "Finished" }), + ), + ]; + + for (event, expected) in events { + assert_eq!(serde_json::to_value(event).unwrap(), expected); + } + } + + fn github_alpha_release(tag_name: &str, browser_download_url: &str) -> GitHubRelease { + GitHubRelease { + tag_name: tag_name.into(), + draft: false, + assets: vec![GitHubAsset { + name: "alpha-latest.json".into(), + browser_download_url: browser_download_url.into(), + }], + } + } +} diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs new file mode 100644 index 0000000..fc1da69 --- /dev/null +++ b/src-tauri/src/claude_cli.rs @@ -0,0 +1,1680 @@ +pub use crate::cli_agent_runtime::AgentStreamRequest; +use crate::cli_agent_runtime::EnvName; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; + +const CLAUDE_PROVIDER_ENV_KEYS: &[EnvName<'static>] = &[ + EnvName::trusted("ANTHROPIC_API_KEY"), + EnvName::trusted("ANTHROPIC_AUTH_TOKEN"), + EnvName::trusted("ANTHROPIC_BASE_URL"), + EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"), + EnvName::trusted("ANTHROPIC_MODEL"), + EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"), + EnvName::trusted("CLAUDE_CODE_USE_BEDROCK"), + EnvName::trusted("CLAUDE_CODE_USE_VERTEX"), + EnvName::trusted("AWS_ACCESS_KEY_ID"), + EnvName::trusted("AWS_SECRET_ACCESS_KEY"), + EnvName::trusted("AWS_SESSION_TOKEN"), + EnvName::trusted("AWS_PROFILE"), + EnvName::trusted("AWS_REGION"), + EnvName::trusted("AWS_DEFAULT_REGION"), + EnvName::trusted("GOOGLE_APPLICATION_CREDENTIALS"), + EnvName::trusted("CLOUD_ML_REGION"), + EnvName::trusted("VERTEX_REGION"), + EnvName::trusted("HTTPS_PROXY"), + EnvName::trusted("HTTP_PROXY"), + EnvName::trusted("NO_PROXY"), + EnvName::trusted("SSL_CERT_FILE"), + EnvName::trusted("SSL_CERT_DIR"), + EnvName::trusted("NODE_EXTRA_CA_CERTS"), +]; +const LOCALIZED_ERROR_PREFIX: &str = "tolaria:i18n-error:"; +const CLAUDE_TOO_MANY_REDIRECTS_KEY: &str = "ai.error.claude.tooManyRedirects"; + +/// Status returned by `check_claude_cli`. +#[derive(Debug, Serialize, Clone)] +pub struct ClaudeCliStatus { + pub installed: bool, + pub version: Option, +} + +/// Event emitted to the frontend during a streaming claude session. +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "kind")] +pub enum ClaudeStreamEvent { + /// Session initialised — carries the session ID for future `--resume`. + Init { session_id: String }, + /// Incremental text chunk. + TextDelta { text: String }, + /// Incremental thinking/reasoning chunk. + ThinkingDelta { text: String }, + /// A tool call started (agent mode only). + ToolStart { + tool_name: String, + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option, + }, + /// A tool call finished (agent mode only). + ToolDone { + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + }, + /// Final result text + session ID. + Result { text: String, session_id: String }, + /// Something went wrong. + Error { message: String }, + /// Stream finished. + Done, +} + +/// Parameters accepted by `stream_claude_chat`. +#[derive(Debug, Deserialize)] +pub struct ChatStreamRequest { + pub message: String, + pub system_prompt: Option, + pub session_id: Option, +} + +// --------------------------------------------------------------------------- +// Finding the `claude` binary +// --------------------------------------------------------------------------- + +pub(crate) fn find_claude_binary() -> Result { + if let Some(binary) = find_claude_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_claude_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + claude_binary_candidates(), + "Claude CLI", + )? { + return Ok(binary); + } + + Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into()) +} + +fn find_claude_binary_on_path() -> Option { + crate::hidden_command(claude_path_lookup_command()) + .arg("claude") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn claude_path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + +fn find_claude_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| claude_path_from_shell(&shell)) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn claude_path_from_shell(shell: &Path) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg("command -v claude") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + first_existing_path_for_platform(stdout, cfg!(windows)) +} + +fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { + let mut paths = stdout.lines().filter_map(existing_path); + if windows { + return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path)); + } + + paths.next() +} + +fn existing_path(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) +} + +fn claude_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| claude_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn claude_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = vec![ + home.join(".local/bin/claude"), + home.join(".local/bin/claude.exe"), + home.join(".claude/local/claude"), + home.join(".claude/local/claude.exe"), + home.join(".local/share/mise/shims/claude"), + home.join(".local/share/mise/shims/claude.exe"), + home.join(".asdf/shims/claude"), + home.join(".asdf/shims/claude.exe"), + home.join(".npm-global/bin/claude"), + home.join(".npm-global/bin/claude.cmd"), + home.join(".npm-global/bin/claude.exe"), + home.join(".npm/bin/claude"), + home.join(".npm/bin/claude.cmd"), + home.join(".npm/bin/claude.exe"), + home.join(".linuxbrew/bin/claude"), + home.join("AppData/Roaming/npm/claude.cmd"), + home.join("AppData/Roaming/npm/claude.exe"), + home.join("AppData/Local/pnpm/claude.cmd"), + home.join("AppData/Local/pnpm/claude.exe"), + home.join("scoop/shims/claude.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), + PathBuf::from("/opt/homebrew/bin/claude"), + PathBuf::from("/usr/local/bin/claude"), + ]; + candidates.extend(nvm_claude_binary_candidates_for_home(home)); + candidates +} + +fn nvm_claude_binary_candidates_for_home(home: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { + return Vec::new(); + }; + + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.join("bin").join("claude")) + .collect::>(); + candidates.sort(); + candidates +} + +// --------------------------------------------------------------------------- +// Public Tauri commands +// --------------------------------------------------------------------------- + +/// Check whether the `claude` CLI is installed and return its version. +pub fn check_cli() -> ClaudeCliStatus { + let bin = match find_claude_binary() { + Ok(b) => b, + Err(_) => { + return ClaudeCliStatus { + installed: false, + version: None, + } + } + }; + + ClaudeCliStatus { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&bin), + } +} + +/// Spawn `claude -p` for a simple chat (no tools) and stream events via the +/// provided callback. Returns the session ID for future `--resume` calls. +pub fn run_chat_stream(req: ChatStreamRequest, mut emit: F) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let bin = find_claude_binary()?; + let invocation = crate::claude_invocation::chat(&req); + run_claude_subprocess( + ClaudeSubprocessRequest { + bin: &bin, + args: &invocation.args, + fallback_args: &invocation.fallback_args, + stdin_text: invocation.stdin_text.as_deref(), + cwd: None, + }, + &mut emit, + ) +} + +/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task. +pub fn run_agent_stream(req: AgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let bin = find_claude_binary()?; + let invocation = crate::claude_invocation::agent(&req)?; + run_claude_subprocess( + ClaudeSubprocessRequest { + bin: &bin, + args: &invocation.args, + fallback_args: &invocation.fallback_args, + stdin_text: invocation.stdin_text.as_deref(), + cwd: Some(&req.vault_path), + }, + &mut emit, + ) +} + +/// Mutable state accumulated across the JSON stream for a single subprocess. +struct StreamState { + session_id: String, + /// Accumulates `input_json_delta` chunks keyed by tool_use id. + tool_inputs: HashMap, + /// The tool_use id of the block currently being streamed. + current_tool_id: Option, + /// Tracks whether response text has already been emitted for this run. + emitted_text: bool, +} + +struct ClaudeSubprocessRequest<'a> { + bin: &'a Path, + args: &'a [String], + fallback_args: &'a [Vec], + stdin_text: Option<&'a str>, + cwd: Option<&'a str>, +} + +struct ClaudeCommandRequest<'a> { + bin: &'a Path, + args: &'a [String], + cwd: Option<&'a str>, +} + +#[derive(Clone, Copy)] +struct ClaudeStderr<'a>(&'a str); + +impl<'a> ClaudeStderr<'a> { + fn is_empty(self) -> bool { + self.0.is_empty() + } + + fn lines(self) -> std::str::Lines<'a> { + self.0.lines() + } + + fn to_ascii_lowercase(self) -> String { + self.0.to_ascii_lowercase() + } +} + +struct ClaudeFailure<'a> { + stderr: ClaudeStderr<'a>, + status: ExitStatus, +} + +#[derive(Clone, Copy)] +struct TextDeltaChunk<'a>(&'a str); + +impl<'a> TextDeltaChunk<'a> { + fn is_empty(self) -> bool { + self.0.is_empty() + } + + fn as_str(self) -> &'a str { + self.0 + } +} + +#[derive(Clone, Copy)] +struct ToolUseId<'a>(&'a str); + +impl<'a> ToolUseId<'a> { + fn as_str(self) -> &'a str { + self.0 + } +} + +/// Core subprocess runner shared by chat and agent modes. +/// When `cwd` is `Some`, the subprocess starts with that working directory. +fn run_claude_subprocess( + request: ClaudeSubprocessRequest<'_>, + emit: &mut F, +) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let attempts = std::iter::once(request.args) + .chain(request.fallback_args.iter().map(Vec::as_slice)) + .enumerate(); + + for (index, attempt_args) in attempts { + let mut state = StreamState { + session_id: String::new(), + tool_inputs: HashMap::new(), + current_tool_id: None, + emitted_text: false, + }; + + let cmd = build_claude_command(ClaudeCommandRequest { + bin: request.bin, + args: attempt_args, + cwd: request.cwd, + })?; + let process = crate::cli_agent_runtime::JsonLineProcess::new(cmd, "claude") + .with_stdin(request.stdin_text); + let run = crate::cli_agent_runtime::run_json_line_process_with_stdin( + process, + emit, + |message| ClaudeStreamEvent::Error { message }, + |json, emit, session_id| { + dispatch_event(json, &mut state, emit); + *session_id = state.session_id.clone(); + }, + )?; + + if !run.status.success() && state.session_id.is_empty() { + let has_fallback = index < request.fallback_args.len(); + let stderr = ClaudeStderr(&run.stderr_output); + if has_fallback && is_unsupported_claude_flag_error(stderr) { + continue; + } + + emit(ClaudeStreamEvent::Error { + message: format_failed_claude_exit(ClaudeFailure { + stderr, + status: run.status, + }), + }); + } + + emit(ClaudeStreamEvent::Done); + return Ok(state.session_id); + } + + Ok(String::new()) +} + +fn build_claude_command( + request: ClaudeCommandRequest<'_>, +) -> Result { + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?; + let mut cmd = crate::hidden_command(&target.program); + configure_claude_command_environment(&mut cmd, request.bin); + if let Some(first_arg) = target.first_arg { + cmd.arg(first_arg); + } + cmd.args(request.args) + .env_remove("CLAUDECODE") // prevent "nested session" guard + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = request.cwd { + cmd.current_dir(dir); + } + Ok(cmd) +} + +fn configure_claude_command_environment(cmd: &mut std::process::Command, bin: &Path) { + crate::cli_agent_runtime::configure_agent_command_environment(cmd, bin); + crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(cmd, CLAUDE_PROVIDER_ENV_KEYS); +} + +fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String { + if is_claude_too_many_redirects_error(failure.stderr) { + return localized_error(CLAUDE_TOO_MANY_REDIRECTS_KEY); + } + + if is_claude_auth_error(failure.stderr) { + return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into(); + } + + if failure.stderr.is_empty() { + format!("claude exited with status {}", failure.status) + } else { + failure + .stderr + .lines() + .take(3) + .collect::>() + .join("\n") + } +} + +fn localized_error(key: &str) -> String { + let payload = serde_json::json!({ + "key": key, + "values": {}, + }); + format!("{LOCALIZED_ERROR_PREFIX}{payload}") +} + +fn is_claude_too_many_redirects_error(stderr: ClaudeStderr<'_>) -> bool { + let lower = stderr.to_ascii_lowercase(); + lower.contains("toomanyredirects") || lower.contains("too many redirects") +} + +fn is_claude_auth_error(stderr: ClaudeStderr<'_>) -> bool { + let lower = stderr.to_ascii_lowercase(); + ["not logged in", "authentication", "auth"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +fn is_unsupported_claude_flag_error(stderr: ClaudeStderr<'_>) -> bool { + let lower = stderr.to_ascii_lowercase(); + let mentions_compat_flag = ["--tools", "--no-session-persistence"] + .iter() + .any(|flag| lower.contains(flag)); + let looks_unsupported = [ + "unknown option", + "unknown argument", + "unrecognized option", + "unexpected argument", + "unknown command-line option", + ] + .iter() + .any(|pattern| lower.contains(pattern)); + + mentions_compat_flag && looks_unsupported +} + +/// Parse a single JSON line from the stream and emit the appropriate event. +fn dispatch_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + let msg_type = json["type"].as_str().unwrap_or(""); + + match msg_type { + // --- System init → capture session_id --- + "system" if json["subtype"].as_str() == Some("init") => { + if let Some(sid) = json["session_id"].as_str() { + state.session_id = sid.to_string(); + emit(ClaudeStreamEvent::Init { + session_id: sid.to_string(), + }); + } + } + + // --- Streaming partial events (text deltas, tool_use starts) --- + "stream_event" => { + dispatch_stream_event(json, state, emit); + } + + // --- Tool progress (agent mode) --- + "tool_progress" => { + if let (Some(name), Some(id)) = + (json["tool_name"].as_str(), json["tool_use_id"].as_str()) + { + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + input: None, + }); + } + } + + // --- Tool result (agent mode) --- + "tool_result" => { + if let Some(id) = json["tool_use_id"].as_str() { + let output = extract_tool_result_text(json); + emit(ClaudeStreamEvent::ToolDone { + tool_id: id.to_string(), + output, + }); + } + } + + // --- Final result --- + "result" => { + let sid = json["session_id"].as_str().unwrap_or("").to_string(); + if !sid.is_empty() { + state.session_id = sid.clone(); + } + let text = if state.emitted_text { + String::new() + } else { + let text = json["result"].as_str().unwrap_or("").to_string(); + if !text.is_empty() { + state.emitted_text = true; + } + text + }; + emit(ClaudeStreamEvent::Result { + text, + session_id: sid, + }); + } + + // --- Complete assistant message (fallback for text when no partials) --- + "assistant" => { + if let Some(content) = json["message"]["content"].as_array() { + let emit_text = !state.emitted_text; + for block in content { + dispatch_assistant_content_block(block, emit_text, state, emit); + } + } + } + + _ => {} // ignore other event types + } +} + +/// Handle a `stream_event` (partial assistant message). +fn dispatch_stream_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + let event = &json["event"]; + let event_type = event["type"].as_str().unwrap_or(""); + + match event_type { + "content_block_delta" => { + let delta = &event["delta"]; + match delta["type"].as_str() { + Some("text_delta") => { + if let Some(text) = delta["text"].as_str() { + emit_text_delta(TextDeltaChunk(text), state, emit); + } + } + Some("thinking_delta") => { + if let Some(text) = delta["thinking"].as_str() { + emit(ClaudeStreamEvent::ThinkingDelta { + text: text.to_string(), + }); + } + } + Some("input_json_delta") => { + if let (Some(partial), Some(ref tid)) = + (delta["partial_json"].as_str(), &state.current_tool_id) + { + state + .tool_inputs + .entry(tid.clone()) + .or_default() + .push_str(partial); + } + } + _ => {} + } + } + "content_block_start" => { + let block = &event["content_block"]; + if block["type"].as_str() == Some("tool_use") { + if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + state.current_tool_id = Some(id.to_string()); + state.tool_inputs.entry(id.to_string()).or_default(); + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + input: None, + }); + } + } + } + "content_block_stop" => { + state.current_tool_id = None; + } + _ => {} + } +} + +fn dispatch_assistant_content_block( + block: &serde_json::Value, + emit_text: bool, + state: &mut StreamState, + emit: &mut F, +) where + F: FnMut(ClaudeStreamEvent), +{ + match block["type"].as_str() { + Some("text") if emit_text => { + if let Some(text) = block["text"].as_str() { + emit_text_delta(TextDeltaChunk(text), state, emit); + } + } + Some("tool_use") => { + if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + let input = format_tool_input(&block["input"], state, ToolUseId(id)); + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + input, + }); + } + } + _ => {} + } +} + +fn emit_text_delta(text: TextDeltaChunk<'_>, state: &mut StreamState, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + if !text.is_empty() { + state.emitted_text = true; + } + emit(ClaudeStreamEvent::TextDelta { + text: text.as_str().to_string(), + }); +} + +/// Build the tool input string, preferring accumulated delta chunks over the +/// block's `input` field (which may be empty at stream start). +fn format_tool_input( + block_input: &serde_json::Value, + state: &StreamState, + tool_id: ToolUseId<'_>, +) -> Option { + if let Some(accumulated) = state.tool_inputs.get(tool_id.as_str()) { + if !accumulated.is_empty() { + return Some(accumulated.clone()); + } + } + if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) { + return Some(block_input.to_string()); + } + None +} + +/// Extract displayable text from a `tool_result` event. +fn extract_tool_result_text(json: &serde_json::Value) -> Option { + // String content field + if let Some(s) = json["content"].as_str() { + return Some(s.to_string()); + } + // Array of content blocks (Claude format) + if let Some(arr) = json["content"].as_array() { + let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect(); + if !texts.is_empty() { + return Some(texts.join("\n")); + } + } + // Fallback: "output" field + json["output"].as_str().map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + use std::ffi::OsStr; + use std::ffi::OsString; + use std::process::Command; + + #[cfg(target_os = "linux")] + fn current_test_binary() -> PathBuf { + std::fs::read_link("/proc/self/exe").unwrap() + } + + #[cfg(target_os = "macos")] + fn current_test_binary() -> PathBuf { + let pid = std::process::id().to_string(); + let output = Command::new("/bin/ps") + .args(["-p", pid.as_str(), "-o", "comm="]) + .output() + .unwrap(); + let path = String::from_utf8(output.stdout).unwrap(); + PathBuf::from(path.trim()) + } + + fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) { + let candidates = claude_binary_candidates_for_home(home); + for candidate in expected { + assert!( + candidates.contains(candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn check_cli_returns_status() { + let status = check_cli(); + if status.installed { + assert!(status.version.is_some()); + } else { + assert!(status.version.is_none()); + } + } + + #[test] + fn claude_binary_candidates_include_nvm_managed_node_installs() { + let home = tempfile::tempdir().unwrap(); + let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude"); + std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); + std::fs::write(&claude, "#!/bin/sh\n").unwrap(); + + let candidates = claude_binary_candidates_for_home(home.path()); + + assert!(candidates.contains(&claude), "missing {}", claude.display()); + } + + #[test] + fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() { + let dir = tempfile::tempdir().unwrap(); + let shell_script = dir.path().join("claude"); + let cmd_shim = dir.path().join("claude.cmd"); + std::fs::write(&shell_script, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd_shim, "@ECHO off\n").unwrap(); + + let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display()); + + assert_eq!( + first_existing_path_for_platform(&stdout, true), + Some(cmd_shim) + ); + } + + #[test] + fn unsupported_claude_flag_errors_are_detected() { + assert!(is_unsupported_claude_flag_error(ClaudeStderr( + "error: unknown option '--tools'" + ))); + assert!(is_unsupported_claude_flag_error(ClaudeStderr( + "Unknown argument: --no-session-persistence" + ))); + assert!(!is_unsupported_claude_flag_error(ClaudeStderr( + "Claude CLI is not authenticated" + ))); + } + + #[test] + fn format_failed_claude_exit_sanitizes_too_many_redirects() { + let message = format_failed_claude_exit(ClaudeFailure { + stderr: ClaudeStderr( + "API Error: Unable to connect to API (TooManyRedirects)\nredirected to https://example.invalid/callback?token=secret", + ), + status: failed_exit_status(), + }); + + assert_eq!( + ( + message.starts_with("tolaria:i18n-error:"), + message.contains(r#""key":"ai.error.claude.tooManyRedirects""#), + message.contains("https://example.invalid"), + message.contains("secret"), + ), + (true, true, false, false) + ); + } + + // --- dispatch_event / dispatch_stream_event --- + + #[cfg(unix)] + fn failed_exit_status() -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + + ExitStatus::from_raw(1 << 8) + } + + #[cfg(windows)] + fn failed_exit_status() -> ExitStatus { + use std::os::windows::process::ExitStatusExt; + + ExitStatus::from_raw(1) + } + + fn new_state() -> StreamState { + StreamState { + session_id: String::new(), + tool_inputs: HashMap::new(), + current_tool_id: None, + emitted_text: false, + } + } + + /// Run dispatch_event on the given JSON and return (session_id, events). + fn run_dispatch(json: serde_json::Value) -> (String, Vec) { + let mut state = new_state(); + let mut events = vec![]; + dispatch_event(&json, &mut state, &mut |e| events.push(e)); + (state.session_id, events) + } + + #[derive(Clone, Copy)] + struct InitialSessionId<'a>(&'a str); + + /// Run dispatch_event with a pre-set session_id. + fn run_dispatch_with_sid( + json: serde_json::Value, + initial_sid: InitialSessionId<'_>, + ) -> (String, Vec) { + let mut state = new_state(); + state.session_id = initial_sid.0.to_string(); + let mut events = vec![]; + dispatch_event(&json, &mut state, &mut |e| events.push(e)); + (state.session_id, events) + } + + /// Run multiple dispatch_event calls sharing state (for multi-event sequences). + fn run_dispatch_sequence( + events_json: Vec, + ) -> (StreamState, Vec) { + let mut state = new_state(); + let mut events = vec![]; + for json in &events_json { + dispatch_event(json, &mut state, &mut |e| events.push(e)); + } + (state, events) + } + + #[test] + fn dispatch_event_handles_init() { + let (sid, events) = run_dispatch(serde_json::json!({ + "type": "system", "subtype": "init", "session_id": "test-session-123" + })); + assert_eq!(sid, "test-session-123"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123") + ); + } + + #[test] + fn dispatch_event_system_without_init_subtype_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_system_init_without_session_id_is_ignored() { + let (sid, events) = + run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" })); + assert!(events.is_empty()); + assert!(sid.is_empty()); + } + + #[test] + fn dispatch_event_handles_text_delta() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } } + })); + assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello")); + } + + #[test] + fn dispatch_event_handles_tool_start() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } } + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc") + ); + } + + #[test] + fn dispatch_event_handles_result() { + let (sid, events) = run_dispatch(serde_json::json!({ + "type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456" + })); + assert_eq!(sid, "sess-456"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456") + ); + } + + #[test] + fn dispatch_event_result_with_empty_session_id() { + let (sid, events) = run_dispatch_with_sid( + serde_json::json!({ "type": "result", "result": "text here" }), + InitialSessionId("prev-session"), + ); + assert_eq!(sid, "prev-session"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here") + ); + } + + #[test] + fn dispatch_event_handles_tool_progress() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz" + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz") + ); + } + + #[test] + fn dispatch_event_tool_progress_missing_fields_is_ignored() { + let (_, events) = + run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_handles_assistant_with_tool_use() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "text", "text": "Let me search." }, + { "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} } + ] } + })); + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.") + ); + assert!( + matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1") + ); + } + + #[test] + fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() { + let (state, events) = run_dispatch_sequence(vec![ + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } } + }), + serde_json::json!({ + "type": "result", + "session_id": "session-1", + "result": "Visible reply" + }), + ]); + + assert_eq!(state.session_id, "session-1"); + assert!(matches!(&events[..], + [ + ClaudeStreamEvent::TextDelta { text }, + ClaudeStreamEvent::Result { text: result_text, session_id }, + ] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1")); + } + + #[test] + fn dispatch_event_assistant_without_content_is_noop() { + let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_ignores_unknown() { + let (_, events) = + run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_input_json_delta_accumulates_silently() { + // input_json_delta doesn't emit events directly — it accumulates in state + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } } + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_non_tool_block_start_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } } + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_unknown_type_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", "event": { "type": "message_stop" } + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_handles_tool_result_string_content() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", + "tool_use_id": "tool_abc", + "content": "Found 3 notes matching query" + })); + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output } + if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query")) + ); + } + + #[test] + fn dispatch_event_handles_tool_result_array_content() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", + "tool_use_id": "tool_def", + "content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }] + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. } + if output.as_deref() == Some("Line 1\nLine 2")) + ); + } + + #[test] + fn dispatch_event_tool_result_missing_tool_id_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_result", "content": "result text" + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_accumulates_input_json_deltas() { + let (_, events) = run_dispatch_sequence(vec![ + // Start tool_use block + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } } + }), + // Input delta chunks + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } } + }), + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } } + }), + // Stop block + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_stop" } + }), + // Assistant message triggers ToolStart with accumulated input + serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } } + ] } + }), + ]); + // First event: ToolStart with no input (from content_block_start) + assert!(matches!( + &events[0], + ClaudeStreamEvent::ToolStart { input: None, .. } + )); + // Second event: ToolStart with accumulated input (from assistant) + assert!( + matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } + if inp == "{\"query\":\"test\"}") + ); + } + + #[test] + fn dispatch_assistant_uses_block_input_when_no_deltas() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } } + ] } + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } + if inp.contains("title") && inp.contains("Hello")) + ); + } + + #[test] + fn content_block_stop_clears_current_tool() { + let (state, _) = run_dispatch_sequence(vec![ + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } } + }), + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_stop" } + }), + ]); + assert!(state.current_tool_id.is_none()); + } + + // --- run_claude_subprocess with mock scripts --- + + #[test] + fn build_claude_command_keeps_streaming_process_contract() { + let bin = PathBuf::from("claude"); + let args = vec!["-p".to_string(), "hello".to_string()]; + let command = build_claude_command(ClaudeCommandRequest { + bin: &bin, + args: &args, + cwd: Some("/tmp/vault"), + }) + .unwrap(); + let actual_args: Vec = command.get_args().map(OsStr::to_os_string).collect(); + let claude_code_env = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("CLAUDECODE")) + .map(|(_, value)| value.map(OsStr::to_os_string)); + + assert_eq!( + ( + command.get_program().to_os_string(), + actual_args, + command.get_current_dir().map(Path::to_path_buf), + claude_code_env, + ), + ( + OsString::from("claude"), + vec![OsString::from("-p"), OsString::from("hello")], + Some(PathBuf::from("/tmp/vault")), + Some(None), + ), + ); + } + + #[test] + fn build_claude_command_avoids_windows_cmd_shim_for_prompt_args() { + let dir = tempfile::tempdir().unwrap(); + let shim = dir.path().join("claude.cmd"); + let script = dir + .path() + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("cli.js"); + std::fs::create_dir_all(script.parent().unwrap()).unwrap(); + std::fs::write(&script, "console.log('claude')\n").unwrap(); + std::fs::write( + &shim, + r#"@ECHO off +"%_prog%" "%~dp0\node_modules\@anthropic-ai\claude-code\cli.js" %* +"#, + ) + .unwrap(); + + let args = vec![ + "-p".to_string(), + "Rename the note after reading the active vault".to_string(), + ]; + let command = build_claude_command(ClaudeCommandRequest { + bin: &shim, + args: &args, + cwd: Some("/tmp/vault"), + }) + .unwrap(); + let actual_args = command.get_args().collect::>(); + + assert_ne!( + command.get_program(), + shim.as_os_str(), + "Claude npm .cmd shims cannot safely receive prompt args directly" + ); + assert_eq!(actual_args.first().copied(), Some(script.as_os_str())); + assert!(actual_args.iter().any(|arg| *arg == OsStr::new("-p"))); + assert!(actual_args + .iter() + .any(|arg| *arg == OsStr::new("Rename the note after reading the active vault"))); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + } + + #[test] + fn claude_provider_env_keys_include_reported_anthropic_overrides() { + assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY"))); + assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_BASE_URL"))); + } + + #[cfg(unix)] + #[derive(Clone, Copy)] + struct MockClaudeScript<'a>(&'a str); + + #[cfg(unix)] + #[derive(Clone, Copy)] + struct MockClaudeArgs<'a>(&'a [String]); + + #[cfg(unix)] + #[derive(Clone, Copy)] + struct MockClaudeStdin<'a>(Option<&'a str>); + + #[cfg(unix)] + fn run_mock_script( + script: MockClaudeScript<'_>, + ) -> (Result, Vec) { + run_mock_script_with_args(script, MockClaudeArgs(&[])) + } + + #[cfg(unix)] + fn run_mock_script_with_args( + script: MockClaudeScript<'_>, + args: MockClaudeArgs<'_>, + ) -> (Result, Vec) { + run_mock_script_with_args_and_stdin(script, args, MockClaudeStdin(None)) + } + + #[cfg(unix)] + fn run_mock_script_with_args_and_stdin( + script: MockClaudeScript<'_>, + args: MockClaudeArgs<'_>, + stdin_text: MockClaudeStdin<'_>, + ) -> (Result, Vec) { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mock-claude"); + std::fs::write(&path, script.0).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut events = vec![]; + let result = run_claude_subprocess( + ClaudeSubprocessRequest { + bin: &path, + args: args.0, + fallback_args: &[], + stdin_text: stdin_text.0, + cwd: None, + }, + &mut |e| events.push(e), + ); + (result, events) + } + + #[cfg(unix)] + #[test] + fn run_subprocess_parses_ndjson_stream() { + let (result, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n", + "echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n", + "echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n", + ))); + assert_eq!(result.unwrap(), "s1"); + assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1")); + assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi")); + assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. })); + assert!(matches!(&events[3], ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_retries_with_fallback_args_for_removed_flags() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mock-claude"); + std::fs::write( + &path, + concat!( + "#!/bin/sh\n", + "for arg in \"$@\"; do\n", + " if [ \"$arg\" = \"--tools\" ]; then\n", + " echo \"error: unknown option '--tools'\" >&2\n", + " exit 1\n", + " fi\n", + "done\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fallback\"}'\n", + "echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"fallback\"}'\n", + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let args = vec!["--tools".to_string(), "Read".to_string()]; + let fallback_args = vec![vec!["--allowedTools".to_string(), "Read".to_string()]]; + let mut events = vec![]; + let result = run_claude_subprocess( + ClaudeSubprocessRequest { + bin: &path, + args: &args, + fallback_args: &fallback_args, + stdin_text: None, + cwd: None, + }, + &mut |event| events.push(event), + ); + + assert_eq!(result.unwrap(), "fallback"); + assert!(matches!( + events.first(), + Some(ClaudeStreamEvent::Init { session_id }) if session_id == "fallback" + )); + assert!(!events + .iter() + .any(|event| matches!(event, ClaudeStreamEvent::Error { .. }))); + assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() { + use std::io::Read; + use std::time::{Duration, Instant}; + + let mut child = Command::new(current_test_binary()) + .arg("stdin_probe_parent_child") + .arg("--ignored") + .arg("--nocapture") + .env("TOLARIA_STDIN_PROBE_CHILD", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let child_stdin = child.stdin.take().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + drop(child_stdin); + panic!("stdin probe child timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + + drop(child_stdin); + let mut stdout_text = String::new(); + let mut stderr_text = String::new(); + stdout.read_to_string(&mut stdout_text).unwrap(); + stderr.read_to_string(&mut stderr_text).unwrap(); + + assert!( + status.success(), + "stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}" + ); + } + + #[cfg(unix)] + #[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"] + #[test] + fn stdin_probe_parent_child() { + if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() { + return; + } + + let (result, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "stdin=\"$(cat)\"\n", + "if [ -n \"$stdin\" ]; then\n", + " echo \"stdin was not closed\" >&2\n", + " exit 9\n", + "fi\n", + "printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n", + ))); + + assert_eq!(result.unwrap(), "stdin-ok"); + assert!(matches!( + events.first(), + Some(ClaudeStreamEvent::Result { text, session_id }) + if text == "stdin closed" && session_id == "stdin-ok" + )); + assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_skips_blank_and_non_json_lines() { + let (result, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "echo ''\n", + "echo 'not json at all'\n", + "echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n", + ))); + assert_eq!(result.unwrap(), "s2"); + assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok")); + assert!(matches!(&events[1], ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_emits_error_on_nonzero_exit() { + let (_, events) = run_mock_script(MockClaudeScript( + "#!/bin/sh\necho 'auth problem' >&2\nexit 1\n", + )); + assert!(events + .iter() + .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); + assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_detects_auth_error_in_stderr() { + let (_, events) = run_mock_script(MockClaudeScript( + "#!/bin/sh\necho 'not logged in' >&2\nexit 1\n", + )); + assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated")))); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_reports_exit_code_on_empty_stderr() { + let (_, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 2\n")); + assert!(events.iter().any( + |e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with")) + )); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_success_with_no_events() { + let (result, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 0\n")); + assert!(result.is_ok()); + assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_passes_args_through() { + let args: Vec = vec!["--foo".into(), "bar".into()]; + let (_, events) = run_mock_script_with_args( + MockClaudeScript(concat!( + "#!/bin/sh\n", + "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n", + )), + MockClaudeArgs(&args), + ); + let text = events.iter().find_map(|e| match e { + ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()), + _ => None, + }); + assert!(text.unwrap().contains("--foo")); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_writes_prompt_to_stdin() { + let args: Vec = vec!["-p".into()]; + let (result, events) = run_mock_script_with_args_and_stdin( + MockClaudeScript(concat!( + "#!/bin/sh\n", + "stdin=$(cat)\n", + "if [ \"$stdin\" != \"hello from stdin\" ]; then\n", + " echo \"unexpected stdin: $stdin\" >&2\n", + " exit 3\n", + "fi\n", + "echo '{\"type\":\"result\",\"result\":\"stdin ok\",\"session_id\":\"sx\"}'\n", + )), + MockClaudeArgs(&args), + MockClaudeStdin(Some("hello from stdin")), + ); + + assert_eq!(result.unwrap(), "sx"); + assert!(events.iter().any( + |event| matches!(event, ClaudeStreamEvent::Result { text, .. } if text == "stdin ok") + )); + } + + // --- find_claude_binary --- + + #[test] + fn claude_binary_candidates_include_supported_local_and_toolchain_installs() { + let home = PathBuf::from("/Users/alex"); + let expected = [ + home.join(".local/bin/claude"), + home.join(".claude/local/claude"), + home.join(".local/share/mise/shims/claude"), + home.join(".npm-global/bin/claude"), + ]; + + assert_binary_candidates_include(&home, &expected); + } + + #[test] + fn claude_binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let expected = [ + home.join(".linuxbrew/bin/claude"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), + ]; + + assert_binary_candidates_include(&home, &expected); + } + + #[test] + fn claude_binary_candidates_include_windows_exe_installs() { + let home = PathBuf::from(r"C:\Users\alex"); + let expected = [ + home.join(".local/bin/claude.exe"), + home.join(".claude/local/claude.exe"), + home.join("AppData/Roaming/npm/claude.cmd"), + ]; + + assert_binary_candidates_include(&home, &expected); + } + + #[test] + fn claude_path_lookup_command_matches_current_platform() { + let expected = if cfg!(windows) { "where" } else { "which" }; + + assert_eq!(claude_path_lookup_command(), expected); + } + + #[test] + fn find_existing_binary_finds_windows_exe_candidate() { + let dir = tempfile::tempdir().unwrap(); + let claude = dir.path().join(".local/bin/claude.exe"); + std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); + std::fs::write(&claude, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + assert_eq!( + crate::cli_agent_runtime::find_executable_binary_candidate( + claude_binary_candidates_for_home(dir.path()), + "Claude CLI", + ) + .unwrap(), + Some(claude) + ); + } + + #[test] + fn find_claude_binary_returns_result() { + let result = find_claude_binary(); + // On dev machines claude may be installed; on CI it may not. + // Either way, the function should return Ok(path) or Err(message). + match &result { + Ok(path) => assert!(path.exists()), + Err(msg) => assert!(msg.contains("not found")), + } + } + + // --- run_chat_stream / run_agent_stream error paths --- + + #[test] + fn run_chat_stream_returns_result() { + let req = ChatStreamRequest { + message: "test".into(), + system_prompt: None, + session_id: None, + }; + let mut events = vec![]; + // This will either succeed (if claude is installed) or fail (if not). + let result = run_chat_stream(req, |e| events.push(e)); + // Either way the function should have returned without panicking. + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn run_agent_stream_returns_result() { + let req = AgentStreamRequest { + message: "test".into(), + system_prompt: Some("sys".into()), + vault_path: "/tmp/nonexistent".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + }; + let mut events = vec![]; + let result = run_agent_stream(req, |e| events.push(e)); + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn run_subprocess_spawn_failure() { + let fake_bin = PathBuf::from("/nonexistent/binary/path"); + let mut events = vec![]; + let result = run_claude_subprocess( + ClaudeSubprocessRequest { + bin: &fake_bin, + args: &[], + fallback_args: &[], + stdin_text: None, + cwd: None, + }, + &mut |e| events.push(e), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to start claude")); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_with_tool_progress_and_assistant() { + let (result, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n", + "echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n", + "echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n", + "echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n", + ))); + assert_eq!(result.unwrap(), "s3"); + assert!(events.len() >= 4); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_success_exit_with_session_id_skips_error() { + let (_, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n", + "echo 'some warning' >&2\n", + "exit 1\n", + ))); + // Should NOT have an error event because session_id is non-empty + assert!(!events + .iter() + .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); + } +} diff --git a/src-tauri/src/claude_invocation.rs b/src-tauri/src/claude_invocation.rs new file mode 100644 index 0000000..248dbbe --- /dev/null +++ b/src-tauri/src/claude_invocation.rs @@ -0,0 +1,613 @@ +use crate::ai_agents::AiAgentPermissionMode; +use crate::claude_cli::ChatStreamRequest; +use crate::cli_agent_runtime::AgentStreamRequest; + +const CLAUDE_SAFE_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS"; +const CLAUDE_POWER_USER_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"; +const CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT: &str = + "Bash,Glob,Grep,Read,Edit,Write,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task,MultiEdit,LS"; +const CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT: &str = + "Bash,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task"; +const CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT: &str = + "NotebookEdit,WebFetch,WebSearch,TodoWrite,Task"; +const WINDOWS_COMMAND_LINE_STDIN_THRESHOLD: usize = 24 * 1024; + +#[derive(Debug)] +pub(crate) struct ClaudeInvocation { + pub(crate) args: Vec, + pub(crate) fallback_args: Vec>, + pub(crate) stdin_text: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PromptSource { + Argument, + Stdin, +} + +pub(crate) fn chat(req: &ChatStreamRequest) -> ClaudeInvocation { + chat_with_windows_limit(req, cfg!(windows)) +} + +pub(crate) fn agent(req: &AgentStreamRequest) -> Result { + agent_with_windows_limit(req, cfg!(windows)) +} + +fn chat_with_windows_limit( + req: &ChatStreamRequest, + enforce_windows_limit: bool, +) -> ClaudeInvocation { + let args = chat_args(req); + let fallback_args = vec![chat_args_compat(req)]; + if should_pipe_prompt_for_windows(enforce_windows_limit, &args, &fallback_args) { + return ClaudeInvocation { + args: chat_args_with_tool_policy(req, "--tools", String::new(), PromptSource::Stdin), + fallback_args: vec![chat_args_with_tool_policy( + req, + "--disallowedTools", + CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT.into(), + PromptSource::Stdin, + )], + stdin_text: Some(stdin_prompt(&req.message, req.system_prompt.as_deref())), + }; + } + + ClaudeInvocation { + args, + fallback_args, + stdin_text: None, + } +} + +fn agent_with_windows_limit( + req: &AgentStreamRequest, + enforce_windows_limit: bool, +) -> Result { + let args = agent_args(req)?; + let fallback_args = vec![ + agent_args_without_session_persistence(req)?, + agent_args_compat(req)?, + ]; + if should_pipe_prompt_for_windows(enforce_windows_limit, &args, &fallback_args) { + return Ok(ClaudeInvocation { + args: agent_args_with_tool_policy( + req, + AgentToolPolicy::strict(req.permission_mode), + PromptSource::Stdin, + )?, + fallback_args: vec![ + agent_args_with_tool_policy( + req, + AgentToolPolicy::strict_without_session_persistence(req.permission_mode), + PromptSource::Stdin, + )?, + agent_args_with_tool_policy( + req, + AgentToolPolicy::compat(req.permission_mode), + PromptSource::Stdin, + )?, + ], + stdin_text: Some(stdin_prompt(&req.message, req.system_prompt.as_deref())), + }); + } + + Ok(ClaudeInvocation { + args, + fallback_args, + stdin_text: None, + }) +} + +fn chat_args(req: &ChatStreamRequest) -> Vec { + chat_args_with_tool_policy(req, "--tools", String::new(), PromptSource::Argument) +} + +fn chat_args_compat(req: &ChatStreamRequest) -> Vec { + chat_args_with_tool_policy( + req, + "--disallowedTools", + CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT.into(), + PromptSource::Argument, + ) +} + +fn chat_args_with_tool_policy( + req: &ChatStreamRequest, + tool_flag: &str, + tool_value: String, + prompt_source: PromptSource, +) -> Vec { + let mut args: Vec = vec!["-p".into()]; + if prompt_source == PromptSource::Argument { + args.push(req.message.clone()); + } + args.extend([ + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + tool_flag.into(), + tool_value, + ]); + + if prompt_source == PromptSource::Argument { + append_non_empty_arg_pair(&mut args, "--system-prompt", req.system_prompt.as_deref()); + } + if let Some(ref session_id) = req.session_id { + args.push("--resume".into()); + args.push(session_id.clone()); + } + + args +} + +fn agent_args(req: &AgentStreamRequest) -> Result, String> { + agent_args_with_tool_policy( + req, + AgentToolPolicy::strict(req.permission_mode), + PromptSource::Argument, + ) +} + +fn agent_args_without_session_persistence(req: &AgentStreamRequest) -> Result, String> { + agent_args_with_tool_policy( + req, + AgentToolPolicy::strict_without_session_persistence(req.permission_mode), + PromptSource::Argument, + ) +} + +fn agent_args_compat(req: &AgentStreamRequest) -> Result, String> { + agent_args_with_tool_policy( + req, + AgentToolPolicy::compat(req.permission_mode), + PromptSource::Argument, + ) +} + +fn agent_args_with_tool_policy( + req: &AgentStreamRequest, + policy: AgentToolPolicy, + prompt_source: PromptSource, +) -> Result, String> { + let mut args: Vec = vec!["-p".into()]; + if prompt_source == PromptSource::Argument { + args.push(req.message.clone()); + } + args.extend([ + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--mcp-config".into(), + mcp_config(&req.vault_path, &req.vault_paths)?, + "--strict-mcp-config".into(), + "--permission-mode".into(), + "acceptEdits".into(), + policy.tool_flag.into(), + policy.tool_value.into(), + ]); + + if policy.include_session_persistence_flag { + args.push("--no-session-persistence".into()); + } + if let Some(allowed_tools) = policy.preapproved_tools { + args.push("--allowedTools".into()); + args.push(allowed_tools.into()); + } + if let Some(disallowed_tools) = policy.disallowed_tools { + args.push("--disallowedTools".into()); + args.push(disallowed_tools.into()); + } + if prompt_source == PromptSource::Argument { + append_non_empty_arg_pair( + &mut args, + "--append-system-prompt", + req.system_prompt.as_deref(), + ); + } + + Ok(args) +} + +fn append_non_empty_arg_pair(args: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + args.push(flag.into()); + args.push(value.into()); + } +} + +fn stdin_prompt(message: &str, system_prompt: Option<&str>) -> String { + crate::cli_agent_runtime::build_prompt(message, system_prompt) +} + +fn mcp_config(vault_path: &str, vault_paths: &[String]) -> Result { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + let config = serde_json::json!({ + "mcpServers": { + "tolaria": crate::cli_agent_runtime::tolaria_node_mcp_server( + &mcp_server_path, + vault_path, + vault_paths, + false, + ) + } + }); + serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}")) +} + +fn should_pipe_prompt_for_windows( + enforce_windows_limit: bool, + args: &[String], + fallback_args: &[Vec], +) -> bool { + enforce_windows_limit + && std::iter::once(args) + .chain(fallback_args.iter().map(Vec::as_slice)) + .any(args_exceed_windows_stdin_threshold) +} + +fn args_exceed_windows_stdin_threshold(args: &[String]) -> bool { + windows_command_line_utf16_units(args) >= WINDOWS_COMMAND_LINE_STDIN_THRESHOLD +} + +fn windows_command_line_utf16_units(args: &[String]) -> usize { + args.iter().map(|arg| arg.encode_utf16().count() + 3).sum() +} + +struct AgentToolPolicy { + tool_flag: &'static str, + tool_value: &'static str, + include_session_persistence_flag: bool, + preapproved_tools: Option<&'static str>, + disallowed_tools: Option<&'static str>, +} + +impl AgentToolPolicy { + fn strict(permission_mode: AiAgentPermissionMode) -> Self { + Self { + tool_flag: "--tools", + tool_value: agent_tools(permission_mode), + include_session_persistence_flag: true, + preapproved_tools: preapproved_agent_tools(permission_mode), + disallowed_tools: None, + } + } + + fn strict_without_session_persistence(permission_mode: AiAgentPermissionMode) -> Self { + Self { + include_session_persistence_flag: false, + ..Self::strict(permission_mode) + } + } + + fn compat(permission_mode: AiAgentPermissionMode) -> Self { + Self { + tool_flag: "--allowedTools", + tool_value: agent_tools(permission_mode), + include_session_persistence_flag: false, + preapproved_tools: None, + disallowed_tools: Some(disallowed_agent_tools_compat(permission_mode)), + } + } +} + +fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str { + match permission_mode { + AiAgentPermissionMode::Safe => CLAUDE_SAFE_AGENT_TOOLS, + AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_AGENT_TOOLS, + } +} + +fn preapproved_agent_tools(permission_mode: AiAgentPermissionMode) -> Option<&'static str> { + match permission_mode { + AiAgentPermissionMode::Safe => None, + AiAgentPermissionMode::PowerUser => Some("Bash"), + } +} + +fn disallowed_agent_tools_compat(permission_mode: AiAgentPermissionMode) -> &'static str { + match permission_mode { + AiAgentPermissionMode::Safe => CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT, + AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! assert_args_contain { + ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { + $( + assert!($args.contains(&$value.to_string()), "missing {}", $value); + )+ + }; + } + + macro_rules! assert_args_lack { + ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { + $( + assert!(!$args.iter().any(|arg| arg == &$value.to_string()), "unexpected {}", $value); + )+ + }; + } + + macro_rules! assert_no_arg_contains { + ($args:expr, $fragment:expr $(,)?) => { + assert!(!$args.iter().any(|arg| arg.contains($fragment))); + }; + } + + macro_rules! chat_request { + ($message:expr, None, None $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: None, + session_id: None, + } + }; + ($message:expr, Some($system_prompt:expr), None $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: Some($system_prompt.to_string()), + session_id: None, + } + }; + ($message:expr, None, Some($session_id:expr) $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: None, + session_id: Some($session_id.to_string()), + } + }; + ($message:expr, Some($system_prompt:expr), Some($session_id:expr) $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: Some($system_prompt.to_string()), + session_id: Some($session_id.to_string()), + } + }; + } + + fn agent_request( + message: &str, + system_prompt: Option<&str>, + permission_mode: AiAgentPermissionMode, + ) -> AgentStreamRequest { + AgentStreamRequest { + message: message.into(), + system_prompt: system_prompt.map(str::to_string), + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode, + } + } + + fn arg_value_after<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + let index = args.iter().position(|arg| arg == name)?; + args.get(index + 1).map(String::as_str) + } + + #[test] + fn chat_args_basic() { + let args = chat_args(&chat_request!("hello", None, None)); + + assert_args_contain!(args, ["-p", "hello", "stream-json"]); + assert_args_lack!(args, ["--system-prompt", "--resume"]); + } + + #[test] + fn chat_args_with_system_prompt() { + let args = chat_args(&chat_request!("hi", Some("You are helpful."), None)); + + assert_args_contain!(args, ["--system-prompt", "You are helpful."]); + } + + #[test] + fn chat_args_empty_system_prompt_is_skipped() { + let args = chat_args(&chat_request!("hi", Some(""), None)); + + assert!(!args.contains(&"--system-prompt".to_string())); + } + + #[test] + fn chat_args_with_session_id() { + let args = chat_args(&chat_request!("continue", None, Some("sess-abc"))); + + assert_args_contain!(args, ["--resume", "sess-abc"]); + } + + #[test] + fn chat_args_compat_disallows_builtin_tools_when_tools_flag_is_unavailable() { + let args = chat_args_compat(&chat_request!("hello", None, None)); + + assert_args_contain!(args, ["--disallowedTools"]); + assert_args_lack!(args, ["--tools"]); + assert!(arg_value_after(&args, "--disallowedTools") + .is_some_and(|tools| tools.contains("NotebookEdit"))); + } + + #[test] + fn oversized_windows_invocations_pipe_prompt_to_stdin() { + let long_message = "x".repeat(WINDOWS_COMMAND_LINE_STDIN_THRESHOLD); + let chat_req = chat_request!(&long_message, Some("Use context."), Some("sess-abc")); + let chat_invocation = chat_with_windows_limit(&chat_req, true); + + assert_eq!(chat_invocation.args.first().map(String::as_str), Some("-p")); + assert_args_lack!( + chat_invocation.args, + [long_message.as_str(), "--system-prompt", "Use context."] + ); + assert_args_contain!(chat_invocation.args, ["--resume", "sess-abc"]); + assert!(chat_invocation + .fallback_args + .iter() + .flatten() + .all(|arg| arg != &long_message)); + let chat_stdin = chat_invocation.stdin_text.as_deref().unwrap(); + assert!(chat_stdin.contains("System instructions:\nUse context.")); + assert!(chat_stdin.contains("User request:\n")); + assert!(chat_stdin.contains(&long_message)); + + let agent_req = agent_request( + &long_message, + Some("Read the active vault first."), + AiAgentPermissionMode::Safe, + ); + let agent_invocation = agent_with_windows_limit(&agent_req, true).unwrap(); + + assert_eq!( + agent_invocation.args.first().map(String::as_str), + Some("-p") + ); + assert_args_lack!( + agent_invocation.args, + [ + long_message.as_str(), + "--append-system-prompt", + "Read the active vault first." + ] + ); + assert_args_contain!( + agent_invocation.args, + ["--mcp-config", "--strict-mcp-config"] + ); + assert!(agent_invocation + .fallback_args + .iter() + .flatten() + .all(|arg| arg != &long_message)); + let agent_stdin = agent_invocation.stdin_text.as_deref().unwrap(); + assert!(agent_stdin.contains("System instructions:\nRead the active vault first.")); + assert!(agent_stdin.contains("User request:\n")); + assert!(agent_stdin.contains(&long_message)); + } + + #[test] + fn agent_args_with_system_prompt() { + if let Ok(args) = agent_args(&agent_request( + "do it", + Some("Act as expert."), + AiAgentPermissionMode::Safe, + )) { + assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]); + } + } + + #[test] + fn agent_args_empty_system_prompt_is_skipped() { + if let Ok(args) = agent_args(&agent_request("x", Some(""), AiAgentPermissionMode::Safe)) { + assert!(!args.contains(&"--append-system-prompt".to_string())); + } + } + + #[test] + fn agent_args_without_session_persistence_keeps_tools_allowlist() { + let args = agent_args_without_session_persistence(&agent_request( + "Rename the note", + None, + AiAgentPermissionMode::Safe, + )) + .unwrap(); + + assert_args_contain!(args, ["--tools", "Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); + assert_args_lack!(args, ["--no-session-persistence"]); + } + + #[test] + fn agent_args_compat_uses_allowed_and_disallowed_tools_without_removed_flags() { + let args = agent_args_compat(&agent_request( + "Rename the note", + None, + AiAgentPermissionMode::Safe, + )) + .unwrap(); + + assert_eq!( + arg_value_after(&args, "--allowedTools"), + Some("Read,Edit,MultiEdit,Write,Glob,Grep,LS") + ); + assert_args_contain!(args, ["--disallowedTools"]); + assert_args_lack!(args, ["--tools"]); + assert_args_lack!(args, ["--no-session-persistence"]); + } + + #[test] + fn agent_args_use_safe_mode_without_bash_by_default() { + let args = agent_args(&agent_request( + "Rename the note", + None, + AiAgentPermissionMode::Safe, + )) + .unwrap(); + + assert_args_contain!( + args, + ["--strict-mcp-config", "--permission-mode", "acceptEdits"] + ); + assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); + assert_no_arg_contains!(args, "Bash"); + assert_args_lack!(args, ["--allowedTools"]); + assert_args_lack!(args, ["--dangerously-skip-permissions"]); + } + + #[test] + fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() { + let args = agent_args(&agent_request( + "Rename the note", + None, + AiAgentPermissionMode::PowerUser, + )) + .unwrap(); + + assert_args_contain!(args, ["--strict-mcp-config"]); + assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]); + assert_args_lack!(args, ["--dangerously-skip-permissions"]); + } + + #[test] + fn agent_args_preapprove_bash_for_power_user_runs() { + let args = agent_args(&agent_request( + "Run a local script", + None, + AiAgentPermissionMode::PowerUser, + )) + .unwrap(); + + assert_eq!(arg_value_after(&args, "--allowedTools"), Some("Bash")); + } + + #[test] + fn agent_invocation_keeps_short_windows_prompt_on_args() { + let req = agent_request( + "summarize the inbox", + Some("Read the active vault first."), + AiAgentPermissionMode::Safe, + ); + let invocation = agent_with_windows_limit(&req, true).unwrap(); + + assert_args_contain!( + invocation.args, + ["-p", "summarize the inbox", "--append-system-prompt"] + ); + assert!(invocation.stdin_text.is_none()); + } + + #[test] + fn mcp_config_is_valid_json() { + let extra_vaults = vec!["/tmp/secondary-vault".to_string()]; + if let Ok(config_str) = mcp_config("/tmp/test-vault", &extra_vaults) { + let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap(); + assert!(parsed["mcpServers"]["tolaria"]["command"].is_string()); + assert_eq!( + parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], + "/tmp/test-vault" + ); + assert_eq!( + parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"], + "[\"/tmp/test-vault\",\"/tmp/secondary-vault\"]" + ); + } + } +} diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs new file mode 100644 index 0000000..584b79e --- /dev/null +++ b/src-tauri/src/cli_agent_runtime.rs @@ -0,0 +1,679 @@ +use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent}; +use serde::Deserialize; +use std::ffi::OsString; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; + +mod line_stream; +mod mcp_config; +mod shell_env; +mod windows_cmd_shim; + +#[cfg(test)] +pub(crate) use line_stream::strip_ansi_codes; +pub(crate) use line_stream::{run_ai_agent_line_stream, LineStreamProcess}; +pub(crate) use mcp_config::tolaria_node_mcp_server; +pub(crate) use shell_env::{ + apply_user_shell_env_vars_if_missing, env_value_from_process_or_user_shell, EnvName, +}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentStreamRequest { + pub message: String, + pub system_prompt: Option, + pub vault_path: String, + #[serde(default)] + pub vault_paths: Vec, + pub permission_mode: AiAgentPermissionMode, +} + +pub(crate) struct JsonLineRun { + pub session_id: String, + pub parsed_json_lines: usize, + pub ignored_stdout_output: String, + pub stderr_output: String, + pub status: ExitStatus, +} + +impl JsonLineRun { + pub(crate) fn diagnostic_output(&self) -> String { + self.ignored_stdout_output + .lines() + .chain(self.stderr_output.lines()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .take(3) + .collect::>() + .join("\n") + } +} + +pub(crate) struct AgentCommandTarget { + pub program: PathBuf, + pub first_arg: Option, +} + +pub(crate) struct JsonLineProcess<'a> { + command: Command, + process_name: &'static str, + stdin_input: Option<&'a str>, +} + +impl<'a> JsonLineProcess<'a> { + pub(crate) fn new(command: Command, process_name: &'static str) -> Self { + Self { + command, + process_name, + stdin_input: None, + } + } + + pub(crate) fn with_stdin(mut self, stdin_input: Option<&'a str>) -> Self { + self.stdin_input = stdin_input; + self + } +} + +pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String { + match system_prompt + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + { + Some(system_prompt) => { + format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}") + } + None => message.to_string(), + } +} + +pub(crate) fn mcp_server_path_string() -> Result { + crate::mcp::mcp_server_index_js_path_string() +} + +pub(crate) fn active_vault_paths(primary_vault_path: &str, vault_paths: &[String]) -> Vec { + let mut paths = Vec::new(); + push_unique_vault_path(&mut paths, primary_vault_path); + for path in vault_paths { + push_unique_vault_path(&mut paths, path); + } + paths +} + +pub(crate) fn active_vault_paths_json(primary_vault_path: &str, vault_paths: &[String]) -> String { + serde_json::to_string(&active_vault_paths(primary_vault_path, vault_paths)) + .unwrap_or_else(|_| format!("[{}]", serde_json::json!(primary_vault_path))) +} + +fn push_unique_vault_path(paths: &mut Vec, path: &str) { + let trimmed = path.trim(); + if trimmed.is_empty() || paths.iter().any(|existing| existing == trimmed) { + return; + } + paths.push(trimmed.to_string()); +} + +pub(crate) fn version_for_binary(binary: &Path) -> Option { + let target = command_target_avoiding_windows_cmd_shim(binary).ok()?; + let mut command = crate::hidden_command(&target.program); + configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub(crate) fn command_target_avoiding_windows_cmd_shim( + binary: &Path, +) -> Result { + if let Some(target) = windows_cmd_shim::command_target(binary)? { + return Ok(target); + } + + Ok(AgentCommandTarget { + program: binary.to_path_buf(), + first_arg: None, + }) +} + +pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) { + if let Some(path) = expanded_agent_path(binary) { + command.env("PATH", path); + } +} + +fn expanded_agent_path(binary: &Path) -> Option { + let mut paths = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + + for candidate in agent_path_candidates(binary) { + push_unique_path(&mut paths, candidate); + } + + std::env::join_paths(paths).ok() +} + +fn agent_path_candidates(binary: &Path) -> Vec { + let mut candidates = Vec::new(); + + if let Some(parent) = binary + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + candidates.push(parent.to_path_buf()); + } + + if let Some(home) = dirs::home_dir() { + candidates.extend([ + home.join(".local/bin"), + home.join(".local/share/mise/shims"), + home.join(".asdf/shims"), + home.join(".npm-global/bin"), + home.join(".npm/bin"), + home.join(".bun/bin"), + home.join(".linuxbrew/bin"), + home.join("AppData/Roaming/npm"), + home.join("AppData/Local/pnpm"), + home.join("scoop/shims"), + ]); + } + + candidates.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin"), + ]); + + candidates +} + +fn push_unique_path(paths: &mut Vec, candidate: PathBuf) { + if candidate.as_os_str().is_empty() { + return; + } + if paths.iter().any(|path| path == &candidate) { + return; + } + paths.push(candidate); +} + +pub(crate) fn find_executable_binary_candidate( + candidates: Vec, + agent_label: &str, +) -> Result, String> { + let mut first_unusable_candidate = None; + + for candidate in candidates { + if !candidate.exists() { + continue; + } + + if is_executable_file(&candidate) { + return Ok(Some(candidate)); + } + + if first_unusable_candidate.is_none() { + first_unusable_candidate = Some(candidate); + } + } + + match first_unusable_candidate { + Some(candidate) => Err(format!( + "{agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI.", + candidate.display() + )), + None => Ok(None), + } +} + +fn is_executable_file(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + #[cfg(windows)] + { + path.is_file() && has_windows_cli_extension(path) + } + + #[cfg(all(not(unix), not(windows)))] + { + path.is_file() + } +} + +pub(crate) fn has_windows_cli_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + ["bat", "cmd", "com", "exe"] + .iter() + .any(|expected| extension.eq_ignore_ascii_case(expected)) + }) +} + +#[cfg(test)] +pub(crate) fn parse_json_line( + line: Result, +) -> Result, String> { + let line = line.map_err(|error| format!("Read error: {error}"))?; + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + Ok(serde_json::from_str::(trimmed).ok()) +} + +fn parse_process_stdout_line( + line: Result, + ignored_stdout_lines: &mut Vec, +) -> Result, String> { + let line = line.map_err(|error| format!("Read error: {error}"))?; + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + match serde_json::from_str::(trimmed) { + Ok(json) => Ok(Some(json)), + Err(_) => { + push_ignored_stdout_line(ignored_stdout_lines, trimmed); + Ok(None) + } + } +} + +fn push_ignored_stdout_line(lines: &mut Vec, line: &str) { + if lines.len() < 3 { + lines.push(line.to_string()); + } +} + +#[cfg(test)] +pub(crate) fn parse_ai_agent_json_line( + line: Result, + emit: &mut F, +) -> Option +where + F: FnMut(AiAgentStreamEvent), +{ + match parse_json_line(line) { + Ok(json) => json, + Err(message) => { + emit(AiAgentStreamEvent::Error { message }); + None + } + } +} + +pub(crate) fn run_json_line_process( + command: Command, + process_name: &'static str, + emit: &mut F, + error_event: impl Fn(String) -> Event, + handle_json: H, +) -> Result +where + F: FnMut(Event), + H: FnMut(&serde_json::Value, &mut F, &mut String), +{ + run_json_line_process_with_stdin( + JsonLineProcess::new(command, process_name), + emit, + error_event, + handle_json, + ) +} + +pub(crate) fn run_json_line_process_with_stdin( + mut process: JsonLineProcess<'_>, + emit: &mut F, + error_event: impl Fn(String) -> Event, + mut handle_json: H, +) -> Result +where + F: FnMut(Event), + H: FnMut(&serde_json::Value, &mut F, &mut String), +{ + if process.stdin_input.is_some() { + process.command.stdin(std::process::Stdio::piped()); + } + + let mut child = process + .command + .spawn() + .map_err(|error| format_spawn_error(process.process_name, &error))?; + let stdin_write_error = + write_stdin_input(&mut child, process.process_name, process.stdin_input); + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let stderr = child.stderr.take(); + let child = crate::ai_agent_processes::register_current_stream_child(child); + let reader = std::io::BufReader::new(stdout); + let mut session_id = String::new(); + let mut ignored_stdout_lines = Vec::new(); + let mut parsed_json_lines = 0; + + for line in reader.lines() { + match parse_process_stdout_line(line, &mut ignored_stdout_lines) { + Ok(Some(json)) => { + parsed_json_lines += 1; + handle_json(&json, emit, &mut session_id); + } + Ok(None) => {} + Err(message) => { + emit(error_event(message)); + break; + } + } + } + + let stderr_output = stderr + .and_then(|stderr| std::io::read_to_string(stderr).ok()) + .unwrap_or_default(); + let status = child.wait()?; + let stderr_output = with_stdin_write_error(stderr_output, stdin_write_error); + + Ok(JsonLineRun { + session_id, + parsed_json_lines, + ignored_stdout_output: ignored_stdout_lines.join("\n"), + stderr_output, + status, + }) +} + +fn write_stdin_input( + child: &mut std::process::Child, + process_name: &str, + stdin_input: Option<&str>, +) -> Option { + let input = stdin_input?; + let Some(mut stdin) = child.stdin.take() else { + return Some(format!( + "Failed to write {process_name} stdin: no stdin handle" + )); + }; + + stdin + .write_all(input.as_bytes()) + .err() + .map(|error| format!("Failed to write {process_name} stdin: {error}")) +} + +fn with_stdin_write_error(mut stderr_output: String, stdin_write_error: Option) -> String { + let Some(error) = stdin_write_error else { + return stderr_output; + }; + + if !stderr_output.is_empty() { + stderr_output.push('\n'); + } + stderr_output.push_str(&error); + stderr_output +} + +fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + return format!( + "Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}" + ); + } + + format!("Failed to spawn {process_name}: {error}") +} + +pub(crate) fn run_ai_agent_json_stream( + command: Command, + process_name: &'static str, + emit: F, + session_id: impl Fn(&serde_json::Value) -> Option<&str>, + dispatch_event: impl Fn(&serde_json::Value, &mut F), + format_error: impl Fn(String, String) -> String, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + run_ai_agent_json_stream_with_success_check( + command, + process_name, + emit, + session_id, + dispatch_event, + format_error, + |_| None, + ) +} + +pub(crate) fn run_ai_agent_json_stream_with_success_check( + command: Command, + process_name: &'static str, + mut emit: F, + session_id: impl Fn(&serde_json::Value) -> Option<&str>, + dispatch_event: impl Fn(&serde_json::Value, &mut F), + format_error: impl Fn(String, String) -> String, + success_check: impl Fn(&JsonLineRun) -> Option, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let run = run_json_line_process( + command, + process_name, + &mut emit, + |message| AiAgentStreamEvent::Error { message }, + |json, emit, active_session_id| { + if let Some(id) = session_id(json) { + *active_session_id = id.to_string(); + } + dispatch_event(json, emit); + }, + )?; + + let session_id = run.session_id.clone(); + if !run.status.success() { + emit(AiAgentStreamEvent::Error { + message: format_error(run.stderr_output.clone(), run.status.to_string()), + }); + } else if let Some(message) = success_check(&run) { + emit(AiAgentStreamEvent::Error { message }); + } + + emit(AiAgentStreamEvent::Done); + Ok(session_id) +} + +/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates. +pub(crate) fn find_cli_binary( + name: &str, + candidates: Vec, + label: &str, + install_hint: &str, +) -> Result { + if let Some(binary) = find_binary_on_path(name) { + return Ok(binary); + } + if let Some(binary) = find_binary_in_user_shell(name) { + return Ok(binary); + } + if let Some(binary) = find_executable_binary_candidate(candidates, label)? { + return Ok(binary); + } + Err(format!("{label} not found. Install it: {install_hint}")) +} + +fn find_binary_on_path(name: &str) -> Option { + let cmd = if cfg!(windows) { "where" } else { "which" }; + crate::hidden_command(cmd) + .arg(name) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn find_binary_in_user_shell(name: &str) -> Option { + shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, name)) +} + +fn shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +pub(crate) fn first_existing_path(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) + }) +} + +/// Shared check_cli pattern for CLI agents. +pub(crate) fn check_cli_availability( + find_binary: impl FnOnce() -> Result, +) -> crate::ai_agents::AiAgentAvailability { + match find_binary() { + Ok(binary) => crate::ai_agents::AiAgentAvailability { + installed: true, + version: version_for_binary(&binary), + }, + Err(_) => crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_prompt_keeps_system_prompt_first() { + let prompt = build_prompt("Rename the note", Some("Be concise")); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[test] + fn build_prompt_skips_blank_system_prompt() { + assert_eq!( + build_prompt("Rename the note", Some(" ")), + "Rename the note" + ); + } + + #[test] + fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + assert!(parse_json_line(Ok(" ".into())).unwrap().is_none()); + assert!(parse_json_line(Ok("not json".into())).unwrap().is_none()); + + let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err(); + assert!(error.contains("broken pipe")); + } + + #[test] + fn agent_command_environment_keeps_homebrew_shims_available() { + let mut command = Command::new("/opt/homebrew/bin/codex"); + configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex")); + let path = command + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PATH")) + .and_then(|(_, value)| value) + .expect("PATH should be set"); + let paths = std::env::split_paths(path).collect::>(); + + assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin"))); + assert!(paths.contains(&PathBuf::from("/usr/local/bin"))); + } + + #[test] + fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() { + let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory"); + let message = format_spawn_error("codex", &error); + + assert!(message.contains("Failed to start codex")); + assert!(message.contains("/opt/homebrew/bin")); + assert!(message.contains("Node.js")); + } + + #[cfg(unix)] + #[test] + fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let unusable = dir.path().join("codex-unusable"); + let executable = dir.path().join("codex"); + std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); + std::fs::write(&executable, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let found = + find_executable_binary_candidate(vec![unusable, executable.clone()], "Codex CLI") + .unwrap(); + + assert_eq!(found, Some(executable)); + } + + #[cfg(unix)] + #[test] + fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let unusable = dir.path().join("opencode"); + std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let error = + find_executable_binary_candidate(vec![unusable.clone()], "OpenCode CLI").unwrap_err(); + + assert!(error.contains("OpenCode CLI binary found")); + assert!(error.contains(&unusable.display().to_string())); + assert!(error.contains("not executable")); + } +} diff --git a/src-tauri/src/cli_agent_runtime/line_stream.rs b/src-tauri/src/cli_agent_runtime/line_stream.rs new file mode 100644 index 0000000..0e42112 --- /dev/null +++ b/src-tauri/src/cli_agent_runtime/line_stream.rs @@ -0,0 +1,189 @@ +use crate::ai_agents::AiAgentStreamEvent; +use regex::Regex; +use std::io::{BufRead, Read, Write}; +use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; + +pub(crate) struct LineStreamProcess { + command: Command, + process_name: &'static str, + session_prefix: &'static str, + stdin_input: Option, +} + +struct LineStreamRun { + session_id: String, + stderr_output: String, + status: ExitStatus, +} + +impl LineStreamProcess { + pub(crate) fn new( + command: Command, + process_name: &'static str, + session_prefix: &'static str, + ) -> Self { + Self { + command, + process_name, + session_prefix, + stdin_input: None, + } + } + + pub(crate) fn with_stdin(mut self, stdin_input: String) -> Self { + self.stdin_input = Some(stdin_input); + self + } +} + +pub(crate) fn run_ai_agent_line_stream( + process: LineStreamProcess, + mut emit: F, + format_error: impl Fn(&str, &str) -> String, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let run = run_line_stream_process(process, &mut emit)?; + + if !run.status.success() { + emit(AiAgentStreamEvent::Error { + message: format_error(&run.stderr_output, &run.status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + Ok(run.session_id) +} + +fn run_line_stream_process( + mut process: LineStreamProcess, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + if process.stdin_input.is_some() { + process.command.stdin(std::process::Stdio::piped()); + } + + let mut child = process + .command + .spawn() + .map_err(|error| super::format_spawn_error(process.process_name, &error))?; + let stdin_writer = + write_stdin_input_async(&mut child, process.process_name, process.stdin_input.take()); + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let stderr_reader = read_stderr_async(child.stderr.take()); + let child = crate::ai_agent_processes::register_current_stream_child(child); + let session_id = agent_session_id(process.session_prefix); + + emit(AiAgentStreamEvent::Init { + session_id: session_id.clone(), + }); + stream_text_stdout(stdout, emit); + + let mut stderr_output = stderr_reader.join().unwrap_or_default(); + if let Some(error) = stdin_write_error(stdin_writer) { + append_diagnostic_line(&mut stderr_output, &error); + } + let status = child.wait()?; + + Ok(LineStreamRun { + session_id, + stderr_output, + status, + }) +} + +fn agent_session_id(prefix: &str) -> String { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + format!("{prefix}-{}-{ts}", std::process::id()) +} + +fn stream_text_stdout(stdout: ChildStdout, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let reader = std::io::BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(line) => emit(AiAgentStreamEvent::TextDelta { + text: format!("{}\n", strip_ansi_codes(&line)), + }), + Err(error) => { + emit(AiAgentStreamEvent::Error { + message: format!("Read error: {error}"), + }); + break; + } + } + } +} + +fn write_stdin_input_async( + child: &mut std::process::Child, + process_name: &'static str, + stdin_input: Option, +) -> Option>> { + let input = stdin_input?; + let Some(stdin) = child.stdin.take() else { + return Some(std::thread::spawn(move || { + Err(format!( + "Failed to write {process_name} stdin: no stdin handle" + )) + })); + }; + + Some(std::thread::spawn(move || { + write_all_to_stdin(stdin, process_name, input) + })) +} + +fn write_all_to_stdin( + mut stdin: ChildStdin, + process_name: &str, + input: String, +) -> Result<(), String> { + stdin + .write_all(input.as_bytes()) + .map_err(|error| format!("Failed to write {process_name} stdin: {error}")) +} + +fn stdin_write_error( + handle: Option>>, +) -> Option { + match handle?.join() { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(_) => Some("Failed to write AI agent stdin: writer thread panicked".into()), + } +} + +fn read_stderr_async(stderr: Option) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let Some(mut stderr) = stderr else { + return String::new(); + }; + + let mut output = String::new(); + let _ = stderr.read_to_string(&mut output); + output + }) +} + +fn append_diagnostic_line(output: &mut String, line: &str) { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(line); +} + +pub(crate) fn strip_ansi_codes(input: &str) -> String { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap()); + re.replace_all(input, "").to_string() +} diff --git a/src-tauri/src/cli_agent_runtime/mcp_config.rs b/src-tauri/src/cli_agent_runtime/mcp_config.rs new file mode 100644 index 0000000..90acc1f --- /dev/null +++ b/src-tauri/src/cli_agent_runtime/mcp_config.rs @@ -0,0 +1,21 @@ +pub(crate) fn tolaria_node_mcp_server( + mcp_server_path: &str, + vault_path: &str, + vault_paths: &[String], + include_ui_bridge_env: bool, +) -> serde_json::Value { + let mut env = serde_json::json!({ + "VAULT_PATH": vault_path, + "VAULT_PATHS": super::active_vault_paths_json(vault_path, vault_paths) + }); + + if include_ui_bridge_env { + env["WS_UI_PORT"] = serde_json::json!("9711"); + } + + serde_json::json!({ + "command": "node", + "args": [mcp_server_path], + "env": env + }) +} diff --git a/src-tauri/src/cli_agent_runtime/shell_env.rs b/src-tauri/src/cli_agent_runtime/shell_env.rs new file mode 100644 index 0000000..cb7674f --- /dev/null +++ b/src-tauri/src/cli_agent_runtime/shell_env.rs @@ -0,0 +1,313 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EnvName<'a>(&'a str); + +impl<'a> EnvName<'a> { + pub(crate) fn new(raw: &'a str) -> Option { + is_valid_name(raw).then_some(Self(raw)) + } + + pub(crate) const fn trusted(raw: &'a str) -> Self { + Self(raw) + } + + pub(crate) fn as_str(self) -> &'a str { + self.0 + } +} + +pub(crate) fn apply_user_shell_env_vars_if_missing(command: &mut Command, names: &[EnvName<'_>]) { + let missing = valid_unique_names(names) + .into_iter() + .filter(|name| !process_has_value(name) && !command_has_value(command, name)) + .collect::>(); + for binding in user_shell_bindings(&missing) { + command.env(binding.name, binding.value); + } +} + +pub(crate) fn env_value_from_process_or_user_shell(name: EnvName<'_>) -> Option { + process_value(name).or_else(|| user_shell_value(name)) +} + +#[derive(Debug, PartialEq, Eq)] +struct EnvBinding { + name: String, + value: String, +} + +fn process_has_value(name: &EnvName<'_>) -> bool { + std::env::var_os(name.as_str()).is_some_and(|value| !value.is_empty()) +} + +fn command_has_value(command: &Command, name: &EnvName<'_>) -> bool { + command.get_envs().any(|(key, value)| { + key == OsStr::new(name.as_str()) && value.is_some_and(|value| !value.is_empty()) + }) +} + +fn process_value(name: EnvName<'_>) -> Option { + std::env::var(name.as_str()) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn user_shell_value(name: EnvName<'_>) -> Option { + user_shell_bindings(&[name]) + .into_iter() + .find_map(|binding| (binding.name == name.as_str()).then_some(binding.value)) +} + +fn user_shell_bindings(names: &[EnvName<'_>]) -> Vec { + let names = valid_unique_names(names); + if names.is_empty() { + return Vec::new(); + } + user_shell_bindings_for_platform(&names) +} + +fn valid_unique_names<'a>(names: &[EnvName<'a>]) -> Vec> { + let mut unique = Vec::new(); + for name in names.iter().copied() { + if !unique.iter().any(|existing| existing == &name) { + unique.push(name); + } + } + unique +} + +fn is_valid_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +#[cfg(unix)] +fn user_shell_bindings_for_platform(names: &[EnvName<'_>]) -> Vec { + shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| user_shell_bindings_from_shell(&shell, names)) + .unwrap_or_default() +} + +#[cfg(not(unix))] +fn user_shell_bindings_for_platform(_names: &[EnvName<'_>]) -> Vec { + Vec::new() +} + +#[cfg(unix)] +fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option> { + let output = crate::hidden_command(shell) + .arg("-lc") + .arg(shell_probe_script(shell, names)) + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let bindings = parse_probe_output(&String::from_utf8_lossy(&output.stdout), names); + (!bindings.is_empty()).then_some(bindings) +} + +#[cfg(unix)] +fn shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +#[cfg(unix)] +fn shell_probe_script(shell: &Path, names: &[EnvName<'_>]) -> String { + format!( + "{}\nfor name in {}; do\n value=$(printenv \"$name\" 2>/dev/null || true)\n if [ -n \"$value\" ]; then\n printf '{}%s=%s\\n' \"$name\" \"$value\"\n fi\ndone\n", + rc_source_command(shell), + joined_names(names), + OUTPUT_PREFIX + ) +} + +fn joined_names(names: &[EnvName<'_>]) -> String { + names + .iter() + .map(|name| name.as_str()) + .collect::>() + .join(" ") +} + +#[cfg(unix)] +fn rc_source_command(shell: &Path) -> &'static str { + let name = shell + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + if name.contains("zsh") { + return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"${ZDOTDIR}/.zshrc\" ]; then . \"${ZDOTDIR}/.zshrc\" >/dev/null 2>&1 || true; elif [ -r \"$HOME/.zshrc\" ]; then . \"$HOME/.zshrc\" >/dev/null 2>&1 || true; fi"; + } + if name.contains("bash") { + return "if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\" >/dev/null 2>&1 || true; fi"; + } + "" +} + +struct ProbeOutput<'a>(&'a str); + +struct ProbeLine<'a>(&'a str); + +fn parse_probe_output(stdout: &str, names: &[EnvName<'_>]) -> Vec { + ProbeOutput(stdout) + .0 + .lines() + .filter_map(|line| parse_probe_line(ProbeLine(line), names)) + .collect() +} + +fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option { + let (name, value) = line.0.strip_prefix(OUTPUT_PREFIX)?.split_once('=')?; + let name = EnvName::new(name)?; + let value = value.trim(); + (names.iter().any(|expected| expected == &name) && !value.is_empty()).then(|| EnvBinding { + name: name.as_str().to_string(), + value: value.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_value_uses_trimmed_process_value() { + let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE"; + std::env::set_var(key, " process-secret "); + + let value = env_value_from_process_or_user_shell(EnvName::trusted(key)); + + std::env::remove_var(key); + assert_eq!(value.as_deref(), Some("process-secret")); + } + + #[test] + fn command_value_marks_name_as_already_available() { + let mut command = Command::new("demo"); + command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret"); + + apply_user_shell_env_vars_if_missing( + &mut command, + &[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")], + ); + + let values = command + .get_envs() + .map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some())) + .collect::>(); + assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]); + } + + #[test] + fn parse_probe_output_filters_invalid_unexpected_and_blank_values() { + let names = [ + EnvName::trusted("GOOD_VALUE"), + EnvName::trusted("OTHER_VALUE"), + ]; + + let bindings = parse_probe_output( + "__TOLARIA_ENV__:GOOD_VALUE=kept\n\ + ignored\n\ + __TOLARIA_ENV__:BAD-NAME=bad\n\ + __TOLARIA_ENV__:OTHER_VALUE= \n\ + __TOLARIA_ENV__:UNEXPECTED=value\n", + &names, + ); + + assert_eq!( + bindings, + vec![EnvBinding { + name: "GOOD_VALUE".into(), + value: "kept".into(), + }] + ); + } + + #[cfg(unix)] + #[test] + fn user_shell_bindings_from_shell_returns_none_for_failing_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("zsh"); + std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]); + + assert_eq!(values, None); + } + + #[cfg(unix)] + #[test] + fn rc_source_command_ignores_unknown_shells() { + assert_eq!(rc_source_command(Path::new("fish")), ""); + } + + #[cfg(unix)] + #[test] + fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("zsh"); + let zshrc = dir.path().join(".zshrc"); + std::fs::write( + &shell, + "#!/bin/sh\nexport HOME=$(dirname \"$0\")\nexec /bin/sh -c \"$2\"\n", + ) + .unwrap(); + std::fs::write( + &zshrc, + "export ANTHROPIC_API_KEY=from-zshrc\nexport ANTHROPIC_BASE_URL=https://proxy.example.test\n", + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let values = user_shell_bindings_from_shell( + &shell, + &[ + EnvName::trusted("ANTHROPIC_API_KEY"), + EnvName::trusted("ANTHROPIC_BASE_URL"), + EnvName::trusted("IGNORED_SECRET"), + ], + ) + .expect("zshrc exports should be readable"); + + assert_eq!( + values, + vec![ + EnvBinding { + name: "ANTHROPIC_API_KEY".to_string(), + value: "from-zshrc".to_string(), + }, + EnvBinding { + name: "ANTHROPIC_BASE_URL".to_string(), + value: "https://proxy.example.test".to_string(), + }, + ] + ); + } +} diff --git a/src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs b/src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs new file mode 100644 index 0000000..377d16d --- /dev/null +++ b/src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs @@ -0,0 +1,115 @@ +use super::AgentCommandTarget; +use std::path::{Path, PathBuf}; + +pub(super) fn command_target(binary: &Path) -> Result, String> { + if !is_batch_shim(binary) { + return Ok(None); + } + + let Some(target) = target_path(binary) else { + return Ok(None); + }; + + if is_node_script(&target) { + return Ok(Some(AgentCommandTarget { + program: crate::mcp::find_node()?, + first_arg: Some(target), + })); + } + + Ok(Some(AgentCommandTarget { + program: target, + first_arg: None, + })) +} + +fn is_batch_shim(binary: &Path) -> bool { + has_extension(binary, &["cmd", "bat"]) +} + +fn target_path(binary: &Path) -> Option { + let contents = std::fs::read_to_string(binary).ok()?; + contents + .split('"') + .skip(1) + .step_by(2) + .find_map(|token| resolve_target_path(binary, token)) +} + +fn resolve_target_path(binary: &Path, token: &str) -> Option { + let relative = token + .strip_prefix("%dp0%") + .or_else(|| token.strip_prefix("%~dp0"))? + .trim_start_matches(['\\', '/']); + let mut target = binary.parent()?.to_path_buf(); + for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) { + target.push(part); + } + is_supported_target(&target) + .then_some(target) + .filter(|target| target.is_file()) +} + +fn is_supported_target(path: &Path) -> bool { + is_node_script(path) || is_native_executable(path) +} + +fn is_node_script(path: &Path) -> bool { + has_extension(path, &["js", "mjs", "cjs"]) +} + +fn is_native_executable(path: &Path) -> bool { + has_extension(path, &["exe", "com"]) && !has_file_name(path, "node.exe") +} + +fn has_extension(path: &Path, expected_extensions: &[&str]) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + expected_extensions + .iter() + .any(|expected| extension.eq_ignore_ascii_case(expected)) + }) +} + +fn has_file_name(path: &Path, expected_name: &str) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case(expected_name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_target_uses_native_exe_from_windows_cmd_shim() { + let dir = tempfile::tempdir().unwrap(); + let shim = dir.path().join("claude.cmd"); + let native_exe = dir + .path() + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.exe"); + std::fs::create_dir_all(native_exe.parent().unwrap()).unwrap(); + std::fs::write(dir.path().join("node.exe"), "node runtime").unwrap(); + std::fs::write(&native_exe, "native claude launcher").unwrap(); + std::fs::write( + &shim, + r#"@ECHO off +IF EXIST "%~dp0\node.exe" ( + SET "_prog=%~dp0\node.exe" +) +"%~dp0\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %* +"#, + ) + .unwrap(); + + let target = command_target(&shim).unwrap().unwrap(); + + assert_eq!(target.program, native_exe); + assert_eq!(target.first_arg, None); + } +} diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs new file mode 100644 index 0000000..8d2181a --- /dev/null +++ b/src-tauri/src/codex_cli.rs @@ -0,0 +1,1195 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +pub fn check_cli() -> AiAgentAvailability { + codex_availability_from_binary_result(find_codex_binary()) +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = find_codex_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn find_codex_binary() -> Result { + if let Some(binary) = find_codex_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_codex_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + codex_binary_candidates(), + "Codex CLI", + )? { + return Ok(binary); + } + + Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) +} + +fn codex_availability_from_binary_result( + binary_result: Result, +) -> AiAgentAvailability { + match binary_result { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +fn find_codex_binary_on_path() -> Option { + crate::hidden_command(codex_path_lookup_command()) + .arg("codex") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn codex_path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + +fn find_codex_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| codex_path_from_shell(&shell)) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn codex_path_from_shell(shell: &Path) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg("command -v codex") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + first_existing_path_for_platform(stdout, cfg!(windows)) +} + +fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { + let mut paths = stdout.lines().filter_map(existing_path); + if windows { + return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path)); + } + + paths.next() +} + +fn existing_path(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) +} + +fn codex_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| codex_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn codex_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = vec![ + home.join(".local/bin/codex"), + home.join(".local/bin/codex.exe"), + home.join(".local/bin/codex.cmd"), + home.join(".codex/bin/codex"), + home.join(".codex/bin/codex.exe"), + home.join(".codex/bin/codex.cmd"), + home.join(".local/share/mise/shims/codex"), + home.join(".local/share/mise/shims/codex.exe"), + home.join(".local/share/mise/shims/codex.cmd"), + home.join(".asdf/shims/codex"), + home.join(".asdf/shims/codex.exe"), + home.join(".asdf/shims/codex.cmd"), + home.join(".npm-global/bin/codex"), + home.join(".npm-global/bin/codex.cmd"), + home.join(".npm-global/bin/codex.exe"), + home.join(".npm/bin/codex"), + home.join(".npm/bin/codex.cmd"), + home.join(".npm/bin/codex.exe"), + home.join(".bun/bin/codex"), + home.join(".bun/bin/codex.exe"), + home.join(".bun/bin/codex.cmd"), + home.join(".linuxbrew/bin/codex"), + home.join("AppData/Roaming/npm/codex.cmd"), + home.join("AppData/Roaming/npm/codex.exe"), + home.join("AppData/Local/pnpm/codex.cmd"), + home.join("AppData/Local/pnpm/codex.exe"), + home.join("scoop/shims/codex.cmd"), + home.join("scoop/shims/codex.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), + PathBuf::from("/usr/local/bin/codex"), + PathBuf::from("/opt/homebrew/bin/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]; + candidates.extend(nvm_codex_binary_candidates_for_home(home)); + candidates +} + +fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { + return Vec::new(); + }; + + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.join("bin").join("codex")) + .collect::>(); + candidates.sort(); + candidates +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let last_message_dir = tempfile::Builder::new() + .prefix("tolaria-codex-last-message-") + .tempdir() + .map_err(|error| format!("Failed to create Codex output directory: {error}"))?; + let last_message_path = last_message_dir.path().join("last-message.txt"); + let args = build_codex_args(&request, Some(&last_message_path))?; + let prompt = build_codex_prompt(&request); + let command = build_codex_command(binary, args, prompt, &request.vault_path)?; + let emit = with_codex_last_message_fallback(emit, last_message_path); + + crate::cli_agent_runtime::run_ai_agent_json_stream( + command, + "codex", + emit, + codex_session_id, + dispatch_codex_event, + |stderr_output, status| { + format_codex_error(CodexProcessError { + stderr_output, + status, + }) + }, + ) +} + +fn with_codex_last_message_fallback( + mut emit: F, + last_message_path: PathBuf, +) -> impl FnMut(AiAgentStreamEvent) +where + F: FnMut(AiAgentStreamEvent), +{ + let mut text_emitted = false; + + move |event| { + match &event { + AiAgentStreamEvent::TextDelta { text } if !text.trim().is_empty() => { + text_emitted = true; + } + AiAgentStreamEvent::Done if !text_emitted => { + if let Some(text) = read_codex_last_message(&last_message_path) { + text_emitted = true; + emit(AiAgentStreamEvent::TextDelta { text }); + } + } + _ => {} + } + + emit(event); + } +} + +fn build_codex_command( + binary: &Path, + args: Vec, + prompt: String, + vault_path: &str, +) -> Result { + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .args(args) + .arg(prompt) + .current_dir(vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn build_codex_args( + request: &AgentStreamRequest, + last_message_path: Option<&Path>, +) -> Result, String> { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + let node_path = crate::mcp::find_node()?; + + let mut args = vec![ + "--sandbox".into(), + codex_sandbox(request.permission_mode).into(), + "--ask-for-approval".into(), + codex_approval_policy(request.permission_mode).into(), + "exec".into(), + "--json".into(), + "-C".into(), + request.vault_path.clone(), + "-c".into(), + codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()), + "-c".into(), + codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]), + "-c".into(), + codex_mcp_env_config(request), + ]; + + if let Some(path) = last_message_path { + args.push("--output-last-message".into()); + args.push(path.to_string_lossy().into_owned()); + } + + Ok(args) +} + +fn codex_config_string(key: &str, value: &str) -> String { + format!(r#"{key}="{}""#, toml_escape(value)) +} + +fn codex_config_string_list(key: &str, values: &[&str]) -> String { + let values = values + .iter() + .map(|value| format!(r#""{}""#, toml_escape(value))) + .collect::>() + .join(","); + format!("{key}=[{values}]") +} + +fn codex_mcp_env_config(request: &AgentStreamRequest) -> String { + let vault_paths = crate::cli_agent_runtime::active_vault_paths_json( + &request.vault_path, + &request.vault_paths, + ); + format!( + r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#, + toml_escape(&request.vault_path), + toml_escape(&vault_paths) + ) +} + +fn toml_escape(value: &str) -> String { + value.replace('\\', r#"\\"#).replace('"', r#"\""#) +} + +fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { + match permission_mode { + crate::ai_agents::AiAgentPermissionMode::Safe => "read-only", + crate::ai_agents::AiAgentPermissionMode::PowerUser => "workspace-write", + } +} + +fn codex_approval_policy(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { + match permission_mode { + crate::ai_agents::AiAgentPermissionMode::Safe => "untrusted", + crate::ai_agents::AiAgentPermissionMode::PowerUser => "never", + } +} + +fn build_codex_prompt(request: &AgentStreamRequest) -> String { + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) +} + +fn codex_session_id(json: &serde_json::Value) -> Option<&str> { + json["thread_id"].as_str() +} + +fn dispatch_codex_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + match json["type"].as_str().unwrap_or_default() { + "thread.started" => { + if let Some(thread_id) = json["thread_id"].as_str() { + emit(AiAgentStreamEvent::Init { + session_id: thread_id.to_string(), + }); + } + } + "item.started" => emit_codex_item_event(json, false, emit), + "item.completed" => emit_codex_item_event(json, true, emit), + _ => {} + } +} + +fn emit_codex_item_event(json: &serde_json::Value, completed: bool, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let item = &json["item"]; + let item_type = item["type"].as_str().unwrap_or_default(); + let item_id = item["id"].as_str().unwrap_or_default(); + + match item_type { + "command_execution" => { + if completed { + emit(AiAgentStreamEvent::ToolDone { + tool_id: item_id.to_string(), + output: item["aggregated_output"] + .as_str() + .map(|output| output.to_string()), + }); + } else { + emit(AiAgentStreamEvent::ToolStart { + tool_name: "Bash".into(), + tool_id: item_id.to_string(), + input: item["command"] + .as_str() + .map(|command| serde_json::json!({ "command": command }).to_string()), + }); + } + } + "mcp_tool_call" => emit_codex_mcp_tool_event(item, item_id, completed, emit), + "agent_message" if completed => { + if let Some(text) = item["text"].as_str() { + emit(AiAgentStreamEvent::TextDelta { + text: text.to_string(), + }); + } + } + _ => {} + } +} + +fn emit_codex_mcp_tool_event( + item: &serde_json::Value, + item_id: &str, + completed: bool, + emit: &mut F, +) where + F: FnMut(AiAgentStreamEvent), +{ + if completed { + emit(AiAgentStreamEvent::ToolDone { + tool_id: item_id.to_string(), + output: codex_tool_output(item), + }); + return; + } + + let tool_name = item["tool"].as_str().unwrap_or("MCP tool"); + let input = json_field_to_string(&item["arguments"]); + emit(AiAgentStreamEvent::ToolStart { + tool_name: tool_name.to_string(), + tool_id: item_id.to_string(), + input, + }); +} + +fn codex_tool_output(item: &serde_json::Value) -> Option { + item["error"]["message"] + .as_str() + .map(|message| format!("Error: {message}")) + .or_else(|| json_field_to_string(&item["result"])) +} + +fn json_field_to_string(value: &serde_json::Value) -> Option { + if value.is_null() { + None + } else { + value + .as_str() + .map(str::to_string) + .or_else(|| Some(value.to_string())) + } +} + +fn read_codex_last_message(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +struct CodexProcessError { + stderr_output: String, + status: String, +} + +fn format_codex_error(error: CodexProcessError) -> String { + let lower = error.stderr_output.to_ascii_lowercase(); + if is_codex_auth_error(&lower) { + return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); + } + + if is_codex_write_permission_error(&lower) { + return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into(); + } + + if error.stderr_output.trim().is_empty() { + format!("codex exited with status {}", error.status) + } else { + error + .stderr_output + .lines() + .take(3) + .collect::>() + .join("\n") + } +} + +fn is_codex_auth_error(lower: &str) -> bool { + ["auth", "login", "sign in"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +fn is_codex_write_permission_error(lower: &str) -> bool { + [ + "read-only sandbox", + "writing is blocked", + "rejected by user approval", + "rejected by the environment", + ] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + use std::ffi::OsStr; + + #[cfg(target_os = "linux")] + fn current_test_binary() -> PathBuf { + std::fs::read_link("/proc/self/exe").unwrap() + } + + #[cfg(target_os = "macos")] + fn current_test_binary() -> PathBuf { + let pid = std::process::id().to_string(); + let output = std::process::Command::new("/bin/ps") + .args(["-p", pid.as_str(), "-o", "comm="]) + .output() + .unwrap(); + let path = String::from_utf8(output.stdout).unwrap(); + PathBuf::from(path.trim()) + } + + #[cfg(unix)] + fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join(name); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn codex_request( + vault_path: &Path, + permission_mode: AiAgentPermissionMode, + ) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path: vault_path.to_string_lossy().into_owned(), + vault_paths: Vec::new(), + permission_mode, + } + } + + fn assert_codex_permission_contract(args: &[String], permission_mode: AiAgentPermissionMode) { + let sandbox = codex_sandbox(permission_mode); + let approval = codex_approval_policy(permission_mode); + let prefix = ["--sandbox", sandbox, "--ask-for-approval", approval]; + + assert_eq!(&args[..prefix.len()], prefix); + assert!(!args.iter().any(|arg| arg == "danger-full-access")); + assert!(!args + .iter() + .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox")); + } + + #[cfg(unix)] + fn run_codex_script(body: &str) -> (String, Vec) { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script(dir.path(), "codex", body); + let mut events = Vec::new(); + let thread_id = run_agent_stream_with_binary( + &binary, + codex_request(vault.path(), AiAgentPermissionMode::Safe), + |event| events.push(event), + ) + .unwrap(); + + (thread_id, events) + } + + fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == session + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == text_delta + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn build_codex_prompt_keeps_system_prompt_first() { + let prompt = build_codex_prompt(&AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: Some("Be concise".into()), + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[test] + fn build_codex_args_uses_safe_default_permissions() { + if let Ok(args) = build_codex_args( + &AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + }, + None, + ) { + assert_eq!(args[4], "exec"); + assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe); + assert!(args.contains(&"--json".to_string())); + assert!(args.contains(&"-C".to_string())); + } + } + + #[test] + fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() { + if let Ok(args) = build_codex_args( + &AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::PowerUser, + }, + None, + ) { + assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser); + } + } + + #[test] + fn build_codex_args_can_request_last_message_output_file() { + if let Ok(args) = build_codex_args( + &AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + }, + Some(Path::new("/tmp/tolaria-codex-last-message.txt")), + ) { + assert!(args.windows(2).any(|window| window + == [ + "--output-last-message", + "/tmp/tolaria-codex-last-message.txt", + ])); + } + } + + #[test] + fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() { + let args = build_codex_args( + &AgentStreamRequest { + message: "Read [[Test note]]".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + }, + None, + ) + .unwrap(); + + let command_override = args + .iter() + .find(|arg| arg.starts_with("mcp_servers.tolaria.command=")) + .expect("Codex should receive a transient Tolaria MCP command"); + + assert!( + !command_override.ends_with(r#""node""#), + "Codex MCP command should use Tolaria's resolved Node path, got {command_override}" + ); + assert!( + command_override.contains('/'), + "Codex MCP command should be an absolute Node path, got {command_override}" + ); + assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#))); + } + + #[test] + fn build_codex_command_keeps_agent_process_contract() { + let binary = PathBuf::from("codex"); + let args = vec!["exec".to_string(), "--json".to_string()]; + let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault").unwrap(); + let actual_args: Vec<&OsStr> = command.get_args().collect(); + + assert_eq!(command.get_program(), OsStr::new("codex")); + assert_eq!( + actual_args, + vec![ + OsStr::new("exec"), + OsStr::new("--json"), + OsStr::new("Summarize") + ] + ); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + } + + #[test] + fn build_codex_command_extends_path_with_resolved_homebrew_bin() { + let binary = PathBuf::from("/opt/homebrew/bin/codex"); + let command = build_codex_command( + &binary, + vec!["exec".to_string(), "--json".to_string()], + "Summarize".into(), + "/tmp/vault", + ) + .unwrap(); + let path_value = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("PATH")) + .and_then(|(_, value)| value) + .expect("PATH should be set"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert!( + paths.contains(&PathBuf::from("/opt/homebrew/bin")), + "PATH should include the resolved Codex binary directory, got {paths:?}" + ); + } + + #[test] + fn build_codex_command_avoids_windows_cmd_shim_for_complex_args() { + let dir = tempfile::tempdir().unwrap(); + let shim = dir.path().join("codex.cmd"); + let script = dir + .path() + .join("node_modules") + .join("@openai") + .join("codex") + .join("bin") + .join("codex.js"); + std::fs::create_dir_all(script.parent().unwrap()).unwrap(); + std::fs::write(&script, "console.log('codex')\n").unwrap(); + std::fs::write( + &shim, + r#"@ECHO off +"%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %* +"#, + ) + .unwrap(); + + let command = build_codex_command( + &shim, + vec![ + "exec".to_string(), + "-c".to_string(), + r#"mcp_servers.tolaria.command="C:\\Program Files\\node.exe""#.to_string(), + ], + "Summarize".into(), + "/tmp/vault", + ) + .unwrap(); + + assert_ne!( + command.get_program(), + shim.as_os_str(), + "Codex npm .cmd shims cannot safely receive quoted -c args directly" + ); + let actual_args = command.get_args().collect::>(); + assert_eq!(actual_args.first().copied(), Some(script.as_os_str())); + assert!(actual_args + .iter() + .any(|arg| *arg == OsStr::new("Summarize"))); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}' +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert_codex_text_flow(&events, "thread_1", "Done"); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_uses_last_message_file_when_stream_has_no_text() { + let (thread_id, events) = run_codex_script( + r#"last_message="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output-last-message" ]; then + shift + last_message="$1" + fi + shift +done +printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s' 'Recovered final answer' > "$last_message" +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert_codex_text_flow(&events, "thread_1", "Recovered final answer"); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_does_not_duplicate_last_message_file_after_text_event() { + let (thread_id, events) = run_codex_script( + r#"last_message="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output-last-message" ]; then + shift + last_message="$1" + fi + shift +done +printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Streamed answer"}}' +printf '%s' 'Recovered final answer' > "$last_message" +"#, + ); + + let text_events = events + .iter() + .filter(|event| matches!(event, AiAgentStreamEvent::TextDelta { .. })) + .count(); + + assert_eq!(thread_id, "thread_1"); + assert_eq!(text_events, 1); + assert_codex_text_flow(&events, "thread_1", "Streamed answer"); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reports_nonzero_exit_errors() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' 'login required' >&2 +exit 2 +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not authenticated") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() { + use std::io::Read; + use std::time::{Duration, Instant}; + + let mut child = std::process::Command::new(current_test_binary()) + .arg("codex_stdin_probe_parent_child") + .arg("--ignored") + .arg("--nocapture") + .env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let child_stdin = child.stdin.take().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + drop(child_stdin); + panic!("Codex stdin probe child timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + + drop(child_stdin); + let mut stdout_text = String::new(); + let mut stderr_text = String::new(); + stdout.read_to_string(&mut stdout_text).unwrap(); + stderr.read_to_string(&mut stderr_text).unwrap(); + + assert!( + status.success(), + "Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}" + ); + } + + #[cfg(unix)] + #[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"] + #[test] + fn codex_stdin_probe_parent_child() { + if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + "codex", + r#"stdin="$(cat)" +if [ -n "$stdin" ]; then + echo "stdin was not closed" >&2 + exit 9 +fi +printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}' +"#, + ); + let mut events = Vec::new(); + let result = run_agent_stream_with_binary( + &binary, + codex_request(vault.path(), AiAgentPermissionMode::Safe), + |event| events.push(event), + ); + + assert_eq!(result.unwrap(), "stdin-ok"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "stdin closed" + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn codex_binary_candidates_include_supported_macos_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = codex_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/codex"), + home.join(".codex/bin/codex"), + home.join(".local/share/mise/shims/codex"), + home.join(".asdf/shims/codex"), + home.join(".npm-global/bin/codex"), + home.join(".bun/bin/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn codex_binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let candidates = codex_binary_candidates_for_home(&home); + let expected = [ + home.join(".linuxbrew/bin/codex"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() { + let home = PathBuf::from("C:/Users/alex"); + let candidates = codex_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/codex.exe"), + home.join(".local/bin/codex.cmd"), + home.join(".local/share/mise/shims/codex.exe"), + home.join(".local/share/mise/shims/codex.cmd"), + home.join(".asdf/shims/codex.exe"), + home.join(".asdf/shims/codex.cmd"), + home.join(".codex/bin/codex.cmd"), + home.join(".npm-global/bin/codex.cmd"), + home.join(".npm-global/bin/codex.exe"), + home.join(".npm/bin/codex.cmd"), + home.join(".npm/bin/codex.exe"), + home.join(".bun/bin/codex.cmd"), + home.join("AppData/Roaming/npm/codex.cmd"), + home.join("AppData/Roaming/npm/codex.exe"), + home.join("AppData/Local/pnpm/codex.cmd"), + home.join("AppData/Local/pnpm/codex.exe"), + home.join("scoop/shims/codex.cmd"), + home.join("scoop/shims/codex.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn codex_availability_reports_installed_even_when_version_probe_fails() { + let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd"); + + let availability = codex_availability_from_binary_result(Ok(binary)); + + assert!(availability.installed); + assert_eq!(availability.version, None); + } + + #[test] + fn codex_binary_candidates_include_nvm_managed_node_installs() { + let home = tempfile::tempdir().unwrap(); + let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex"); + std::fs::create_dir_all(codex.parent().unwrap()).unwrap(); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + + let candidates = codex_binary_candidates_for_home(home.path()); + + assert!(candidates.contains(&codex), "missing {}", codex.display()); + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-codex"); + let codex = dir.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), codex.display()); + + assert_eq!(first_existing_path(&stdout), Some(codex)); + } + + #[test] + fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() { + let dir = tempfile::tempdir().unwrap(); + let shell_script = dir.path().join("codex"); + let cmd_shim = dir.path().join("codex.cmd"); + std::fs::write(&shell_script, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd_shim, "@ECHO off\n").unwrap(); + + let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display()); + + assert_eq!( + first_existing_path_for_platform(&stdout, true), + Some(cmd_shim) + ); + } + + #[cfg(unix)] + #[test] + fn command_path_from_shell_finds_codex_from_login_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let codex = dir.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shell = dir.path().join("shell"); + std::fs::write( + &shell, + format!( + "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n", + codex.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(codex_path_from_shell(&shell), Some(codex)); + } + + #[test] + fn dispatch_codex_command_events_maps_to_bash_events() { + let mut events = Vec::new(); + let started = serde_json::json!({ + "type": "item.started", + "item": { + "id": "item_1", + "type": "command_execution", + "command": "/bin/zsh -lc pwd" + } + }); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_1", + "type": "command_execution", + "aggregated_output": "/private/tmp\n" + } + }); + + dispatch_codex_event(&started, &mut |event| events.push(event)); + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. } + if tool_name == "Bash" && tool_id == "item_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n") + )); + } + + #[test] + fn dispatch_codex_mcp_tool_call_maps_to_tool_events() { + let mut events = Vec::new(); + let started = serde_json::json!({ + "type": "item.started", + "item": { + "id": "item_1", + "type": "mcp_tool_call", + "server": "tolaria", + "tool": "search_notes", + "arguments": { "query": "meeting", "limit": 5 }, + "status": "in_progress" + } + }); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_1", + "type": "mcp_tool_call", + "server": "tolaria", + "tool": "search_notes", + "arguments": { "query": "meeting", "limit": 5 }, + "result": [{ "title": "Meeting notes" }], + "status": "completed" + } + }); + + dispatch_codex_event(&started, &mut |event| events.push(event)); + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } + if tool_name == "search_notes" + && tool_id == "item_1" + && input.as_deref().is_some_and(|value| value.contains("meeting")) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "item_1" + && output.as_deref().is_some_and(|value| value.contains("Meeting notes")) + )); + } + + #[test] + fn dispatch_codex_agent_message_maps_to_text_delta() { + let mut events = Vec::new(); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_2", + "type": "agent_message", + "text": "All set" + } + }); + + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::TextDelta { text } if text == "All set" + )); + } + + #[test] + fn format_codex_error_explains_vault_write_permission_failures() { + let message = format_codex_error(CodexProcessError { + stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), + status: "exit status: 1".into(), + }); + + assert!(message.contains("active vault")); + assert!(message.contains("writable")); + assert!(message.contains("outside")); + } +} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs new file mode 100644 index 0000000..32dbbc5 --- /dev/null +++ b/src-tauri/src/commands/ai.rs @@ -0,0 +1,471 @@ +#[cfg(desktop)] +use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus}; +#[cfg(desktop)] +use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest}; +use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus}; +use crate::vault::VaultAiGuidanceStatus; + +use super::expand_tilde; + +#[cfg(desktop)] +type StreamEmitter = Box; + +#[cfg(desktop)] +const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs"; + +#[cfg(desktop)] +struct DesktopStreamScope { + event_name: String, + stream_id: Option, +} + +#[cfg(desktop)] +impl DesktopStreamScope { + fn shared(event_name: impl Into) -> Self { + Self { + event_name: event_name.into(), + stream_id: None, + } + } + + fn cancellable(event_name: impl Into) -> Self { + let event_name = event_name.into(); + Self { + stream_id: Some(event_name.clone()), + event_name, + } + } +} + +#[cfg(desktop)] +async fn run_desktop_stream( + app_handle: tauri::AppHandle, + scope: DesktopStreamScope, + request: Request, + runner: Runner, +) -> Result +where + Event: serde::Serialize + Send + 'static, + Request: Send + 'static, + Runner: FnOnce(Request, StreamEmitter) -> Result + Send + 'static, +{ + use tauri::Emitter; + + tokio::task::spawn_blocking(move || { + let DesktopStreamScope { + event_name, + stream_id, + } = scope; + let run = || { + runner( + request, + Box::new(move |event| { + let _ = app_handle.emit(event_name.as_str(), &event); + }), + ) + }; + match stream_id { + Some(stream_id) => crate::ai_agent_processes::with_stream_id(stream_id, run), + None => run(), + } + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +#[cfg(desktop)] +macro_rules! define_desktop_stream_command { + ($name:ident, $request:ty, $event_name:literal, $runner:path) => { + #[tauri::command] + pub async fn $name( + app_handle: tauri::AppHandle, + request: $request, + ) -> Result { + run_desktop_stream( + app_handle, + DesktopStreamScope::shared($event_name), + request, + $runner, + ) + .await + } + }; +} + +#[cfg(desktop)] +fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool { + event_name + .strip_prefix(default_event_name) + .and_then(|suffix| suffix.strip_prefix('-')) + .is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) +} + +#[cfg(desktop)] +fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String { + requested + .filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name)) + .unwrap_or(default_event_name) + .to_string() +} + +// ── Claude CLI commands (desktop) ─────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub fn check_claude_cli() -> ClaudeCliStatus { + crate::claude_cli::check_cli() +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn get_ai_agents_status() -> AiAgentsStatus { + crate::ai_agents::get_ai_agents_status().await +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_agent_docs_path(app_handle: tauri::AppHandle) -> Result { + use std::path::PathBuf; + use tauri::path::BaseDirectory; + use tauri::Manager; + + let mut candidates = Vec::new(); + + if let Ok(resource_path) = app_handle + .path() + .resolve(AGENT_DOCS_RESOURCE_DIR, BaseDirectory::Resource) + { + candidates.push(resource_path); + } + + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join(AGENT_DOCS_RESOURCE_DIR), + ); + + candidates + .into_iter() + .find(|path| path.join("index.md").is_file()) + .map(|path| path.to_string_lossy().into_owned()) + .ok_or_else(|| "Tolaria agent docs are not bundled in this build.".to_string()) +} + +#[tauri::command] +pub fn get_vault_ai_guidance_status(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::vault::get_ai_guidance_status(vault_path.as_ref()) +} + +#[tauri::command] +pub fn restore_vault_ai_guidance(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::vault::restore_ai_guidance_files(vault_path.as_ref()) +} + +#[cfg(desktop)] +define_desktop_stream_command!( + stream_claude_chat, + ChatStreamRequest, + "claude-stream", + crate::claude_cli::run_chat_stream +); + +#[cfg(desktop)] +fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRequest { + request.vault_path = expand_tilde(&request.vault_path).into_owned(); + request.vault_paths = request + .vault_paths + .into_iter() + .map(|path| expand_tilde(&path).into_owned()) + .collect(); + request +} + +#[cfg(desktop)] +fn run_normalized_ai_agent_stream( + request: AiAgentStreamRequest, + emitter: StreamEmitter, +) -> Result { + crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn stream_ai_agent( + app_handle: tauri::AppHandle, + request: AiAgentStreamRequest, +) -> Result { + let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref()); + run_desktop_stream( + app_handle, + DesktopStreamScope::cancellable(event_name), + request, + run_normalized_ai_agent_stream, + ) + .await +} + +#[cfg(desktop)] +#[tauri::command] +pub fn abort_ai_agent_stream(event_name: String) -> Result { + if !is_scoped_stream_event_name("ai-agent-stream", &event_name) { + return Err("Invalid AI agent stream id".into()); + } + + crate::ai_agent_processes::abort_stream(&event_name) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn stream_ai_model( + app_handle: tauri::AppHandle, + request: AiModelStreamRequest, +) -> Result { + let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref()); + run_desktop_stream( + app_handle, + DesktopStreamScope::shared(event_name), + request, + crate::ai_models::run_ai_model_stream, + ) + .await +} + +#[cfg(desktop)] +#[tauri::command] +pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> { + crate::ai_models::save_provider_api_key(provider_id, api_key) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> { + crate::ai_models::delete_provider_api_key(provider_id) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result { + crate::ai_models::test_ai_model_provider(request) +} + +// ── Claude CLI (mobile stubs) ─────────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub fn check_claude_cli() -> ClaudeCliStatus { + ClaudeCliStatus { + installed: false, + version: None, + } +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_ai_agents_status() -> AiAgentsStatus { + AiAgentsStatus { + claude_code: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + codex: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + copilot: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + opencode: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + pi: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + antigravity: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + kiro: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + hermes: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_agent_docs_path() -> Result { + Err("Bundled agent docs are only available in the desktop app.".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_claude_chat( + _app_handle: tauri::AppHandle, + _request: ChatStreamRequest, +) -> Result { + Err("Claude CLI is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_ai_agent( + _app_handle: tauri::AppHandle, + _request: AiAgentStreamRequest, +) -> Result { + Err("CLI AI agents are not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn abort_ai_agent_stream(_event_name: String) -> Result { + Err("CLI AI agents are not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_ai_model( + _app_handle: tauri::AppHandle, + _request: crate::ai_models::AiModelStreamRequest, +) -> Result { + Err("Direct AI model chat is not available in this mobile build yet.".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn save_ai_model_provider_api_key( + _provider_id: String, + _api_key: String, +) -> Result<(), String> { + Err("Local AI provider secret storage is only available in the desktop app.".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> { + Err("Local AI provider secret storage is only available in the desktop app.".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn test_ai_model_provider( + _request: crate::ai_models::AiModelProviderTestRequest, +) -> Result { + Err("Direct AI model tests are not available in this mobile build yet.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::AiGuidanceFileState; + + #[cfg(desktop)] + #[test] + fn normalize_agent_request_expands_tilde_in_vault_path() { + use crate::ai_agents::AiAgentId; + + let home = dirs::home_dir().unwrap(); + let request = AiAgentStreamRequest { + agent: AiAgentId::ClaudeCode, + message: "hi".into(), + system_prompt: None, + vault_path: "~/Vaults/content".into(), + vault_paths: vec!["~/Vaults/secondary".into()], + permission_mode: None, + event_name: None, + }; + + let normalized = normalize_agent_request(request); + + assert_eq!( + normalized.vault_path, + format!("{}/Vaults/content", home.display()), + "vault_path must be tilde-expanded so spawned agents can chdir into it", + ); + assert_eq!( + normalized.vault_paths, + vec![format!("{}/Vaults/secondary", home.display())], + "vault_paths must be tilde-expanded so spawned agents can access every active vault", + ); + } + + #[cfg(desktop)] + #[test] + fn normalize_agent_request_leaves_absolute_vault_path_untouched() { + use crate::ai_agents::AiAgentId; + + let request = AiAgentStreamRequest { + agent: AiAgentId::Codex, + message: "hi".into(), + system_prompt: None, + vault_path: "/Users/example/vault".into(), + vault_paths: Vec::new(), + permission_mode: None, + event_name: None, + }; + + let normalized = normalize_agent_request(request); + + assert_eq!(normalized.vault_path, "/Users/example/vault"); + } + + #[cfg(desktop)] + #[test] + fn stream_event_name_accepts_only_scoped_names() { + assert_eq!( + stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")), + "ai-agent-stream-chat-123", + ); + assert_eq!( + stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")), + "ai-agent-stream", + ); + assert_eq!( + stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")), + "ai-agent-stream", + ); + } + + #[cfg(desktop)] + #[test] + fn abort_ai_agent_stream_rejects_unscoped_names() { + let result = abort_ai_agent_stream("ai-model-stream-chat-123".into()); + + assert!(matches!(result, Err(message) if message.contains("Invalid AI agent stream id"))); + } + + #[test] + fn guidance_commands_report_and_restore_vault_guidance_files() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().to_string_lossy().to_string(); + + let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap(); + assert_eq!(initial.agents_state, AiGuidanceFileState::Missing); + assert_eq!(initial.claude_state, AiGuidanceFileState::Missing); + assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing); + assert!(initial.can_restore); + + let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap(); + assert_eq!(restored.agents_state, AiGuidanceFileState::Managed); + assert_eq!(restored.claude_state, AiGuidanceFileState::Managed); + assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed); + assert!(!restored.can_restore); + + assert!(dir.path().join("AGENTS.md").exists()); + assert!(dir.path().join("CLAUDE.md").exists()); + assert!(dir.path().join("GEMINI.md").exists()); + } +} diff --git a/src-tauri/src/commands/app_icon.rs b/src-tauri/src/commands/app_icon.rs new file mode 100644 index 0000000..d591ad3 --- /dev/null +++ b/src-tauri/src/commands/app_icon.rs @@ -0,0 +1,11 @@ +#[cfg(desktop)] +#[tauri::command] +pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> { + crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn update_app_icon(_theme_mode: String) -> Result<(), String> { + Ok(()) +} diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs new file mode 100644 index 0000000..3e1c144 --- /dev/null +++ b/src-tauri/src/commands/clipboard.rs @@ -0,0 +1,207 @@ +#[cfg(desktop)] +use std::io::Write; +#[cfg(desktop)] +use std::process::{Child, Command, Output, Stdio}; +#[cfg(desktop)] +use std::thread; +#[cfg(desktop)] +use std::time::{Duration, Instant}; + +#[cfg(desktop)] +const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(desktop)] +const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25); + +#[cfg(target_os = "macos")] +fn clipboard_command() -> Command { + crate::hidden_command("pbcopy") +} + +#[cfg(target_os = "macos")] +fn clipboard_read_command() -> Command { + crate::hidden_command("pbpaste") +} + +#[cfg(target_os = "windows")] +fn clipboard_command() -> Command { + crate::hidden_command("clip.exe") +} + +#[cfg(target_os = "windows")] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("powershell.exe"); + command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]); + command +} + +#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] +fn clipboard_command() -> Command { + let mut command = crate::hidden_command("sh"); + command.args([ + "-c", + "if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi", + ]); + command +} + +#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("sh"); + command.args([ + "-c", + "if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi", + ]); + command +} + +#[cfg(desktop)] +fn clipboard_failure_message(stderr: &[u8]) -> String { + let message = String::from_utf8_lossy(stderr).trim().to_string(); + if message.is_empty() { + "Native clipboard command failed".to_string() + } else { + format!("Native clipboard command failed: {message}") + } +} + +#[cfg(desktop)] +fn clipboard_timeout_message(timeout: Duration) -> String { + format!( + "Native clipboard command timed out after {}ms", + timeout.as_millis() + ) +} + +#[cfg(desktop)] +fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result { + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_status)) => { + return child + .wait_with_output() + .map_err(|e| format!("Native clipboard command did not finish: {e}")); + } + Ok(None) if started.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(clipboard_timeout_message(timeout)); + } + Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL), + Err(e) => return Err(format!("Native clipboard command did not finish: {e}")), + } + } +} + +#[cfg(desktop)] +fn write_native_clipboard_with_timeout( + mut command: Command, + text: &str, + timeout: Duration, +) -> Result<(), String> { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to open native clipboard command: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?; + stdin + .write_all(text.as_bytes()) + .map_err(|e| format!("Failed to write native clipboard text: {e}"))?; + drop(stdin); + + let output = wait_for_native_clipboard_output(child, timeout)?; + if output.status.success() { + Ok(()) + } else { + Err(clipboard_failure_message(&output.stderr)) + } +} + +#[cfg(desktop)] +fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> { + write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT) +} + +#[cfg(desktop)] +fn read_native_clipboard_with_timeout( + mut command: Command, + timeout: Duration, +) -> Result { + let child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to read native clipboard text: {e}"))?; + + let output = wait_for_native_clipboard_output(child, timeout)?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(clipboard_failure_message(&output.stderr)) + } +} + +#[cfg(desktop)] +fn read_native_clipboard(command: Command) -> Result { + read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text)) + .await + .map_err(|e| format!("Native clipboard task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn read_text_from_clipboard() -> Result { + tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command())) + .await + .map_err(|e| format!("Native clipboard task failed: {e}"))? +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> { + Err("Clipboard is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn read_text_from_clipboard() -> Result { + Err("Clipboard is not available on mobile".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(desktop, unix))] + #[test] + fn native_clipboard_write_times_out_slow_commands() { + let mut command = Command::new("sh"); + command.args(["-c", "cat >/dev/null; sleep 2"]); + + let started = Instant::now(); + let result = + write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50)); + + let error = result.expect_err("slow clipboard command should time out"); + assert!( + error.contains("timed out"), + "unexpected clipboard timeout error: {error}" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "clipboard timeout should return promptly" + ); + } +} diff --git a/src-tauri/src/commands/delete.rs b/src-tauri/src/commands/delete.rs new file mode 100644 index 0000000..c087f1a --- /dev/null +++ b/src-tauri/src/commands/delete.rs @@ -0,0 +1,44 @@ +use crate::vault; + +use super::vault::VaultBoundary; + +#[tauri::command] +pub async fn batch_delete_notes_async( + paths: Vec, + vault_path: Option, +) -> Result, String> { + let boundary = VaultBoundary::from_request(vault_path.as_deref())?; + let validated_paths = boundary.validate_existing_paths(&paths)?; + tokio::task::spawn_blocking(move || vault::batch_delete_notes(&validated_paths)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn batch_delete_notes_async_validates_and_deletes_inside_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let first = dir.path().join("first.md"); + let second = dir.path().join("nested/second.md"); + std::fs::create_dir_all(second.parent().unwrap()).unwrap(); + std::fs::write(&first, "# First\n").unwrap(); + std::fs::write(&second, "# Second\n").unwrap(); + + let deleted = batch_delete_notes_async( + vec![ + first.to_string_lossy().to_string(), + "nested/second.md".to_string(), + ], + Some(dir.path().to_string_lossy().to_string()), + ) + .await + .unwrap(); + + assert_eq!(deleted.len(), 2); + assert!(!first.exists()); + assert!(!second.exists()); + } +} diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs new file mode 100644 index 0000000..e0b9a0e --- /dev/null +++ b/src-tauri/src/commands/folders.rs @@ -0,0 +1,50 @@ +use crate::vault::{self, FolderRenameResult}; + +use super::expand_tilde; + +#[tauri::command] +pub fn rename_vault_folder( + vault_path: String, + folder_path: String, + new_name: String, +) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::rename_folder( + std::path::Path::new(vault_path.as_ref()), + &folder_path, + &new_name, + ) +} + +#[tauri::command] +pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_commands_route_through_vault_path_boundary() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().to_string_lossy().to_string(); + let folder = dir.path().join("Inbox"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(folder.join("note.md"), "# Note\n").unwrap(); + + let renamed = rename_vault_folder( + vault_path.clone(), + "Inbox".to_string(), + "Organized".to_string(), + ) + .unwrap(); + assert!(renamed.new_path.ends_with("Organized")); + assert!(dir.path().join("Organized/note.md").exists()); + + let deleted = delete_vault_folder(vault_path, "Organized".to_string()).unwrap(); + assert_eq!(deleted, "Organized"); + assert!(!dir.path().join("Organized").exists()); + } +} diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs new file mode 100644 index 0000000..09f850b --- /dev/null +++ b/src-tauri/src/commands/git.rs @@ -0,0 +1,646 @@ +use crate::git::{ + GitAuthorIdentity, GitCommit, GitProviderProbe, GitProviderStatus, GitPullResult, + GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile, PulseCommit, +}; + +use super::expand_tilde; + +type VaultPathArg = String; +type NotePathArg = String; +type CommitHashArg = String; +type CommitMessageArg = String; +type ConflictStrategyArg = String; +const GIT_PROVIDER_PROBE_TIMEOUT_SECONDS: u64 = 12; + +// ── Git commands (desktop) ────────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_history( + vault_path: VaultPathArg, + path: NotePathArg, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_history(&vault_path, &path) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn get_modified_files( + vault_path: VaultPathArg, + include_stats: Option, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + if include_stats.unwrap_or(false) { + crate::git::get_modified_files_with_stats(&vault_path) + } else { + crate::git::get_modified_files(&vault_path) + } + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_diff(&vault_path, &path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_diff_at_commit( + vault_path: VaultPathArg, + path: NotePathArg, + commit_hash: CommitHashArg, +) -> Result { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_diff_at_commit(&vault_path, &path, &commit_hash) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_vault_pulse( + vault_path: VaultPathArg, + limit: Option, + skip: Option, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + let limit = limit.unwrap_or(20); + let skip = skip.unwrap_or(0); + crate::git::get_vault_pulse(vault_path.as_ref(), limit, skip) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::git::git_commit(&vault_path, &message) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_author_identity(vault_path: VaultPathArg) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::git::git_author_identity(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + crate::git::get_last_commit_info(vault_path.as_ref()) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_pull(vault_path: VaultPathArg) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) { + return Ok(GitPullResult { + status: "no_remote".to_string(), + message: "No remote configured".to_string(), + updated_files: vec![], + conflict_files: vec![], + }); + } + + crate::git::git_pull(&vault_path) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_conflict_files(vault_path: VaultPathArg) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + crate::git::get_conflict_files(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_conflict_mode(vault_path: VaultPathArg) -> String { + let vault_path = expand_tilde(&vault_path); + crate::git::get_conflict_mode(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_resolve_conflict( + vault_path: VaultPathArg, + file: NotePathArg, + strategy: ConflictStrategyArg, +) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + crate::git::git_resolve_conflict(&vault_path, &file, &strategy) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::git::git_commit_conflict_resolution(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_push(vault_path: VaultPathArg) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) { + return Ok(GitPushResult { + status: "no_remote".to_string(), + message: "No remote configured".to_string(), + }); + } + + crate::git::git_push(&vault_path) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_remote_status(vault_path: VaultPathArg) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) { + return Ok(GitRemoteStatus { + branch: String::new(), + has_remote: false, + has_upstream: false, + upstream: None, + ahead: 0, + behind: 0, + }); + } + + crate::git::git_remote_status(&vault_path) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_file_url( + vault_path: VaultPathArg, + path: NotePathArg, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path).into_owned(); + let path = expand_tilde(&path).into_owned(); + tokio::task::spawn_blocking(move || crate::git::git_file_url(&vault_path, &path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_discard_file( + vault_path: VaultPathArg, + relative_path: NotePathArg, +) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + crate::git::discard_file_changes(&vault_path, &relative_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn is_git_repo(vault_path: VaultPathArg) -> bool { + let vault_path = expand_tilde(&vault_path); + crate::git::is_inside_work_tree(std::path::Path::new(vault_path.as_ref())) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_provider_status() -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(GIT_PROVIDER_PROBE_TIMEOUT_SECONDS), + tokio::task::spawn_blocking(crate::git::git_provider_status), + ) + .await + .map_err(|_| "Git provider detection timed out".to_string())? + .map_err(|e| format!("Task panicked: {e}")) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn test_git_provider( + provider: String, + distro: Option, + vault_path: Option, +) -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(GIT_PROVIDER_PROBE_TIMEOUT_SECONDS), + tokio::task::spawn_blocking(move || { + crate::git::test_git_provider(&provider, distro.as_deref(), vault_path.as_deref()) + }), + ) + .await + .map_err(|_| "Git provider test timed out".to_string())? + .map_err(|e| format!("Task panicked: {e}")) +} + +#[cfg(desktop)] +fn validate_git_init_target(vault_path: &str) -> Result<(), String> { + let path = std::path::Path::new(vault_path); + if !path.exists() { + return Err("Choose an existing vault folder before initializing Git".to_string()); + } + if !path.is_dir() { + return Err("Choose a folder before initializing Git".to_string()); + } + + if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) { + return Err(format!( + "Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.", + path.display(), + path.join("Tolaria").display() + )); + } + + if crate::git::is_inside_work_tree(path) && !crate::git::has_direct_git_metadata(path) { + return Err( + "This vault is already inside a Git work tree. Tolaria will use the parent repository instead of creating an embedded repository." + .to_string(), + ); + } + + Ok(()) +} + +#[cfg(desktop)] +fn is_broad_personal_folder(path: &std::path::Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + matches!( + name.to_ascii_lowercase().as_str(), + "desktop" + | "documents" + | "downloads" + | "movies" + | "music" + | "pictures" + | "public" + | "templates" + | "videos" + ) +} + +#[cfg(desktop)] +fn has_tolaria_vault_marker(path: &std::path::Path) -> bool { + ["AGENTS.md", "CLAUDE.md", "type.md", "note.md"] + .iter() + .any(|file| path.join(file).is_file()) + || ["attachments", "type", "views"] + .iter() + .any(|dir| path.join(dir).is_dir()) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + validate_git_init_target(&vault_path)?; + crate::git::init_repo(std::path::Path::new(vault_path.as_ref())) +} + +// ── Git commands (mobile stubs) ───────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_history( + _vault_path: VaultPathArg, + _path: NotePathArg, +) -> Result, String> { + Err("Git history is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_modified_files( + _vault_path: VaultPathArg, + _include_stats: Option, +) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result { + Err("Git diff is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_diff_at_commit( + _vault_path: VaultPathArg, + _path: NotePathArg, + _commit_hash: CommitHashArg, +) -> Result { + Err("Git diff is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_vault_pulse( + _vault_path: VaultPathArg, + _limit: Option, + _skip: Option, +) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result { + Err("Git commit is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_author_identity(_vault_path: VaultPathArg) -> Result { + Err("Git author identity is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result, String> { + Ok(None) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_pull(_vault_path: VaultPathArg) -> Result { + Err("Git pull is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String { + "none".to_string() +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_resolve_conflict( + _vault_path: VaultPathArg, + _file: NotePathArg, + _strategy: ConflictStrategyArg, +) -> Result<(), String> { + Err("Git conflict resolution is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result { + Err("Git conflict resolution is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_push(_vault_path: VaultPathArg) -> Result { + Err("Git push is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result { + Ok(GitRemoteStatus { + branch: String::new(), + has_remote: false, + has_upstream: false, + upstream: None, + ahead: 0, + behind: 0, + }) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_file_url( + _vault_path: VaultPathArg, + _path: NotePathArg, +) -> Result, String> { + Ok(None) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_discard_file( + _vault_path: VaultPathArg, + _relative_path: NotePathArg, +) -> Result<(), String> { + Err("Git discard is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn is_git_repo(_vault_path: VaultPathArg) -> bool { + false +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_provider_status() -> Result { + Ok(crate::git::git_provider_status()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn test_git_provider( + provider: String, + distro: Option, + vault_path: Option, +) -> Result { + Ok(crate::git::test_git_provider( + &provider, + distro.as_deref(), + vault_path.as_deref(), + )) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> { + Err("Git init is not available on mobile".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn vault_path(dir: &TempDir) -> String { + dir.path().to_string_lossy().into_owned() + } + + fn note_path(dir: &TempDir, name: &str) -> String { + dir.path().join(name).to_string_lossy().into_owned() + } + + fn create_initialized_vault() -> (TempDir, String) { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("note.md"), "# Note\n").unwrap(); + let vault = vault_path(&dir); + init_git_repo(vault.clone()).unwrap(); + (dir, vault) + } + + #[tokio::test] + async fn desktop_git_commands_route_to_git_backend() { + let (dir, vault) = create_initialized_vault(); + let note = note_path(&dir, "note.md"); + + assert!(is_git_repo(vault.clone())); + + fs::write(dir.path().join("note.md"), "# Updated\n").unwrap(); + let modified = get_modified_files(vault.clone(), None).await.unwrap(); + assert!(modified.iter().any(|file| file.relative_path == "note.md")); + + let diff = get_file_diff(vault.clone(), note.clone()).unwrap(); + assert!(diff.contains("# Updated")); + + git_commit(vault.clone(), "Update note".to_string()).unwrap(); + let history = get_file_history(vault.clone(), note.clone()).unwrap(); + assert!(history.iter().any(|commit| commit.message == "Update note")); + + let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap(); + assert!(!last_commit.short_hash.is_empty()); + + let commit_diff = get_file_diff_at_commit( + vault.clone(), + note.clone(), + history.first().unwrap().hash.clone(), + ) + .unwrap(); + assert!(commit_diff.contains("# Updated")); + + let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap(); + assert!(!pulse.is_empty()); + + fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap(); + git_discard_file(vault.clone(), "note.md".to_string()).unwrap(); + assert_eq!( + fs::read_to_string(dir.path().join("note.md")).unwrap(), + "# Updated\n" + ); + + assert!(get_conflict_files(vault.clone()).unwrap().is_empty()); + assert_eq!(get_conflict_mode(vault.clone()), "none"); + assert!( + git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),) + .is_err() + ); + } + + #[test] + fn init_git_repo_rejects_broad_personal_folders() { + let dir = TempDir::new().unwrap(); + let documents = dir.path().join("Documents"); + fs::create_dir_all(&documents).unwrap(); + fs::write(documents.join("unrelated.txt"), "not a vault").unwrap(); + + let err = init_git_repo(documents.to_string_lossy().into_owned()) + .expect_err("expected Documents itself to be rejected before git init"); + + assert!(err.contains("dedicated vault folder")); + assert!(!documents.join(".git").exists()); + } + + #[test] + fn init_git_repo_allows_named_vault_subfolder_under_documents() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("Documents").join("Tolaria"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + let vault = vault.to_string_lossy().into_owned(); + + init_git_repo(vault.clone()).unwrap(); + + assert!(is_git_repo(vault)); + } + + #[test] + fn is_git_repo_accepts_vault_nested_inside_parent_worktree() { + let parent = TempDir::new().unwrap(); + fs::write(parent.path().join("README.md"), "# Parent\n").unwrap(); + crate::git::init_repo(parent.path()).unwrap(); + + let nested_vault = parent.path().join("demo-vault-v2"); + fs::create_dir_all(&nested_vault).unwrap(); + fs::write(nested_vault.join("note.md"), "# Nested\n").unwrap(); + + assert!(is_git_repo(nested_vault.to_string_lossy().into_owned())); + assert!(!nested_vault.join(".git").exists()); + } + + #[test] + fn init_git_repo_rejects_nested_worktree_vault_without_direct_git_metadata() { + let parent = TempDir::new().unwrap(); + fs::write(parent.path().join("README.md"), "# Parent\n").unwrap(); + crate::git::init_repo(parent.path()).unwrap(); + + let nested_vault = parent.path().join("demo-vault-v2"); + fs::create_dir_all(&nested_vault).unwrap(); + fs::write(nested_vault.join("note.md"), "# Nested\n").unwrap(); + + let err = init_git_repo(nested_vault.to_string_lossy().into_owned()) + .expect_err("expected nested vault to reuse the parent worktree"); + + assert!(err.contains("inside a Git work tree")); + assert!(!nested_vault.join(".git").exists()); + } + + #[tokio::test] + async fn desktop_remote_commands_report_no_remote() { + let (_dir, vault) = create_initialized_vault(); + + let pull = git_pull(vault.clone()).await.unwrap(); + assert_eq!(pull.status, "no_remote"); + + let push = git_push(vault.clone()).await.unwrap(); + assert_eq!(push.status, "no_remote"); + + let status = git_remote_status(vault.clone()).await.unwrap(); + assert!(!status.has_remote); + assert_eq!((status.ahead, status.behind), (0, 0)); + } + + #[tokio::test] + async fn desktop_remote_commands_report_no_remote_for_gitless_vault() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("note.md"), "# Note\n").unwrap(); + let vault = vault_path(&dir); + + let pull = git_pull(vault.clone()).await.unwrap(); + assert_eq!(pull.status, "no_remote"); + assert!(pull.updated_files.is_empty()); + assert!(pull.conflict_files.is_empty()); + + let push = git_push(vault.clone()).await.unwrap(); + assert_eq!(push.status, "no_remote"); + + let status = git_remote_status(vault).await.unwrap(); + assert!(!status.has_remote); + assert_eq!(status.branch, ""); + assert_eq!((status.ahead, status.behind), (0, 0)); + } +} diff --git a/src-tauri/src/commands/git_clone.rs b/src-tauri/src/commands/git_clone.rs new file mode 100644 index 0000000..3177a6b --- /dev/null +++ b/src-tauri/src/commands/git_clone.rs @@ -0,0 +1,18 @@ +use super::expand_tilde; + +#[cfg(desktop)] +#[tauri::command] +pub async fn clone_git_repo(url: String, local_path: String) -> Result { + let url = crate::git::validate_user_remote_url(&url)?.to_string(); + let local_path = expand_tilde(&local_path).into_owned(); + + tokio::task::spawn_blocking(move || crate::git::clone_repo(&url, &local_path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn clone_git_repo(_url: String, _local_path: String) -> Result { + Err("Git clone is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/git_connect.rs b/src-tauri/src/commands/git_connect.rs new file mode 100644 index 0000000..cccd455 --- /dev/null +++ b/src-tauri/src/commands/git_connect.rs @@ -0,0 +1,36 @@ +use crate::git::GitAddRemoteResult; +use serde::Deserialize; + +use super::expand_tilde; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitAddRemoteRequest { + vault_path: String, + remote_url: String, +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_add_remote(request: GitAddRemoteRequest) -> Result { + let vault_path = expand_tilde(&request.vault_path).into_owned(); + let remote_url = match crate::git::validate_user_remote_url(&request.remote_url) { + Ok(url) => url.to_string(), + Err(message) => { + return Ok(GitAddRemoteResult { + status: "error".to_string(), + message, + }); + } + }; + + tokio::task::spawn_blocking(move || crate::git::git_add_remote(&vault_path, &remote_url)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_add_remote(_request: GitAddRemoteRequest) -> Result { + Err("Adding git remotes is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs new file mode 100644 index 0000000..76ef245 --- /dev/null +++ b/src-tauri/src/commands/memory.rs @@ -0,0 +1,167 @@ +use serde::Serialize; +use std::process::Command; + +const WEBKIT_AUX_PID_WINDOW: u32 = 512; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMemoryEntry { + pub pid: u32, + pub parent_pid: u32, + pub rss_bytes: u64, + pub role: String, + pub command: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMemorySnapshot { + pub current_pid: u32, + pub total_rss_bytes: u64, + pub entries: Vec, +} + +struct ProcessRow { + pid: u32, + parent_pid: u32, + rss_kib: u64, + command: String, +} + +#[tauri::command] +pub fn get_process_memory_snapshot() -> Result { + let current_pid = std::process::id(); + let entries = collect_related_process_memory(current_pid)?; + let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum(); + + Ok(ProcessMemorySnapshot { + current_pid, + total_rss_bytes, + entries, + }) +} + +fn collect_related_process_memory(current_pid: u32) -> Result, String> { + let rows = read_process_rows()?; + Ok(rows + .into_iter() + .filter_map(|row| related_process_entry(row, current_pid)) + .collect()) +} + +fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option { + let role = classify_related_process(&row, current_pid)?; + Some(ProcessMemoryEntry { + pid: row.pid, + parent_pid: row.parent_pid, + rss_bytes: row.rss_kib.saturating_mul(1024), + role, + command: row.command, + }) +} + +fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option { + if row.pid == current_pid { + return Some("app".to_string()); + } + if !is_nearby_webkit_auxiliary(row, current_pid) { + return None; + } + + if row.command.contains("WebKit.WebContent") { + return Some("webkit-webcontent".to_string()); + } + if row.command.contains("WebKit.GPU") { + return Some("webkit-gpu".to_string()); + } + if row.command.contains("WebKit.Networking") { + return Some("webkit-networking".to_string()); + } + Some("webkit".to_string()) +} + +fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool { + row.pid > current_pid + && row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW + && row.command.contains("com.apple.WebKit.") +} + +fn parse_process_row(line: &str) -> Option { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse().ok()?; + let parent_pid = fields.next()?.parse().ok()?; + let rss_kib = fields.next()?.parse().ok()?; + let command = fields.collect::>().join(" "); + if command.is_empty() { + return None; + } + + Some(ProcessRow { + pid, + parent_pid, + rss_kib, + command, + }) +} + +#[cfg(unix)] +fn read_process_rows() -> Result, String> { + let output = Command::new("ps") + .args(["-axo", "pid=,ppid=,rss=,command="]) + .output() + .map_err(|error| format!("Failed to sample process memory: {error}"))?; + + if !output.status.success() { + return Err("Failed to sample process memory with ps".to_string()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().filter_map(parse_process_row).collect()) +} + +#[cfg(not(unix))] +fn read_process_rows() -> Result, String> { + Err("Process memory snapshots are only implemented on Unix platforms".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ps_rows_with_spaced_commands() { + let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap(); + + assert_eq!(row.pid, 42); + assert_eq!(row.parent_pid, 1); + assert_eq!(row.rss_kib, 1024); + assert_eq!(row.command, "/System/WebKit WebContent"); + } + + #[test] + fn classifies_nearby_webkit_auxiliaries() { + let row = ProcessRow { + pid: 120, + parent_pid: 1, + rss_kib: 10, + command: "/System/com.apple.WebKit.WebContent.xpc".to_string(), + }; + + assert_eq!( + classify_related_process(&row, 100), + Some("webkit-webcontent".to_string()), + ); + } + + #[test] + fn ignores_unrelated_webkit_auxiliaries() { + let row = ProcessRow { + pid: 900, + parent_pid: 1, + rss_kib: 10, + command: "/System/com.apple.WebKit.WebContent.xpc".to_string(), + }; + + assert_eq!(classify_related_process(&row, 100), None); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..90a17ed --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,102 @@ +mod ai; +mod app_icon; +mod clipboard; +mod delete; +mod folders; +mod git; +pub mod git_clone; +mod git_connect; +mod memory; +mod pdf_export; +mod runtime; +mod sheet; +mod system; +mod vault; +mod version; + +use std::borrow::Cow; + +pub use ai::*; +pub use app_icon::*; +pub use clipboard::*; +pub use delete::*; +pub use folders::*; +pub use git::*; +pub use git_connect::*; +pub use memory::*; +pub use pdf_export::*; +pub use runtime::*; +pub use sheet::*; +pub use system::*; +pub use vault::*; +pub use version::*; + +/// Expand a leading `~` or `~/` in a path string to the user's home directory. +/// Returns the original string unchanged if it doesn't start with `~` or if the +/// home directory cannot be determined. +pub fn expand_tilde(path: &str) -> Cow<'_, str> { + let Some(home) = dirs::home_dir() else { + return Cow::Borrowed(path); + }; + + match path { + "~" => Cow::Owned(home.to_string_lossy().into_owned()), + _ => path + .strip_prefix("~/") + .map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned())) + .unwrap_or(Cow::Borrowed(path)), + } +} + +fn is_numeric_version_part(part: &str) -> bool { + !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_legacy_build_version(minor: &str, patch: &str) -> bool { + minor.len() >= 6 && is_numeric_version_part(minor) && is_numeric_version_part(patch) +} + +fn parse_legacy_build_label(version: &str) -> Option { + let parts: Vec<&str> = version.split('.').collect(); + match parts.as_slice() { + [_, minor, patch] if is_legacy_build_version(minor, patch) => Some(format!("b{}", patch)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_tilde_with_subpath() { + let home = dirs::home_dir().unwrap(); + let result = expand_tilde("~/Documents/vault"); + assert_eq!(result, format!("{}/Documents/vault", home.display())); + } + + #[test] + fn expand_tilde_alone() { + let home = dirs::home_dir().unwrap(); + let result = expand_tilde("~"); + assert_eq!(result, home.to_string_lossy()); + } + + #[test] + fn expand_tilde_noop_for_absolute_path() { + let result = expand_tilde("/usr/local/bin"); + assert_eq!(result, "/usr/local/bin"); + } + + #[test] + fn expand_tilde_noop_for_relative_path() { + let result = expand_tilde("some/relative/path"); + assert_eq!(result, "some/relative/path"); + } + + #[test] + fn expand_tilde_noop_for_tilde_in_middle() { + let result = expand_tilde("/home/~user/path"); + assert_eq!(result, "/home/~user/path"); + } +} diff --git a/src-tauri/src/commands/pdf_export.rs b/src-tauri/src/commands/pdf_export.rs new file mode 100644 index 0000000..e035fe2 --- /dev/null +++ b/src-tauri/src/commands/pdf_export.rs @@ -0,0 +1,141 @@ +#[tauri::command] +pub fn export_current_webview_pdf( + window: tauri::WebviewWindow, + output_path: String, +) -> Result<(), String> { + native::export_current_webview_pdf(window, output_path) +} + +#[tauri::command] +pub fn can_export_current_webview_pdf() -> bool { + native::can_export_current_webview_pdf() +} + +#[cfg(target_os = "macos")] +mod native { + use std::path::Path; + use std::sync::mpsc; + use std::time::Duration; + + use objc2::runtime::ProtocolObject; + use objc2::ClassType; + use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob}; + use objc2_foundation::{NSString, NSURL}; + use objc2_web_kit::WKWebView; + + const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15); + + pub fn can_export_current_webview_pdf() -> bool { + true + } + + pub fn export_current_webview_pdf( + window: tauri::WebviewWindow, + output_path: String, + ) -> Result<(), String> { + validate_output_path(&output_path)?; + let (sender, receiver) = mpsc::channel(); + + window + .with_webview(move |webview| { + let result = save_webview_pdf(webview, &output_path); + let _ = sender.send(result); + }) + .map_err(|error| format!("Failed to access the current webview: {error}"))?; + + receiver + .recv_timeout(PDF_EXPORT_TIMEOUT) + .map_err(|_| "Timed out while exporting the current note as PDF".to_string())? + } + + fn validate_output_path(output_path: &str) -> Result<(), String> { + if output_path.trim().is_empty() { + return Err("Missing PDF export path".to_string()); + } + + let path = Path::new(output_path); + if path.file_name().is_none() { + return Err("PDF export path must include a file name".to_string()); + } + + Ok(()) + } + + fn save_webview_pdf( + webview: tauri::webview::PlatformWebview, + output_path: &str, + ) -> Result<(), String> { + let output = NSString::from_str(output_path); + let output_url = NSURL::fileURLWithPath(&output); + let print_info = NSPrintInfo::sharedPrintInfo(); + let print_settings = unsafe { print_info.dictionary() }; + let previous_job_disposition = print_info.jobDisposition(); + + print_info.setJobDisposition(unsafe { NSPrintSaveJob }); + unsafe { + print_settings.setObject_forKey( + output_url.as_super().as_super(), + ProtocolObject::from_ref(NSPrintJobSavingURL), + ); + } + + let webview: &WKWebView = unsafe { &*webview.inner().cast() }; + let window = webview + .window() + .ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?; + let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) }; + operation.setShowsPrintPanel(false); + operation.setShowsProgressPanel(false); + operation.setCanSpawnSeparateThread(true); + + unsafe { + operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo( + &window, + None, + None, + std::ptr::null_mut(), + ); + } + print_info.setJobDisposition(&previous_job_disposition); + unsafe { + print_settings.removeObjectForKey(NSPrintJobSavingURL); + } + + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::validate_output_path; + + #[test] + fn output_path_requires_a_non_blank_value() { + assert!(validate_output_path("").is_err()); + assert!(validate_output_path(" ").is_err()); + } + + #[test] + fn output_path_requires_a_file_name() { + assert!(validate_output_path("/").is_err()); + } + + #[test] + fn output_path_accepts_a_pdf_file_path() { + assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok()); + } + } +} + +#[cfg(not(target_os = "macos"))] +mod native { + pub fn can_export_current_webview_pdf() -> bool { + false + } + + pub fn export_current_webview_pdf( + _window: tauri::WebviewWindow, + _output_path: String, + ) -> Result<(), String> { + Err("Direct PDF export is currently only supported on macOS".to_string()) + } +} diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs new file mode 100644 index 0000000..185f5d5 --- /dev/null +++ b/src-tauri/src/commands/runtime.rs @@ -0,0 +1,48 @@ +fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bool { + is_linux_appimage +} + +fn map_print_result(result: Result<(), E>) -> Result<(), String> { + result.map_err(|error| format!("Failed to open the system print dialog: {error}")) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn linux_appimage_running() -> bool { + crate::linux_appimage::is_running() +} + +#[cfg(not(all(desktop, target_os = "linux")))] +fn linux_appimage_running() -> bool { + false +} + +#[tauri::command] +pub fn should_use_external_media_preview() -> bool { + should_use_external_media_preview_for_appimage(linux_appimage_running()) +} + +#[tauri::command] +pub fn print_current_webview(window: tauri::WebviewWindow) -> Result<(), String> { + map_print_result(window.print()) +} + +#[cfg(test)] +mod tests { + use super::{map_print_result, should_use_external_media_preview_for_appimage}; + + #[test] + fn external_media_preview_is_limited_to_linux_appimage() { + assert!(should_use_external_media_preview_for_appimage(true)); + assert!(!should_use_external_media_preview_for_appimage(false)); + } + + #[test] + fn print_errors_are_formatted_for_the_renderer() { + let result = map_print_result::<&str>(Err("printer unavailable")); + + assert_eq!( + result, + Err("Failed to open the system print dialog: printer unavailable".to_string()) + ); + } +} diff --git a/src-tauri/src/commands/sheet.rs b/src-tauri/src/commands/sheet.rs new file mode 100644 index 0000000..89d2492 --- /dev/null +++ b/src-tauri/src/commands/sheet.rs @@ -0,0 +1,679 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use ironcalc_base::Model; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +const SHEET_INDEX: u32 = 0; +const DEFAULT_MAX_DEPTH: usize = 4; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetDependencyContent { + pub path: String, + pub content: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetExternalReferenceLink { + pub source_path: String, + pub target: String, + pub target_path: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveSheetExternalFormulaInputsRequest { + pub content: String, + pub current_path: String, + pub dependencies: Vec, + pub links: Vec, + pub max_depth: Option, + pub timezone: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedSheetExternalFormulaInput { + pub cell: String, + pub evaluated: String, + pub source: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveSheetExternalFormulaInputsResponse { + pub inputs: Vec, +} + +#[tauri::command] +pub async fn resolve_sheet_external_formula_inputs( + request: ResolveSheetExternalFormulaInputsRequest, +) -> Result { + tokio::task::spawn_blocking(move || resolve_sheet_external_formula_inputs_sync(request)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +fn resolve_sheet_external_formula_inputs_sync( + request: ResolveSheetExternalFormulaInputsRequest, +) -> Result { + let mut resolver = ExternalFormulaResolver::new(request); + resolver.resolve_current_sheet() +} + +struct ExternalFormulaResolver { + content_by_path: HashMap, + current_path: String, + link_targets: HashMap, + max_depth: usize, + sheet_literal_cache: HashMap>, + timezone: String, +} + +struct ResolveStack { + depth: usize, + paths: HashSet, +} + +struct SheetBuildInputs { + external_inputs: HashMap, + unresolved_external_cells: HashSet, +} + +struct SheetRows<'a> { + path: &'a str, + rows: &'a [Vec], +} + +impl ResolveStack { + fn new(root_path: &str) -> Self { + Self { + depth: 0, + paths: HashSet::from([root_path.to_string()]), + } + } + + fn can_enter(&self, path: &str, max_depth: usize) -> bool { + self.depth < max_depth && !self.paths.contains(path) + } + + fn enter(&mut self, path: &str) { + self.depth += 1; + self.paths.insert(path.to_string()); + } + + fn exit(&mut self, path: &str) { + self.depth = self.depth.saturating_sub(1); + self.paths.remove(path); + } +} + +impl ExternalFormulaResolver { + fn new(request: ResolveSheetExternalFormulaInputsRequest) -> Self { + let mut content_by_path = HashMap::from([(request.current_path.clone(), request.content)]); + for dependency in request.dependencies { + content_by_path.insert(dependency.path, dependency.content); + } + + let link_targets = request + .links + .into_iter() + .map(|link| (link_key(&link.source_path, &link.target), link.target_path)) + .collect(); + + Self { + content_by_path, + current_path: request.current_path, + link_targets, + max_depth: request.max_depth.unwrap_or(DEFAULT_MAX_DEPTH), + sheet_literal_cache: HashMap::new(), + timezone: request.timezone.unwrap_or_else(|| "UTC".to_string()), + } + } + + fn resolve_current_sheet( + &mut self, + ) -> Result { + let content = self + .content_by_path + .get(&self.current_path) + .cloned() + .ok_or_else(|| "Current sheet content is missing".to_string())?; + let rows = parse_sheet_rows(&content); + let mut inputs = Vec::new(); + + for (row_index, row) in rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + let source = parse_sheet_markdown_cell_value(value); + if !is_external_formula_input(&source) { + continue; + } + + let mut stack = ResolveStack::new(&self.current_path); + if let Some(evaluated) = self.resolve_external_formula_input( + &source, + &self.current_path.clone(), + &mut stack, + )? { + inputs.push(ResolvedSheetExternalFormulaInput { + cell: cell_address(row_index + 1, column_index + 1), + evaluated, + source, + }); + } + } + } + + Ok(ResolveSheetExternalFormulaInputsResponse { inputs }) + } + + fn resolve_external_formula_input( + &mut self, + value: &str, + source_path: &str, + stack: &mut ResolveStack, + ) -> Result, String> { + if !is_external_formula_input(value) { + return Ok(None); + } + + let mut unresolved = false; + let evaluated = external_ref_regex() + .replace_all(value, |captures: ®ex::Captures<'_>| { + let raw_target = captures.get(1).map(|m| m.as_str()).unwrap_or_default(); + let column_absolute = captures.get(2).map(|m| m.as_str()).unwrap_or_default(); + let raw_column = captures.get(3).map(|m| m.as_str()).unwrap_or_default(); + let row_absolute = captures.get(4).map(|m| m.as_str()).unwrap_or_default(); + let raw_row = captures.get(5).map(|m| m.as_str()).unwrap_or_default(); + let target = wikilink_target(raw_target); + let Some(target_path) = self + .link_targets + .get(&link_key(source_path, &target)) + .cloned() + else { + unresolved = true; + return captures[0].to_string(); + }; + + if target_path == source_path { + return format!( + "{}{}{}{}", + column_absolute, + raw_column.to_ascii_uppercase(), + row_absolute, + raw_row, + ); + } + + let Some(row) = raw_row.parse::().ok() else { + unresolved = true; + return captures[0].to_string(); + }; + let Some(column) = column_index_from_name(raw_column) else { + unresolved = true; + return captures[0].to_string(); + }; + let address = cell_address(row, column); + + match self.resolve_external_cell_literal(&target_path, &address, stack) { + Ok(Some(literal)) => literal, + _ => { + unresolved = true; + captures[0].to_string() + } + } + }) + .to_string(); + + if unresolved || evaluated == value { + Ok(None) + } else { + Ok(Some(evaluated)) + } + } + + fn resolve_external_cell_literal( + &mut self, + path: &str, + address: &str, + stack: &mut ResolveStack, + ) -> Result, String> { + if !stack.can_enter(path, self.max_depth) { + return Ok(None); + } + + if let Some(cached_sheet) = self.sheet_literal_cache.get(path) { + return Ok(cached_sheet.get(address).cloned()); + } + + let Some(content) = self.content_by_path.get(path).cloned() else { + return Ok(None); + }; + + stack.enter(path); + let result = self.build_sheet_literal_cache(path, &content, stack); + stack.exit(path); + result?; + + Ok(self + .sheet_literal_cache + .get(path) + .and_then(|sheet| sheet.get(address).cloned())) + } + + fn build_sheet_literal_cache( + &mut self, + path: &str, + content: &str, + stack: &mut ResolveStack, + ) -> Result<(), String> { + let rows = parse_sheet_rows(content); + let workbook_name = workbook_name_from_path(path); + let timezone = self.timezone.clone(); + let mut model = Model::new_empty(workbook_name.as_str(), "en", timezone.as_str(), "en")?; + + let sheet_rows = SheetRows { path, rows: &rows }; + let build_inputs = self.populate_model_from_rows(&mut model, &sheet_rows, stack)?; + model.evaluate(); + self.sheet_literal_cache.insert( + path.to_string(), + collect_sheet_literals(&model, &rows, &build_inputs), + ); + Ok(()) + } + + fn populate_model_from_rows( + &mut self, + model: &mut Model<'_>, + sheet_rows: &SheetRows<'_>, + stack: &mut ResolveStack, + ) -> Result { + let mut external_inputs = HashMap::::new(); + let mut unresolved_external_cells = HashSet::::new(); + + for (row_index, row) in sheet_rows.rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + let source = parse_sheet_markdown_cell_value(value); + if source.is_empty() { + continue; + } + + let address = cell_address(row_index + 1, column_index + 1); + let model_input = + match self.resolve_external_formula_input(&source, sheet_rows.path, stack)? { + Some(evaluated) => { + external_inputs.insert(address, evaluated.clone()); + evaluated + } + None => { + if is_external_formula_input(&source) { + unresolved_external_cells.insert(address); + } + source + } + }; + + model.set_user_input( + SHEET_INDEX, + row_index as i32 + 1, + column_index as i32 + 1, + model_input, + )?; + } + } + + Ok(SheetBuildInputs { + external_inputs, + unresolved_external_cells, + }) + } +} + +fn collect_sheet_literals( + model: &Model<'_>, + rows: &[Vec], + build_inputs: &SheetBuildInputs, +) -> HashMap { + let mut literals = HashMap::new(); + for (row_index, row) in rows.iter().enumerate() { + collect_sheet_row_literals(model, row_index, row, build_inputs, &mut literals); + } + literals +} + +fn collect_sheet_row_literals( + model: &Model<'_>, + row_index: usize, + row: &[String], + build_inputs: &SheetBuildInputs, + literals: &mut HashMap, +) { + for (column_index, _value) in row.iter().enumerate() { + let row_number = row_index as i32 + 1; + let column_number = column_index as i32 + 1; + let address = cell_address(row_index + 1, column_index + 1); + if build_inputs.unresolved_external_cells.contains(&address) { + continue; + } + let content = build_inputs + .external_inputs + .get(&address) + .cloned() + .unwrap_or_else(|| { + model + .get_localized_cell_content(SHEET_INDEX, row_number, column_number) + .unwrap_or_default() + }); + literals.insert( + address, + external_cell_formula_literal(model, row_number, column_number, &content), + ); + } +} + +fn external_ref_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\[\[([^\]\n]+?)\]\]\.(\$?)([A-Za-z]+)(\$?)([1-9]\d*)").unwrap()) +} + +fn numeric_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?$").unwrap()) +} + +fn percent_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^-?[$€£]?\s*[\d,]+(?:\.\d+)?%$").unwrap()) +} + +fn is_external_formula_input(value: &str) -> bool { + value.trim_start().starts_with('=') && external_ref_regex().is_match(value) +} + +fn normalize_target(target: &str) -> String { + target.trim().to_lowercase() +} + +fn wikilink_target(raw: &str) -> String { + raw.split_once('|') + .map(|(target, _)| target) + .unwrap_or(raw) + .to_string() +} + +fn link_key(source_path: &str, target: &str) -> String { + format!("{}\n{}", source_path, normalize_target(target)) +} + +fn workbook_name_from_path(path: &str) -> String { + path.rsplit(['/', '\\']) + .next() + .unwrap_or("Tolaria Sheet") + .trim_end_matches(".md") + .to_string() +} + +fn first_line_break_len(content: &str, index: usize) -> usize { + let bytes = content.as_bytes(); + match (bytes.get(index), bytes.get(index + 1)) { + (Some(b'\r'), Some(b'\n')) => 2, + (Some(b'\n' | b'\r'), _) => 1, + _ => 0, + } +} + +fn is_frontmatter_delimiter(line: &str) -> bool { + line.strip_prefix("---") + .map(|rest| rest.chars().all(|ch| ch == ' ' || ch == '\t')) + .unwrap_or(false) +} + +fn split_sheet_body(content: &str) -> &str { + if !content.starts_with("---") { + return content; + } + + let opening_line_break = first_line_break_len(content, 3); + if opening_line_break == 0 { + return content; + } + + let mut line_start = 3 + opening_line_break; + while line_start < content.len() { + let mut line_end = line_start; + while line_end < content.len() && !matches!(content.as_bytes()[line_end], b'\n' | b'\r') { + line_end += 1; + } + + if is_frontmatter_delimiter(&content[line_start..line_end]) { + let closing_line_break = first_line_break_len(content, line_end); + return &content[line_end + closing_line_break..]; + } + + let line_break = first_line_break_len(content, line_end); + if line_break == 0 { + break; + } + line_start = line_end + line_break; + } + + content +} + +fn parse_sheet_rows(content: &str) -> Vec> { + parse_csv_rows(split_sheet_body(content).trim_end()) +} + +fn parse_csv_rows(source: &str) -> Vec> { + if source.is_empty() { + return Vec::new(); + } + + let mut reader = csv::ReaderBuilder::new() + .flexible(true) + .has_headers(false) + .from_reader(source.as_bytes()); + + reader + .records() + .filter_map(Result::ok) + .map(|record| record.iter().map(str::to_string).collect()) + .collect() +} + +fn strip_symmetric_markup(value: &str, marker: &str) -> Option { + let inner = value.strip_prefix(marker)?.strip_suffix(marker)?; + let formula_candidate = inner.trim_start_matches(['*', '_', '~']).trim_start(); + if formula_candidate.starts_with('=') || inner.is_empty() { + return None; + } + Some(inner.to_string()) +} + +fn parse_sheet_markdown_cell_value(value: &str) -> String { + if value.starts_with('=') { + return value.to_string(); + } + + for marker in ["***", "**", "__", "_", "*", "~~"] { + if let Some(inner) = strip_symmetric_markup(value, marker) { + return inner; + } + } + + value.to_string() +} + +fn column_index_from_name(name: &str) -> Option { + let mut value = 0usize; + for ch in name.chars() { + if !ch.is_ascii_alphabetic() { + return None; + } + value = value * 26 + (ch.to_ascii_uppercase() as usize - 'A' as usize + 1); + } + (value > 0).then_some(value) +} + +fn column_name_from_index(mut index: usize) -> String { + let mut name = String::new(); + while index > 0 { + let remainder = (index - 1) % 26; + name.insert(0, (b'A' + remainder as u8) as char); + index = (index - 1) / 26; + } + name +} + +fn cell_address(row: usize, column: usize) -> String { + format!("{}{}", column_name_from_index(column), row) +} + +fn normalized_numeric_formula_literal(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Some("0".to_string()); + } + if numeric_regex().is_match(trimmed) { + return Some(trimmed.to_string()); + } + + if percent_regex().is_match(trimmed) { + let normalized = trimmed + .chars() + .filter(|ch| !matches!(ch, '$' | '€' | '£' | ',' | ' ' | '%')) + .collect::(); + if let Ok(parsed) = normalized.parse::() { + return Some((parsed / 100.0).to_string()); + } + } + + let normalized = trimmed + .trim_start_matches(['$', '€', '£']) + .trim_start() + .replace(',', ""); + if numeric_regex().is_match(&normalized) { + return Some(normalized); + } + + None +} + +fn text_formula_literal(value: &str) -> String { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + ) +} + +fn external_cell_formula_literal( + model: &Model<'_>, + row: i32, + column: i32, + raw_content: &str, +) -> String { + if !raw_content.trim_start().starts_with('=') { + return normalized_numeric_formula_literal(raw_content) + .unwrap_or_else(|| text_formula_literal(raw_content)); + } + + let formatted = model + .get_formatted_cell_value(SHEET_INDEX, row, column) + .unwrap_or_default(); + normalized_numeric_formula_literal(&formatted) + .unwrap_or_else(|| text_formula_literal(&formatted)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request( + content: &str, + dependencies: Vec, + links: Vec, + ) -> ResolveSheetExternalFormulaInputsRequest { + ResolveSheetExternalFormulaInputsRequest { + content: content.to_string(), + current_path: "/vault/a.md".to_string(), + dependencies, + links, + max_depth: Some(4), + timezone: Some("UTC".to_string()), + } + } + + fn dependency(path: &str, content: &str) -> SheetDependencyContent { + SheetDependencyContent { + path: path.to_string(), + content: content.to_string(), + } + } + + fn link(source_path: &str, target: &str, target_path: &str) -> SheetExternalReferenceLink { + SheetExternalReferenceLink { + source_path: source_path.to_string(), + target: target.to_string(), + target_path: target_path.to_string(), + } + } + + #[test] + fn resolves_direct_external_formula_input() { + let response = resolve_sheet_external_formula_inputs_sync(request( + "Total\n=[[b]].A1+5", + vec![dependency("/vault/b.md", "40")], + vec![link("/vault/a.md", "b", "/vault/b.md")], + )) + .unwrap(); + + assert_eq!( + response.inputs, + vec![ResolvedSheetExternalFormulaInput { + cell: "A2".to_string(), + evaluated: "=40+5".to_string(), + source: "=[[b]].A1+5".to_string(), + }], + ); + } + + #[test] + fn resolves_transitive_external_formula_input() { + let response = resolve_sheet_external_formula_inputs_sync(request( + "=[[b]].A1*2", + vec![ + dependency("/vault/b.md", "=[[c]].A1+1"), + dependency("/vault/c.md", "20"), + ], + vec![ + link("/vault/a.md", "b", "/vault/b.md"), + link("/vault/b.md", "c", "/vault/c.md"), + ], + )) + .unwrap(); + + assert_eq!(response.inputs[0].evaluated, "=21*2"); + } + + #[test] + fn leaves_cycles_unresolved() { + let response = resolve_sheet_external_formula_inputs_sync(request( + "=[[b]].A1", + vec![dependency("/vault/b.md", "=[[a]].A1")], + vec![ + link("/vault/a.md", "b", "/vault/b.md"), + link("/vault/b.md", "a", "/vault/a.md"), + ], + )) + .unwrap(); + + assert!(response.inputs.is_empty()); + } +} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs new file mode 100644 index 0000000..31045b2 --- /dev/null +++ b/src-tauri/src/commands/system.rs @@ -0,0 +1,656 @@ +#[cfg(desktop)] +use std::process::Command; + +#[cfg(desktop)] +use crate::menu; +use crate::settings::Settings; +use crate::vault_list; +use crate::vault_list::VaultList; +use serde::Deserialize; +#[cfg(desktop)] +use tauri::ipc::Channel; +#[cfg(desktop)] +use tauri::LogicalSize; +#[cfg(desktop)] +use tauri::Window; + +use super::parse_build_label; + +#[cfg(desktop)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TitleBarDoubleClickAction { + Fill, + Minimize, + None, +} + +#[cfg(desktop)] +fn parse_title_bar_double_click_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill), + "minimize" => Some(TitleBarDoubleClickAction::Minimize), + "none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None), + _ => None, + } +} + +#[cfg(desktop)] +fn parse_legacy_title_bar_double_click_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize), + "0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill), + _ => None, + } +} + +#[cfg(desktop)] +fn read_global_defaults_value(key: &str) -> Option { + let output = Command::new("defaults") + .args(["read", "-g", key]) + .output() + .ok()?; + parse_defaults_read_output(output) +} + +#[cfg(desktop)] +fn resolve_title_bar_double_click_action( + read_value: impl Fn(&str) -> Option, +) -> TitleBarDoubleClickAction { + read_value("AppleActionOnDoubleClick") + .as_deref() + .and_then(parse_title_bar_double_click_action) + .or_else(|| { + read_value("AppleMiniaturizeOnDoubleClick") + .as_deref() + .and_then(parse_legacy_title_bar_double_click_action) + }) + .unwrap_or(TitleBarDoubleClickAction::Fill) +} + +#[cfg(desktop)] +fn parse_defaults_read_output(output: std::process::Output) -> Option { + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(trimmed.to_string()) +} + +#[cfg(desktop)] +fn apply_title_bar_double_click_action( + action: TitleBarDoubleClickAction, + is_maximized: impl FnOnce() -> Result, + maximize: impl FnOnce() -> Result<(), String>, + unmaximize: impl FnOnce() -> Result<(), String>, + minimize: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + match action { + TitleBarDoubleClickAction::Fill => { + if is_maximized()? { + unmaximize() + } else { + maximize() + } + } + TitleBarDoubleClickAction::Minimize => minimize(), + TitleBarDoubleClickAction::None => Ok(()), + } +} + +// ── MCP commands (desktop) ────────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub async fn register_mcp_tools(vault_path: String) -> Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path)) + .await + .map_err(|e| format!("Registration task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn remove_mcp_tools() -> Result { + tokio::task::spawn_blocking(crate::mcp::remove_mcp) + .await + .map_err(|e| format!("Removal task failed: {e}")) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn check_mcp_status(vault_path: String) -> Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::check_mcp_status(&vault_path)) + .await + .map_err(|e| format!("MCP status check failed: {e}")) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn get_mcp_config_snippet(vault_path: String) -> Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path)) + .await + .map_err(|e| format!("MCP config task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn get_opencode_mcp_config_snippet(vault_path: String) -> Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::opencode_mcp_config_snippet(&vault_path)) + .await + .map_err(|e| format!("OpenCode MCP config task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn sync_mcp_bridge_vault( + app: tauri::AppHandle, + vault_path: Option, + vault_paths: Option>, +) -> Result { + let expanded_vault_path = vault_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(|path| super::expand_tilde(path).into_owned()); + let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new); + let expanded_vault_paths = vault_paths + .unwrap_or_default() + .into_iter() + .map(|path| super::expand_tilde(path.trim()).into_owned()) + .filter(|path| !path.is_empty()) + .map(std::path::PathBuf::from) + .collect::>(); + + crate::sync_ws_bridge_for_vault(&app, vault_path, &expanded_vault_paths).map(str::to_string) +} + +// ── MCP commands (mobile stubs) ───────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub async fn register_mcp_tools(_vault_path: String) -> Result { + Err("MCP is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn remove_mcp_tools() -> Result { + Err("MCP is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn check_mcp_status(_vault_path: String) -> Result { + Ok(crate::mcp::McpStatus::NotInstalled) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn get_mcp_config_snippet(_vault_path: String) -> Result { + Err("MCP is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn get_opencode_mcp_config_snippet(_vault_path: String) -> Result { + Err("MCP is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn sync_mcp_bridge_vault( + _vault_path: Option, + _vault_paths: Option>, +) -> Result { + Err("MCP is not available on mobile".into()) +} + +// ── Menu commands ─────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MenuStateUpdate { + has_active_note: bool, + has_modified_files: Option, + has_conflicts: Option, + has_restorable_deleted_note: Option, + has_no_remote: Option, + note_list_search_enabled: Option, + editor_find_enabled: Option, +} + +#[cfg(desktop)] +#[tauri::command] +pub fn update_menu_state( + app_handle: tauri::AppHandle, + state: MenuStateUpdate, +) -> Result<(), String> { + menu::set_note_items_enabled(&app_handle, state.has_active_note); + if let Some(v) = state.has_modified_files { + menu::set_git_commit_items_enabled(&app_handle, v); + } + if let Some(v) = state.has_conflicts { + menu::set_git_conflict_items_enabled(&app_handle, v); + } + if let Some(v) = state.has_restorable_deleted_note { + menu::set_restore_deleted_item_enabled(&app_handle, v); + } + if let Some(v) = state.has_no_remote { + menu::set_git_no_remote_items_enabled(&app_handle, v); + } + if let Some(v) = state.note_list_search_enabled { + menu::set_note_list_search_items_enabled(&app_handle, v); + } + if let Some(v) = state.editor_find_enabled { + menu::set_editor_find_items_enabled(&app_handle, v); + } + Ok(()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn update_menu_state( + _app_handle: tauri::AppHandle, + _state: MenuStateUpdate, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn trigger_menu_command(app_handle: tauri::AppHandle, id: String) -> Result<(), String> { + menu::emit_custom_menu_event(&app_handle, &id) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Result<(), String> { + Err("Native menu commands are not available on mobile".into()) +} + +#[cfg(desktop)] +fn should_apply_window_min_size_constraints( + is_windows: bool, + is_fullscreen: bool, + is_maximized: bool, +) -> bool { + !(is_windows && (is_fullscreen || is_maximized)) +} + +#[cfg(desktop)] +fn should_skip_window_min_size_update(window: &Window) -> Result { + if !cfg!(target_os = "windows") { + return Ok(false); + } + + let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?; + let is_maximized = window.is_maximized().map_err(|e| e.to_string())?; + + Ok(!should_apply_window_min_size_constraints( + true, + is_fullscreen, + is_maximized, + )) +} + +#[cfg(desktop)] +fn apply_window_min_size_update( + window: &Window, + min_width: f64, + min_height: f64, + grow_to_fit: bool, +) -> Result<(), String> { + window + .set_min_size(Some(LogicalSize::new(min_width, min_height))) + .map_err(|e| e.to_string())?; + + if !grow_to_fit { + return Ok(()); + } + + let scale_factor = window.scale_factor().map_err(|e| e.to_string())?; + let current_size = window + .inner_size() + .map_err(|e| e.to_string())? + .to_logical::(scale_factor); + + let next_width = current_size.width.max(min_width); + let next_height = current_size.height.max(min_height); + if next_width == current_size.width && next_height == current_size.height { + return Ok(()); + } + + window + .set_size(LogicalSize::new(next_width, next_height)) + .map_err(|e| e.to_string()) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn update_current_window_min_size( + window: Window, + min_width: f64, + min_height: f64, + grow_to_fit: bool, +) -> Result<(), String> { + if should_skip_window_min_size_update(&window)? { + return Ok(()); + } + + apply_window_min_size_update(&window, min_width, min_height, grow_to_fit) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> { + let action = resolve_title_bar_double_click_action(read_global_defaults_value); + + apply_title_bar_double_click_action( + action, + || window.is_maximized().map_err(|e| e.to_string()), + || window.maximize().map_err(|e| e.to_string()), + || window.unmaximize().map_err(|e| e.to_string()), + || window.minimize().map_err(|e| e.to_string()), + ) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn update_current_window_min_size( + _window: tauri::Window, + _min_width: f64, + _min_height: f64, + _grow_to_fit: bool, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> { + Ok(()) +} + +// ── Settings & config commands ────────────────────────────────────────────── + +#[tauri::command] +pub fn get_build_number(app_handle: tauri::AppHandle) -> String { + let version = app_handle.package_info().version.to_string(); + parse_build_label(&version) +} + +#[tauri::command] +pub fn get_settings() -> Result { + crate::settings::get_settings() +} + +#[tauri::command] +pub fn save_settings(settings: Settings) -> Result<(), String> { + crate::settings::save_settings(settings) +} + +#[tauri::command] +pub fn get_ai_workspace_sessions() -> Result { + crate::settings::get_ai_workspace_sessions() +} + +#[tauri::command] +pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> { + crate::settings::save_ai_workspace_sessions(sessions) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn check_for_app_update( + app_handle: tauri::AppHandle, + release_channel: Option, +) -> Result, String> { + crate::app_updater::check_for_app_update(app_handle, release_channel).await +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn check_for_app_update( + _app_handle: tauri::AppHandle, + _release_channel: Option, +) -> Result, String> { + Ok(None) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn download_and_install_app_update( + app_handle: tauri::AppHandle, + release_channel: Option, + expected_version: String, + on_event: Channel, +) -> Result<(), String> { + crate::app_updater::download_and_install_app_update( + app_handle, + release_channel, + expected_version, + on_event, + ) + .await +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn download_and_install_app_update( + _app_handle: tauri::AppHandle, + _release_channel: Option, + _expected_version: String, + _on_event: tauri::ipc::Channel, +) -> Result<(), String> { + Err("App updates are not available on mobile".into()) +} + +#[tauri::command] +pub fn reinit_telemetry() { + crate::telemetry::reinit_sentry(); +} + +#[tauri::command] +pub fn load_vault_list() -> Result { + vault_list::load_vault_list() +} + +#[tauri::command] +pub fn save_vault_list(list: VaultList) -> Result<(), String> { + vault_list::save_vault_list(&list) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(desktop)] + use std::cell::RefCell; + #[cfg(desktop)] + use std::os::unix::process::ExitStatusExt; + #[cfg(desktop)] + use std::process::{ExitStatus, Output}; + #[cfg(desktop)] + use std::rc::Rc; + + #[test] + fn parses_title_bar_action_values() { + for (value, expected) in [ + ("Fill", Some(TitleBarDoubleClickAction::Fill)), + ("zoom", Some(TitleBarDoubleClickAction::Fill)), + ("Minimize", Some(TitleBarDoubleClickAction::Minimize)), + ("No Action", Some(TitleBarDoubleClickAction::None)), + ("tile", None), + ] { + assert_eq!(parse_title_bar_double_click_action(value), expected); + } + + for (value, expected) in [ + ("1", Some(TitleBarDoubleClickAction::Minimize)), + ("false", Some(TitleBarDoubleClickAction::Fill)), + ("maybe", None), + ] { + assert_eq!(parse_legacy_title_bar_double_click_action(value), expected); + } + } + + #[test] + fn resolves_title_bar_action_preferences() { + assert_eq!( + resolve_with(&[ + ("AppleActionOnDoubleClick", "No Action"), + ("AppleMiniaturizeOnDoubleClick", "1"), + ]), + TitleBarDoubleClickAction::None + ); + assert_eq!( + resolve_with(&[("AppleMiniaturizeOnDoubleClick", "1")]), + TitleBarDoubleClickAction::Minimize + ); + assert_eq!( + resolve_with(&[ + ("AppleActionOnDoubleClick", "tile"), + ("AppleMiniaturizeOnDoubleClick", "1"), + ]), + TitleBarDoubleClickAction::Minimize + ); + assert_eq!(resolve_with(&[]), TitleBarDoubleClickAction::Fill); + } + + #[test] + fn parses_defaults_output_variants() { + for (code, stdout, expected) in [ + (0, b" Maximize \n".to_vec(), Some("Maximize")), + (1, b"Minimize\n".to_vec(), None), + (0, b" \n".to_vec(), None), + (0, vec![0xff], None), + ] { + assert_eq!( + parse_defaults_read_output(output(code, stdout)), + expected.map(str::to_string) + ); + } + } + + #[test] + fn routes_title_bar_actions_to_expected_window_calls() { + for (action, state, expected_calls) in [ + ( + TitleBarDoubleClickAction::Fill, + Ok(false), + vec!["is_maximized", "maximize"], + ), + ( + TitleBarDoubleClickAction::Fill, + Ok(true), + vec!["is_maximized", "unmaximize"], + ), + ( + TitleBarDoubleClickAction::Minimize, + Ok(false), + vec!["minimize"], + ), + (TitleBarDoubleClickAction::None, Ok(false), Vec::new()), + ] { + let (result, calls) = run_action(action, state, Ok(()), Ok(()), Ok(())); + assert_eq!(result, Ok(())); + assert_eq!(calls, expected_calls); + } + } + + #[test] + fn skips_min_size_updates_for_windows_fullscreen_or_maximized_windows() { + for (is_fullscreen, is_maximized) in [(true, false), (false, true), (true, true)] { + assert!(!should_apply_window_min_size_constraints( + true, + is_fullscreen, + is_maximized + )); + } + + assert!(should_apply_window_min_size_constraints(true, false, false)); + assert!(should_apply_window_min_size_constraints(false, true, true)); + } + + #[test] + fn propagates_title_bar_action_errors() { + for (state, maximize, unmaximize, expected) in [ + (Err("state"), Ok(()), Ok(()), "state"), + (Ok(false), Err("maximize"), Ok(()), "maximize"), + (Ok(true), Ok(()), Err("unmaximize"), "unmaximize"), + ] { + let (result, _) = run_action( + TitleBarDoubleClickAction::Fill, + state, + maximize, + unmaximize, + Ok(()), + ); + assert_eq!(result, Err(expected.to_string())); + } + } + + fn exit_status(code: i32) -> ExitStatus { + ExitStatus::from_raw(code << 8) + } + + fn output(code: i32, stdout: Vec) -> Output { + Output { + status: exit_status(code), + stdout, + stderr: Vec::new(), + } + } + + fn resolve_with(values: &[(&str, &str)]) -> TitleBarDoubleClickAction { + resolve_title_bar_double_click_action(|key| { + values + .iter() + .find(|(candidate, _)| *candidate == key) + .map(|(_, value)| (*value).to_string()) + }) + } + + fn run_action( + action: TitleBarDoubleClickAction, + state: Result, + maximize: Result<(), &'static str>, + unmaximize: Result<(), &'static str>, + minimize: Result<(), &'static str>, + ) -> (Result<(), String>, Vec<&'static str>) { + let calls = Rc::new(RefCell::new(Vec::new())); + let state_calls = Rc::clone(&calls); + let maximize_calls = Rc::clone(&calls); + let unmaximize_calls = Rc::clone(&calls); + let minimize_calls = Rc::clone(&calls); + let result = apply_title_bar_double_click_action( + action, + move || { + state_calls.borrow_mut().push("is_maximized"); + state.map_err(str::to_string) + }, + move || { + maximize_calls.borrow_mut().push("maximize"); + maximize.map_err(str::to_string) + }, + move || { + unmaximize_calls.borrow_mut().push("unmaximize"); + unmaximize.map_err(str::to_string) + }, + move || { + minimize_calls.borrow_mut().push("minimize"); + minimize.map_err(str::to_string) + }, + ); + let call_log = calls.borrow().clone(); + (result, call_log) + } +} diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs new file mode 100644 index 0000000..6dca1fd --- /dev/null +++ b/src-tauri/src/commands/vault.rs @@ -0,0 +1,397 @@ +mod boundary; +mod file_cmds; +mod frontmatter_cmds; +mod lifecycle_cmds; +mod rename_cmds; +mod scan_cmds; +mod view_cmds; + +pub(super) use boundary::VaultBoundary; +pub use file_cmds::*; +pub use frontmatter_cmds::*; +pub use lifecycle_cmds::*; +pub use rename_cmds::*; +pub use scan_cmds::*; +pub use view_cmds::*; + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::ViewDefinition; + use std::path::Path; + + const ACTIVE_VAULT_PATH_ERROR: &str = super::boundary::ACTIVE_VAULT_PATH_ERROR; + const INVALID_VIEW_FILENAME_ERROR: &str = super::boundary::INVALID_VIEW_FILENAME_ERROR; + + fn vault_path_arg(vault_path: &Path) -> Option { + Some(vault_path.to_path_buf()) + } + + fn vault_path_string_arg(vault_path: &Path) -> Option { + Some(vault_path.to_string_lossy().to_string()) + } + + fn assert_note_write_rejects_escape( + action: impl FnOnce(std::path::PathBuf, String, Option) -> Result, + ) { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let escape_path = vault_path.join("../outside.md"); + + let err = action( + escape_path, + "# Outside\n".to_string(), + vault_path_arg(vault_path), + ) + .expect_err("expected traversal write to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); + } + + fn sample_view_definition() -> ViewDefinition { + ViewDefinition { + name: "Inbox".to_string(), + icon: None, + color: None, + order: None, + sort: None, + list_properties_display: vec![], + filters: crate::vault::FilterGroup::All(vec![]), + } + } + + fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) { + let dir = tempfile::TempDir::new().unwrap(); + + let err = save_view_cmd( + dir.path().to_string_lossy().to_string(), + filename.to_string(), + sample_view_definition(), + ) + .expect_err("expected invalid filename to be rejected"); + + assert_eq!(err, INVALID_VIEW_FILENAME_ERROR); + } + + fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::TempDir::new().unwrap(); + let note = dir.path().join("note.md"); + std::fs::write(¬e, body).unwrap(); + (dir, note) + } + + fn assert_paths_exist(root: &Path, paths: &[&str]) { + for path in paths { + assert!(root.join(path).exists(), "{path} should exist"); + } + } + + fn assert_paths_absent(root: &Path, paths: &[&str]) { + for path in paths { + assert!(!root.join(path).exists(), "{path} should be absent"); + } + } + + fn assert_seeded_guidance_content(vault_path: &Path) { + let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap(); + let claude = std::fs::read_to_string(vault_path.join("CLAUDE.md")).unwrap(); + + assert!(agents.contains("Use the first H1 as the note title.")); + assert!(agents.contains("Tolaria reads notes recursively from all folders")); + assert!(agents.contains("views/*.yml")); + assert!(claude.starts_with("---\ntype: Note\n_organized: true\n---")); + assert!(claude.contains("@AGENTS.md")); + assert!(claude.contains("only a Claude Code compatibility shim")); + assert!(!claude.contains("# CLAUDE.md")); + } + + fn assert_seeded_type_scaffolding(vault_path: &Path) { + let type_definition = std::fs::read_to_string(vault_path.join("type.md")).unwrap(); + + assert!(type_definition.contains("visible: false")); + assert!(type_definition.contains("# Type")); + } + + #[test] + fn test_batch_archive_notes() { + let (dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n"); + assert_eq!( + batch_archive_notes( + vec![note.to_str().unwrap().to_string()], + vault_path_string_arg(dir.path()), + ) + .unwrap(), + 1 + ); + let content = std::fs::read_to_string(¬e).unwrap(); + assert!(content.contains("_archived: true")); + assert!(content.contains("Status: Active")); + } + + #[test] + fn test_reload_vault_entry_reads_from_disk() { + let dir = tempfile::TempDir::new().unwrap(); + let note = dir.path().join("test.md"); + std::fs::write(¬e, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap(); + + let entry = reload_vault_entry(note.clone(), vault_path_arg(dir.path())).unwrap(); + assert_eq!(entry.title, "Test"); + assert_eq!(entry.status, Some("Active".to_string())); + + std::fs::write(¬e, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap(); + let fresh = reload_vault_entry(note, vault_path_arg(dir.path())).unwrap(); + assert_eq!(fresh.status, Some("Done".to_string())); + } + + #[test] + fn test_reload_vault_entry_nonexistent() { + let result = reload_vault_entry("/nonexistent/path.md".into(), None); + assert!(result.is_err()); + } + + #[test] + fn test_get_note_content_rejects_path_outside_active_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let inside = vault_path.join("inside.md"); + let outside_dir = tempfile::TempDir::new().unwrap(); + let outside = outside_dir.path().join("outside.md"); + + std::fs::write(&inside, "# Inside\n").unwrap(); + std::fs::write(&outside, "# Outside\n").unwrap(); + + let err = get_note_content(outside, vault_path_arg(vault_path)) + .expect_err("expected out-of-vault read to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); + } + + #[tokio::test] + async fn test_save_note_content_rejects_traversal_outside_active_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let escape_path = vault_path.join("../outside.md"); + + let err = save_note_content( + escape_path, + "# Outside\n".to_string(), + vault_path_arg(vault_path), + ) + .await + .expect_err("expected traversal write to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); + } + + #[test] + fn test_create_note_content_rejects_traversal_outside_active_vault() { + assert_note_write_rejects_escape(create_note_content); + } + + #[test] + fn test_create_vault_folder_rejects_escape_path() { + let dir = tempfile::TempDir::new().unwrap(); + + let err = create_vault_folder(dir.path().into(), "../escape".into(), None) + .expect_err("expected escaping folder path to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); + } + + #[test] + fn test_create_vault_folder_rejects_windows_invalid_names() { + let dir = tempfile::TempDir::new().unwrap(); + + let err = create_vault_folder(dir.path().into(), "con".into(), None) + .expect_err("expected Windows-invalid folder name to be rejected"); + + assert_eq!(err, "Invalid folder name"); + } + + #[test] + fn test_create_vault_folder_nests_inside_parent_path() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("Projects")).unwrap(); + + let name = create_vault_folder( + dir.path().into(), + "Laputa".into(), + Some(std::path::PathBuf::from("Projects")), + ) + .expect("expected nested folder to be created"); + + assert_eq!(name, "Laputa"); + assert!(dir.path().join("Projects").join("Laputa").is_dir()); + } + + #[test] + fn test_create_vault_folder_rejects_escape_via_parent_path() { + let dir = tempfile::TempDir::new().unwrap(); + + let err = create_vault_folder( + dir.path().into(), + "Laputa".into(), + Some(std::path::PathBuf::from("../escape")), + ) + .expect_err("expected escaping parent path to be rejected"); + + assert_eq!(err, ACTIVE_VAULT_PATH_ERROR); + } + + #[test] + fn test_create_vault_folder_treats_empty_parent_as_root() { + let dir = tempfile::TempDir::new().unwrap(); + + let name = create_vault_folder( + dir.path().into(), + "Inbox".into(), + Some(std::path::PathBuf::from("")), + ) + .expect("expected empty parent to fall back to vault root"); + + assert_eq!(name, "Inbox"); + assert!(dir.path().join("Inbox").is_dir()); + } + + #[test] + fn test_save_view_cmd_rejects_nested_filename() { + assert_save_view_cmd_rejects_invalid_filename("../escape.yml"); + } + + #[test] + fn test_save_view_cmd_rejects_windows_invalid_filename() { + assert_save_view_cmd_rejects_invalid_filename("con.yml"); + } + + #[tokio::test] + async fn test_reload_vault_invalidates_cache_and_rescans() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "t@t.com"]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "T"]) + .current_dir(vault_path) + .output() + .unwrap(); + + let cache_dir = tempfile::TempDir::new().unwrap(); + std::env::set_var( + "LAPUTA_CACHE_DIR", + cache_dir.path().to_string_lossy().as_ref(), + ); + + std::fs::write( + vault_path.join("note.md"), + "---\n_archived: false\n---\n# Note\n", + ) + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(vault_path) + .output() + .unwrap(); + + let entries = list_vault(vault_path.into()).await.unwrap(); + assert!(!entries[0].archived); + + std::fs::write( + vault_path.join("note.md"), + "---\n_archived: true\n---\n# Note\n", + ) + .unwrap(); + + let vp_str = vault_path.to_str().unwrap(); + crate::vault::invalidate_cache(std::path::Path::new(vp_str)); + let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap(); + assert!( + fresh[0].archived, + "reload_vault must reflect disk state after archiving" + ); + } + + #[test] + fn test_check_vault_exists_false() { + assert!(!check_vault_exists("/nonexistent/path/abc123".to_string())); + } + + #[test] + fn test_get_default_vault_path_returns_ok() { + let result = get_default_vault_path(); + assert!(result.is_ok()); + } + + #[test] + fn test_repair_vault_migrates_is_a_to_type() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let note_dir = vault_path.join("note"); + std::fs::create_dir_all(¬e_dir).unwrap(); + std::fs::write(note_dir.join("hello.md"), "---\nis_a: Note\n---\n# Hello\n").unwrap(); + + let result = repair_vault(vault_path.to_str().unwrap().to_string()); + assert!(result.is_ok()); + assert!(note_dir.join("hello.md").exists()); + let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap(); + assert!(content.contains("type: Note")); + assert!(!content.contains("is_a:")); + } + + #[test] + fn test_repair_vault_creates_config_files() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + + let result = repair_vault(vault_path.to_str().unwrap().to_string()); + assert!(result.is_ok()); + assert_paths_exist( + vault_path, + &["AGENTS.md", "CLAUDE.md", "type.md", "note.md", ".gitignore"], + ); + assert_paths_absent(vault_path, &["config.md"]); + } + + #[test] + fn test_create_empty_vault_seeds_agents_and_type_scaffolding() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().join("fresh-vault"); + + let result = create_empty_vault(vault_path.to_string_lossy().to_string()); + assert!(result.is_ok()); + assert_paths_exist( + &vault_path, + &[".git", "AGENTS.md", "CLAUDE.md", "type.md", "note.md"], + ); + assert_paths_absent(&vault_path, &["config.md"]); + assert_seeded_guidance_content(&vault_path); + assert_seeded_type_scaffolding(&vault_path); + } + + #[test] + fn test_create_empty_vault_rejects_nonempty_target() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().join("existing-folder"); + std::fs::create_dir_all(&vault_path).unwrap(); + std::fs::write(vault_path.join("keep.txt"), "keep").unwrap(); + + let result = create_empty_vault(vault_path.to_string_lossy().to_string()); + let err = result.expect_err("expected non-empty folder to be rejected"); + + assert_eq!(err, "Choose an empty folder to create a new vault"); + assert_paths_exist(&vault_path, &["keep.txt"]); + assert_paths_absent(&vault_path, &[".git", "AGENTS.md"]); + } +} diff --git a/src-tauri/src/commands/vault/boundary.rs b/src-tauri/src/commands/vault/boundary.rs new file mode 100644 index 0000000..1b5be6b --- /dev/null +++ b/src-tauri/src/commands/vault/boundary.rs @@ -0,0 +1,460 @@ +use crate::commands::expand_tilde; +use crate::vault::filename_rules::validate_view_filename_stem; +use crate::vault_list; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +pub(crate) const ACTIVE_VAULT_PATH_ERROR: &str = "Path must stay inside the active vault"; +const ACTIVE_VAULT_MISMATCH_ERROR: &str = "Vault path must match the active vault"; +const ACTIVE_VAULT_UNAVAILABLE_ERROR: &str = "Active vault is not available"; +const NO_ACTIVE_VAULT_ERROR: &str = "No active vault selected"; +pub(crate) const INVALID_VIEW_FILENAME_ERROR: &str = "Invalid view filename"; + +#[derive(Clone, Debug)] +struct VaultRootPaths { + requested: PathBuf, + canonical: PathBuf, +} + +#[derive(Clone, Debug)] +pub(crate) struct VaultBoundary { + requested_root: PathBuf, + canonical_root: PathBuf, +} + +impl VaultBoundary { + pub(crate) fn from_request(requested_vault_path: Option<&str>) -> Result { + let configured_root = if cfg!(test) { + None + } else { + load_configured_active_vault_root()? + }; + let requested_root = requested_vault_path + .filter(|path| !path.trim().is_empty()) + .map(build_vault_root_paths) + .transpose()?; + + let root = match (configured_root, requested_root) { + (Some(configured), Some(requested)) => { + if configured.canonical != requested.canonical + && !is_registered_vault_root(&requested)? + { + return Err(ACTIVE_VAULT_MISMATCH_ERROR.to_string()); + } + requested + } + (Some(configured), None) => configured, + (None, Some(requested)) => requested, + (None, None) => return Err(NO_ACTIVE_VAULT_ERROR.to_string()), + }; + + Ok(Self { + requested_root: root.requested, + canonical_root: root.canonical, + }) + } + + pub(crate) fn requested_root(&self) -> &Path { + &self.requested_root + } + + fn requested_root_str(&self) -> String { + path_to_string(&self.requested_root) + } + + fn validate_existing_path(&self, raw_path: &str) -> Result { + self.validate_path(raw_path, false) + } + + pub(crate) fn validate_existing_paths( + &self, + raw_paths: &[String], + ) -> Result, String> { + raw_paths + .iter() + .map(|path| self.validate_existing_path(path)) + .collect() + } + + fn validate_writable_path(&self, raw_path: &str) -> Result { + self.validate_path(raw_path, true) + } + + pub(crate) fn child_path(&self, relative_path: &str) -> Result { + validate_relative_child_path(relative_path)?; + let requested = self.requested_root.join(relative_path); + let canonical = canonicalize_candidate_for_write(&requested)?; + self.ensure_within_root(&canonical)?; + Ok(requested) + } + + fn validate_path(&self, raw_path: &str, allow_missing_leaf: bool) -> Result { + let requested = self.requested_path(raw_path); + let canonical = if allow_missing_leaf { + canonicalize_candidate_for_write(&requested)? + } else { + requested + .canonicalize() + .map_err(|_| "File does not exist".to_string())? + }; + self.ensure_within_root(&canonical)?; + Ok(path_to_string(&requested)) + } + + fn requested_path(&self, raw_path: &str) -> PathBuf { + let expanded = PathBuf::from(expand_tilde(raw_path).into_owned()); + if expanded.is_absolute() { + expanded + } else { + self.requested_root.join(expanded) + } + } + + fn ensure_within_root(&self, candidate: &Path) -> Result<(), String> { + candidate + .strip_prefix(&self.canonical_root) + .map(|_| ()) + .map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string()) + } +} + +fn load_configured_active_vault_root() -> Result, String> { + let list = vault_list::load_vault_list()?; + list.active_vault + .as_deref() + .filter(|path| !path.trim().is_empty()) + .map(build_vault_root_paths) + .transpose() +} + +fn load_registered_vault_roots() -> Result, String> { + let list = vault_list::load_vault_list()?; + Ok(registered_vault_roots(&list)) +} + +fn push_unique_vault_root_path(paths: &mut Vec, path: String) { + if path.trim().is_empty() || paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + +#[cfg(all(debug_assertions, not(test)))] +fn local_dev_demo_vault_path() -> Option { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .map(|root| root.join("demo-vault-v2").to_string_lossy().into_owned()) +} + +#[cfg(not(all(debug_assertions, not(test))))] +fn local_dev_demo_vault_path() -> Option { + None +} + +fn configured_vault_root_paths(list: &vault_list::VaultList) -> Vec { + let mut paths = Vec::new(); + for entry in &list.vaults { + push_unique_vault_root_path(&mut paths, entry.path.clone()); + } + if let Some(active_vault) = &list.active_vault { + push_unique_vault_root_path(&mut paths, active_vault.clone()); + } + for hidden_default in &list.hidden_defaults { + push_unique_vault_root_path(&mut paths, hidden_default.clone()); + } + #[cfg(not(test))] + if let Ok(default_path) = crate::vault::default_vault_path() { + push_unique_vault_root_path(&mut paths, default_path.to_string_lossy().into_owned()); + } + if let Some(dev_demo_path) = local_dev_demo_vault_path() { + push_unique_vault_root_path(&mut paths, dev_demo_path); + } + paths +} + +fn registered_vault_roots(list: &vault_list::VaultList) -> Vec { + configured_vault_root_paths(list) + .into_iter() + .filter(|path| !path.trim().is_empty()) + .filter_map(|path| build_vault_root_paths(&path).ok()) + .collect() +} + +fn is_registered_vault_root(requested: &VaultRootPaths) -> Result { + let list = vault_list::load_vault_list()?; + for root in registered_vault_roots(&list) { + if root.canonical == requested.canonical { + return Ok(true); + } + } + Ok(false) +} + +fn find_registered_root_for_absolute_path( + raw_path: &str, +) -> Result, String> { + let requested = PathBuf::from(expand_tilde(raw_path).into_owned()); + if !requested.is_absolute() { + return Ok(None); + } + + let canonical = canonicalize_candidate_for_write(&requested)?; + let roots = match load_registered_vault_roots() { + Ok(roots) => roots, + Err(_) => return Ok(None), + }; + let root = roots + .into_iter() + .filter(|root| canonical.starts_with(&root.canonical)) + .max_by_key(|root| root.canonical.components().count()); + Ok(root) +} + +fn build_vault_root_paths(raw_vault_path: &str) -> Result { + let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned()); + let canonical = requested + .canonicalize() + .map_err(|_| ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string())?; + if !canonical.is_dir() { + return Err(ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string()); + } + + Ok(VaultRootPaths { + requested, + canonical, + }) +} + +fn canonicalize_candidate_for_write(path: &Path) -> Result { + let (ancestor, tail) = find_existing_ancestor(path)?; + Ok(tail + .into_iter() + .fold(ancestor, |current, segment| current.join(segment))) +} + +fn find_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec), String> { + let mut current = path; + let mut tail = Vec::new(); + + loop { + if current.exists() { + let canonical = current + .canonicalize() + .map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())?; + tail.reverse(); + return Ok((canonical, tail)); + } + + let file_name = current + .file_name() + .ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?; + tail.push(file_name.to_os_string()); + current = current + .parent() + .ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?; + } +} + +fn validate_relative_child_path(relative_path: &str) -> Result<(), String> { + if relative_path.trim().is_empty() { + return Err(ACTIVE_VAULT_PATH_ERROR.to_string()); + } + + let path = Path::new(relative_path); + if path.is_absolute() { + return Err(ACTIVE_VAULT_PATH_ERROR.to_string()); + } + + if path.components().any(|component| { + matches!( + component, + Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(ACTIVE_VAULT_PATH_ERROR.to_string()); + } + + Ok(()) +} + +pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> { + if !filename.ends_with(".yml") { + return Err("Filename must end with .yml".to_string()); + } + + let path = Path::new(filename); + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Normal(value)), None) => { + let stem = value.to_string_lossy(); + let stem = stem.strip_suffix(".yml").unwrap_or(&stem); + validate_view_filename_stem(stem) + } + _ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()), + } +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +pub(crate) fn with_boundary( + requested_vault_path: Option<&str>, + action: impl FnOnce(&VaultBoundary) -> Result, +) -> Result { + let boundary = VaultBoundary::from_request(requested_vault_path)?; + action(&boundary) +} + +pub(crate) enum ValidatedPathMode { + Existing, + Writable, +} + +pub(crate) fn with_validated_path( + path: &str, + vault_path: Option<&str>, + mode: ValidatedPathMode, + action: impl FnOnce(&str) -> Result, +) -> Result { + if vault_path.is_none() { + if let Some(root) = find_registered_root_for_absolute_path(path)? { + let boundary = VaultBoundary { + requested_root: root.requested, + canonical_root: root.canonical, + }; + let validated_path = match mode { + ValidatedPathMode::Existing => boundary.validate_existing_path(path)?, + ValidatedPathMode::Writable => boundary.validate_writable_path(path)?, + }; + return action(&validated_path); + } + } + + with_boundary(vault_path, |boundary| { + let validated_path = match mode { + ValidatedPathMode::Existing => boundary.validate_existing_path(path)?, + ValidatedPathMode::Writable => boundary.validate_writable_path(path)?, + }; + action(&validated_path) + }) +} + +pub(crate) fn with_existing_paths( + paths: &[String], + vault_path: Option<&str>, + action: impl FnOnce(Vec) -> Result, +) -> Result { + with_boundary(vault_path, |boundary| { + let validated_paths = boundary.validate_existing_paths(paths)?; + action(validated_paths) + }) +} + +pub(crate) fn with_requested_root( + vault_path: &str, + action: impl FnOnce(&str) -> Result, +) -> Result { + with_boundary(Some(vault_path), |boundary| { + let requested_root = boundary.requested_root_str(); + action(&requested_root) + }) +} + +pub(crate) fn with_existing_path_in_requested_vault( + vault_path: &str, + path: &str, + action: impl FnOnce(&str, &str) -> Result, +) -> Result { + let requested_validation = with_boundary(Some(vault_path), |boundary| { + Ok(( + boundary.requested_root_str(), + boundary.validate_existing_path(path)?, + )) + }); + + let validated = match requested_validation { + Ok(validated) => validated, + Err(error) if error == ACTIVE_VAULT_PATH_ERROR || error == ACTIVE_VAULT_MISMATCH_ERROR => { + let Some(root) = find_registered_root_for_absolute_path(path)? else { + return Err(error); + }; + let boundary = VaultBoundary { + requested_root: root.requested, + canonical_root: root.canonical, + }; + ( + boundary.requested_root_str(), + boundary.validate_existing_path(path)?, + ) + } + Err(error) => return Err(error), + }; + action(&validated.0, &validated.1) +} + +pub(crate) fn with_view_file( + vault_path: &str, + filename: &str, + action: impl FnOnce(&str, &str) -> Result, +) -> Result { + with_boundary(Some(vault_path), |boundary| { + validate_view_filename(filename)?; + let requested_root = boundary.requested_root_str(); + action(&requested_root, filename) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault_list::{VaultEntry, VaultList}; + + #[test] + fn registered_vault_roots_skip_unavailable_vaults() { + let available = tempfile::TempDir::new().unwrap(); + let missing = available.path().join("missing-vault"); + let list = VaultList { + vaults: vec![ + VaultEntry { + label: "Missing".to_string(), + path: missing.to_string_lossy().to_string(), + ..Default::default() + }, + VaultEntry { + label: "Available".to_string(), + path: available.path().to_string_lossy().to_string(), + ..Default::default() + }, + ], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec![], + }; + + let roots = registered_vault_roots(&list); + + assert!(roots + .iter() + .any(|root| root.canonical == available.path().canonicalize().unwrap())); + assert!(roots.iter().all(|root| root.canonical != missing)); + } + + #[test] + fn registered_vault_roots_include_hidden_default_vaults() { + let hidden = tempfile::TempDir::new().unwrap(); + let list = VaultList { + vaults: Vec::new(), + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec![hidden.path().to_string_lossy().to_string()], + }; + + let roots = registered_vault_roots(&list); + + assert!(roots + .iter() + .any(|root| root.canonical == hidden.path().canonicalize().unwrap())); + } +} diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs new file mode 100644 index 0000000..29dd807 --- /dev/null +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -0,0 +1,458 @@ +use crate::commands::expand_tilde; +use crate::vault::filename_rules::validate_folder_name; +use crate::vault::{self, FolderNode, VaultEntry}; +use std::path::{Path, PathBuf}; + +use super::boundary::{ + with_boundary, with_existing_paths, with_requested_root, with_validated_path, ValidatedPathMode, +}; + +fn with_note_path( + path: &Path, + vault_path: Option<&Path>, + mode: ValidatedPathMode, + action: impl FnOnce(&Path) -> Result, +) -> Result { + let raw_path = path.to_string_lossy(); + let raw_vault_path = vault_path.map(|value| value.to_string_lossy()); + with_validated_path( + &raw_path, + raw_vault_path.as_deref(), + mode, + |validated_path| action(Path::new(validated_path)), + ) +} + +fn with_external_file_path( + path: &Path, + vault_path: Option<&Path>, + action: impl FnOnce(&Path) -> Result, +) -> Result { + with_note_path(path, vault_path, ValidatedPathMode::Existing, action) +} + +fn with_expanded_vault_root( + path: &Path, + action: impl FnOnce(&Path) -> Result, +) -> Result { + let raw_path = path.to_string_lossy(); + let expanded = expand_tilde(raw_path.as_ref()).into_owned(); + action(Path::new(&expanded)) +} + +fn with_requested_root_path( + vault_path: &Path, + action: impl FnOnce(&str) -> Result, +) -> Result { + let raw_vault_path = vault_path.to_string_lossy(); + with_requested_root(raw_vault_path.as_ref(), action) +} + +fn sync_image_asset_scope( + app_handle: &tauri::AppHandle, + requested_root: &str, +) -> Result<(), String> { + #[cfg(desktop)] + crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?; + #[cfg(not(desktop))] + let _ = requested_root; + #[cfg(not(desktop))] + let _ = app_handle; + Ok(()) +} + +fn with_image_asset_scope( + app_handle: &tauri::AppHandle, + vault_path: &Path, + action: impl FnOnce(&str) -> Result, +) -> Result { + with_requested_root_path(vault_path, |requested_root| { + let saved_path = action(requested_root)?; + sync_image_asset_scope(app_handle, requested_root)?; + Ok(saved_path) + }) +} + +#[tauri::command] +pub fn sync_vault_asset_scope_for_window( + app_handle: tauri::AppHandle, + vault_path: PathBuf, +) -> Result<(), String> { + with_requested_root_path(vault_path.as_path(), |requested_root| { + sync_image_asset_scope(&app_handle, requested_root) + }) +} + +#[tauri::command] +pub fn open_vault_file_external( + app_handle: tauri::AppHandle, + path: PathBuf, + vault_path: Option, +) -> Result<(), String> { + with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| { + open_path_with_default_app(&app_handle, validated_path) + }) +} + +fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + + app_handle + .opener() + .open_path(path.to_string_lossy().into_owned(), None::) + .map_err(|error| error.to_string()) +} + +fn with_writable_note_path( + path: PathBuf, + vault_path: Option, + action: impl FnOnce(&str) -> Result, +) -> Result { + with_validated_path( + path.to_string_lossy().as_ref(), + vault_path + .as_ref() + .map(|value| value.to_string_lossy()) + .as_deref(), + ValidatedPathMode::Writable, + action, + ) +} + +#[tauri::command] +pub fn get_note_content(path: PathBuf, vault_path: Option) -> Result { + with_note_path( + path.as_path(), + vault_path.as_deref(), + ValidatedPathMode::Existing, + vault::get_note_content, + ) +} + +#[tauri::command] +pub fn validate_note_content( + path: PathBuf, + content: String, + vault_path: Option, +) -> Result { + with_note_path( + path.as_path(), + vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| vault::note_content_matches(validated_path, &content), + ) +} + +#[tauri::command] +pub async fn save_note_content( + path: PathBuf, + content: String, + vault_path: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + with_writable_note_path(path, vault_path, |validated_path| { + vault::save_note_content(validated_path, &content) + }) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[tauri::command] +pub fn create_note_content( + path: PathBuf, + content: String, + vault_path: Option, +) -> Result<(), String> { + with_writable_note_path(path, vault_path, |validated_path| { + vault::create_note_content(validated_path, &content) + }) +} + +#[tauri::command] +pub fn delete_note(path: PathBuf) -> Result { + with_validated_path( + path.to_string_lossy().as_ref(), + None, + ValidatedPathMode::Existing, + vault::delete_note, + ) +} + +#[tauri::command] +pub fn batch_delete_notes(paths: Vec) -> Result, String> { + let raw_paths = paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(); + with_existing_paths(&raw_paths, None, |validated_paths| { + vault::batch_delete_notes(&validated_paths) + }) +} + +#[tauri::command] +pub fn create_vault_folder( + vault_path: PathBuf, + folder_name: PathBuf, + parent_path: Option, +) -> Result { + let raw_vault_path = vault_path.to_string_lossy(); + with_boundary(Some(raw_vault_path.as_ref()), |boundary| { + let folder_name = folder_name.to_string_lossy(); + let relative_path = match parent_path.as_deref() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(folder_name.as_ref()), + _ => PathBuf::from(folder_name.as_ref()), + }; + let folder_path = boundary.child_path(&relative_path.to_string_lossy())?; + validate_folder_name(folder_name.as_ref())?; + ensure_missing_folder(&folder_path, folder_name.as_ref())?; + std::fs::create_dir_all(&folder_path) + .map_err(|e| format!("Failed to create folder: {}", e))?; + Ok(folder_name.into_owned()) + }) +} + +fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), String> { + if folder_path.exists() { + return Err(format!("Folder '{}' already exists", folder_name)); + } + Ok(()) +} + +fn scan_visible_vault_entries(vault_path: &Path) -> Result, String> { + let entries = vault::scan_vault_cached(vault_path)?; + Ok(vault::filter_gitignored_entries( + vault_path, + entries, + crate::settings::hide_gitignored_files_enabled(), + )) +} + +fn scan_visible_vault_folders(vault_path: &Path) -> Result, String> { + let folders = vault::scan_vault_folders(vault_path)?; + Ok(vault::filter_gitignored_folders( + vault_path, + folders, + crate::settings::hide_gitignored_files_enabled(), + )) +} + +/// Sync the `title` frontmatter field with the filename on note open. +/// Returns `true` if the file was modified (title was absent or desynced). +#[tauri::command] +pub fn sync_note_title(path: PathBuf, vault_path: Option) -> Result { + use vault::SyncAction; + + with_note_path( + path.as_path(), + vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| { + let action = vault::sync_title_on_open(validated_path)?; + Ok(matches!(action, SyncAction::Updated { .. })) + }, + ) +} + +#[tauri::command] +pub fn save_image( + app_handle: tauri::AppHandle, + vault_path: PathBuf, + filename: String, + data: String, +) -> Result { + with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| { + vault::save_image(requested_root, &filename, &data) + }) +} + +#[tauri::command] +pub fn copy_image_to_vault( + app_handle: tauri::AppHandle, + vault_path: PathBuf, + source_path: PathBuf, +) -> Result { + with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| { + vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref()) + }) +} + +#[tauri::command] +pub async fn list_vault(path: PathBuf) -> Result, String> { + tokio::task::spawn_blocking(move || { + with_expanded_vault_root(path.as_path(), scan_visible_vault_entries) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[tauri::command] +pub async fn list_vault_folders(path: PathBuf) -> Result, String> { + tokio::task::spawn_blocking(move || { + with_expanded_vault_root(path.as_path(), scan_visible_vault_folders) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn vault_root(dir: &TempDir) -> PathBuf { + dir.path().to_path_buf() + } + + fn note_path(dir: &TempDir, name: &str) -> PathBuf { + dir.path().join(name) + } + + #[tokio::test] + async fn note_content_commands_roundtrip_with_requested_vault() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let note = note_path(&dir, "notes/command-note.md"); + + create_note_content( + note.clone(), + "# Command Note\n".to_string(), + Some(root.clone()), + ) + .unwrap(); + assert_eq!( + get_note_content(note.clone(), Some(root.clone())).unwrap(), + "# Command Note\n" + ); + + save_note_content( + note.clone(), + "---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(), + Some(root.clone()), + ) + .await + .unwrap(); + assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap()); + + save_note_content( + note.clone(), + "# Updated Command Note\n".to_string(), + Some(root.clone()), + ) + .await + .unwrap(); + assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap()); + assert!(get_note_content(note, Some(root)) + .unwrap() + .contains("title: Command Note")); + } + + #[tokio::test] + async fn note_content_commands_accept_windows_sensitive_valid_segments() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let note = root + .join("@raflymln") + .join("notes with spaces") + .join("résumé note.md"); + + save_note_content( + note.clone(), + "# Windows-Sensitive Path\n\nBody\n".to_string(), + Some(root.clone()), + ) + .await + .unwrap(); + + assert_eq!( + get_note_content(note, Some(root)).unwrap(), + "# Windows-Sensitive Path\n\nBody\n" + ); + } + + #[tokio::test] + async fn folder_and_listing_commands_use_expanded_vault_root() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + fs::write(dir.path().join("root.md"), "# Root\n").unwrap(); + + assert_eq!( + create_vault_folder(root.clone(), PathBuf::from("Projects"), None).unwrap(), + "Projects" + ); + fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap(); + + let entries = list_vault(root.clone()).await.unwrap(); + assert!(entries.iter().any(|entry| entry.filename == "root.md")); + assert!(entries.iter().any(|entry| entry.filename == "project.md")); + + let folders = list_vault_folders(root).await.unwrap(); + assert!(folders.iter().any(|folder| folder.name == "Projects")); + } + + #[test] + fn commands_reject_paths_outside_requested_vault() { + let vault = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let outside_note = outside.path().join("outside.md"); + fs::write(&outside_note, "# Outside\n").unwrap(); + + let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err(); + assert!(error.contains("Path must stay inside the active vault")); + + let folder_error = + create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"), None) + .unwrap_err(); + assert!(folder_error.contains("Path must stay inside the active vault")); + } + + #[test] + fn external_file_paths_accept_files_inside_requested_vault() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let attachment = note_path(&dir, "attachments/photo.png"); + fs::create_dir_all(attachment.parent().unwrap()).unwrap(); + fs::write(&attachment, "image-bytes").unwrap(); + + let validated = with_external_file_path( + attachment.as_path(), + Some(root.as_path()), + |validated_path| Ok(validated_path.to_path_buf()), + ) + .unwrap(); + + assert_eq!(validated, attachment); + } + + #[test] + fn external_file_paths_reject_files_outside_requested_vault() { + let vault = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let outside_file = outside.path().join("photo.png"); + fs::write(&outside_file, "image-bytes").unwrap(); + + let error = with_external_file_path( + outside_file.as_path(), + Some(vault.path()), + |validated_path| Ok(validated_path.to_path_buf()), + ) + .unwrap_err(); + + assert!(error.contains("Path must stay inside the active vault")); + } + + #[test] + fn validate_note_content_compares_against_disk() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let note = note_path(&dir, "note.md"); + fs::write(¬e, "# Fresh\n").unwrap(); + + assert!( + validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),) + .unwrap() + ); + assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap()); + } +} diff --git a/src-tauri/src/commands/vault/frontmatter_cmds.rs b/src-tauri/src/commands/vault/frontmatter_cmds.rs new file mode 100644 index 0000000..a71535c --- /dev/null +++ b/src-tauri/src/commands/vault/frontmatter_cmds.rs @@ -0,0 +1,135 @@ +use crate::frontmatter; +use crate::frontmatter::FrontmatterValue; + +use super::boundary::{with_existing_paths, with_validated_path, ValidatedPathMode}; + +#[tauri::command] +pub fn update_frontmatter( + path: String, + key: String, + value: FrontmatterValue, + vault_path: Option, +) -> Result { + with_validated_path( + &path, + vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| frontmatter::update_frontmatter(validated_path, &key, value), + ) +} + +#[tauri::command] +pub fn delete_frontmatter_property( + path: String, + key: String, + vault_path: Option, +) -> Result { + with_validated_path( + &path, + vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| frontmatter::delete_frontmatter_property(validated_path, &key), + ) +} + +#[tauri::command] +pub fn batch_archive_notes( + paths: Vec, + vault_path: Option, +) -> Result { + with_existing_paths(&paths, vault_path.as_deref(), |validated_paths| { + let mut count = 0; + for path in &validated_paths { + frontmatter::update_frontmatter(path, "_archived", FrontmatterValue::Bool(true))?; + count += 1; + } + Ok(count) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn note_path(dir: &tempfile::TempDir, name: &str) -> String { + dir.path().join(name).to_string_lossy().into_owned() + } + + fn write_note(path: &str, content: &str) { + std::fs::write(path, content).unwrap(); + } + + #[test] + fn update_frontmatter_command_validates_and_updates_note() { + let dir = tempfile::TempDir::new().unwrap(); + let path = note_path(&dir, "note.md"); + write_note(&path, "---\nStatus: Draft\n---\n# Note\n"); + + let updated = update_frontmatter( + path.clone(), + "Status".to_string(), + FrontmatterValue::String("Done".to_string()), + Some(dir.path().to_string_lossy().into_owned()), + ) + .unwrap(); + + assert!(updated.contains("Status: Done")); + assert_eq!(std::fs::read_to_string(path).unwrap(), updated); + } + + #[test] + fn delete_frontmatter_property_command_removes_existing_key() { + let dir = tempfile::TempDir::new().unwrap(); + let path = note_path(&dir, "note.md"); + write_note(&path, "---\nStatus: Draft\nOwner: Ada\n---\n# Note\n"); + + let updated = delete_frontmatter_property( + path, + "Owner".to_string(), + Some(dir.path().to_string_lossy().into_owned()), + ) + .unwrap(); + + assert!(!updated.contains("Owner:")); + assert!(updated.contains("Status: Draft")); + } + + #[test] + fn batch_archive_notes_command_marks_each_note_archived() { + let dir = tempfile::TempDir::new().unwrap(); + let first = note_path(&dir, "first.md"); + let second = note_path(&dir, "second.md"); + write_note(&first, "---\nStatus: Draft\n---\n# First\n"); + write_note(&second, "# Second\n"); + + let count = batch_archive_notes( + vec![first.clone(), second.clone()], + Some(dir.path().to_string_lossy().into_owned()), + ) + .unwrap(); + + assert_eq!(count, 2); + assert!(std::fs::read_to_string(first) + .unwrap() + .contains("_archived: true")); + assert!(std::fs::read_to_string(second) + .unwrap() + .contains("_archived: true")); + } + + #[test] + fn batch_archive_notes_command_rejects_notes_outside_vault() { + let vault = tempfile::TempDir::new().unwrap(); + let outside = tempfile::TempDir::new().unwrap(); + let outside_note = note_path(&outside, "outside.md"); + write_note(&outside_note, "# Outside\n"); + + let error = batch_archive_notes( + vec![outside_note], + Some(vault.path().to_string_lossy().into_owned()), + ) + .unwrap_err(); + + assert!(error.contains("Path must stay inside the active vault")); + } +} diff --git a/src-tauri/src/commands/vault/lifecycle_cmds.rs b/src-tauri/src/commands/vault/lifecycle_cmds.rs new file mode 100644 index 0000000..4125ce9 --- /dev/null +++ b/src-tauri/src/commands/vault/lifecycle_cmds.rs @@ -0,0 +1,156 @@ +use crate::commands::expand_tilde; +use crate::{git, vault}; +use std::path::Path; + +#[tauri::command] +pub fn migrate_is_a_to_type(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::migrate_is_a_to_type(&vault_path) +} + +#[tauri::command] +pub fn create_empty_vault(target_path: String) -> Result { + let path = expand_tilde(&target_path).into_owned(); + let vault_dir = Path::new(&path); + initialize_empty_vault(vault_dir, &path)?; + Ok(canonical_vault_path_string(vault_dir)) +} + +fn initialize_empty_vault(vault_dir: &Path, vault_path: &str) -> Result<(), String> { + ensure_directory_is_missing_or_empty(vault_dir)?; + std::fs::create_dir_all(vault_dir) + .map_err(|e| format!("Failed to create vault directory: {}", e))?; + + git::init_repo(vault_path)?; + vault::seed_config_files(vault_path); + Ok(()) +} + +fn ensure_directory_is_missing_or_empty(vault_dir: &Path) -> Result<(), String> { + if !vault_dir.exists() { + return Ok(()); + } + + let metadata = std::fs::metadata(vault_dir) + .map_err(|e| format!("Failed to inspect target folder: {e}"))?; + if !metadata.is_dir() { + return Err("Choose a folder path for the new vault".to_string()); + } + + let has_entries = std::fs::read_dir(vault_dir) + .map_err(|e| format!("Failed to inspect target folder: {e}"))? + .next() + .is_some(); + if has_entries { + return Err("Choose an empty folder to create a new vault".to_string()); + } + + Ok(()) +} + +fn canonical_vault_path_string(vault_dir: &Path) -> String { + vault_dir + .canonicalize() + .unwrap_or_else(|_| vault_dir.to_path_buf()) + .to_string_lossy() + .to_string() +} + +#[tauri::command] +pub async fn create_getting_started_vault(target_path: Option) -> Result { + let path = resolve_getting_started_target(target_path.as_deref())?; + tokio::task::spawn_blocking(move || vault::create_getting_started_vault(&path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +fn resolve_getting_started_target(target_path: Option<&str>) -> Result { + match target_path { + Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()), + _ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()), + } +} + +#[tauri::command] +pub fn check_vault_exists(path: String) -> bool { + let path = expand_tilde(&path); + vault::vault_exists(&path) +} + +#[tauri::command] +pub fn get_default_vault_path() -> Result { + vault::default_vault_path().map(|path| path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn repair_vault(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::migrate_is_a_to_type(&vault_path)?; + vault::repair_config_files(&vault_path)?; + git::ensure_gitignore(std::path::Path::new(vault_path.as_ref()))?; + Ok("Vault repaired".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn empty_vault_target_validation_allows_missing_or_empty_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let missing = dir.path().join("new-vault"); + let empty = dir.path().join("empty-vault"); + fs::create_dir(&empty).unwrap(); + + assert_eq!(ensure_directory_is_missing_or_empty(&missing), Ok(())); + assert_eq!(ensure_directory_is_missing_or_empty(&empty), Ok(())); + } + + #[test] + fn empty_vault_target_validation_rejects_files_and_nonempty_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("vault.md"); + let nonempty = dir.path().join("vault"); + fs::write(&file, "# Not a folder").unwrap(); + fs::create_dir(&nonempty).unwrap(); + fs::write(nonempty.join("note.md"), "# Existing note").unwrap(); + + assert_eq!( + ensure_directory_is_missing_or_empty(&file), + Err("Choose a folder path for the new vault".to_string()) + ); + assert_eq!( + ensure_directory_is_missing_or_empty(&nonempty), + Err("Choose an empty folder to create a new vault".to_string()) + ); + } + + #[test] + fn canonical_vault_path_uses_existing_canonical_path_or_original_path() { + let dir = tempfile::TempDir::new().unwrap(); + let existing = dir.path().join("existing"); + let missing = dir.path().join("missing"); + fs::create_dir(&existing).unwrap(); + + assert_eq!( + canonical_vault_path_string(&existing), + existing.canonicalize().unwrap().to_string_lossy() + ); + assert_eq!( + canonical_vault_path_string(&missing), + missing.to_string_lossy() + ); + } + + #[test] + fn getting_started_target_uses_explicit_path_when_provided() { + let dir = tempfile::TempDir::new().unwrap(); + let explicit = dir.path().join("starter"); + + assert_eq!( + resolve_getting_started_target(explicit.to_str()), + Ok(explicit.to_string_lossy().to_string()) + ); + } +} diff --git a/src-tauri/src/commands/vault/rename_cmds.rs b/src-tauri/src/commands/vault/rename_cmds.rs new file mode 100644 index 0000000..a752aa2 --- /dev/null +++ b/src-tauri/src/commands/vault/rename_cmds.rs @@ -0,0 +1,452 @@ +use crate::commands::expand_tilde; +use crate::vault::{self, DetectedRename, RenameResult}; +use serde::Deserialize; +use std::path::Path; + +use super::boundary::{ + with_boundary, with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode, +}; + +struct RequestedNotePath<'a> { + vault_path: &'a str, + note_path: &'a str, +} + +struct ValidatedNotePath<'a> { + vault_path: &'a str, + note_path: &'a str, +} + +impl<'a> RequestedNotePath<'a> { + fn new(vault_path: &'a str, note_path: &'a str) -> Self { + Self { + vault_path, + note_path, + } + } +} + +fn with_note_path_in_vault( + request: RequestedNotePath<'_>, + action: impl FnOnce(ValidatedNotePath<'_>) -> Result, +) -> Result { + with_existing_path_in_requested_vault( + request.vault_path, + request.note_path, + |requested_root, validated_path| { + action(ValidatedNotePath { + vault_path: requested_root, + note_path: validated_path, + }) + }, + ) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveNoteToWorkspaceCommandArgs { + source_vault_path: String, + destination_vault_path: String, + old_path: String, + replacement_target: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameNoteCommandArgs { + vault_path: String, + old_path: String, + new_title: String, + old_title: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameNoteFilenameCommandArgs { + vault_path: String, + old_path: String, + new_filename_stem: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveNoteToFolderCommandArgs { + vault_path: String, + old_path: String, + folder_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoRenameUntitledCommandArgs { + vault_path: String, + note_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultPathCommandArgs { + vault_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateWikilinksForRenamesCommandArgs { + vault_path: String, + renames: Vec, +} + +enum NoteRenameCommandArgs { + Title { + new_title: String, + old_title: Option, + }, + Filename { + new_filename_stem: String, + }, +} + +impl NoteRenameCommandArgs { + fn run(self, note: ValidatedNotePath<'_>) -> Result { + match self { + Self::Title { + new_title, + old_title, + } => vault::rename_note(vault::RenameNoteRequest { + vault_path: note.vault_path, + old_path: note.note_path, + new_title: &new_title, + old_title_hint: old_title.as_deref(), + }), + Self::Filename { new_filename_stem } => { + vault::rename_note_filename(vault::RenameNoteFilenameRequest { + vault_path: note.vault_path, + old_path: note.note_path, + new_filename_stem: &new_filename_stem, + }) + } + } + } +} + +struct PendingNoteRenameCommand { + vault_path: String, + old_path: String, + args: NoteRenameCommandArgs, +} + +enum PublicNoteRenameCommandArgs { + Title(RenameNoteCommandArgs), + Filename(RenameNoteFilenameCommandArgs), +} + +fn pending_note_rename( + vault_path: String, + old_path: String, + args: NoteRenameCommandArgs, +) -> PendingNoteRenameCommand { + PendingNoteRenameCommand { + vault_path, + old_path, + args, + } +} + +fn rename_existing_note(command: PendingNoteRenameCommand) -> Result { + let request = RequestedNotePath::new(&command.vault_path, &command.old_path); + with_note_path_in_vault(request, |note| command.args.run(note)) +} + +fn rename_public_note(args: PublicNoteRenameCommandArgs) -> Result { + let command = match args { + PublicNoteRenameCommandArgs::Title(args) => pending_note_rename( + args.vault_path, + args.old_path, + NoteRenameCommandArgs::Title { + new_title: args.new_title, + old_title: args.old_title, + }, + ), + PublicNoteRenameCommandArgs::Filename(args) => pending_note_rename( + args.vault_path, + args.old_path, + NoteRenameCommandArgs::Filename { + new_filename_stem: args.new_filename_stem, + }, + ), + }; + rename_existing_note(command) +} + +#[tauri::command] +pub fn rename_note(args: RenameNoteCommandArgs) -> Result { + rename_public_note(PublicNoteRenameCommandArgs::Title(args)) +} + +#[tauri::command] +pub fn rename_note_filename(args: RenameNoteFilenameCommandArgs) -> Result { + rename_public_note(PublicNoteRenameCommandArgs::Filename(args)) +} + +fn run_folder_move(args: MoveNoteToFolderCommandArgs) -> Result { + let request = RequestedNotePath::new(&args.vault_path, &args.old_path); + with_note_path_in_vault(request, |note| { + let trimmed_folder_path = args.folder_path.trim(); + if trimmed_folder_path.is_empty() { + return Err("Folder path cannot be empty".to_string()); + } + + let folder_absolute_path = Path::new(note.vault_path).join(trimmed_folder_path); + with_validated_path( + folder_absolute_path.to_string_lossy().as_ref(), + Some(args.vault_path.as_str()), + ValidatedPathMode::Existing, + |validated_folder_path| { + let validated_folder = Path::new(validated_folder_path); + if !validated_folder.is_dir() { + return Err(format!("Folder does not exist: {}", trimmed_folder_path)); + } + vault::move_note_to_folder(vault::MoveNoteToFolderRequest { + vault_path: note.vault_path, + old_path: note.note_path, + destination_folder_path: validated_folder_path, + }) + }, + ) + }) +} + +#[tauri::command] +pub fn move_note_to_folder(args: MoveNoteToFolderCommandArgs) -> Result { + run_folder_move(args) +} + +#[tauri::command] +pub fn move_note_to_workspace( + args: MoveNoteToWorkspaceCommandArgs, +) -> Result { + let request = RequestedNotePath::new(&args.source_vault_path, &args.old_path); + with_note_path_in_vault(request, |note| { + let source_root_path = Path::new(note.vault_path); + let old_file = Path::new(note.note_path); + let relative_path = old_file + .strip_prefix(source_root_path) + .map_err(|_| "Path must stay inside the source vault".to_string())?; + let relative_path = relative_path.to_string_lossy(); + + with_boundary(Some(&args.destination_vault_path), |destination_boundary| { + let destination_path = destination_boundary.child_path(relative_path.as_ref())?; + let destination_root = destination_boundary + .requested_root() + .to_string_lossy() + .into_owned(); + let destination_path = destination_path.to_string_lossy().into_owned(); + vault::move_note_to_workspace(vault::MoveNoteToWorkspaceRequest { + source_vault_path: note.vault_path, + destination_vault_path: &destination_root, + old_path: note.note_path, + destination_path: &destination_path, + replacement_target: args.replacement_target.as_deref(), + }) + }) + }) +} + +#[tauri::command] +pub fn auto_rename_untitled( + args: AutoRenameUntitledCommandArgs, +) -> Result, String> { + with_existing_path_in_requested_vault( + &args.vault_path, + &args.note_path, + |requested_root, validated_path| { + vault::auto_rename_untitled(vault::AutoRenameUntitledRequest { + vault_path: requested_root, + note_path: validated_path, + }) + }, + ) +} + +#[tauri::command] +pub fn detect_renames(args: VaultPathCommandArgs) -> Result, String> { + let vault_path = expand_tilde(&args.vault_path); + vault::detect_renames(Path::new(vault_path.as_ref())) +} + +#[tauri::command] +pub fn update_wikilinks_for_renames( + args: UpdateWikilinksForRenamesCommandArgs, +) -> Result { + let vault_path = expand_tilde(&args.vault_path); + vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &args.renames) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn vault_path(dir: &TempDir) -> String { + dir.path().to_string_lossy().into_owned() + } + + fn write_note(dir: &TempDir, relative_path: &str, content: &str) -> String { + let path = dir.path().join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, content).unwrap(); + path.to_string_lossy().into_owned() + } + + #[test] + fn rename_note_command_updates_title_file_and_links() { + let dir = TempDir::new().unwrap(); + let vault = vault_path(&dir); + let old_path = write_note( + &dir, + "old-title.md", + "---\ntitle: Old Title\n---\n# Old Title\n", + ); + let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n"); + + let result = rename_note(RenameNoteCommandArgs { + vault_path: vault.clone(), + old_path: old_path.clone(), + new_title: "New Title".to_string(), + old_title: None, + }) + .unwrap(); + + assert!(result.new_path.ends_with("new-title.md")); + assert!(!Path::new(&old_path).exists()); + assert!(Path::new(&result.new_path).exists()); + assert!(fs::read_to_string(linked_path) + .unwrap() + .contains("[[new-title]]")); + assert_eq!(result.failed_updates, 0); + } + + #[test] + fn filename_and_folder_commands_preserve_note_content() { + let dir = TempDir::new().unwrap(); + let vault = vault_path(&dir); + let old_path = write_note( + &dir, + "draft.md", + "---\ntitle: Draft Title\n---\n# Draft Title\n", + ); + + let renamed = rename_note_filename(RenameNoteFilenameCommandArgs { + vault_path: vault.clone(), + old_path, + new_filename_stem: "custom-name".to_string(), + }) + .unwrap(); + assert!(renamed.new_path.ends_with("custom-name.md")); + + fs::create_dir(dir.path().join("Projects")).unwrap(); + let moved = move_note_to_folder(MoveNoteToFolderCommandArgs { + vault_path: vault.clone(), + old_path: renamed.new_path.clone(), + folder_path: "Projects".to_string(), + }) + .unwrap(); + + assert!(moved.new_path.ends_with("Projects/custom-name.md")); + assert!(fs::read_to_string(moved.new_path) + .unwrap() + .contains("Draft Title")); + } + + #[test] + fn move_note_to_workspace_command_preserves_relative_path() { + let source = TempDir::new().unwrap(); + let destination = TempDir::new().unwrap(); + let source_vault = vault_path(&source); + let destination_vault = vault_path(&destination); + let old_path = write_note( + &source, + "Projects/draft.md", + "---\ntitle: Draft Title\n---\n# Draft Title\n", + ); + let linked_path = write_note(&source, "linked.md", "See [[Draft Title]].\n"); + + let moved = move_note_to_workspace(MoveNoteToWorkspaceCommandArgs { + source_vault_path: source_vault, + destination_vault_path: destination_vault.clone(), + old_path: old_path.clone(), + replacement_target: Some("team/Projects/draft".to_string()), + }) + .unwrap(); + + assert!(!Path::new(&old_path).exists()); + assert!(moved.new_path.ends_with("Projects/draft.md")); + assert!(moved.new_path.starts_with(&destination_vault)); + assert!(fs::read_to_string(moved.new_path) + .unwrap() + .contains("Draft Title")); + assert!(fs::read_to_string(linked_path) + .unwrap() + .contains("[[team/Projects/draft]]")); + } + + #[test] + fn auto_rename_and_detected_rename_commands_route_through_vault() { + let dir = TempDir::new().unwrap(); + let vault = vault_path(&dir); + let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n"); + + let auto = auto_rename_untitled(AutoRenameUntitledCommandArgs { + vault_path: vault.clone(), + note_path: untitled, + }) + .unwrap() + .unwrap(); + assert!(auto.new_path.ends_with("project-plan.md")); + + crate::git::init_repo(&vault).unwrap(); + let old_path = dir.path().join("project-plan.md"); + let new_path = dir.path().join("plans.md"); + fs::rename(&old_path, &new_path).unwrap(); + crate::hidden_command("git") + .args(["add", "-A"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let renames = detect_renames(VaultPathCommandArgs { + vault_path: vault.clone(), + }) + .unwrap(); + assert_eq!(renames.len(), 1); + assert_eq!(renames[0].old_path, "project-plan.md"); + assert_eq!(renames[0].new_path, "plans.md"); + + assert_eq!( + update_wikilinks_for_renames(UpdateWikilinksForRenamesCommandArgs { + vault_path: vault, + renames, + }) + .unwrap(), + 0, + ); + } + + #[test] + fn move_note_to_folder_rejects_empty_folder() { + let dir = TempDir::new().unwrap(); + let vault = vault_path(&dir); + let note = write_note(&dir, "note.md", "# Note\n"); + + let error = move_note_to_folder(MoveNoteToFolderCommandArgs { + vault_path: vault, + old_path: note, + folder_path: " ".to_string(), + }) + .unwrap_err(); + assert!(error.contains("Folder path cannot be empty")); + } +} diff --git a/src-tauri/src/commands/vault/scan_cmds.rs b/src-tauri/src/commands/vault/scan_cmds.rs new file mode 100644 index 0000000..e53b91c --- /dev/null +++ b/src-tauri/src/commands/vault/scan_cmds.rs @@ -0,0 +1,361 @@ +use crate::commands::expand_tilde; +use crate::search::SearchResponse; +use crate::vault::VaultEntry; +use crate::{search, vault, vault_list}; +use std::path::{Path, PathBuf}; + +use super::boundary::{with_validated_path, ValidatedPathMode}; + +fn collect_registered_vault_roots(vault_list: &vault_list::VaultList) -> Vec { + let mut roots = Vec::new(); + for entry in &vault_list.vaults { + push_unique_vault_root_path( + &mut roots, + PathBuf::from(expand_tilde(&entry.path).into_owned()), + ); + } + + if let Some(active_vault) = &vault_list.active_vault { + push_unique_vault_root_path( + &mut roots, + PathBuf::from(expand_tilde(active_vault).into_owned()), + ); + } + + for hidden_default in &vault_list.hidden_defaults { + push_unique_vault_root_path( + &mut roots, + PathBuf::from(expand_tilde(hidden_default).into_owned()), + ); + } + + #[cfg(not(test))] + if let Ok(default_path) = vault::default_vault_path() { + push_unique_vault_root_path(&mut roots, default_path); + } + + if let Some(dev_demo_path) = local_dev_demo_vault_path() { + push_unique_vault_root_path(&mut roots, dev_demo_path); + } + + roots +} + +fn push_unique_vault_root_path(paths: &mut Vec, path: PathBuf) { + if paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + +#[cfg(all(debug_assertions, not(test)))] +fn local_dev_demo_vault_path() -> Option { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.parent().map(|root| root.join("demo-vault-v2")) +} + +#[cfg(not(all(debug_assertions, not(test))))] +fn local_dev_demo_vault_path() -> Option { + None +} + +fn find_registered_vault_root(path: &Path, registered_roots: &[PathBuf]) -> Option { + registered_roots + .iter() + .filter_map(|root| { + let canonical_root = root.canonicalize().ok()?; + path.starts_with(&canonical_root) + .then_some((canonical_root.components().count(), root.clone())) + }) + .max_by_key(|(depth, _)| *depth) + .map(|(_, root)| root) +} + +fn resolve_reload_vault_path( + path: &Path, + vault_path: Option<&Path>, +) -> Result, String> { + if let Some(vault_path) = vault_path { + return Ok(Some(vault_path.to_path_buf())); + } + + if !path.is_absolute() { + return Ok(None); + } + + let canonical_path = match path.canonicalize() { + Ok(canonical_path) => canonical_path, + Err(_) => return Ok(None), + }; + + let vault_list = vault_list::load_vault_list()?; + let registered_roots = collect_registered_vault_roots(&vault_list); + Ok(find_registered_vault_root( + canonical_path.as_path(), + ®istered_roots, + )) +} + +#[tauri::command] +pub fn reload_vault_entry( + path: PathBuf, + vault_path: Option, +) -> Result { + let resolved_vault_path = resolve_reload_vault_path(path.as_path(), vault_path.as_deref())?; + let raw_path = path.to_string_lossy(); + let raw_vault_path = resolved_vault_path + .as_ref() + .map(|value| value.to_string_lossy().into_owned()); + with_validated_path( + &raw_path, + raw_vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| vault::reload_entry(Path::new(validated_path)), + ) +} + +#[tauri::command] +pub async fn reload_vault( + app_handle: tauri::AppHandle, + path: String, +) -> Result, String> { + let path = expand_tilde(&path).into_owned(); + crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?; + tokio::task::spawn_blocking(move || { + let vault_path = Path::new(&path); + vault::invalidate_cache(vault_path); + let entries = vault::scan_vault_cached(vault_path)?; + Ok(vault::filter_gitignored_entries( + vault_path, + entries, + crate::settings::hide_gitignored_files_enabled(), + )) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[tauri::command] +pub async fn search_vault( + vault_path: String, + query: String, + limit: Option, + exclude_frontmatter: Option, +) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + let limit = limit.unwrap_or(20); + let exclude_frontmatter = exclude_frontmatter.unwrap_or(false); + tokio::task::spawn_blocking(move || { + search::search_vault_with_options(search::SearchOptions { + vault_path: &vault_path, + query: &query, + mode: "keyword", + limit, + hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(), + exclude_frontmatter, + }) + }) + .await + .map_err(|e| format!("Search task failed: {}", e))? +} + +#[cfg(test)] +mod tests { + use super::{ + collect_registered_vault_roots, find_registered_vault_root, reload_vault_entry, + resolve_reload_vault_path, search_vault, + }; + use crate::vault_list::{VaultEntry as VaultListEntry, VaultList}; + use std::path::{Path, PathBuf}; + + fn write_note(root: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = root.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn finds_registered_vault_root_for_an_absolute_note_path() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_root = dir.path().join("vault"); + let note_path = vault_root.join("note.md"); + std::fs::create_dir_all(&vault_root).unwrap(); + std::fs::write(¬e_path, "# Note\n").unwrap(); + + let vault_list = VaultList { + vaults: vec![VaultListEntry { + label: "Test".to_string(), + path: vault_root.to_string_lossy().into_owned(), + ..Default::default() + }], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec![], + }; + + let registered_roots = collect_registered_vault_roots(&vault_list); + let canonical_note_path = note_path.canonicalize().unwrap(); + + assert_eq!( + find_registered_vault_root(canonical_note_path.as_path(), ®istered_roots), + Some(vault_root), + ); + } + + #[test] + fn prefers_the_deepest_registered_vault_root() { + let dir = tempfile::TempDir::new().unwrap(); + let parent_root = dir.path().join("vault"); + let nested_root = parent_root.join("projects"); + let note_path = nested_root.join("note.md"); + std::fs::create_dir_all(&nested_root).unwrap(); + std::fs::write(¬e_path, "# Note\n").unwrap(); + + let vault_list = VaultList { + vaults: vec![ + VaultListEntry { + label: "Parent".to_string(), + path: parent_root.to_string_lossy().into_owned(), + ..Default::default() + }, + VaultListEntry { + label: "Nested".to_string(), + path: nested_root.to_string_lossy().into_owned(), + ..Default::default() + }, + ], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec![], + }; + + let registered_roots = collect_registered_vault_roots(&vault_list); + let canonical_note_path = note_path.canonicalize().unwrap(); + + assert_eq!( + find_registered_vault_root(canonical_note_path.as_path(), ®istered_roots), + Some(nested_root), + ); + } + + #[test] + fn find_registered_vault_root_ignores_missing_registered_roots() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_root = dir.path().join("vault"); + std::fs::create_dir_all(&vault_root).unwrap(); + let note_path = write_note(&vault_root, "note.md", "# Note\n"); + let registered_roots = vec![dir.path().join("missing"), vault_root.clone()]; + let canonical_note_path = note_path.canonicalize().unwrap(); + + assert_eq!( + find_registered_vault_root(canonical_note_path.as_path(), ®istered_roots), + Some(vault_root), + ); + } + + #[test] + fn collect_registered_vault_roots_includes_active_vault() { + let vault_list = VaultList { + vaults: vec![VaultListEntry { + label: "Listed".to_string(), + path: "/listed".to_string(), + ..Default::default() + }], + active_vault: Some("/active".to_string()), + default_workspace_path: None, + hidden_defaults: vec![], + }; + + let roots = collect_registered_vault_roots(&vault_list); + + assert_eq!( + roots, + vec![PathBuf::from("/listed"), PathBuf::from("/active")] + ); + } + + #[test] + fn collect_registered_vault_roots_includes_hidden_defaults() { + let vault_list = VaultList { + vaults: vec![], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec!["/hidden-default".to_string()], + }; + + let roots = collect_registered_vault_roots(&vault_list); + + assert_eq!(roots, vec![PathBuf::from("/hidden-default")]); + } + + #[test] + fn resolve_reload_vault_path_uses_explicit_vault_path() { + let explicit = Path::new("/tmp/vault"); + + assert_eq!( + resolve_reload_vault_path(Path::new("note.md"), Some(explicit)).unwrap(), + Some(explicit.to_path_buf()), + ); + } + + #[test] + fn resolve_reload_vault_path_skips_relative_note_paths() { + assert_eq!( + resolve_reload_vault_path(Path::new("note.md"), None).unwrap(), + None, + ); + } + + #[test] + fn reload_vault_entry_command_reads_note_inside_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let note_path = write_note(dir.path(), "note.md", "# Reloaded Title\n\nBody"); + + let entry = reload_vault_entry(note_path, Some(dir.path().to_path_buf())).unwrap(); + + assert_eq!(entry.title, "Reloaded Title"); + } + + #[tokio::test] + async fn search_vault_command_uses_default_limit_and_returns_results() { + let dir = tempfile::Builder::new() + .prefix("scan-search-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + write_note(dir.path(), "search.md", "# Searchable\n\nneedle"); + + let response = search_vault( + dir.path().to_string_lossy().into_owned(), + "needle".to_string(), + None, + None, + ) + .await + .unwrap(); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].title, "Searchable"); + assert_eq!(response.mode, "keyword"); + } + + #[tokio::test] + async fn search_vault_command_honors_explicit_limit() { + let dir = tempfile::Builder::new() + .prefix("scan-search-limit-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + write_note(dir.path(), "first.md", "# First\n\nneedle"); + write_note(dir.path(), "second.md", "# Second\n\nneedle"); + + let response = search_vault( + dir.path().to_string_lossy().into_owned(), + "needle".to_string(), + Some(1), + None, + ) + .await + .unwrap(); + + assert_eq!(response.results.len(), 1); + } +} diff --git a/src-tauri/src/commands/vault/view_cmds.rs b/src-tauri/src/commands/vault/view_cmds.rs new file mode 100644 index 0000000..84af615 --- /dev/null +++ b/src-tauri/src/commands/vault/view_cmds.rs @@ -0,0 +1,94 @@ +use crate::vault::{self, ViewDefinition, ViewFile}; +use std::path::Path; + +use super::boundary::{with_boundary, with_view_file}; + +#[tauri::command] +pub fn list_views(vault_path: String) -> Result, String> { + with_boundary(Some(vault_path.as_str()), |boundary| { + Ok(vault::scan_views(boundary.requested_root())) + }) +} + +#[tauri::command] +pub fn save_view_cmd( + vault_path: String, + filename: String, + definition: ViewDefinition, +) -> Result<(), String> { + with_view_file( + &vault_path, + &filename, + |requested_root, validated_filename| { + vault::save_view(Path::new(requested_root), validated_filename, &definition) + }, + ) +} + +#[tauri::command] +pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> { + with_view_file( + &vault_path, + &filename, + |requested_root, validated_filename| { + vault::delete_view(Path::new(requested_root), validated_filename) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::{FilterCondition, FilterGroup, FilterNode, FilterOp}; + + fn definition(name: &str) -> ViewDefinition { + ViewDefinition { + name: name.to_string(), + icon: Some("star".to_string()), + color: None, + order: None, + sort: Some("modified:desc".to_string()), + list_properties_display: vec!["Priority".to_string()], + filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { + field: "type".to_string(), + op: FilterOp::Equals, + value: Some(serde_yaml::Value::String("Project".to_string())), + regex: false, + })]), + } + } + + #[test] + fn view_commands_roundtrip_through_validated_vault_paths() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().to_string_lossy().to_string(); + + assert!(list_views(vault_path.clone()).unwrap().is_empty()); + + save_view_cmd( + vault_path.clone(), + "active-projects.yml".to_string(), + definition("Active Projects"), + ) + .unwrap(); + + let views = list_views(vault_path.clone()).unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].filename, "active-projects.yml"); + assert_eq!(views[0].definition.name, "Active Projects"); + + delete_view_cmd(vault_path.clone(), "active-projects.yml".to_string()).unwrap(); + + assert!(list_views(vault_path).unwrap().is_empty()); + } + + #[test] + fn delete_view_command_treats_missing_backing_file_as_deleted() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().to_string_lossy().to_string(); + + delete_view_cmd(vault_path.clone(), "stale-view.yml".to_string()).unwrap(); + + assert!(list_views(vault_path).unwrap().is_empty()); + } +} diff --git a/src-tauri/src/commands/version.rs b/src-tauri/src/commands/version.rs new file mode 100644 index 0000000..ba6b496 --- /dev/null +++ b/src-tauri/src/commands/version.rs @@ -0,0 +1,121 @@ +use super::{is_numeric_version_part, parse_legacy_build_label}; + +pub fn parse_build_label(version: &str) -> String { + let version = version.trim(); + if version.is_empty() { + return "b?".to_string(); + } + + parse_legacy_build_label(version) + .or_else(|| parse_calendar_build_label(version)) + .or_else(|| parse_semver_build_label(version)) + .unwrap_or_else(|| "b?".to_string()) +} + +fn strip_build_metadata(version: &str) -> &str { + version.split_once('+').map_or(version, |(base, _)| base) +} + +fn parse_calendar_build_label(version: &str) -> Option { + let semver = strip_build_metadata(version); + let (core, prerelease) = semver + .split_once('-') + .map_or((semver, None), |(base, suffix)| (base, Some(suffix))); + let [year, month, day] = split_numeric_version_parts(core)?; + if year.len() != 4 { + return None; + } + + let core_version = format!( + "{}.{}.{}", + year.parse::().ok()?, + month.parse::().ok()?, + day.parse::().ok()? + ); + + match prerelease { + Some(suffix) if suffix.starts_with("alpha.") => suffix + .strip_prefix("alpha.") + .map(|sequence| format!("Alpha {}.{}", core_version, sequence)), + Some(suffix) if suffix.starts_with("stable.") => Some(core_version), + Some(_) => None, + None => Some(core_version), + } +} + +fn parse_semver_build_label(version: &str) -> Option { + let semver = strip_build_metadata(version); + let (core, prerelease) = semver + .split_once('-') + .map_or((semver, None), |(base, suffix)| (base, Some(suffix))); + split_numeric_version_parts(core)?; + + match prerelease { + Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)), + Some(_) => Some(format!("v{}", semver)), + None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()), + None => Some(format!("v{}", semver)), + } +} + +fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> { + let parts: Vec<&str> = version.split('.').collect(); + let [major, minor, patch] = parts.as_slice() else { + return None; + }; + if ![major, minor, patch] + .iter() + .all(|part| is_numeric_version_part(part)) + { + return None; + } + + Some([major, minor, patch]) +} + +#[cfg(test)] +mod tests { + use super::parse_build_label; + + #[test] + fn parse_build_label_release_version() { + assert_eq!(parse_build_label("0.20260303.281"), "b281"); + assert_eq!(parse_build_label("0.20251215.42"), "b42"); + } + + #[test] + fn parse_build_label_calendar_versions() { + assert_eq!(parse_build_label("2026.4.16"), "2026.4.16"); + assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16"); + assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3"); + assert_eq!( + parse_build_label("2026.4.16-alpha.3+darwin"), + "Alpha 2026.4.16.3" + ); + } + + #[test] + fn parse_build_label_legacy_semver_releases() { + assert_eq!(parse_build_label("1.2.3"), "v1.2.3"); + assert_eq!( + parse_build_label("1.2.4-alpha.202604122135.7"), + "Alpha 1.2.4-alpha.202604122135.7" + ); + assert_eq!( + parse_build_label("1.2.4-alpha.202604122135.7+darwin"), + "Alpha 1.2.4-alpha.202604122135.7" + ); + } + + #[test] + fn parse_build_label_dev_version() { + assert_eq!(parse_build_label("0.1.0"), "dev"); + assert_eq!(parse_build_label("0.0.0"), "dev"); + } + + #[test] + fn parse_build_label_malformed() { + assert_eq!(parse_build_label("invalid"), "b?"); + assert_eq!(parse_build_label(""), "b?"); + } +} diff --git a/src-tauri/src/copilot_cli.rs b/src-tauri/src/copilot_cli.rs new file mode 100644 index 0000000..58f640c --- /dev/null +++ b/src-tauri/src/copilot_cli.rs @@ -0,0 +1,332 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent}; +use crate::cli_agent_runtime::{AgentStreamRequest, LineStreamProcess}; +use std::path::Path; +use std::process::{Command, Stdio}; + +struct CopilotCommandSpec { + prompt: String, + mcp_config: String, + vault_path: String, + permission_mode: AiAgentPermissionMode, +} + +struct CopilotMcpConfigInput<'a> { + request: &'a AgentStreamRequest, + mcp_server_path: &'a str, + node_command: &'a str, +} + +pub fn check_cli() -> AiAgentAvailability { + crate::copilot_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::copilot_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let mcp_config = build_copilot_mcp_config(&request)?; + let spec = command_spec(request, mcp_config); + run_agent_stream_with_spec(binary, spec, emit) +} + +fn command_spec(request: AgentStreamRequest, mcp_config: String) -> CopilotCommandSpec { + let prompt = + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()); + CopilotCommandSpec { + prompt, + mcp_config, + vault_path: request.vault_path, + permission_mode: request.permission_mode, + } +} + +fn run_agent_stream_with_spec( + binary: &Path, + spec: CopilotCommandSpec, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let command = build_copilot_command(binary, spec)?; + crate::cli_agent_runtime::run_ai_agent_line_stream( + LineStreamProcess::new(command, "copilot", "copilot"), + emit, + format_copilot_error, + ) +} + +fn build_copilot_command(binary: &Path, spec: CopilotCommandSpec) -> Result { + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .args(build_copilot_args(&spec)) + .current_dir(spec.vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn build_copilot_args(spec: &CopilotCommandSpec) -> Vec { + let mut args = vec![ + "-p".into(), + spec.prompt.clone(), + "-s".into(), + "--no-ask-user".into(), + "--additional-mcp-config".into(), + spec.mcp_config.clone(), + ]; + append_permission_args(&mut args, spec.permission_mode); + args +} + +fn append_permission_args(args: &mut Vec, permission_mode: AiAgentPermissionMode) { + match permission_mode { + AiAgentPermissionMode::Safe => { + args.push("--available-tools=write,tolaria".into()); + args.push("--allow-tool=write,tolaria".into()); + args.push("--deny-tool=shell".into()); + } + AiAgentPermissionMode::PowerUser => args.push("--allow-all-tools".into()), + } +} + +fn build_copilot_mcp_config(request: &AgentStreamRequest) -> Result { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + let node_path = crate::mcp::find_node()?; + let node_command = node_path.to_string_lossy(); + copilot_mcp_config_json(CopilotMcpConfigInput { + request, + mcp_server_path: &mcp_server_path, + node_command: node_command.as_ref(), + }) +} + +fn copilot_mcp_config_json(input: CopilotMcpConfigInput<'_>) -> Result { + let mut server = crate::cli_agent_runtime::tolaria_node_mcp_server( + input.mcp_server_path, + &input.request.vault_path, + &input.request.vault_paths, + true, + ); + server["type"] = serde_json::json!("stdio"); + server["tools"] = serde_json::json!(["*"]); + server["command"] = serde_json::json!(input.node_command); + + let config = serde_json::json!({ + "mcpServers": { + "tolaria": server + } + }); + serde_json::to_string(&config) + .map_err(|error| format!("Failed to serialise MCP config: {error}")) +} + +fn format_copilot_error(stderr_output: &str, status: &str) -> String { + if is_auth_or_setup_error(stderr_output) { + return "GitHub Copilot CLI is not ready. Run `copilot login` in your terminal, then run `copilot` from this vault folder and trust it before retrying in Tolaria.".into(); + } + + let stderr = stderr_output.trim(); + if stderr.is_empty() { + format!("copilot exited with status {status}") + } else { + stderr.lines().take(3).collect::>().join("\n") + } +} + +fn is_auth_or_setup_error(stderr_output: &str) -> bool { + let lower = stderr_output.to_ascii_lowercase(); + [ + "auth", + "login", + "oauth", + "policy", + "sign in", + "subscription", + "token", + "trust", + "unauthorized", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn spec(permission_mode: AiAgentPermissionMode) -> CopilotCommandSpec { + CopilotCommandSpec { + prompt: "Prompt".into(), + mcp_config: r#"{"mcpServers":{"tolaria":{}}}"#.into(), + vault_path: "/tmp/vault".into(), + permission_mode, + } + } + + fn request(vault_path: String, permission_mode: AiAgentPermissionMode) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: Some("Use Tolaria conventions".into()), + vault_path, + vault_paths: vec!["/team-vault".into()], + permission_mode, + } + } + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("copilot"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + #[test] + fn build_copilot_command_uses_programmatic_prompt_and_safe_tools() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("copilot"); + let command = build_copilot_command(&binary, spec(AiAgentPermissionMode::Safe)).unwrap(); + let actual_args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + assert_eq!(command.get_program(), binary.as_os_str()); + assert!(actual_args + .windows(2) + .any(|window| window == ["-p", "Prompt"])); + assert!(actual_args.contains(&"-s".to_string())); + assert!(actual_args.contains(&"--no-ask-user".to_string())); + assert!(actual_args.contains(&"--available-tools=write,tolaria".to_string())); + assert!(actual_args.contains(&"--allow-tool=write,tolaria".to_string())); + assert!(actual_args.contains(&"--deny-tool=shell".to_string())); + assert!(!actual_args + .iter() + .any(|arg| arg == "--allow-all" || arg == "--yolo")); + } + + #[test] + fn build_copilot_args_uses_power_user_without_path_bypass() { + let args = build_copilot_args(&spec(AiAgentPermissionMode::PowerUser)); + + assert!(args.contains(&"--allow-all-tools".to_string())); + assert!(!args.iter().any(|arg| { + matches!( + arg.as_str(), + "--allow-all" | "--yolo" | "--allow-all-paths" | "--allow-all-urls" + ) + })); + } + + #[test] + fn copilot_mcp_config_uses_tolaria_stdio_server() { + let request = request("/tmp/vault".into(), AiAgentPermissionMode::Safe); + let config = copilot_mcp_config_json(CopilotMcpConfigInput { + request: &request, + mcp_server_path: "/opt/tolaria/mcp-server/index.js", + node_command: "/usr/local/bin/node", + }) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + let server = &json["mcpServers"]["tolaria"]; + + assert_eq!(server["type"], "stdio"); + assert_eq!(server["command"], "/usr/local/bin/node"); + assert_eq!(server["args"][0], "/opt/tolaria/mcp-server/index.js"); + assert_eq!(server["tools"][0], "*"); + assert_eq!(server["env"]["VAULT_PATH"], "/tmp/vault"); + assert_eq!(server["env"]["WS_UI_PORT"], "9711"); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_copilot_stdout() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'Hello from Copilot' +printf '%s\n' 'Second line' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_spec( + &binary, + CopilotCommandSpec { + vault_path: vault.path().to_string_lossy().into_owned(), + ..spec(AiAgentPermissionMode::Safe) + }, + |event| events.push(event), + ) + .unwrap(); + + assert!(session_id.starts_with("copilot-")); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id.starts_with("copilot-") + )); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Hello from Copilot\n" + ))); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Second line\n" + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_copilot_auth_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'login required' >&2 +exit 2 +"#, + ); + + let mut events = Vec::new(); + run_agent_stream_with_spec( + &binary, + CopilotCommandSpec { + vault_path: vault.path().to_string_lossy().into_owned(), + ..spec(AiAgentPermissionMode::Safe) + }, + |event| events.push(event), + ) + .unwrap(); + + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("copilot login") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/copilot_discovery.rs b/src-tauri/src/copilot_discovery.rs new file mode 100644 index 0000000..a3cffb1 --- /dev/null +++ b/src-tauri/src/copilot_discovery.rs @@ -0,0 +1,116 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + crate::cli_agent_runtime::check_cli_availability(find_binary) +} + +pub(crate) fn find_binary() -> Result { + crate::cli_agent_runtime::find_cli_binary( + "copilot", + copilot_binary_candidates(), + "GitHub Copilot CLI", + "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli", + ) +} + +fn copilot_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| copilot_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn copilot_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = vec![ + home.join(".local/bin/copilot"), + home.join(".local/bin/copilot.exe"), + home.join(".local/bin/copilot.cmd"), + home.join(".local/share/mise/shims/copilot"), + home.join(".local/share/mise/shims/copilot.exe"), + home.join(".local/share/mise/shims/copilot.cmd"), + home.join(".asdf/shims/copilot"), + home.join(".asdf/shims/copilot.exe"), + home.join(".asdf/shims/copilot.cmd"), + home.join(".npm-global/bin/copilot"), + home.join(".npm-global/bin/copilot.cmd"), + home.join(".npm-global/bin/copilot.exe"), + home.join(".npm/bin/copilot"), + home.join(".npm/bin/copilot.cmd"), + home.join(".npm/bin/copilot.exe"), + home.join(".bun/bin/copilot"), + home.join(".bun/bin/copilot.exe"), + home.join(".bun/bin/copilot.cmd"), + home.join(".linuxbrew/bin/copilot"), + home.join("AppData/Roaming/npm/copilot.cmd"), + home.join("AppData/Roaming/npm/copilot.exe"), + home.join("AppData/Local/pnpm/copilot.cmd"), + home.join("AppData/Local/pnpm/copilot.exe"), + home.join("scoop/shims/copilot.cmd"), + home.join("scoop/shims/copilot.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/copilot"), + PathBuf::from("/usr/local/bin/copilot"), + PathBuf::from("/opt/homebrew/bin/copilot"), + ]; + candidates.extend(nvm_copilot_binary_candidates_for_home(home)); + candidates +} + +fn nvm_copilot_binary_candidates_for_home(home: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { + return Vec::new(); + }; + + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.join("bin").join("copilot")) + .collect::>(); + candidates.sort(); + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = copilot_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/copilot"), + home.join(".npm-global/bin/copilot"), + home.join(".local/share/mise/shims/copilot"), + home.join(".asdf/shims/copilot"), + PathBuf::from("/opt/homebrew/bin/copilot"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn binary_candidates_include_windows_node_shims() { + let home = PathBuf::from("C:/Users/alex"); + let candidates = copilot_binary_candidates_for_home(&home); + let expected = [ + home.join("AppData/Roaming/npm/copilot.cmd"), + home.join("AppData/Local/pnpm/copilot.exe"), + home.join("scoop/shims/copilot.cmd"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } +} diff --git a/src-tauri/src/frontmatter/keys.rs b/src-tauri/src/frontmatter/keys.rs new file mode 100644 index 0000000..c51b674 --- /dev/null +++ b/src-tauri/src/frontmatter/keys.rs @@ -0,0 +1,184 @@ +#[derive(Clone, Copy)] +pub(crate) struct FrontmatterKeyRule { + read_key: &'static str, + write_key: &'static str, + aliases: &'static [&'static str], + canonicalize_on_write: bool, +} + +#[derive(Clone, Copy)] +pub(crate) struct FrontmatterKey<'a>(&'a str); + +impl<'a> FrontmatterKey<'a> { + pub(crate) fn new(key: &'a str) -> Self { + Self(key) + } + + pub(crate) fn normalized(self) -> String { + self.0.trim().to_ascii_lowercase().replace(' ', "_") + } + + pub(crate) fn is_reserved(self) -> bool { + self.normalized().starts_with('_') || is_known_frontmatter_key(self) + } +} + +const KNOWN_FRONTMATTER_KEYS: &[FrontmatterKeyRule] = &[ + FrontmatterKeyRule { + read_key: "title", + write_key: "title", + aliases: &["title"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "type", + write_key: "type", + aliases: &["type", "is_a", "Is A"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "aliases", + write_key: "aliases", + aliases: &["aliases"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_archived", + write_key: "_archived", + aliases: &["_archived", "Archived", "archived"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "Status", + write_key: "Status", + aliases: &["Status", "status"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_icon", + write_key: "_icon", + aliases: &["_icon", "icon"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "color", + write_key: "color", + aliases: &["color"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_order", + write_key: "_order", + aliases: &["_order", "order"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "_sidebar_label", + write_key: "_sidebar_label", + aliases: &["_sidebar_label", "sidebar_label", "sidebar label"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "template", + write_key: "template", + aliases: &["template"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_sort", + write_key: "_sort", + aliases: &["_sort", "sort"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "view", + write_key: "view", + aliases: &["view"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_width", + write_key: "_width", + aliases: &["_width", "width"], + canonicalize_on_write: true, + }, + FrontmatterKeyRule { + read_key: "_display", + write_key: "_display", + aliases: &["_display"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "visible", + write_key: "visible", + aliases: &["visible"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_organized", + write_key: "_organized", + aliases: &["_organized"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_favorite", + write_key: "_favorite", + aliases: &["_favorite"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_favorite_index", + write_key: "_favorite_index", + aliases: &["_favorite_index"], + canonicalize_on_write: false, + }, + FrontmatterKeyRule { + read_key: "_list_properties_display", + write_key: "_list_properties_display", + aliases: &["_list_properties_display"], + canonicalize_on_write: false, + }, +]; + +impl FrontmatterKeyRule { + pub(crate) fn read_key(self) -> &'static str { + self.read_key + } + + pub(crate) fn write_key(self) -> &'static str { + self.write_key + } + + pub(crate) fn canonicalizes_on_write(self) -> bool { + self.canonicalize_on_write + } + + fn matches(self, key: FrontmatterKey<'_>) -> bool { + let normalized = key.normalized(); + self.aliases + .iter() + .any(|alias| FrontmatterKey::new(alias).normalized() == normalized) + } +} + +pub(crate) fn frontmatter_key_rule(key: FrontmatterKey<'_>) -> Option { + KNOWN_FRONTMATTER_KEYS + .iter() + .copied() + .find(|rule| rule.matches(key)) +} + +pub(crate) fn canonical_known_frontmatter_key(key: FrontmatterKey<'_>) -> Option<&'static str> { + frontmatter_key_rule(key).map(FrontmatterKeyRule::read_key) +} + +pub(crate) fn frontmatter_keys_match(left: FrontmatterKey<'_>, right: FrontmatterKey<'_>) -> bool { + match (frontmatter_key_rule(left), frontmatter_key_rule(right)) { + (Some(left_rule), Some(right_rule)) => left_rule.read_key() == right_rule.read_key(), + _ => left.normalized() == right.normalized(), + } +} + +pub(crate) fn is_known_frontmatter_key(key: FrontmatterKey<'_>) -> bool { + frontmatter_key_rule(key).is_some() +} diff --git a/src-tauri/src/frontmatter/mod.rs b/src-tauri/src/frontmatter/mod.rs new file mode 100644 index 0000000..d876712 --- /dev/null +++ b/src-tauri/src/frontmatter/mod.rs @@ -0,0 +1,254 @@ +pub(crate) mod keys; +mod ops; +#[cfg(test)] +mod ops_update_tests; +mod yaml; + +use std::fs; +use std::path::Path; + +pub use ops::update_frontmatter_content; +pub use yaml::{format_yaml_key, FrontmatterValue}; + +fn is_markdown_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") + }) +} + +fn validate_frontmatter_path(path: &str, file_path: &Path) -> Result<(), String> { + if !file_path.exists() { + return Err(format!("File does not exist: {}", path)); + } + + if !is_markdown_path(file_path) { + return Err(format!( + "Frontmatter can only be updated on Markdown notes: {}", + path + )); + } + + Ok(()) +} + +/// Helper to read a file, apply a frontmatter transformation, and write back. +pub fn with_frontmatter(path: &str, transform: F) -> Result +where + F: FnOnce(&str) -> Result, +{ + let file_path = Path::new(path); + validate_frontmatter_path(path, file_path)?; + + let content = + fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?; + + let updated = transform(&content)?; + + fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?; + + Ok(updated) +} + +/// Update a single frontmatter property in a markdown file. +pub fn update_frontmatter( + path: &str, + key: &str, + value: FrontmatterValue, +) -> Result { + with_frontmatter(path, |content| { + update_frontmatter_content(content, key, Some(value.clone())) + }) +} + +/// Delete a frontmatter property from a markdown file. +pub fn delete_frontmatter_property(path: &str, key: &str) -> Result { + with_frontmatter(path, |content| { + update_frontmatter_content(content, key, None) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_with_frontmatter_file_not_found() { + let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string())); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn test_update_frontmatter_rejects_binary_attachment_before_utf8_read() { + let dir = tempfile::tempdir().unwrap(); + let attachment_dir = dir.path().join("attachments"); + fs::create_dir_all(&attachment_dir).unwrap(); + let attachment_path = attachment_dir.join("screenshot.png"); + fs::write(&attachment_path, [0xff, 0xfe, 0xfd]).unwrap(); + + let err = update_frontmatter( + attachment_path.to_str().unwrap(), + "Status", + FrontmatterValue::String("Done".to_string()), + ) + .unwrap_err(); + + assert!(err.contains("Frontmatter can only be updated on Markdown notes")); + assert!(err.contains("screenshot.png")); + } + + #[test] + fn test_roundtrip_update_string() { + let content = "---\nStatus: Draft\n---\n# Test\n"; + let updated = update_frontmatter_content( + content, + "Status", + Some(FrontmatterValue::String("Active".to_string())), + ) + .unwrap(); + let matter = gray_matter::Matter::::new(); + let parsed = matter.parse(&updated); + let data = parsed.data.unwrap(); + if let gray_matter::Pod::Hash(map) = data { + assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active"); + } else { + panic!("Expected hash"); + } + } + + #[test] + fn test_roundtrip_update_list() { + let content = "---\nStatus: Draft\n---\n# Test\n"; + let updated = update_frontmatter_content( + content, + "aliases", + Some(FrontmatterValue::List(vec![ + "A".to_string(), + "B".to_string(), + ])), + ) + .unwrap(); + let matter = gray_matter::Matter::::new(); + let parsed = matter.parse(&updated); + let data = parsed.data.unwrap(); + if let gray_matter::Pod::Hash(map) = data { + let aliases = map.get("aliases").unwrap(); + if let gray_matter::Pod::Array(arr) = aliases { + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].as_string().unwrap(), "A"); + assert_eq!(arr[1].as_string().unwrap(), "B"); + } else { + panic!("Expected array"); + } + } else { + panic!("Expected hash"); + } + } + + #[test] + fn test_roundtrip_add_then_delete() { + let content = "---\nStatus: Draft\n---\n# Test\n"; + let with_owner = update_frontmatter_content( + content, + "Owner", + Some(FrontmatterValue::String("Luca".to_string())), + ) + .unwrap(); + assert!(with_owner.contains("Owner: Luca")); + let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap(); + assert!(!without_owner.contains("Owner")); + assert!(without_owner.contains("Status: Draft")); + } + + #[test] + fn test_update_frontmatter_empty_block() { + let content = "---\n---\n\n# Test\n"; + let result = update_frontmatter_content( + content, + "title", + Some(FrontmatterValue::String("New Title".to_string())), + ); + assert!(result.is_ok()); + assert!(result.unwrap().contains("title: New Title")); + } + + #[test] + fn test_update_frontmatter_block_scalar_writes_and_rewrites() { + let cases = [ + ( + "---\ntype: Type\n---\n# Project\n", + "## Objective\n\n## Timeline", + &["template: |", " ## Objective", "type: Type"][..], + &[][..], + ), + ( + "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n", + "## New\n\n## Content", + &[" ## New", "color: green"][..], + &["## Old"][..], + ), + ]; + + for (content, template, expected_present, expected_absent) in cases { + let updated = update_frontmatter_content( + content, + "template", + Some(FrontmatterValue::String(template.to_string())), + ) + .unwrap(); + for expected in expected_present { + assert!(updated.contains(expected)); + } + for unexpected in expected_absent { + assert!(!updated.contains(unexpected)); + } + } + } + + #[test] + fn test_delete_frontmatter_block_scalar() { + let content = + "---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n"; + let updated = update_frontmatter_content(content, "template", None).unwrap(); + assert!(!updated.contains("template")); + assert!(updated.contains("color: green")); + } + + #[test] + fn test_update_frontmatter_no_body_after_closing() { + let content = "---\ntitle: Old\n---\n"; + let updated = update_frontmatter_content( + content, + "title", + Some(FrontmatterValue::String("New".to_string())), + ) + .unwrap(); + assert!(updated.contains("title: New")); + assert!(!updated.contains("title: Old")); + } + + #[test] + fn test_roundtrip_block_scalar() { + let content = "---\ntype: Type\n---\n# Project\n"; + let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates."; + let updated = update_frontmatter_content( + content, + "template", + Some(FrontmatterValue::String(template.to_string())), + ) + .unwrap(); + let matter = gray_matter::Matter::::new(); + let parsed = matter.parse(&updated); + let data = parsed.data.unwrap(); + if let gray_matter::Pod::Hash(map) = data { + let roundtripped = map.get("template").unwrap().as_string().unwrap(); + assert!(roundtripped.contains("## Objective")); + assert!(roundtripped.contains("## Timeline")); + assert!(roundtripped.contains("Describe the goal.")); + } else { + panic!("Expected hash"); + } + } +} diff --git a/src-tauri/src/frontmatter/ops.rs b/src-tauri/src/frontmatter/ops.rs new file mode 100644 index 0000000..a5f67b3 --- /dev/null +++ b/src-tauri/src/frontmatter/ops.rs @@ -0,0 +1,267 @@ +use super::keys::{frontmatter_key_rule, frontmatter_keys_match, FrontmatterKey}; +use super::yaml::{format_yaml_field, FrontmatterValue}; + +/// Check if a line continues the previous key's value (indented list item, +/// block scalar content, or blank line inside a block scalar). +fn is_value_continuation(line: FrontmatterLine<'_>) -> bool { + line.0.is_empty() || line.0.starts_with(" ") || line.0.starts_with('\t') +} + +#[derive(Clone, Copy)] +enum KeyMatchMode { + Exact, + Canonical, +} + +#[derive(Clone, Copy)] +struct DocumentText<'a>(&'a str); + +#[derive(Clone, Copy)] +struct FrontmatterLine<'a>(&'a str); + +#[derive(Clone, Copy)] +struct PropertyKey<'a>(&'a str); + +#[derive(Clone, Copy)] +struct FrontmatterBlock<'a> { + body: &'a str, + rest: &'a str, + line_ending: &'static str, +} + +impl<'a> PropertyKey<'a> { + fn as_str(self) -> &'a str { + self.0 + } + + fn matches(self, candidate: &str, mode: KeyMatchMode) -> bool { + match mode { + KeyMatchMode::Exact => candidate == self.as_str(), + KeyMatchMode::Canonical => frontmatter_keys_match( + FrontmatterKey::new(candidate), + FrontmatterKey::new(self.as_str()), + ), + } + } +} + +impl<'a> FrontmatterLine<'a> { + fn key(self) -> Option<&'a str> { + let trimmed = self.0.trim_start(); + if let Some(raw) = trimmed.strip_prefix('"') { + return quoted_yaml_key(raw, '"'); + } + if let Some(raw) = trimmed.strip_prefix('\'') { + return quoted_yaml_key(raw, '\''); + } + trimmed + .split_once(':') + .map(|(key, _)| key.trim()) + .filter(|key| !key.is_empty()) + } +} + +fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> { + let (key, rest) = raw.split_once(quote)?; + rest.trim_start().starts_with(':').then_some(key) +} + +fn frontmatter_open(content: &str) -> Option<(&str, &'static str)> { + content + .strip_prefix("---\n") + .map(|after| (after, "\n")) + .or_else(|| content.strip_prefix("---\r\n").map(|after| (after, "\r\n"))) +} + +fn close_marker(line_ending: &str) -> String { + format!("{line_ending}---") +} + +fn split_frontmatter_block(content: &str) -> Result>, String> { + let Some((after_open, line_ending)) = frontmatter_open(content) else { + return Ok(None); + }; + + if let Some(rest) = after_open.strip_prefix("---") { + return Ok(Some(FrontmatterBlock { + body: "", + rest, + line_ending, + })); + } + + let marker = close_marker(line_ending); + let close_start = after_open + .find(&marker) + .ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?; + let rest_start = close_start + marker.len(); + Ok(Some(FrontmatterBlock { + body: &after_open[..close_start], + rest: &after_open[rest_start..], + line_ending, + })) +} + +#[derive(Clone, Copy)] +struct FieldUpdate<'a> { + key: PropertyKey<'a>, + value: Option<&'a FrontmatterValue>, + match_mode: KeyMatchMode, +} + +impl<'a> FieldUpdate<'a> { + fn matches_line(self, line: FrontmatterLine<'_>) -> bool { + line.key() + .is_some_and(|candidate| self.key.matches(candidate, self.match_mode)) + } + + fn prepend_to(self, content: DocumentText<'_>) -> String { + let field_lines = + format_yaml_field(self.key.as_str(), self.value.expect("value must exist")); + format!("---\n{}\n---\n{}", field_lines.join("\n"), content.0) + } + + fn apply_to_lines(self, lines: &[FrontmatterLine<'_>]) -> Vec { + let mut new_lines: Vec = Vec::new(); + let mut found_key = false; + let mut i = 0; + + while i < lines.len() { + if !self.matches_line(lines[i]) { + new_lines.push(lines[i].0.to_string()); + i += 1; + continue; + } + + found_key = true; + i += 1; + while i < lines.len() && is_value_continuation(lines[i]) { + i += 1; + } + if let Some(v) = self.value { + new_lines.extend(format_yaml_field(self.key.as_str(), v)); + } + } + + if let (false, Some(v)) = (found_key, self.value) { + new_lines.extend(format_yaml_field(self.key.as_str(), v)); + } + + new_lines + } + + fn apply_to_content(self, content: DocumentText<'_>) -> Result { + let Some(block) = split_frontmatter_block(content.0)? else { + return match self.value { + Some(_) => Ok(self.prepend_to(content)), + None => Ok(content.0.to_string()), + }; + }; + + let lines: Vec> = block.body.lines().map(FrontmatterLine).collect(); + let new_fm = self.apply_to_lines(&lines).join(block.line_ending); + Ok(format!( + "---{}{}{}---{}", + block.line_ending, new_fm, block.line_ending, block.rest + )) + } +} + +/// Internal function to update frontmatter content +pub fn update_frontmatter_content( + content: &str, + key: &str, + value: Option, +) -> Result { + let update = FieldUpdate { + key: PropertyKey(key), + value: value.as_ref(), + match_mode: KeyMatchMode::Exact, + }; + let Some(rule) = frontmatter_key_rule(FrontmatterKey::new(update.key.as_str())) + .filter(|rule| rule.canonicalizes_on_write()) + else { + return update.apply_to_content(DocumentText(content)); + }; + + let updated = FieldUpdate { + key: PropertyKey(rule.write_key()), + value: None, + match_mode: KeyMatchMode::Canonical, + } + .apply_to_content(DocumentText(content))?; + + FieldUpdate { + key: PropertyKey(rule.write_key()), + value: update.value, + match_mode: KeyMatchMode::Exact, + } + .apply_to_content(DocumentText(&updated)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bool_value(value: bool) -> FrontmatterValue { + FrontmatterValue::Bool(value) + } + + fn string_value(value: &str) -> FrontmatterValue { + FrontmatterValue::String(value.to_string()) + } + + fn frontmatter_delimiter_lines(content: &str) -> usize { + content.lines().filter(|line| *line == "---").count() + } + + #[test] + fn updates_existing_crlf_frontmatter_without_creating_a_second_block() { + let content = concat!( + "---\r\n", + "type: Note\r\n", + "related_to:\r\n", + " - \"[[tolaria]]\"\r\n", + "---\r\n", + "# Properties Panel\r\n", + ); + + let updated = update_frontmatter_content(content, "_organized", Some(bool_value(true))) + .expect("frontmatter update should succeed"); + + assert_eq!(frontmatter_delimiter_lines(&updated), 2); + assert_eq!( + updated, + concat!( + "---\r\n", + "type: Note\r\n", + "related_to:\r\n", + " - \"[[tolaria]]\"\r\n", + "_organized: true\r\n", + "---\r\n", + "# Properties Panel\r\n", + ) + ); + } + + #[test] + fn repeated_crlf_updates_stay_in_the_original_frontmatter_block() { + let content = concat!( + "---\r\n", + "type: Note\r\n", + "related_to: \"[[tolaria]]\"\r\n", + "---\r\n", + "# Properties Panel\r\n", + ); + + let widened = update_frontmatter_content(content, "_width", Some(string_value("wide"))) + .expect("width update should succeed"); + let organized = update_frontmatter_content(&widened, "_organized", Some(bool_value(true))) + .expect("organized update should succeed"); + + assert_eq!(frontmatter_delimiter_lines(&organized), 2); + assert!(organized.contains("type: Note\r\n")); + assert!(organized.contains("_width: wide\r\n")); + assert!(organized.contains("_organized: true\r\n")); + } +} diff --git a/src-tauri/src/frontmatter/ops_update_tests.rs b/src-tauri/src/frontmatter/ops_update_tests.rs new file mode 100644 index 0000000..966a92f --- /dev/null +++ b/src-tauri/src/frontmatter/ops_update_tests.rs @@ -0,0 +1,237 @@ +use super::{update_frontmatter_content, FrontmatterValue}; + +struct UpdateCase<'a> { + content: &'a str, + key: &'a str, + value: Option, + expected_present: &'a [&'a str], + expected_absent: &'a [&'a str], +} + +fn assert_updated_content(case: UpdateCase<'_>) { + let updated = update_frontmatter_content(case.content, case.key, case.value).unwrap(); + for expected in case.expected_present { + assert!( + updated.contains(expected), + "missing expected snippet: {expected}" + ); + } + for unexpected in case.expected_absent { + assert!( + !updated.contains(unexpected), + "found unexpected snippet: {unexpected}" + ); + } +} + +#[test] +fn test_update_frontmatter_replaces_or_adds_scalar_fields() { + let cases = [ + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "Status", + value: Some(FrontmatterValue::String("Active".to_string())), + expected_present: &["Status: Active"], + expected_absent: &["Status: Draft"], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "Owner", + value: Some(FrontmatterValue::String("Luca".to_string())), + expected_present: &["Owner: Luca", "Status: Draft"], + expected_absent: &[], + }, + UpdateCase { + content: "---\n\"Is A\": Note\n---\n# Test\n", + key: "Is A", + value: Some(FrontmatterValue::String("Project".to_string())), + expected_present: &["type: Project"], + expected_absent: &["\"Is A\": Note", "\"Is A\": Project"], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "Reviewed", + value: Some(FrontmatterValue::Bool(true)), + expected_present: &["Reviewed: true"], + expected_absent: &[], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "Priority", + value: Some(FrontmatterValue::Number(5.0)), + expected_present: &["Priority: 5"], + expected_absent: &[], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "Score", + value: Some(FrontmatterValue::Number(9.5)), + expected_present: &["Score: 9.5"], + expected_absent: &[], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "ClearMe", + value: Some(FrontmatterValue::Null), + expected_present: &["ClearMe: null"], + expected_absent: &[], + }, + ]; + + for case in cases { + assert_updated_content(case); + } +} + +#[test] +fn test_update_frontmatter_list_and_delete_paths() { + let list_cases = [ + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "aliases", + value: Some(FrontmatterValue::List(vec![ + "Alias1".to_string(), + "Alias2".to_string(), + ])), + expected_present: &["aliases:", " - \"Alias1\"", " - \"Alias2\""], + expected_absent: &[], + }, + UpdateCase { + content: "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n", + key: "aliases", + value: Some(FrontmatterValue::List(vec!["New1".to_string()])), + expected_present: &[" - \"New1\"", "Status: Draft"], + expected_absent: &["Old1", "Old2"], + }, + UpdateCase { + content: "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n", + key: "aliases", + value: None, + expected_present: &["Status: Draft"], + expected_absent: &["aliases", "Alias1"], + }, + UpdateCase { + content: "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n", + key: "Owner", + value: None, + expected_present: &["Status: Draft"], + expected_absent: &["Owner"], + }, + UpdateCase { + content: "---\nStatus: Draft\n---\n# Test\n", + key: "tags", + value: Some(FrontmatterValue::List(vec![])), + expected_present: &["tags: []"], + expected_absent: &[], + }, + ]; + + for case in list_cases { + assert_updated_content(case); + } +} + +#[test] +fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() { + let inserted = update_frontmatter_content( + "# Test\n\nSome content here.", + "Status", + Some(FrontmatterValue::String("Draft".to_string())), + ) + .unwrap(); + assert!(inserted.starts_with("---\n")); + assert!(inserted.contains("Status: Draft")); + assert!(inserted.contains("# Test")); + + let malformed = update_frontmatter_content( + "---\nStatus: Draft\nNo closing fence here", + "Status", + Some(FrontmatterValue::String("Active".to_string())), + ); + assert!(malformed.is_err()); + assert!(malformed.unwrap_err().contains("Malformed frontmatter")); + + let unchanged = + update_frontmatter_content("---\nStatus: Draft\n---\n# Test\n", "Missing", None).unwrap(); + assert_eq!(unchanged, "---\nStatus: Draft\n---\n# Test\n"); + + let no_frontmatter = + update_frontmatter_content("# Test\n\nSome content.", "Missing", None).unwrap(); + assert_eq!(no_frontmatter, "# Test\n\nSome content."); +} + +#[test] +fn test_update_frontmatter_canonicalizes_system_metadata_keys() { + let cases = [ + UpdateCase { + content: "---\narchived: false\n---\n# Test\n", + key: "_archived", + value: Some(FrontmatterValue::Bool(true)), + expected_present: &["_archived: true"], + expected_absent: &["archived: false"], + }, + UpdateCase { + content: "---\nicon: rocket\n---\n# Test\n", + key: "icon", + value: Some(FrontmatterValue::String("star".to_string())), + expected_present: &["_icon: star"], + expected_absent: &["\nicon:", "rocket"], + }, + UpdateCase { + content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n", + key: "_sidebar_label", + value: Some(FrontmatterValue::String("Programs".to_string())), + expected_present: &["_sidebar_label: Programs"], + expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"], + }, + UpdateCase { + content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n", + key: "_sort", + value: None, + expected_present: &["# Test"], + expected_absent: &["\nsort:", "\n_sort:"], + }, + ]; + + for case in cases { + assert_updated_content(case); + } +} + +#[test] +fn test_update_frontmatter_canonicalizes_type_key_case() { + let cases = [ + UpdateCase { + content: "---\nType: Note\n---\n# Test\n", + key: "type", + value: Some(FrontmatterValue::String("Project".to_string())), + expected_present: &["type: Project"], + expected_absent: &["Type: Note"], + }, + UpdateCase { + content: "---\n\"Is A\": Note\nis_a: Topic\n---\n# Test\n", + key: "type", + value: Some(FrontmatterValue::String("Project".to_string())), + expected_present: &["type: Project"], + expected_absent: &["\"Is A\": Note", "is_a: Topic"], + }, + UpdateCase { + content: "---\nTYPE: Note\n---\n# Test\n", + key: "Type", + value: Some(FrontmatterValue::String("Person".to_string())), + expected_present: &["type: Person"], + expected_absent: &["TYPE: Note"], + }, + UpdateCase { + content: "---\nType: Note\nstatus: Active\n---\n# Test\n", + key: "type", + value: None, + expected_present: &["status: Active", "# Test"], + expected_absent: &["Type: Note", "\ntype:"], + }, + ]; + + for case in cases { + assert_updated_content(case); + } +} diff --git a/src-tauri/src/frontmatter/yaml.rs b/src-tauri/src/frontmatter/yaml.rs new file mode 100644 index 0000000..5b74e4d --- /dev/null +++ b/src-tauri/src/frontmatter/yaml.rs @@ -0,0 +1,253 @@ +use serde::{Deserialize, Serialize}; + +/// Value type for frontmatter updates +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum FrontmatterValue { + String(String), + Number(f64), + Bool(bool), + List(Vec), + Null, +} + +#[derive(Clone, Copy)] +struct YamlText<'a>(&'a str); + +impl<'a> YamlText<'a> { + /// Characters that require a YAML string value to be quoted. + fn has_special_chars(self) -> bool { + self.0.contains(':') || self.0.contains('#') + } + + /// Check if a string starts with a YAML collection indicator (array or map). + fn starts_as_collection(self) -> bool { + self.0.starts_with('[') || self.0.starts_with('{') + } + + /// Check whether a YAML string value needs quoting to avoid ambiguity. + fn needs_quoting(self) -> bool { + self.has_special_chars() + || self.starts_as_collection() + || matches!(self.0, "true" | "false" | "null") + || self.0.parse::().is_ok() + } + + /// Quote a string value for YAML, escaping internal double quotes. + fn quoted(self) -> String { + format!("\"{}\"", self.0.replace('\"', "\\\"")) + } + + /// Format a single YAML list item as ` - "value"`. + fn as_list_item(self) -> String { + format!(" - {}", self.quoted()) + } + + /// Format a multi-line string as a YAML block scalar (`|`). + /// Each line is indented by 2 spaces; empty lines are preserved as blank. + fn as_block_scalar(self) -> String { + let indented = self + .0 + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + format!("|\n{}", indented) + } + + /// Check whether a YAML key needs quoting (contains spaces, special chars, etc.). + fn needs_key_quoting(self) -> bool { + self.0 + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') + } +} + +/// Format a key for YAML output (quote if necessary) +pub fn format_yaml_key(key: &str) -> String { + let yaml_key = YamlText(key); + if yaml_key.needs_key_quoting() { + yaml_key.quoted() + } else { + key.to_string() + } +} + +/// Format a number for YAML (integers without decimal, floats with). +fn format_yaml_number(n: f64) -> String { + if n.fract() == 0.0 { + format!("{}", n as i64) + } else { + format!("{}", n) + } +} + +impl FrontmatterValue { + pub fn to_yaml_value(&self) -> String { + match self { + FrontmatterValue::String(s) => { + let yaml_text = YamlText(s); + if s.contains('\n') { + yaml_text.as_block_scalar() + } else if yaml_text.needs_quoting() { + yaml_text.quoted() + } else { + s.clone() + } + } + FrontmatterValue::Number(n) => format_yaml_number(*n), + FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), + FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(), + FrontmatterValue::List(items) => items + .iter() + .map(|item| YamlText(item).as_list_item()) + .collect::>() + .join("\n"), + FrontmatterValue::Null => "null".to_string(), + } + } +} + +/// Format a key-value pair as one or more YAML lines. +pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec { + let yaml_key = format_yaml_key(key); + let yaml_value = value.to_yaml_value(); + if yaml_value.starts_with("|\n") { + // Block scalar: key and indicator on the same line, content follows + vec![format!("{}: {}", yaml_key, yaml_value)] + } else if matches!(value, FrontmatterValue::List(items) if !items.is_empty()) { + vec![format!("{}:", yaml_key), yaml_value] + } else { + vec![format!("{}: {}", yaml_key, yaml_value)] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_string_yaml_value(input: &str, expected: &str) { + let value = FrontmatterValue::String(input.to_string()); + assert_eq!(value.to_yaml_value(), expected); + } + + fn assert_field_lines(key: &str, value: FrontmatterValue, expected: &[&str]) { + let lines = format_yaml_field(key, &value); + let expected_lines = expected + .iter() + .map(|line| line.to_string()) + .collect::>(); + assert_eq!(lines, expected_lines); + } + + #[test] + fn test_to_yaml_value_string_needs_quoting_cases() { + for (input, expected) in [ + ("key: value", "\"key: value\""), + ("has # comment", "\"has # comment\""), + ("[array-like]", "\"[array-like]\""), + ("{object-like}", "\"{object-like}\""), + ("true", "\"true\""), + ("false", "\"false\""), + ("null", "\"null\""), + ("42", "\"42\""), + ("3.14", "\"3.14\""), + ] { + assert_string_yaml_value(input, expected); + } + } + + #[test] + fn test_to_yaml_value_string_plain() { + assert_string_yaml_value("Hello World", "Hello World"); + } + + #[test] + fn test_to_yaml_value_number_integer() { + let v = FrontmatterValue::Number(42.0); + assert_eq!(v.to_yaml_value(), "42"); + } + + #[test] + fn test_to_yaml_value_number_float() { + let v = FrontmatterValue::Number(3.125); + assert_eq!(v.to_yaml_value(), "3.125"); + } + + #[test] + fn test_to_yaml_value_null() { + assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null"); + } + + #[test] + fn test_to_yaml_value_empty_list() { + let v = FrontmatterValue::List(vec![]); + assert_eq!(v.to_yaml_value(), "[]"); + } + + #[test] + fn test_to_yaml_value_list_with_colon() { + let v = FrontmatterValue::List(vec!["key: value".to_string()]); + assert_eq!(v.to_yaml_value(), " - \"key: value\""); + } + + #[test] + fn test_to_yaml_value_multiline_uses_block_scalar() { + let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string()); + let yaml = v.to_yaml_value(); + assert!(yaml.starts_with("|\n")); + assert!(yaml.contains(" line 1")); + assert!(yaml.contains(" line 2")); + } + + #[test] + fn test_format_yaml_key_simple() { + for (input, expected) in [("Status", "Status"), ("is_a", "is_a")] { + assert_eq!(format_yaml_key(input), expected); + } + } + + #[test] + fn test_format_yaml_key_quotes_when_needed() { + for (input, expected) in [ + ("Is A", "\"Is A\""), + ("Created at", "\"Created at\""), + ("key:value", "\"key:value\""), + ("has#tag", "\"has#tag\""), + ("key.name", "\"key.name\""), + ] { + assert_eq!(format_yaml_key(input), expected); + } + } + + #[test] + fn test_format_yaml_field_block_scalar() { + let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string()); + let lines = format_yaml_field("template", &v); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("template: |\n")); + assert!(lines[0].contains(" ## Objective")); + assert!(lines[0].contains(" ## Timeline")); + } + + #[test] + fn test_format_yaml_field_list_layouts() { + assert_field_lines( + "_list_properties_display", + FrontmatterValue::List(vec!["Belongs to".to_string()]), + &["_list_properties_display:", " - \"Belongs to\""], + ); + assert_field_lines("tags", FrontmatterValue::List(vec![]), &["tags: []"]); + assert_field_lines( + "tags", + FrontmatterValue::List(vec!["Alpha".to_string(), "Beta".to_string()]), + &["tags:", " - \"Alpha\"\n - \"Beta\""], + ); + } +} diff --git a/src-tauri/src/git/author.rs b/src-tauri/src/git/author.rs new file mode 100644 index 0000000..3463546 --- /dev/null +++ b/src-tauri/src/git/author.rs @@ -0,0 +1,294 @@ +use serde::Serialize; +use std::path::Path; + +use super::command::git_output_result; +use super::run_git; + +pub(crate) const FALLBACK_AUTHOR_NAME: &str = "Tolaria"; +pub(crate) const FALLBACK_AUTHOR_EMAIL: &str = "vault@tolaria.default"; +pub(crate) const LEGACY_FALLBACK_EMAIL: &str = "vault@tolaria.md"; + +const SOURCE_FALLBACK: &str = "fallback"; +const SOURCE_GLOBAL: &str = "global"; +const SOURCE_REPOSITORY: &str = "repository"; +const SOURCE_SYSTEM: &str = "system"; +const SOURCE_UNKNOWN: &str = "unknown"; +const SOURCE_ENVIRONMENT: &str = "environment"; +const WARNING_LOCAL_OVERRIDES_GLOBAL: &str = "local_overrides_global"; + +#[derive(Clone, Copy)] +pub(crate) enum AuthorConfigKey { + Name, + Email, +} + +#[derive(Clone, Copy)] +enum ConfigScope { + Local, + Global, +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct GitAuthorIdentity { + pub name: String, + pub email: String, + pub source: String, + pub warning: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AuthorIdentity { + name: String, + email: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ScopedConfigValue { + scope: String, + value: String, +} + +pub fn git_author_identity(vault_path: &str) -> Result { + let dir = Path::new(vault_path); + + ensure_author_config(dir)?; + let identity = resolved_git_author_identity(dir)?; + let source = author_identity_source(dir, &identity.email)?; + let warning = local_global_identity_warning(dir)?; + + Ok(GitAuthorIdentity { + name: identity.name, + email: identity.email, + source, + warning, + }) +} + +pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> { + heal_legacy_local_identity(dir)?; + + for (key, fallback, skip_legacy) in [ + (AuthorConfigKey::Name, FALLBACK_AUTHOR_NAME, false), + (AuthorConfigKey::Email, FALLBACK_AUTHOR_EMAIL, true), + ] { + let key_name = match key { + AuthorConfigKey::Name => "user.name", + AuthorConfigKey::Email => "user.email", + }; + let resolved = git_output_result(dir, &["config", key_name]) + .map_err(|e| format!("Failed to check git config {key_name}: {e}"))?; + + let value = String::from_utf8_lossy(&resolved.stdout); + let value = value.trim(); + if resolved.status.success() && resolved_author_value_is_usable(value, skip_legacy) { + continue; + } + + run_git(dir, &["config", "--local", key_name, fallback])?; + } + Ok(()) +} + +fn resolved_author_value_is_usable(value: &str, skip_legacy: bool) -> bool { + if value.is_empty() { + return false; + } + + !skip_legacy || value != LEGACY_FALLBACK_EMAIL +} + +fn heal_legacy_local_identity(dir: &Path) -> Result<(), String> { + let local_email = local_config_value(dir, AuthorConfigKey::Email)?; + if local_email.as_deref() != Some(LEGACY_FALLBACK_EMAIL) { + return Ok(()); + } + + run_git(dir, &["config", "--local", "--unset-all", "user.email"])?; + if local_config_value(dir, AuthorConfigKey::Name)?.as_deref() == Some(FALLBACK_AUTHOR_NAME) { + run_git(dir, &["config", "--local", "--unset-all", "user.name"])?; + } + Ok(()) +} + +pub(crate) fn local_config_value( + dir: &Path, + key: AuthorConfigKey, +) -> Result, String> { + config_value(dir, ConfigScope::Local, key) +} + +fn global_config_value(dir: &Path, key: AuthorConfigKey) -> Result, String> { + config_value(dir, ConfigScope::Global, key) +} + +fn config_value( + dir: &Path, + scope: ConfigScope, + key: AuthorConfigKey, +) -> Result, String> { + let scope_flag = match scope { + ConfigScope::Local => "--local", + ConfigScope::Global => "--global", + }; + let key_name = match key { + AuthorConfigKey::Name => "user.name", + AuthorConfigKey::Email => "user.email", + }; + let output = git_output_result(dir, &["config", scope_flag, key_name]) + .map_err(|e| format!("Failed to check git config {key_name}: {e}"))?; + + let value = String::from_utf8_lossy(&output.stdout); + let value = value.trim(); + Ok((output.status.success() && !value.is_empty()).then(|| value.to_string())) +} + +fn resolved_git_author_identity(dir: &Path) -> Result { + let output = git_output_result(dir, &["var", "GIT_AUTHOR_IDENT"]) + .map_err(|e| format!("Failed to resolve git author identity: {e}"))?; + + if !output.status.success() { + return Err(author_identity_error(&output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_author_ident(&stdout).ok_or_else(|| "Failed to parse git author identity".to_string()) +} + +fn author_identity_error(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + format!("Failed to resolve git author identity: {detail}") +} + +fn parse_author_ident(value: &str) -> Option { + let value = value.trim(); + let close = value.rfind('>')?; + let before_email_close = &value[..close]; + let open = before_email_close.rfind('<')?; + let name = before_email_close[..open].trim(); + let email = before_email_close[open + 1..].trim(); + + if name.is_empty() || email.is_empty() { + return None; + } + + Some(AuthorIdentity { + name: name.to_string(), + email: email.to_string(), + }) +} + +fn author_identity_source(dir: &Path, email: &str) -> Result { + if email == FALLBACK_AUTHOR_EMAIL { + return Ok(SOURCE_FALLBACK.to_string()); + } + + let Some(config) = scoped_config_value(dir, AuthorConfigKey::Email)? else { + return Ok(SOURCE_UNKNOWN.to_string()); + }; + + if config.value != email { + return Ok(SOURCE_ENVIRONMENT.to_string()); + } + + Ok(scope_source(&config.scope).to_string()) +} + +fn scoped_config_value( + dir: &Path, + key: AuthorConfigKey, +) -> Result, String> { + let key_name = match key { + AuthorConfigKey::Name => "user.name", + AuthorConfigKey::Email => "user.email", + }; + let output = git_output_result(dir, &["config", "--show-scope", "--get", key_name]) + .map_err(|e| format!("Failed to check git config {key_name}: {e}"))?; + + if !output.status.success() { + return Ok(None); + } + + Ok(parse_scoped_config_value(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +fn parse_scoped_config_value(stdout: &str) -> Option { + let line = stdout.lines().next()?.trim(); + let (scope, value) = line.split_once('\t')?; + let value = value.trim(); + (!scope.is_empty() && !value.is_empty()).then(|| ScopedConfigValue { + scope: scope.to_string(), + value: value.to_string(), + }) +} + +fn scope_source(scope: &str) -> &str { + match scope { + "local" | "worktree" => SOURCE_REPOSITORY, + "global" => SOURCE_GLOBAL, + "system" => SOURCE_SYSTEM, + "command" => SOURCE_ENVIRONMENT, + _ => SOURCE_UNKNOWN, + } +} + +fn local_global_identity_warning(dir: &Path) -> Result, String> { + let Some(local) = config_identity(dir, local_config_value)? else { + return Ok(None); + }; + let Some(global) = config_identity(dir, global_config_value)? else { + return Ok(None); + }; + + Ok((local != global).then(|| WARNING_LOCAL_OVERRIDES_GLOBAL.to_string())) +} + +fn config_identity( + dir: &Path, + reader: fn(&Path, AuthorConfigKey) -> Result, String>, +) -> Result, String> { + let name = reader(dir, AuthorConfigKey::Name)?; + let email = reader(dir, AuthorConfigKey::Email)?; + + Ok(match (name, email) { + (Some(name), Some(email)) => Some(AuthorIdentity { name, email }), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_author_ident_with_spaces_in_name() { + let identity = parse_author_ident("Vault Owner 1781170560 +0200") + .expect("author identity should parse"); + + assert_eq!( + identity, + AuthorIdentity { + name: "Vault Owner".to_string(), + email: "owner@example.com".to_string(), + } + ); + } + + #[test] + fn parses_scoped_config_value() { + assert_eq!( + parse_scoped_config_value("local\towner@example.com\n"), + Some(ScopedConfigValue { + scope: "local".to_string(), + value: "owner@example.com".to_string(), + }) + ); + } +} diff --git a/src-tauri/src/git/clone.rs b/src-tauri/src/git/clone.rs new file mode 100644 index 0000000..96ee15e --- /dev/null +++ b/src-tauri/src/git/clone.rs @@ -0,0 +1,266 @@ +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +use super::git_command; + +struct CloneRequest<'a> { + url: &'a str, + dest: &'a Path, +} + +/// Clone a git repository to a local path using the system git configuration. +pub fn clone_repo(url: &str, local_path: &str) -> Result { + let dest = Path::new(local_path); + let request = CloneRequest { url, dest }; + prepare_clone_destination(dest)?; + + if let Err(err) = run_clone(&request) { + cleanup_failed_clone(dest); + return Err(err); + } + + Ok(format!("Cloned to {}", dest.display())) +} + +fn prepare_clone_destination(dest: &Path) -> Result<(), String> { + if !dest.exists() { + return ensure_parent_directory(dest); + } + + ensure_empty_directory(dest) +} + +fn ensure_empty_directory(dest: &Path) -> Result<(), String> { + if !dest.is_dir() { + return Err(format!( + "Destination '{}' already exists and is not a directory", + dest.display() + )); + } + + if directory_has_entries(dest)? { + return Err(format!( + "Destination '{}' already exists and is not empty", + dest.display() + )); + } + + Ok(()) +} + +fn ensure_parent_directory(dest: &Path) -> Result<(), String> { + let Some(parent) = dest.parent() else { + return Ok(()); + }; + + if parent.as_os_str().is_empty() { + return Ok(()); + } + + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create parent directory for '{}': {}", + dest.display(), + e + ) + }) +} + +fn directory_has_entries(dest: &Path) -> Result { + dest.read_dir() + .map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e)) + .map(|mut entries| entries.next().is_some()) +} + +fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> { + let destination = request.dest.to_str().ok_or_else(|| { + format!( + "Destination '{}' is not valid UTF-8", + request.dest.display() + ) + })?; + let git_destination = super::git_path_argument(destination)?; + let output = build_clone_command(request, &git_destination) + .output() + .map_err(|e| format!("Failed to run git clone: {}", e))?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "git clone failed: {}", + clone_failure_message(&output) + )) +} + +fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command { + let mut command = git_command(); + command + .args(["clone", "--quiet", "--", request.url, destination]) + .env("GIT_TERMINAL_PROMPT", "0") + .env("SSH_ASKPASS_REQUIRE", "never") + .stdin(Stdio::null()); + command +} + +fn clone_failure_message(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if !stderr.is_empty() { + return stderr.to_string(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = stdout.trim(); + if !stdout.is_empty() { + return stdout.to_string(); + } + + format!("git clone exited with status {}", output.status) +} + +fn cleanup_failed_clone(dest: &Path) { + if dest.exists() && dest.is_dir() { + let _ = std::fs::remove_dir_all(dest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::process::ExitStatusExt; + use std::path::Path; + use std::process::Command as StdCommand; + + fn init_source_repo(path: &Path) { + fs::create_dir_all(path).unwrap(); + fs::write(path.join("welcome.md"), "# Welcome\n").unwrap(); + + StdCommand::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "tolaria@app.local"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Tolaria App"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(path) + .output() + .unwrap(); + } + + #[test] + fn test_clone_repo_clones_local_repository() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("source"); + let dest = dir.path().join("dest"); + init_source_repo(&source); + + let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap(); + + assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy())); + assert!(dest.join(".git").exists()); + assert!(dest.join("welcome.md").exists()); + } + + #[test] + fn test_clone_repo_nonempty_dest() { + let dir = tempfile::TempDir::new().unwrap(); + fs::write(dir.path().join("existing.txt"), "data").unwrap(); + + let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap()); + assert!(result.unwrap_err().contains("not empty")); + } + + #[test] + fn test_clone_repo_empty_dest_allowed() { + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("empty-dir"); + fs::create_dir(&dest).unwrap(); + + let result = clone_repo( + "https://example.com/nonexistent/repo.git", + dest.to_str().unwrap(), + ); + assert!(result.unwrap_err().contains("git clone failed")); + } + + #[test] + fn test_clone_failure_message_falls_back_to_stdout() { + let output = Output { + status: std::process::ExitStatus::from_raw(128), + stdout: b"fatal: stdout only".to_vec(), + stderr: Vec::new(), + }; + + assert_eq!(clone_failure_message(&output), "fatal: stdout only"); + } + + #[test] + fn test_build_clone_command_disables_interactive_prompts() { + let dest = Path::new("/tmp/repo"); + let request = CloneRequest { + url: "https://example.com/repo.git", + dest, + }; + let command = build_clone_command(&request, "/tmp/repo"); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + let envs = command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().to_string(), + value.map(|entry| entry.to_string_lossy().to_string()), + ) + }) + .collect::>(); + + assert_eq!( + envs.get("GIT_TERMINAL_PROMPT"), + Some(&Some("0".to_string())) + ); + assert_eq!( + envs.get("SSH_ASKPASS_REQUIRE"), + Some(&Some("never".to_string())) + ); + assert_eq!( + args, + vec![ + "-c".to_string(), + "core.quotePath=false".to_string(), + "-c".to_string(), + "protocol.ext.allow=never".to_string(), + "-c".to_string(), + "protocol.file.allow=user".to_string(), + "-c".to_string(), + "core.fsmonitor=false".to_string(), + "-c".to_string(), + "core.sshCommand=ssh".to_string(), + "clone".to_string(), + "--quiet".to_string(), + "--".to_string(), + "https://example.com/repo.git".to_string(), + "/tmp/repo".to_string(), + ] + ); + } +} diff --git a/src-tauri/src/git/command.rs b/src-tauri/src/git/command.rs new file mode 100644 index 0000000..209420e --- /dev/null +++ b/src-tauri/src/git/command.rs @@ -0,0 +1,57 @@ +use std::io; +use std::path::Path; +use std::process::Output; + +use super::git_command_at; + +pub(super) fn git_output(dir: &Path, args: &[&str]) -> io::Result { + git_command_at(dir)?.args(args).output() +} + +pub(super) fn git_output_result(dir: &Path, args: &[&str]) -> Result { + git_output(dir, args).map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args))) +} + +pub(super) fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> { + let output = git_output_result(dir, args)?; + + if output.status.success() { + return Ok(()); + } + + Err(stderr_text(&output)) +} + +pub(super) fn stdout_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +pub(super) fn stdout_lines(output: &Output) -> Vec { + stdout_text(output) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +pub(super) fn stderr_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).trim().to_string() +} + +pub(super) fn stderr_or_failure(command: &str, output: &Output) -> String { + let stderr = stderr_text(output); + if stderr.is_empty() { + format!("{command} failed") + } else { + stderr + } +} + +pub(super) fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str { + if args.first() == Some(&"-c") { + return args.get(2).copied().unwrap_or(args[0]); + } + + args[0] +} diff --git a/src-tauri/src/git/commit.rs b/src-tauri/src/git/commit.rs new file mode 100644 index 0000000..efd851a --- /dev/null +++ b/src-tauri/src/git/commit.rs @@ -0,0 +1,274 @@ +use super::command::git_output_result; +use super::{ensure_author_config, git_command_at}; +use std::path::Path; + +struct CommitFailure { + stdout: String, + stderr: String, +} + +/// Commit all changes with a message. +pub fn git_commit(vault_path: &str, message: &str) -> Result { + let vault = Path::new(vault_path); + + // Stage all changes + let add = git_output_result(vault, &["add", "-A"]) + .map_err(|e| format!("Failed to run git add: {}", e))?; + + if !add.status.success() { + let stderr = String::from_utf8_lossy(&add.stderr); + return Err(format!("git add failed: {}", stderr)); + } + + ensure_author_config(vault)?; + + match run_commit(vault, message, false) { + Ok(stdout) => Ok(stdout), + Err(failure) if is_commit_signing_failure(&failure.detail()) => { + run_commit(vault, message, true).map_err(|retry_failure| { + format!( + "git commit signing failed; retried without signing but git commit still failed: {}", + retry_failure.detail() + ) + }) + } + Err(failure) => Err(format!("git commit failed: {}", failure.detail())), + } +} + +fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result { + let mut command = git_command_at(vault).map_err(|e| CommitFailure { + stdout: String::new(), + stderr: format!("Failed to run git commit: {}", e), + })?; + if disable_signing { + command.args(["-c", "commit.gpgsign=false"]); + } + + let commit = command + .args(["commit", "-m", message]) + .output() + .map_err(|e| CommitFailure { + stdout: String::new(), + stderr: format!("Failed to run git commit: {}", e), + })?; + + if commit.status.success() { + return Ok(String::from_utf8_lossy(&commit.stdout).to_string()); + } + + Err(CommitFailure { + stdout: String::from_utf8_lossy(&commit.stdout).to_string(), + stderr: String::from_utf8_lossy(&commit.stderr).to_string(), + }) +} + +impl CommitFailure { + fn detail(&self) -> String { + // git writes "nothing to commit" to stdout, not stderr. + let detail = if self.stderr.trim().is_empty() { + &self.stdout + } else { + &self.stderr + }; + detail.trim().to_string() + } +} + +fn is_commit_signing_failure(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + lower.contains("cannot run gpg") + || lower.contains("gpg failed to sign") + || lower.contains("failed to sign the data") + || lower.contains("gpg.ssh") + || (lower.contains("failed to write commit object") + && (lower.contains("sign") || lower.contains("gpg"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::tests::{setup_git_repo, GitConfigEnvGuard}; + use std::fs; + use std::path::Path; + + fn unset_local_author_config(vault: &Path) { + for key in ["user.name", "user.email"] { + let status = git_command() + .args(["config", "--local", "--unset-all", key]) + .current_dir(vault) + .status() + .unwrap(); + assert!(status.success(), "failed to unset {key}"); + } + } + + fn local_config_value(vault: &Path, key: &str) -> Option { + let output = git_command() + .args(["config", "--local", key]) + .current_dir(vault) + .output() + .unwrap(); + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + #[test] + fn test_git_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + + fs::write(vault.join("commit-test.md"), "# Test\n").unwrap(); + + let result = git_commit(vault.to_str().unwrap(), "Test commit"); + assert!(result.is_ok()); + + // Verify the commit exists + let log = git_command() + .args(["log", "--oneline", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + let log_str = String::from_utf8_lossy(&log.stdout); + assert!(log_str.contains("Test commit")); + } + + #[test] + fn test_git_commit_sets_missing_local_author_identity() { + let _env = GitConfigEnvGuard::isolated(); + + let dir = setup_git_repo(); + let vault = dir.path(); + unset_local_author_config(vault); + + fs::write(vault.join("identity-fallback.md"), "# Identity fallback\n").unwrap(); + + let result = git_commit(vault.to_str().unwrap(), "Commit without local identity"); + assert!( + result.is_ok(), + "commit should set local fallback identity: {result:?}" + ); + + assert_eq!( + local_config_value(vault, "user.name").as_deref(), + Some("Tolaria") + ); + assert_eq!( + local_config_value(vault, "user.email").as_deref(), + Some("vault@tolaria.default") + ); + + let author = git_command() + .args(["log", "-1", "--format=%an <%ae>"]) + .current_dir(vault) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + "Tolaria " + ); + } + + #[test] + fn test_git_commit_respects_global_author_identity() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com"))); + + let dir = setup_git_repo(); + let vault = dir.path(); + unset_local_author_config(vault); + + fs::write(vault.join("global-identity.md"), "# Global identity\n").unwrap(); + + let result = git_commit(vault.to_str().unwrap(), "Commit with global identity"); + assert!( + result.is_ok(), + "commit should use the global identity: {result:?}" + ); + + // The global identity resolves, so no local override is written. + assert_eq!(local_config_value(vault, "user.name"), None); + assert_eq!(local_config_value(vault, "user.email"), None); + + let author = git_command() + .args(["log", "-1", "--format=%an <%ae>"]) + .current_dir(vault) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + "Global User " + ); + } + + #[test] + fn test_commit_nothing_to_commit_returns_error() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + // Create and commit, so working tree is clean + fs::write(vault.join("clean.md"), "# Clean\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + // Committing again with no changes should fail + let result = git_commit(vp, "nothing here"); + assert!(result.is_err(), "Commit should fail when nothing to commit"); + assert!( + result.unwrap_err().contains("nothing to commit"), + "Error should mention 'nothing to commit'" + ); + } + + #[test] + fn test_git_commit_retries_without_signing_when_gpg_is_missing() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + git_command() + .args(["config", "commit.gpgsign", "true"]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["config", "gpg.program", "/missing/tolaria-test-gpg"]) + .current_dir(vault) + .output() + .unwrap(); + fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap(); + + let result = git_commit(vp, "Commit with broken signing config"); + assert!( + result.is_ok(), + "commit should retry unsigned when signing helper is missing: {result:?}" + ); + + let log = git_command() + .args(["log", "--oneline", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config")); + + let config = git_command() + .args(["config", "commit.gpgsign"]) + .current_dir(vault) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true"); + } + + #[test] + fn test_commit_signing_failure_detection_is_specific() { + assert!(is_commit_signing_failure( + "error: cannot run gpg: No such file or directory\nfatal: failed to write commit object" + )); + assert!(!is_commit_signing_failure( + "On branch main\nnothing to commit, working tree clean" + )); + } +} diff --git a/src-tauri/src/git/conflict.rs b/src-tauri/src/git/conflict.rs new file mode 100644 index 0000000..96b9b9b --- /dev/null +++ b/src-tauri/src/git/conflict.rs @@ -0,0 +1,414 @@ +use std::path::Path; + +use super::command::git_output_result; +use super::{ensure_author_config, git_command_at, run_git}; + +/// List files with merge conflicts (unmerged paths). +/// +/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because +/// ls-files reliably detects unmerged index entries even when the merge state is +/// stale (e.g. after a reboot or when MERGE_HEAD is missing). +pub fn get_conflict_files(vault_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + let output = git_output_result(vault, &["ls-files", "--unmerged"]) + .map_err(|e| format!("Failed to check conflicts: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + // Each unmerged file appears multiple times (once per stage: base/ours/theirs). + // Format: " \t" + let mut files: Vec = stdout + .lines() + .filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string())) + .collect(); + files.sort(); + files.dedup(); + Ok(files) +} + +/// Resolve a single conflict file by choosing "ours" or "theirs" strategy, +/// then stage the result. +pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> { + let vault = Path::new(vault_path); + + let checkout_flag = match strategy { + "ours" => "--ours", + "theirs" => "--theirs", + _ => { + return Err(format!( + "Invalid strategy '{}': must be 'ours' or 'theirs'", + strategy + )) + } + }; + + run_git(vault, &["checkout", checkout_flag, "--", file])?; + run_git(vault, &["add", "--", file])?; + + Ok(()) +} + +/// Check whether a rebase is currently in progress. +pub fn is_rebase_in_progress(vault_path: &str) -> bool { + let vault = Path::new(vault_path); + let git_dir = vault.join(".git"); + git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() +} + +/// Check whether a merge is currently in progress. +pub fn is_merge_in_progress(vault_path: &str) -> bool { + Path::new(vault_path) + .join(".git") + .join("MERGE_HEAD") + .exists() +} + +/// Returns the current conflict mode: "rebase", "merge", or "none". +pub fn get_conflict_mode(vault_path: &str) -> String { + if is_rebase_in_progress(vault_path) { + "rebase".to_string() + } else if is_merge_in_progress(vault_path) { + "merge".to_string() + } else { + "none".to_string() + } +} + +/// Commit after all conflicts have been resolved. +/// Detects whether the repo is in a merge or rebase state and uses the +/// appropriate command (`git commit` vs `git rebase --continue`). +pub fn git_commit_conflict_resolution(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + // Verify no remaining conflicts + let remaining = get_conflict_files(vault_path)?; + if !remaining.is_empty() { + return Err(format!( + "Cannot commit: {} file(s) still have unresolved conflicts", + remaining.len() + )); + } + + ensure_author_config(vault)?; + + let mode = get_conflict_mode(vault_path); + let output = match mode.as_str() { + "rebase" => git_command_at(vault) + .and_then(|mut command| { + command + .args(["rebase", "--continue"]) + .env("GIT_EDITOR", "true") + .output() + }) + .map_err(|e| format!("Failed to run git rebase --continue: {}", e))?, + _ => git_command_at(vault) + .and_then(|mut command| { + command + .args(["commit", "-m", "Resolve merge conflicts"]) + .output() + }) + .map_err(|e| format!("Failed to run git commit: {}", e))?, + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout + } else { + stderr + }; + let cmd_name = if mode == "rebase" { + "git rebase --continue" + } else { + "git commit" + }; + return Err(format!("{} failed: {}", cmd_name, detail.trim())); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::tests::{setup_git_repo, setup_remote_pair, GitConfigEnvGuard}; + use crate::git::{git_commit, git_pull, git_push}; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn unset_local_author_config(vault: &Path) { + for key in ["user.name", "user.email"] { + let status = git_command() + .args(["config", "--local", "--unset-all", key]) + .current_dir(vault) + .status() + .unwrap(); + assert!(status.success(), "failed to unset {key}"); + } + } + + fn local_config_value(vault: &Path, key: &str) -> Option { + let output = git_command() + .args(["config", "--local", key]) + .current_dir(vault) + .output() + .unwrap(); + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + #[test] + fn test_get_conflict_files_empty_when_clean() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + let conflicts = get_conflict_files(vp).unwrap(); + assert!(conflicts.is_empty()); + } + + #[test] + fn test_resolve_conflict_invalid_strategy() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + let result = git_resolve_conflict(vp_b, "conflict.md", "invalid"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid strategy")); + } + + #[test] + fn test_conflict_mode_none_for_clean_repo() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + assert_eq!(get_conflict_mode(vp), "none"); + assert!(!is_rebase_in_progress(vp)); + assert!(!is_merge_in_progress(vp)); + } + + /// Set up a pair of clones that have a merge conflict on the same file. + /// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict. + fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) { + let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair(); + + let vp_a = clone_a_dir.path().to_str().unwrap(); + let vp_b = clone_b_dir.path().to_str().unwrap(); + + // A creates the file and pushes + fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap(); + git_commit(vp_a, "create conflict.md").unwrap(); + git_push(vp_a).unwrap(); + + // B pulls to get the file + git_pull(vp_b).unwrap(); + + // A modifies and pushes + fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap(); + git_commit(vp_a, "A's change").unwrap(); + git_push(vp_a).unwrap(); + + // B modifies the same file locally and commits + fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap(); + git_commit(vp_b, "B's change").unwrap(); + + // B pulls — this causes a merge conflict + let result = git_pull(vp_b).unwrap(); + assert_eq!(result.status, "conflict"); + + (bare_dir, clone_a_dir, clone_b_dir) + } + + fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + let conflicts = get_conflict_files(vp_b).unwrap(); + assert!(conflicts.contains(&"conflict.md".to_string())); + + git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap(); + + let remaining = get_conflict_files(vp_b).unwrap(); + assert!(remaining.is_empty()); + + let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap(); + assert_eq!(content, expected_content); + } + + #[test] + fn test_resolve_conflict_ours() { + assert_resolve_conflict_strategy("ours", "# Version B\n"); + } + + #[test] + fn test_resolve_conflict_theirs() { + assert_resolve_conflict_strategy("theirs", "# Version A\n"); + } + + #[test] + fn test_commit_conflict_resolution() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap(); + + let result = git_commit_conflict_resolution(vp_b); + assert!(result.is_ok()); + + let log = git_command() + .args(["log", "--oneline", "-1"]) + .current_dir(clone_b.path()) + .output() + .unwrap(); + let log_str = String::from_utf8_lossy(&log.stdout); + assert!(log_str.contains("Resolve merge conflicts")); + } + + #[test] + fn test_commit_conflict_resolution_fails_with_unresolved() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + let result = git_commit_conflict_resolution(vp_b); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("still have unresolved conflicts")); + } + + #[test] + fn test_conflict_mode_merge_during_merge_conflict() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + assert_eq!(get_conflict_mode(vp_b), "merge"); + assert!(is_merge_in_progress(vp_b)); + assert!(!is_rebase_in_progress(vp_b)); + } + + #[test] + fn test_commit_conflict_resolution_merge_mode() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + assert_eq!(get_conflict_mode(vp_b), "merge"); + + git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap(); + let result = git_commit_conflict_resolution(vp_b); + assert!(result.is_ok()); + + assert_eq!(get_conflict_mode(vp_b), "none"); + } + + #[test] + fn test_commit_conflict_resolution_sets_missing_local_author_identity() { + let _env = GitConfigEnvGuard::isolated(); + + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vault = clone_b.path(); + let vp_b = vault.to_str().unwrap(); + + git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap(); + unset_local_author_config(vault); + + let result = git_commit_conflict_resolution(vp_b); + assert!( + result.is_ok(), + "conflict commit should set local fallback identity: {result:?}" + ); + assert_eq!( + local_config_value(vault, "user.name").as_deref(), + Some("Tolaria") + ); + assert_eq!( + local_config_value(vault, "user.email").as_deref(), + Some("vault@tolaria.default") + ); + } + + /// Set up a rebase conflict: clone_b has diverged from origin and + /// `git pull --rebase` causes a conflict. + fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) { + let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair(); + + let vp_a = clone_a_dir.path().to_str().unwrap(); + let vp_b = clone_b_dir.path().to_str().unwrap(); + + fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap(); + git_commit(vp_a, "create conflict.md").unwrap(); + git_push(vp_a).unwrap(); + + git_pull(vp_b).unwrap(); + + fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap(); + git_commit(vp_a, "A's change").unwrap(); + git_push(vp_a).unwrap(); + + fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap(); + git_commit(vp_b, "B's change").unwrap(); + + let output = git_command() + .args(["pull", "--rebase"]) + .current_dir(clone_b_dir.path()) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "Expected rebase conflict, but pull succeeded" + ); + + (bare_dir, clone_a_dir, clone_b_dir) + } + + #[test] + fn test_conflict_mode_rebase_during_rebase_conflict() { + let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + assert_eq!(get_conflict_mode(vp_b), "rebase"); + assert!(is_rebase_in_progress(vp_b)); + assert!(!is_merge_in_progress(vp_b)); + } + + #[test] + fn test_get_conflict_files_during_rebase() { + let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + let conflicts = get_conflict_files(vp_b).unwrap(); + assert!( + conflicts.contains(&"conflict.md".to_string()), + "Should detect conflict.md during rebase, got: {:?}", + conflicts + ); + } + + #[test] + fn test_resolve_and_continue_rebase() { + let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair(); + let vp_b = clone_b.path().to_str().unwrap(); + + assert_eq!(get_conflict_mode(vp_b), "rebase"); + + git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap(); + let remaining = get_conflict_files(vp_b).unwrap(); + assert!(remaining.is_empty()); + + let result = git_commit_conflict_resolution(vp_b); + assert!(result.is_ok(), "rebase --continue failed: {:?}", result); + + assert_eq!(get_conflict_mode(vp_b), "none"); + } +} diff --git a/src-tauri/src/git/connect.rs b/src-tauri/src/git/connect.rs new file mode 100644 index 0000000..aca6ebb --- /dev/null +++ b/src-tauri/src/git/connect.rs @@ -0,0 +1,591 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Output; + +use super::command::{ + git_output, git_output_result, run_git, stderr_text, stdout_lines, stdout_text, +}; +use super::credentials::request_remote_credentials; +use super::ensure_author_config; +use super::remote_config::{configure_origin_remote, list_configured_remotes}; + +const DEFAULT_REMOTE_NAME: &str = "origin"; + +#[derive(Clone, Copy)] +enum ConnectStatus { + Connected, + AlreadyConfigured, + IncompatibleHistory, + AuthError, + NetworkError, + Error, +} + +impl ConnectStatus { + fn as_str(self) -> &'static str { + match self { + Self::Connected => "connected", + Self::AlreadyConfigured => "already_configured", + Self::IncompatibleHistory => "incompatible_history", + Self::AuthError => "auth_error", + Self::NetworkError => "network_error", + Self::Error => "error", + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitAddRemoteResult { + pub status: String, // "connected" | "already_configured" | "incompatible_history" | "auth_error" | "network_error" | "error" + pub message: String, +} + +struct RemoteConnection { + branch: String, + remote_branch: String, +} + +impl RemoteConnection { + fn new(branch: String) -> Self { + let remote_branch = format!("{DEFAULT_REMOTE_NAME}/{branch}"); + Self { + branch, + remote_branch, + } + } + + fn pushed_history_message(&self) -> String { + format!( + "Remote connected. Tolaria pushed your local commits and is now tracking {}.", + self.remote_branch + ) + } + + fn tracking_message(&self) -> String { + format!( + "Remote connected. This vault now tracks {}.", + self.remote_branch + ) + } +} + +pub fn disconnect_all_remotes(vault_path: &str) -> Result<(), String> { + let vault = Path::new(vault_path); + + for remote in list_remotes(vault)? { + run_git(vault, &["remote", "remove", &remote])?; + } + + unset_upstream(vault); + Ok(()) +} + +pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result { + let vault = Path::new(vault_path); + + if remote_url.trim().is_empty() { + return Ok(connect_result( + ConnectStatus::Error, + "Enter a repository URL before connecting a remote.", + )); + } + + if !list_remotes(vault)?.is_empty() { + return Ok(connect_result( + ConnectStatus::AlreadyConfigured, + "This vault already has a remote configured.", + )); + } + + ensure_author_config(vault)?; + + let branch = current_branch(vault)?; + if branch.is_empty() { + return Ok(connect_result( + ConnectStatus::Error, + "Tolaria could not determine the current branch for this vault.", + )); + } + let connection = RemoteConnection::new(branch); + + let trimmed_url = remote_url.trim(); + configure_origin_remote(vault, trimmed_url)?; + request_remote_credentials(vault, trimmed_url); + + let result = finish_remote_connection(vault, &connection); + if result.status != "connected" { + let _ = disconnect_all_remotes(vault_path); + } + + Ok(result) +} + +fn finish_remote_connection(vault: &Path, connection: &RemoteConnection) -> GitAddRemoteResult { + if let Err(stderr) = fetch_remote(vault) { + return classify_connect_error(&stderr); + } + + let remote_branches = match list_remote_branches(vault) { + Ok(branches) => branches, + Err(err) => return connect_result(ConnectStatus::Error, err), + }; + + if remote_branches.is_empty() { + return push_with_tracking(vault, connection, connection.pushed_history_message()); + } + + if !remote_branches + .iter() + .any(|candidate| candidate == &connection.remote_branch) + { + return connect_result( + ConnectStatus::IncompatibleHistory, + format!( + "This repository already has git branches, but not '{}'. Use an empty repository or one created from this vault.", + connection.branch + ), + ); + } + + if !histories_share_base(vault, connection) { + return connect_result( + ConnectStatus::IncompatibleHistory, + "This repository has unrelated history. Use an empty repository or one created from this vault.", + ); + } + + let (_, behind) = match ahead_behind_counts(vault, connection) { + Ok(counts) => counts, + Err(err) => return connect_result(ConnectStatus::Error, err), + }; + + if behind > 0 { + return connect_result( + ConnectStatus::IncompatibleHistory, + format!( + "This repository already has commits on '{}' that are not in this vault. Tolaria will not connect it automatically.", + connection.branch + ), + ); + } + + push_with_tracking(vault, connection, connection.tracking_message()) +} + +fn connect_result(status: ConnectStatus, message: impl Into) -> GitAddRemoteResult { + GitAddRemoteResult { + status: status.as_str().to_string(), + message: message.into(), + } +} + +fn current_branch(vault: &Path) -> Result { + let output = git_output_result(vault, &["branch", "--show-current"])?; + + if output.status.success() { + return Ok(stdout_text(&output)); + } + + Err(command_error("git branch --show-current", &output)) +} + +fn list_remotes(vault: &Path) -> Result, String> { + list_configured_remotes(vault) +} + +fn unset_upstream(vault: &Path) { + let _ = git_output(vault, &["branch", "--unset-upstream"]); +} + +fn fetch_remote(vault: &Path) -> Result<(), String> { + run_git(vault, &["fetch", DEFAULT_REMOTE_NAME, "--prune"]) +} + +fn list_remote_branches(vault: &Path) -> Result, String> { + let output = git_output_result( + vault, + &[ + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes/origin", + ], + )?; + + if !output.status.success() { + return Err(command_error("git for-each-ref", &output)); + } + + Ok(stdout_lines(&output) + .into_iter() + .filter(|line| line != "origin/HEAD") + .collect()) +} + +fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool { + git_output( + vault, + &["merge-base", "HEAD", connection.remote_branch.as_str()], + ) + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> { + let revision_range = format!("HEAD...{}", connection.remote_branch); + let output = git_output_result( + vault, + &["rev-list", "--left-right", "--count", &revision_range], + )?; + + if !output.status.success() { + return Err(command_error("git rev-list", &output)); + } + + let counts = stdout_text(&output); + let parts: Vec<&str> = counts.trim().split('\t').collect(); + let ahead = parts + .first() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + let behind = parts + .get(1) + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok((ahead, behind)) +} + +fn push_with_tracking( + vault: &Path, + connection: &RemoteConnection, + success_message: String, +) -> GitAddRemoteResult { + match run_git( + vault, + &[ + "push", + "-u", + DEFAULT_REMOTE_NAME, + connection.branch.as_str(), + ], + ) { + Ok(()) => connect_result(ConnectStatus::Connected, success_message), + Err(stderr) => classify_connect_error(&stderr), + } +} + +fn classify_connect_error(stderr: &str) -> GitAddRemoteResult { + let lower = stderr.to_lowercase(); + + if is_auth_error(&lower) { + return connect_result( + ConnectStatus::AuthError, + "Could not connect to that remote because git reported an authentication error. Check your credentials and try again.", + ); + } + + if is_network_error(&lower) { + return connect_result( + ConnectStatus::NetworkError, + "Could not reach that remote. Check your connection and repository URL, then try again.", + ); + } + + connect_result( + ConnectStatus::Error, + format!( + "Could not connect that remote: {}", + concise_git_detail(stderr) + ), + ) +} + +fn command_error(command: &str, output: &Output) -> String { + format!("{command} failed: {}", stderr_text(output)) +} + +fn is_auth_error(lower: &str) -> bool { + [ + "authentication failed", + "could not read username", + "permission denied", + "the requested url returned error: 403", + "invalid credentials", + "repository not found", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn is_network_error(lower: &str) -> bool { + [ + "could not resolve host", + "unable to access", + "connection refused", + "network is unreachable", + "timed out", + "couldn't connect", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn concise_git_detail(stderr: &str) -> String { + stderr + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("git reported an unknown error") + .trim_start_matches("fatal:") + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::tests::{setup_git_repo, GitConfigEnvGuard}; + use crate::git::{git_commit, git_remote_status}; + use std::fs; + use std::process::Command as StdCommand; + use tempfile::TempDir; + + fn init_bare_remote(path: &Path) { + StdCommand::new("git") + .args(["init", "--bare", "--initial-branch=main"]) + .current_dir(path) + .output() + .unwrap(); + } + + fn configure_author(path: &Path, email: &str, name: &str) { + StdCommand::new("git") + .args(["config", "user.email", email]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", name]) + .current_dir(path) + .output() + .unwrap(); + } + + fn seed_remote_history(bare_path: &Path) { + let working = TempDir::new().unwrap(); + + StdCommand::new("git") + .args(["clone", bare_path.to_str().unwrap(), "."]) + .current_dir(working.path()) + .output() + .unwrap(); + configure_author(working.path(), "remote@test.com", "Remote User"); + fs::write(working.path().join("remote.md"), "# Remote\n").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(working.path()) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "Seed remote"]) + .current_dir(working.path()) + .output() + .unwrap(); + StdCommand::new("git") + .args(["push", "origin", "main"]) + .current_dir(working.path()) + .output() + .unwrap(); + } + + fn create_local_commit(path: &Path, filename: &str, title: &str, message: &str) { + fs::write(path.join(filename), format!("# {title}\n")).unwrap(); + git_commit(path.to_str().unwrap(), message).unwrap(); + } + + fn clear_local_author(path: &Path) { + for key in ["user.name", "user.email"] { + StdCommand::new("git") + .args(["config", "--local", "--unset-all", key]) + .current_dir(path) + .output() + .unwrap(); + } + } + + fn local_author_is_configured(path: &Path) -> bool { + ["user.name", "user.email"].into_iter().all(|key| { + let output = StdCommand::new("git") + .args(["config", "--local", key]) + .current_dir(path) + .output() + .unwrap(); + + output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty() + }) + } + + #[test] + fn disconnect_all_remotes_removes_every_remote() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vault_path = vault.to_str().unwrap(); + + StdCommand::new("git") + .args(["remote", "add", "origin", "https://example.com/one.git"]) + .current_dir(vault) + .output() + .unwrap(); + StdCommand::new("git") + .args(["remote", "add", "backup", "https://example.com/two.git"]) + .current_dir(vault) + .output() + .unwrap(); + + disconnect_all_remotes(vault_path).unwrap(); + + assert!(list_remotes(vault).unwrap().is_empty()); + } + + #[test] + fn git_add_remote_connects_an_empty_remote_and_pushes_local_history() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Local", "Initial local commit"); + + let bare = TempDir::new().unwrap(); + init_bare_remote(bare.path()); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + assert!(result.message.contains("tracking")); + + let status = git_remote_status(local.path().to_str().unwrap()).unwrap(); + assert!(status.has_remote); + assert_eq!((status.ahead, status.behind), (0, 0)); + } + + #[test] + fn git_add_remote_sets_local_identity_when_existing_repo_has_none() { + let _env = GitConfigEnvGuard::isolated(); + + let local = setup_git_repo(); + create_local_commit(local.path(), "note.md", "Local", "Initial local commit"); + clear_local_author(local.path()); + assert!(!local_author_is_configured(local.path())); + + let bare = TempDir::new().unwrap(); + init_bare_remote(bare.path()); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + assert!(local_author_is_configured(local.path())); + + let email = StdCommand::new("git") + .args(["config", "--local", "user.email"]) + .current_dir(local.path()) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&email.stdout).trim(), + "vault@tolaria.default" + ); + } + + #[test] + fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Base", "Base commit"); + + let bare = TempDir::new().unwrap(); + StdCommand::new("git") + .args([ + "clone", + "--bare", + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + + create_local_commit(local.path(), "next.md", "Next", "Local follow-up"); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + + let status = git_remote_status(local.path().to_str().unwrap()).unwrap(); + assert!(status.has_remote); + assert_eq!((status.ahead, status.behind), (0, 0)); + } + + #[test] + fn git_add_remote_rejects_unrelated_remote_history_and_cleans_up() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Local", "Local commit"); + + let bare = TempDir::new().unwrap(); + init_bare_remote(bare.path()); + seed_remote_history(bare.path()); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "incompatible_history"); + assert!(result.message.contains("unrelated history")); + assert!(list_remotes(local.path()).unwrap().is_empty()); + } + + #[test] + fn git_add_remote_reports_when_the_vault_is_already_remote_backed() { + let local = setup_git_repo(); + let vault = local.path(); + + StdCommand::new("git") + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(vault) + .output() + .unwrap(); + + let result = + git_add_remote(vault.to_str().unwrap(), "https://example.com/other.git").unwrap(); + + assert_eq!(result.status, "already_configured"); + } + + #[test] + fn classify_connect_error_maps_auth_failures() { + let result = classify_connect_error( + "fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403", + ); + + assert_eq!(result.status, "auth_error"); + } + + #[test] + fn classify_connect_error_maps_network_failures() { + let result = classify_connect_error( + "fatal: unable to access 'https://github.com/org/repo.git/': Could not resolve host: github.com", + ); + + assert_eq!(result.status, "network_error"); + } +} diff --git a/src-tauri/src/git/credentials.rs b/src-tauri/src/git/credentials.rs new file mode 100644 index 0000000..7cdb9f2 --- /dev/null +++ b/src-tauri/src/git/credentials.rs @@ -0,0 +1,122 @@ +use std::path::Path; + +#[cfg(target_os = "macos")] +use std::io::Write; +#[cfg(target_os = "macos")] +use std::process::Stdio; + +#[cfg(target_os = "macos")] +use super::git_command_at; + +#[cfg(target_os = "macos")] +pub(super) fn request_remote_credentials(vault: &Path, remote_url: &str) { + let Some(input) = credential_fill_input(remote_url) else { + return; + }; + + let mut child = match git_command_at(vault).and_then(|mut command| { + command + .args(["credential", "fill"]) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + }) { + Ok(child) => child, + Err(_) => return, + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(input.as_bytes()); + } + let _ = child.wait(); +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn request_remote_credentials(_vault: &Path, _remote_url: &str) {} + +#[cfg(any(test, target_os = "macos"))] +struct CredentialTarget<'a> { + protocol: &'a str, + host: &'a str, + username: Option<&'a str>, + path: Option<&'a str>, +} + +#[cfg(any(test, target_os = "macos"))] +fn credential_fill_input(remote_url: &str) -> Option { + let target = credential_target(remote_url)?; + let mut lines = vec![ + format!("protocol={}", target.protocol), + format!("host={}", target.host), + ]; + + if let Some(username) = target.username { + lines.push(format!("username={username}")); + } + + if let Some(path) = target.path { + lines.push(format!("path={path}")); + } + + Some(format!("{}\n\n", lines.join("\n"))) +} + +#[cfg(any(test, target_os = "macos"))] +fn credential_target(remote_url: &str) -> Option> { + let (protocol, rest) = remote_url.trim().split_once("://")?; + if !matches!(protocol, "https" | "http") { + return None; + } + + let rest = rest.split_once('#').map_or(rest, |(value, _)| value); + let rest = rest.split_once('?').map_or(rest, |(value, _)| value); + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + if authority.is_empty() { + return None; + } + + let (username, host) = match authority.rsplit_once('@') { + Some((userinfo, host)) => { + let username = userinfo + .split_once(':') + .map_or(userinfo, |(name, _)| name) + .trim(); + let username = (!username.is_empty()).then_some(username); + (username, host) + } + None => (None, authority), + }; + + if host.is_empty() { + return None; + } + + Some(CredentialTarget { + protocol, + host, + username, + path: (!path.is_empty()).then_some(path), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credential_fill_input_extracts_https_remote_parts() { + let input = credential_fill_input("https://github.com/refactoringhq/tolaria.git").unwrap(); + + assert!(input.contains("protocol=https\n")); + assert!(input.contains("host=github.com\n")); + assert!(input.contains("path=refactoringhq/tolaria.git\n")); + assert!(input.ends_with("\n\n")); + } + + #[test] + fn credential_fill_input_ignores_ssh_remotes() { + assert!(credential_fill_input("git@github.com:refactoringhq/tolaria.git").is_none()); + } +} diff --git a/src-tauri/src/git/dates.rs b/src-tauri/src/git/dates.rs new file mode 100644 index 0000000..69f4f69 --- /dev/null +++ b/src-tauri/src/git/dates.rs @@ -0,0 +1,230 @@ +use super::git_command_at; +use chrono::DateTime; +use std::collections::HashMap; +use std::path::Path; + +/// Git-derived creation and modification timestamps for a file. +#[derive(Debug, Clone)] +pub struct GitDates { + pub created_at: u64, + pub modified_at: u64, +} + +/// Run a single `git log` to collect creation and modification dates for all +/// tracked files in the repository. Returns a map from relative path to dates. +/// +/// - **modified_at** = author date of the most recent commit touching the file +/// - **created_at** = author date of the oldest commit touching the file +/// +/// Files not yet committed (untracked / only staged) will not appear in the map; +/// callers should fall back to filesystem metadata for those. +pub fn get_all_file_dates(vault_path: &Path) -> HashMap { + let output = match git_command_at(vault_path).and_then(|mut command| { + command + .args(["log", "--format=COMMIT %aI", "--name-only"]) + .output() + }) { + Ok(o) if o.status.success() => o, + _ => return HashMap::new(), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_git_log_output(&stdout) +} + +/// Parse the output of `git log --format="COMMIT %aI" --name-only`. +/// +/// Output looks like: +/// ```text +/// COMMIT 2026-03-15T10:00:00+02:00 +/// +/// file-a.md +/// file-b.md +/// +/// COMMIT 2026-03-10T08:00:00+02:00 +/// +/// file-a.md +/// ``` +/// +/// Commits are ordered newest-first. For each file: +/// - First occurrence → sets `modified_at` +/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins) +fn parse_git_log_output(stdout: &str) -> HashMap { + let mut map: HashMap = HashMap::new(); + let mut current_ts: Option = None; + + for line in stdout.lines() { + if let Some(date_str) = line.strip_prefix("COMMIT ") { + current_ts = parse_author_date(date_str); + continue; + } + + let path = line.trim(); + if path.is_empty() || current_ts.is_none() { + continue; + } + // Only process .md files + if !path.ends_with(".md") { + continue; + } + + let ts = current_ts.unwrap(); + map.entry(path.to_string()) + .and_modify(|d| d.created_at = ts) + .or_insert(GitDates { + created_at: ts, + modified_at: ts, + }); + } + + map +} + +fn parse_author_date(s: &str) -> Option { + DateTime::parse_from_rfc3339(s.trim()) + .ok() + .map(|dt| dt.timestamp() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_git_log_single_commit() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +file-a.md +file-b.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + assert_eq!(map["file-a.md"].created_at, 1773568800); + assert_eq!(map["file-a.md"].modified_at, 1773568800); + } + + #[test] + fn test_parse_git_log_multiple_commits() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +file-a.md + +COMMIT 2026-03-10T08:00:00+00:00 + +file-a.md +file-b.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + // file-a: modified = newest (2026-03-15), created = oldest (2026-03-10) + assert_eq!(map["file-a.md"].modified_at, 1773568800); + assert_eq!(map["file-a.md"].created_at, 1773129600); + // file-b: only in second commit + assert_eq!(map["file-b.md"].modified_at, 1773129600); + assert_eq!(map["file-b.md"].created_at, 1773129600); + } + + #[test] + fn test_non_md_files_filtered_out() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +README.txt +note.md +image.png +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 1); + assert!(map.contains_key("note.md")); + } + + #[test] + fn test_empty_output() { + let map = parse_git_log_output(""); + assert!(map.is_empty()); + } + + #[test] + fn test_subdirectory_paths() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +docs/adr/0001-stack.md +notes/daily.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + assert!(map.contains_key("docs/adr/0001-stack.md")); + assert!(map.contains_key("notes/daily.md")); + } + + #[test] + fn test_get_all_file_dates_in_real_repo() { + let dir = tempfile::TempDir::new().unwrap(); + let vault = dir.path(); + + // Init repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(vault) + .output() + .unwrap(); + + // First commit with one file + std::fs::write(vault.join("first.md"), "# First\n").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "first"]) + .current_dir(vault) + .output() + .unwrap(); + + // Second commit with another file + modify first + std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap(); + std::fs::write(vault.join("second.md"), "# Second\n").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "second"]) + .current_dir(vault) + .output() + .unwrap(); + + let map = get_all_file_dates(vault); + assert_eq!(map.len(), 2); + assert!(map.contains_key("first.md")); + assert!(map.contains_key("second.md")); + + // first.md: created in commit 1, modified in commit 2 + // So modified_at > created_at (or equal if commits are same second) + assert!(map["first.md"].modified_at >= map["first.md"].created_at); + // second.md: only in commit 2 + assert_eq!(map["second.md"].modified_at, map["second.md"].created_at); + } + + #[test] + fn test_get_all_file_dates_no_git_repo() { + let dir = tempfile::TempDir::new().unwrap(); + let map = get_all_file_dates(dir.path()); + assert!(map.is_empty()); + } +} diff --git a/src-tauri/src/git/file_url.rs b/src-tauri/src/git/file_url.rs new file mode 100644 index 0000000..2dfa3f8 --- /dev/null +++ b/src-tauri/src/git/file_url.rs @@ -0,0 +1,403 @@ +use std::path::Path; + +use crate::vault::path_identity::vault_relative_path_string; + +use super::command::{git_output, stderr_or_failure, stdout_text}; +use super::remote_config::primary_remote_url; + +enum RemoteWebKind { + Bitbucket, + Gitea, + GitLab, + Generic, +} + +struct RemoteWebBase { + base_url: String, + kind: RemoteWebKind, +} + +struct GitFileLocation { + branch: BranchName, + relative_path: RelativeGitPath, +} + +struct BranchName(String); + +struct RelativeGitPath(String); + +struct RemoteUrl(String); + +struct RemoteParts { + host: RemoteHost, + repo_path: RepoPath, +} + +struct RemoteHost(String); + +struct RepoPath(String); + +pub fn git_file_url(vault_path: &str, file_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + let file = Path::new(file_path); + let Some(relative_path) = RelativeGitPath::from_paths(vault, file)? else { + return Ok(None); + }; + + let Some(remote_url) = primary_remote_url(vault)?.map(RemoteUrl::new) else { + return Ok(None); + }; + let location = GitFileLocation::new(current_ref_name(vault)?, relative_path); + + let url = match remote_web_base(&remote_url) { + Some(remote) => remote.file_url(&location), + None => remote_url.git_fragment_url(&location), + }; + Ok(Some(url)) +} + +fn current_ref_name(vault: &Path) -> Result { + let output = git_output(vault, &["branch", "--show-current"]) + .map_err(|e| format!("Failed to get branch: {e}"))?; + + if !output.status.success() { + return Err(stderr_or_failure("git branch", &output)); + } + + let branch = stdout_text(&output); + Ok(BranchName::new(branch)) +} + +impl GitFileLocation { + fn new(branch: BranchName, relative_path: RelativeGitPath) -> Self { + Self { + branch, + relative_path, + } + } +} + +impl BranchName { + fn new(value: String) -> Self { + if value.is_empty() { + return Self("HEAD".to_string()); + } + Self(value) + } + + fn encoded_fragment(&self) -> String { + encode_fragment_part(&self.0) + } + + fn encoded_path(&self) -> String { + encode_path(&self.0) + } +} + +impl RelativeGitPath { + fn from_paths(vault: &Path, file: &Path) -> Result, String> { + let value = vault_relative_path_string(vault, file)?; + if value.is_empty() { + return Ok(None); + } + Ok(Some(Self(value))) + } + + fn encoded_fragment(&self) -> String { + encode_fragment_part(&self.0) + } + + fn encoded_path(&self) -> String { + encode_path(&self.0) + } +} + +impl RemoteWebBase { + fn file_url(&self, location: &GitFileLocation) -> String { + let branch = location.branch.encoded_path(); + let path = location.relative_path.encoded_path(); + match self.kind { + RemoteWebKind::Bitbucket => format!("{}/src/{}/{}", self.base_url, branch, path), + RemoteWebKind::Gitea => format!("{}/src/branch/{}/{}", self.base_url, branch, path), + RemoteWebKind::GitLab => format!("{}/-/blob/{}/{}", self.base_url, branch, path), + RemoteWebKind::Generic => format!("{}/blob/{}/{}", self.base_url, branch, path), + } + } +} + +impl RemoteUrl { + fn new(value: String) -> Self { + Self(value) + } + + fn trimmed(&self) -> &str { + self.0.trim() + } + + fn git_fragment_url(&self, location: &GitFileLocation) -> String { + format!( + "{}#{}:{}", + self.trimmed(), + location.branch.encoded_fragment(), + location.relative_path.encoded_fragment(), + ) + } + + fn host_and_path(&self) -> Option { + self.http_parts() + .or_else(|| self.scheme_parts()) + .or_else(|| self.scp_parts()) + } + + fn http_parts(&self) -> Option { + let rest = self + .trimmed() + .strip_prefix("https://") + .or_else(|| self.trimmed().strip_prefix("http://"))?; + RemoteParts::from_authority_path(rest) + } + + fn scheme_parts(&self) -> Option { + let rest = self + .trimmed() + .strip_prefix("ssh://") + .or_else(|| self.trimmed().strip_prefix("git://"))?; + RemoteParts::from_authority_path(rest) + } + + fn scp_parts(&self) -> Option { + let (_, target) = self.trimmed().split_once('@')?; + let (host, path) = target.split_once(':')?; + Some(RemoteParts::new(RemoteHost::new(host), RepoPath::new(path))) + } +} + +impl RemoteParts { + fn new(host: RemoteHost, repo_path: RepoPath) -> Self { + Self { host, repo_path } + } + + fn from_authority_path(value: &str) -> Option { + let (authority, path) = value.split_once('/')?; + Some(Self::new( + RemoteHost::from_authority(authority), + RepoPath::new(path), + )) + } + + fn into_web_base(self) -> Option { + let clean_path = self.repo_path.clean(); + if self.host.is_empty() { + return None; + } + if clean_path.is_empty() { + return None; + } + + let base_url = format!("https://{}/{clean_path}", self.host.as_str()); + Some(RemoteWebBase { + kind: remote_web_kind(&self.host), + base_url, + }) + } +} + +impl RemoteHost { + fn new(value: &str) -> Self { + Self(value.to_string()) + } + + fn from_authority(authority: &str) -> Self { + Self::new(authority.rsplit('@').next().unwrap_or_default()) + } + + fn as_str(&self) -> &str { + &self.0 + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn lower(&self) -> String { + self.0.to_ascii_lowercase() + } +} + +impl RepoPath { + fn new(value: &str) -> Self { + Self(value.to_string()) + } + + fn clean(&self) -> &str { + let trimmed = self.0.trim_matches('/'); + trimmed.strip_suffix(".git").unwrap_or(trimmed) + } +} + +fn remote_web_base(remote_url: &RemoteUrl) -> Option { + remote_url.host_and_path()?.into_web_base() +} + +fn remote_web_kind(host: &RemoteHost) -> RemoteWebKind { + let lower_host = host.lower(); + if lower_host.contains("gitlab") { + return RemoteWebKind::GitLab; + } + if lower_host.contains("bitbucket") { + return RemoteWebKind::Bitbucket; + } + if lower_host.contains("gitea") { + return RemoteWebKind::Gitea; + } + if lower_host.contains("forgejo") { + return RemoteWebKind::Gitea; + } + if lower_host == "codeberg.org" { + return RemoteWebKind::Gitea; + } + RemoteWebKind::Generic +} + +fn encode_path(path: &str) -> String { + path.split('/') + .map(encode_segment) + .collect::>() + .join("/") +} + +fn encode_fragment_part(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + match byte { + b' ' => encoded.push_str("%20"), + b'#' => encoded.push_str("%23"), + b'%' => encoded.push_str("%25"), + _ => encoded.push(char::from(byte)), + } + } + encoded +} + +fn encode_segment(segment: &str) -> String { + segment + .bytes() + .flat_map(|byte| { + if is_unreserved_url_byte(byte) { + vec![byte] + } else { + format!("%{byte:02X}").into_bytes() + } + }) + .map(char::from) + .collect() +} + +fn is_unreserved_url_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::tests::setup_git_repo; + use std::fs; + use std::path::Path; + + struct RemoteFixture { + name: &'static str, + url: &'static str, + } + + struct GitUrlCase { + remote: RemoteFixture, + note_path: &'static str, + expected_url: &'static str, + } + + fn add_remote(vault: &Path, remote: RemoteFixture) { + git_command() + .args(["remote", "add", remote.name, remote.url]) + .current_dir(vault) + .output() + .unwrap(); + } + + fn write_note(vault: &Path, relative_path: &str) -> String { + let file = vault.join(relative_path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, "# Note\n").unwrap(); + file.to_string_lossy().to_string() + } + + fn assert_git_file_url(test_case: GitUrlCase) { + let dir = setup_git_repo(); + let note = write_note(dir.path(), test_case.note_path); + add_remote(dir.path(), test_case.remote); + + let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap(); + + assert_eq!(url.as_deref(), Some(test_case.expected_url)); + } + + #[test] + fn returns_none_without_remote() { + let dir = setup_git_repo(); + let note = write_note(dir.path(), "note.md"); + + let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap(); + + assert_eq!(url, None); + } + + #[test] + fn returns_none_outside_git_repository() { + let dir = tempfile::tempdir().unwrap(); + let note = write_note(dir.path(), "note.md"); + + let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap(); + + assert_eq!(url, None); + } + + #[test] + fn builds_remote_note_urls() { + [ + GitUrlCase { + remote: RemoteFixture { + name: "origin", + url: "git@github.com:owner/repo.git", + }, + note_path: "Notes/Project Plan.md", + expected_url: "https://github.com/owner/repo/blob/main/Notes/Project%20Plan.md", + }, + GitUrlCase { + remote: RemoteFixture { + name: "origin", + url: "https://gho_secret@github.com/owner/repo.git", + }, + note_path: "private.md", + expected_url: "https://github.com/owner/repo/blob/main/private.md", + }, + GitUrlCase { + remote: RemoteFixture { + name: "origin", + url: "https://gitlab.com/group/repo.git", + }, + note_path: "notes/topic.md", + expected_url: "https://gitlab.com/group/repo/-/blob/main/notes/topic.md", + }, + GitUrlCase { + remote: RemoteFixture { + name: "upstream", + url: "https://github.com/team/vault.git", + }, + note_path: "shared.md", + expected_url: "https://github.com/team/vault/blob/main/shared.md", + }, + ] + .into_iter() + .for_each(assert_git_file_url); + } +} diff --git a/src-tauri/src/git/history.rs b/src-tauri/src/git/history.rs new file mode 100644 index 0000000..d38c994 --- /dev/null +++ b/src-tauri/src/git/history.rs @@ -0,0 +1,349 @@ +use super::git_command_at; +use crate::vault::path_identity::vault_relative_path_string; +use std::path::Path; + +use super::GitCommit; + +/// Get git log history for a specific file in the vault. +pub fn get_file_history(vault_path: &str, file_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + let file = Path::new(file_path); + let relative_str = vault_relative_path_string(vault, file)?; + + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args([ + "log", + "--format=%H|%h|%an|%aI|%s", + "-n", + "20", + "--", + &relative_str, + ]) + .output() + }) + .map_err(|e| format!("Failed to run git log: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // No commits yet is not an error - just return empty history + if stderr.contains("does not have any commits yet") { + return Ok(Vec::new()); + } + return Err(format!("git log failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let commits = stdout + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + // Format: hash|short_hash|author|date|message + // Use splitn(5) so message (last) can contain '|' + let parts: Vec<&str> = line.splitn(5, '|').collect(); + if parts.len() != 5 { + return None; + } + let date = chrono::DateTime::parse_from_rfc3339(parts[3]) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + + Some(GitCommit { + hash: parts[0].to_string(), + short_hash: parts[1].to_string(), + author: parts[2].to_string(), + date, + message: parts[4].to_string(), + }) + }) + .collect(); + + Ok(commits) +} + +/// Get git diff for a specific file. +pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result { + let vault = Path::new(vault_path); + let file = Path::new(file_path); + let relative_str = vault_relative_path_string(vault, file)?; + + // First try tracked file diff + let output = git_command_at(vault) + .and_then(|mut command| command.args(["diff", "--", &relative_str]).output()) + .map_err(|e| format!("Failed to run git diff: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // If no diff (maybe staged or untracked), try diff --cached + if stdout.is_empty() { + let cached = git_command_at(vault) + .and_then(|mut command| { + command + .args(["diff", "--cached", "--", &relative_str]) + .output() + }) + .map_err(|e| format!("Failed to run git diff --cached: {}", e))?; + + let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string(); + if !cached_stdout.is_empty() { + return Ok(cached_stdout); + } + + // Try showing untracked file as all-new + let status = git_command_at(vault) + .and_then(|mut command| { + command + .args(["status", "--porcelain", "--", &relative_str]) + .output() + }) + .map_err(|e| format!("Failed to run git status: {}", e))?; + + let status_out = String::from_utf8_lossy(&status.stdout); + if status_out.starts_with("??") { + // Untracked file: show entire content as added + let content = + std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?; + let lines: Vec = content.lines().map(|l| format!("+{}", l)).collect(); + return Ok(format!( + "diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}", + relative_str, + lines.len(), + lines.join("\n") + )); + } + } + + Ok(stdout) +} + +/// Get git diff for a specific file at a given commit (compared to its parent). +pub fn get_file_diff_at_commit( + vault_path: &str, + file_path: &str, + commit_hash: &str, +) -> Result { + let vault = Path::new(vault_path); + let file = Path::new(file_path); + let relative_str = vault_relative_path_string(vault, file)?; + + // Show diff between commit^ and commit for this file + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args([ + "diff", + &format!("{}^", commit_hash), + commit_hash, + "--", + &relative_str, + ]) + .output() + }) + .map_err(|e| format!("Failed to run git diff: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // If diff is empty, it might be the initial commit (no parent). + // Fall back to showing the full file content as added. + if stdout.is_empty() { + let show = git_command_at(vault) + .and_then(|mut command| { + command + .args(["show", &format!("{}:{}", commit_hash, relative_str)]) + .output() + }) + .map_err(|e| format!("Failed to run git show: {}", e))?; + + if show.status.success() { + let content = String::from_utf8_lossy(&show.stdout); + let lines: Vec = content.lines().map(|l| format!("+{}", l)).collect(); + return Ok(format!( + "diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}", + relative_str, + lines.len(), + lines.join("\n") + )); + } + } + + Ok(stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::tests::setup_git_repo; + use std::{fs, path::PathBuf}; + + fn force_quoted_git_paths(vault: &Path) { + git_command() + .args(["config", "core.quotePath", "true"]) + .current_dir(vault) + .output() + .unwrap(); + } + + fn write_and_commit_file( + vault: &Path, + relative_path: &str, + content: &str, + message: &str, + ) -> PathBuf { + let file = vault.join(relative_path); + fs::write(&file, content).unwrap(); + git_command() + .args(["add", relative_path]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["commit", "-m", message]) + .current_dir(vault) + .output() + .unwrap(); + file + } + + fn head_hash(vault: &Path) -> String { + let log = git_command() + .args(["log", "--format=%H", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + String::from_utf8_lossy(&log.stdout).trim().to_string() + } + + #[test] + fn test_get_file_history_with_commits() { + let dir = setup_git_repo(); + let vault = dir.path(); + + let file = write_and_commit_file(vault, "test.md", "# Initial\n", "Initial commit"); + write_and_commit_file(vault, "test.md", "# Updated\n\nNew content.", "Update test"); + + let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap(); + + assert_eq!(history.len(), 2); + assert_eq!(history[0].message, "Update test"); + assert_eq!(history[1].message, "Initial commit"); + assert_eq!(history[0].author, "Test User"); + assert!(!history[0].hash.is_empty()); + assert!(!history[0].short_hash.is_empty()); + } + + #[test] + fn test_get_file_history_no_commits() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("new.md"); + fs::write(&file, "# New\n").unwrap(); + + let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap(); + + assert!(history.is_empty()); + } + + #[test] + fn test_get_file_diff() { + let dir = setup_git_repo(); + let vault = dir.path(); + + let file = write_and_commit_file( + vault, + "diff-test.md", + "# Test\n\nOriginal content.", + "Add diff-test", + ); + + fs::write(&file, "# Test\n\nModified content.").unwrap(); + + let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("-Original content.")); + assert!(diff.contains("+Modified content.")); + } + + #[test] + fn test_get_file_diff_at_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + + let file = write_and_commit_file( + vault, + "diff-at-commit.md", + "# First\n\nOriginal content.", + "First commit", + ); + write_and_commit_file( + vault, + "diff-at-commit.md", + "# First\n\nModified content.", + "Second commit", + ); + + let hash = head_hash(vault); + + let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash) + .unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("-Original content.")); + assert!(diff.contains("+Modified content.")); + } + + #[test] + fn test_get_file_diff_at_initial_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + + let file = write_and_commit_file( + vault, + "initial.md", + "# Initial\n\nHello world.", + "Initial commit", + ); + + let hash = head_hash(vault); + + let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash) + .unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("+# Initial")); + assert!(diff.contains("+Hello world.")); + } + + #[test] + fn test_get_file_diff_at_commit_preserves_chinese_filename_and_content() { + let dir = setup_git_repo(); + let vault = dir.path(); + let relative_path = "中文笔记.md"; + let file = vault.join(relative_path); + + force_quoted_git_paths(vault); + write_and_commit_file( + vault, + relative_path, + "# 初始\n\n第一行\n", + "Add Chinese note", + ); + write_and_commit_file( + vault, + relative_path, + "# 初始\n\n第二行\n", + "Update Chinese note", + ); + let hash = head_hash(vault); + + let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash) + .unwrap(); + + assert!(diff.contains("diff --git a/中文笔记.md b/中文笔记.md")); + assert!(diff.contains("-第一行")); + assert!(diff.contains("+第二行")); + assert!(!diff.contains("\\344")); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs new file mode 100644 index 0000000..6d049ce --- /dev/null +++ b/src-tauri/src/git/mod.rs @@ -0,0 +1,1150 @@ +mod author; +mod clone; +mod command; +mod commit; +mod conflict; +mod connect; +mod credentials; +mod dates; +mod file_url; +mod history; +mod provider; +mod pulse; +mod remote; +#[cfg(test)] +mod remote_branch_tests; +mod remote_config; +mod remote_status; +mod remote_url; +mod status; +mod upstream; + +use std::ffi::{OsStr, OsString}; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +#[cfg(test)] +use std::cell::RefCell; + +use crate::cli_agent_runtime::{env_value_from_process_or_user_shell, EnvName}; + +pub(crate) use author::ensure_author_config; +pub use author::{git_author_identity, GitAuthorIdentity}; +#[cfg(test)] +pub(crate) use author::{ + local_config_value, AuthorConfigKey, FALLBACK_AUTHOR_EMAIL, FALLBACK_AUTHOR_NAME, + LEGACY_FALLBACK_EMAIL, +}; +pub use clone::clone_repo; +pub use commit::git_commit; +pub use conflict::{ + get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict, + is_merge_in_progress, is_rebase_in_progress, +}; +pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult}; +pub use dates::{get_all_file_dates, GitDates}; +pub use file_url::git_file_url; +pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history}; +pub use provider::{git_provider_status, test_git_provider, GitProviderProbe, GitProviderStatus}; +pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile}; +pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult}; +pub use remote_status::{git_remote_status, GitRemoteStatus}; +pub(crate) use remote_url::validate_user_remote_url; +pub use status::{ + discard_file_changes, get_modified_files, get_modified_files_with_stats, ModifiedFile, +}; + +use serde::Serialize; + +#[derive(Debug, Serialize, Clone)] +pub struct GitCommit { + pub hash: String, + #[serde(rename = "shortHash")] + pub short_hash: String, + pub message: String, + pub author: String, + pub date: i64, +} + +const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never commit)\n\ +.laputa/settings.json\n\ +\n\ +# macOS\n\ +.DS_Store\n\ +.AppleDouble\n\ +.LSOverride\n\ +\n\ +# Thumbnails\n\ +._*\n\ +\n\ +# Editors\n\ +.vscode/\n\ +.idea/\n\ +*.swp\n\ +*.swo\n"; + +const GIT_SHELL_ENV_NAMES: [EnvName<'static>; 8] = [ + EnvName::trusted("GIT_AUTHOR_NAME"), + EnvName::trusted("GIT_AUTHOR_EMAIL"), + EnvName::trusted("GIT_COMMITTER_NAME"), + EnvName::trusted("GIT_COMMITTER_EMAIL"), + EnvName::trusted("GIT_CONFIG_GLOBAL"), + EnvName::trusted("GIT_CONFIG_SYSTEM"), + EnvName::trusted("XDG_CONFIG_HOME"), + EnvName::trusted("EMAIL"), +]; + +#[derive(Clone)] +struct GitLaunchConfig { + program: OsString, + prefix_args: Vec, + path: Option, +} + +#[derive(Default)] +struct ShellGitConfig { + git_path: Option, + path: Option, +} + +struct GitShellEnvBinding { + name: &'static str, + value: String, +} + +pub(crate) fn git_command() -> Command { + let config = git_launch_config(); + let mut command = crate::hidden_command(&config.program); + command.args(config.prefix_args); + if let Some(path) = &config.path { + command.env("PATH", path); + } + sanitize_linux_appimage_git_env(&mut command); + apply_git_shell_env(&mut command); + #[cfg(test)] + apply_test_git_config_env(&mut command); + command.args([ + "-c", + "core.quotePath=false", + "-c", + "protocol.ext.allow=never", + "-c", + "protocol.file.allow=user", + "-c", + "core.fsmonitor=false", + "-c", + "core.sshCommand=ssh", + ]); + command +} + +pub(crate) fn git_command_at(path: &Path) -> io::Result { + let path = path.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Git path '{}' is not valid UTF-8", path.display()), + ) + })?; + let path = git_path_argument(path) + .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?; + let mut command = git_command(); + command.args(["-C", &path]); + Ok(command) +} + +pub fn has_direct_git_metadata(path: impl AsRef) -> bool { + path.as_ref().join(".git").exists() +} + +pub fn is_inside_work_tree(path: impl AsRef) -> bool { + let path = path.as_ref(); + if !path.is_dir() { + return false; + } + + let Ok(output) = git_command_at(path).and_then(|mut command| { + command + .args(["rev-parse", "--is-inside-work-tree"]) + .output() + }) else { + return false; + }; + + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" +} + +fn apply_git_shell_env(command: &mut Command) { + for binding in git_shell_env_bindings() { + command.env(binding.name, &binding.value); + } +} + +fn git_shell_env_bindings() -> &'static Vec { + static BINDINGS: OnceLock> = OnceLock::new(); + BINDINGS.get_or_init(|| { + GIT_SHELL_ENV_NAMES + .iter() + .filter_map(|name| { + env_value_from_process_or_user_shell(*name).map(|value| GitShellEnvBinding { + name: name.as_str(), + value, + }) + }) + .collect() + }) +} + +#[cfg(test)] +#[derive(Clone)] +struct TestGitConfigEnv { + global: PathBuf, + system: PathBuf, +} + +#[cfg(test)] +thread_local! { + static TEST_GIT_CONFIG_ENV: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(test)] +fn apply_test_git_config_env(command: &mut Command) { + TEST_GIT_CONFIG_ENV.with(|env| { + if let Some(config) = env.borrow().as_ref() { + command.env("GIT_CONFIG_GLOBAL", &config.global); + command.env("GIT_CONFIG_SYSTEM", &config.system); + } + }); +} + +pub(crate) fn git_path_argument(path: &str) -> Result { + let settings = crate::settings::get_settings().ok(); + provider::selected_git_path_argument(path, settings.as_ref()) +} + +fn git_launch_config() -> GitLaunchConfig { + detect_git_launch_config() +} + +fn detect_git_launch_config() -> GitLaunchConfig { + let parent_path = std::env::var_os("PATH"); + let settings = crate::settings::get_settings().ok(); + if let provider::GitProviderSelection::Wsl { distro } = + provider::GitProviderSelection::from_settings(settings.as_ref()) + { + return GitLaunchConfig { + program: OsString::from("wsl.exe"), + prefix_args: provider::wsl_git_prefix_args(distro.as_deref()), + path: parent_path, + }; + } + + git_launch_config_from_sources( + parent_path, + configured_git_path(), + shell_git_config(), + standard_git_candidates(), + ) +} + +fn git_launch_config_from_sources( + parent_path: Option, + configured_git_path: Option, + shell: Option, + standard_candidates: Vec, +) -> GitLaunchConfig { + let shell = shell.unwrap_or_default(); + let program = configured_git_path + .or(shell.git_path) + .or_else(|| standard_candidates.into_iter().next()) + .map(PathBuf::into_os_string) + .unwrap_or_else(|| OsString::from("git")); + let path = path_with_git_parent(shell.path.or(parent_path), &program); + + GitLaunchConfig { + program, + prefix_args: Vec::new(), + path, + } +} + +fn configured_git_path() -> Option { + crate::settings::get_settings() + .ok() + .and_then(|settings| settings.git_path) + .map(PathBuf::from) + .filter(|path| is_executable_file(path)) +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + + metadata.is_file() && has_executable_bit(&metadata) +} + +#[cfg(unix)] +fn has_executable_bit(metadata: &std::fs::Metadata) -> bool { + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn has_executable_bit(metadata: &std::fs::Metadata) -> bool { + metadata.is_file() +} + +#[cfg(target_os = "macos")] +fn standard_git_candidates() -> Vec { + let mut candidates = vec![ + PathBuf::from("/opt/homebrew/bin/git"), + PathBuf::from("/usr/local/bin/git"), + PathBuf::from("/usr/bin/git"), + ]; + candidates.extend(cellar_git_candidates("/opt/homebrew/Cellar/git")); + candidates.extend(cellar_git_candidates("/usr/local/Cellar/git")); + candidates + .into_iter() + .filter(|path| is_executable_file(path)) + .collect() +} + +#[cfg(not(target_os = "macos"))] +fn standard_git_candidates() -> Vec { + Vec::new() +} + +#[cfg(target_os = "macos")] +fn cellar_git_candidates(root: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path().join("bin").join("git")) + .filter(|path| is_executable_file(path)) + .collect::>(); + candidates.sort(); + candidates.reverse(); + candidates +} + +fn path_with_git_parent(base: Option, program: &OsStr) -> Option { + let mut paths = base + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + + let program_path = Path::new(program); + if let Some(parent) = program_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + push_unique_path(&mut paths, parent.to_path_buf()); + } + + if paths.is_empty() { + return None; + } + + std::env::join_paths(paths).ok() +} + +fn push_unique_path(paths: &mut Vec, candidate: PathBuf) { + if paths.iter().any(|path| path == &candidate) { + return; + } + paths.push(candidate); +} + +#[cfg(target_os = "macos")] +fn shell_git_config() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| shell_git_config_from_shell(&shell)) +} + +#[cfg(not(target_os = "macos"))] +fn shell_git_config() -> Option { + None +} + +#[cfg(target_os = "macos")] +fn shell_git_config_from_shell(shell: &Path) -> Option { + let output = crate::hidden_command(shell) + .arg("-lc") + .arg("printf '%s\\n%s' \"$(command -v git 2>/dev/null || true)\" \"$PATH\"") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let git_path = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .filter(|path| path.exists()); + let path = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(OsString::from); + + if git_path.is_none() && path.is_none() { + return None; + } + + Some(ShellGitConfig { git_path, path }) +} + +#[cfg(target_os = "macos")] +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +#[cfg(any(test, all(desktop, target_os = "linux")))] +const LINUX_APPIMAGE_GIT_ENV_REMOVALS: [&str; 3] = + ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"]; + +#[cfg(all(desktop, target_os = "linux"))] +fn sanitize_linux_appimage_git_env(command: &mut Command) { + sanitize_linux_appimage_git_env_for_launch(command, linux_appimage_env_present()); +} + +#[cfg(not(all(desktop, target_os = "linux")))] +fn sanitize_linux_appimage_git_env(_command: &mut Command) {} + +#[cfg(any(test, all(desktop, target_os = "linux")))] +fn sanitize_linux_appimage_git_env_for_launch(command: &mut Command, is_appimage: bool) { + if !is_appimage { + return; + } + + for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { + command.env_remove(key); + } +} + +#[cfg(all(desktop, target_os = "linux"))] +fn linux_appimage_env_present() -> bool { + ["APPIMAGE", "APPDIR"] + .into_iter() + .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty())) +} + +/// Ensure a `.gitignore` with sensible defaults exists in the vault directory. +/// Creates the file if missing; leaves existing `.gitignore` files untouched. +pub fn ensure_gitignore(path: impl AsRef) -> Result<(), String> { + let gitignore_path = path.as_ref().join(".gitignore"); + if !gitignore_path.exists() { + std::fs::write(&gitignore_path, DEFAULT_GITIGNORE) + .map_err(|e| format!("Failed to write .gitignore: {}", e))?; + } + Ok(()) +} + +/// Initialize a new git repository, stage all files, and create an initial commit. +pub fn init_repo(path: impl AsRef) -> Result<(), String> { + let dir = path.as_ref(); + + run_git(dir, &["init"])?; + ensure_author_config(dir)?; + + // Write .gitignore before the first commit so machine-specific and + // macOS metadata files are never tracked and don't cause conflicts. + ensure_gitignore(dir)?; + + run_git(dir, &["add", "."])?; + commit_initial_vault_setup(dir)?; + + Ok(()) +} + +fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> { + run_git( + dir, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initial vault setup", + ], + ) +} + +/// Run a git command in the given directory, returning an error on failure. +fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> { + let output = command::git_output(dir, args).map_err(|e| { + format!( + "Failed to run git {}: {e}", + command::git_command_label(args) + ) + })?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "git {} failed: {}", + command::git_command_label(args), + String::from_utf8_lossy(&output.stderr) + )) +} + +/// Extract "owner/repo" from a GitHub remote URL. +/// Supports HTTPS (https://github.com/owner/repo.git) and +/// SSH (git@github.com:owner/repo.git) formats. +fn normalize_github_repo_path(repo_path: &str) -> Option { + let repo_path = repo_path.strip_suffix(".git").unwrap_or(repo_path); + repo_path.contains('/').then(|| repo_path.to_string()) +} + +fn github_remote_suffix(url: &str) -> Option<&str> { + const GITHUB_PREFIXES: [&str; 4] = [ + "git@github.com:", + "https://github.com/", + "http://github.com/", + "ssh://git@github.com/", + ]; + + GITHUB_PREFIXES + .iter() + .find_map(|prefix| url.strip_prefix(prefix)) + .or_else(|| url.split_once("@github.com/").map(|(_, suffix)| suffix)) +} + +fn parse_github_repo_path(url: &str) -> Option { + github_remote_suffix(url.trim()).and_then(normalize_github_repo_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::ffi::OsString; + use std::fs; + use tempfile::TempDir; + + /// Redirect global and system git config to files under a TempDir so + /// identity tests are hermetic with respect to the developer's own + /// gitconfig. + pub(crate) struct GitConfigEnvGuard { + previous: Option, + _dir: TempDir, + } + + impl GitConfigEnvGuard { + /// No identity resolvable outside the repo's local config. + pub(crate) fn isolated() -> Self { + Self::with_global_identity(None) + } + + /// Optionally expose a global identity to spawned git commands. + pub(crate) fn with_global_identity(identity: Option<(&str, &str)>) -> Self { + let dir = TempDir::new().unwrap(); + let global = dir.path().join("gitconfig-global"); + if let Some((name, email)) = identity { + fs::write( + &global, + format!("[user]\n\tname = {name}\n\temail = {email}\n"), + ) + .unwrap(); + } + let system = dir.path().join("gitconfig-system"); + + let config = TestGitConfigEnv { global, system }; + let previous = TEST_GIT_CONFIG_ENV.with(|env| env.replace(Some(config))); + + Self { + previous, + _dir: dir, + } + } + } + + impl Drop for GitConfigEnvGuard { + fn drop(&mut self) { + let previous = self.previous.take(); + TEST_GIT_CONFIG_ENV.with(|env| { + env.replace(previous); + }); + } + } + + fn assert_repo_path(url: &str, expected: Option<&str>) { + assert_eq!( + parse_github_repo_path(url), + expected.map(ToString::to_string) + ); + } + + pub(crate) fn setup_git_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + + git_command() + .args(["init", "--initial-branch=main"]) + .current_dir(path) + .output() + .unwrap(); + + git_command() + .args(["config", "user.email", "test@test.com"]) + .current_dir(path) + .output() + .unwrap(); + + git_command() + .args(["config", "user.name", "Test User"]) + .current_dir(path) + .output() + .unwrap(); + + dir + } + + /// Set up a bare "remote" and a clone that acts as the working vault. + pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) { + let bare_dir = TempDir::new().unwrap(); + let bare = bare_dir.path(); + + git_command() + .args(["init", "--bare"]) + .current_dir(bare) + .output() + .unwrap(); + + let clone_a_dir = TempDir::new().unwrap(); + git_command() + .args(["clone", bare.to_str().unwrap(), "."]) + .current_dir(clone_a_dir.path()) + .output() + .unwrap(); + for cmd in &[ + &["config", "user.email", "a@test.com"][..], + &["config", "user.name", "User A"][..], + ] { + git_command() + .args(*cmd) + .current_dir(clone_a_dir.path()) + .output() + .unwrap(); + } + + let clone_b_dir = TempDir::new().unwrap(); + git_command() + .args(["clone", bare.to_str().unwrap(), "."]) + .current_dir(clone_b_dir.path()) + .output() + .unwrap(); + for cmd in &[ + &["config", "user.email", "b@test.com"][..], + &["config", "user.name", "User B"][..], + ] { + git_command() + .args(*cmd) + .current_dir(clone_b_dir.path()) + .output() + .unwrap(); + } + + (bare_dir, clone_a_dir, clone_b_dir) + } + + fn init_plain_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + git_command() + .args(["init", "--initial-branch=main"]) + .current_dir(dir.path()) + .output() + .unwrap(); + dir + } + + fn set_local_identity(dir: &Path, name: &str, email: &str) { + for (key, value) in [("user.name", name), ("user.email", email)] { + git_command() + .args(["config", "--local", key, value]) + .current_dir(dir) + .output() + .unwrap(); + } + } + + fn assert_local_identity(dir: &Path, name: Option<&str>, email: Option<&str>) { + assert_eq!( + local_config_value(dir, AuthorConfigKey::Name) + .unwrap() + .as_deref(), + name + ); + assert_eq!( + local_config_value(dir, AuthorConfigKey::Email) + .unwrap() + .as_deref(), + email + ); + } + + #[test] + fn test_ensure_author_config_respects_existing_global_identity() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com"))); + + let dir = init_plain_repo(); + + ensure_author_config(dir.path()).unwrap(); + + // The globally configured identity resolves, so no local override + // should be written. + assert_local_identity(dir.path(), None, None); + } + + #[test] + fn test_ensure_author_config_sets_fallback_without_any_identity() { + let _env = GitConfigEnvGuard::isolated(); + + let dir = init_plain_repo(); + + ensure_author_config(dir.path()).unwrap(); + + assert_local_identity( + dir.path(), + Some(FALLBACK_AUTHOR_NAME), + Some(FALLBACK_AUTHOR_EMAIL), + ); + } + + #[test] + fn test_ensure_author_config_heals_legacy_identity_when_global_exists() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com"))); + + let dir = init_plain_repo(); + set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL); + + ensure_author_config(dir.path()).unwrap(); + + // The legacy pair is removed so the global identity resolves again. + assert_local_identity(dir.path(), None, None); + } + + #[test] + fn test_ensure_author_config_replaces_legacy_identity_without_global() { + let _env = GitConfigEnvGuard::isolated(); + + let dir = init_plain_repo(); + set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL); + + ensure_author_config(dir.path()).unwrap(); + + // No user identity anywhere: the legacy email is replaced with the + // fallback so commits keep working. + assert_local_identity( + dir.path(), + Some(FALLBACK_AUTHOR_NAME), + Some(FALLBACK_AUTHOR_EMAIL), + ); + } + + #[test] + fn test_ensure_author_config_keeps_user_set_local_identity() { + let _env = GitConfigEnvGuard::isolated(); + + let dir = init_plain_repo(); + set_local_identity(dir.path(), "Vault Owner", "owner@example.com"); + + ensure_author_config(dir.path()).unwrap(); + + // A local identity the user set themselves is never touched. + assert_local_identity(dir.path(), Some("Vault Owner"), Some("owner@example.com")); + } + + #[test] + fn test_git_author_identity_warns_when_local_identity_shadows_global_identity() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Vault Owner", "owner@example.com"))); + + let dir = init_plain_repo(); + set_local_identity(dir.path(), "Unexpected User", "unexpected@example.com"); + + let identity = git_author_identity(dir.path().to_str().unwrap()).unwrap(); + + assert_eq!(identity.name, "Unexpected User"); + assert_eq!(identity.email, "unexpected@example.com"); + assert_eq!(identity.source, "repository"); + assert_eq!(identity.warning.as_deref(), Some("local_overrides_global")); + } + + #[test] + fn test_ensure_author_config_preserves_user_name_when_healing_legacy_email() { + let _env = GitConfigEnvGuard::isolated(); + + let dir = init_plain_repo(); + set_local_identity(dir.path(), "Vault Owner", LEGACY_FALLBACK_EMAIL); + + ensure_author_config(dir.path()).unwrap(); + + assert_local_identity(dir.path(), Some("Vault Owner"), Some(FALLBACK_AUTHOR_EMAIL)); + } + + #[test] + fn test_ensure_author_config_skips_legacy_email_resolved_from_global() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Someone", LEGACY_FALLBACK_EMAIL))); + + let dir = init_plain_repo(); + + ensure_author_config(dir.path()).unwrap(); + + // The name resolves globally; the legacy email is skipped and the + // fallback is written locally instead. + assert_local_identity(dir.path(), None, Some(FALLBACK_AUTHOR_EMAIL)); + } + + #[test] + fn test_init_repo_respects_global_author_identity_for_initial_commit() { + let _env = + GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com"))); + + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("note.md"), "# Note\n").unwrap(); + + init_repo(dir.path()).unwrap(); + + assert_local_identity(dir.path(), None, None); + + let author = git_command() + .args(["log", "-1", "--format=%an <%ae>"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + "Global User " + ); + } + + fn command_envs(command: &Command) -> HashMap> { + command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().to_string(), + value.map(|entry| entry.to_string_lossy().to_string()), + ) + }) + .collect() + } + + struct GitLaunchConfigCase<'a> { + parent_path: Option<&'a str>, + configured_git_path: Option<&'a str>, + shell: Option<(&'a str, &'a str)>, + standard_candidates: &'a [&'a str], + expected_program: &'a str, + expected_path: Option<&'a str>, + } + + fn assert_git_launch_config(case: GitLaunchConfigCase<'_>) { + let shell = case.shell.map(|(git_path, path)| ShellGitConfig { + git_path: Some(PathBuf::from(git_path)), + path: Some(OsString::from(path)), + }); + let config = git_launch_config_from_sources( + case.parent_path.map(OsString::from), + case.configured_git_path.map(PathBuf::from), + shell, + case.standard_candidates + .iter() + .map(|candidate| PathBuf::from(*candidate)) + .collect(), + ); + + assert_eq!(config.program, OsString::from(case.expected_program)); + assert_eq!(config.path, case.expected_path.map(OsString::from)); + } + + #[test] + fn test_git_launch_config_source_precedence() { + for case in [ + GitLaunchConfigCase { + parent_path: Some("/usr/bin:/bin"), + configured_git_path: Some("/custom/bin/git"), + shell: Some(("/opt/homebrew/bin/git", "/opt/homebrew/bin:/usr/bin:/bin")), + standard_candidates: &["/usr/local/bin/git"], + expected_program: "/custom/bin/git", + expected_path: Some("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin"), + }, + GitLaunchConfigCase { + parent_path: Some("/usr/bin:/bin"), + configured_git_path: None, + shell: Some(("/opt/homebrew/bin/git", "/opt/homebrew/bin:/usr/bin:/bin")), + standard_candidates: &[], + expected_program: "/opt/homebrew/bin/git", + expected_path: Some("/opt/homebrew/bin:/usr/bin:/bin"), + }, + GitLaunchConfigCase { + parent_path: Some("/usr/bin:/bin"), + configured_git_path: None, + shell: None, + standard_candidates: &["/opt/homebrew/bin/git"], + expected_program: "/opt/homebrew/bin/git", + expected_path: Some("/usr/bin:/bin:/opt/homebrew/bin"), + }, + GitLaunchConfigCase { + parent_path: Some("/usr/bin:/bin"), + configured_git_path: None, + shell: None, + standard_candidates: &[], + expected_program: "git", + expected_path: Some("/usr/bin:/bin"), + }, + ] { + assert_git_launch_config(case); + } + } + + #[test] + fn test_ensure_gitignore_creates_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap(); + + ensure_gitignore(path).unwrap(); + + let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(content.contains(".DS_Store")); + assert!(content.contains(".laputa/settings.json")); + } + + #[test] + fn test_ensure_gitignore_preserves_existing() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".gitignore"), "my-rule\n").unwrap(); + + ensure_gitignore(dir.path().to_str().unwrap()).unwrap(); + + let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert_eq!(content, "my-rule\n"); + } + + #[test] + fn test_linux_appimage_git_commands_remove_appimage_loader_env() { + let mut command = crate::hidden_command("git"); + + sanitize_linux_appimage_git_env_for_launch(&mut command, true); + + let envs = command_envs(&command); + + for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { + assert_eq!(envs.get(key), Some(&None)); + } + } + + #[test] + fn test_non_appimage_git_commands_keep_parent_env_unmodified() { + let mut command = crate::hidden_command("git"); + + sanitize_linux_appimage_git_env_for_launch(&mut command, false); + + let envs = command_envs(&command); + + for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { + assert!(!envs.contains_key(key)); + } + } + + #[test] + fn test_git_command_applies_security_config() { + let args = git_command() + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + + assert_has_config_arg(&args, "core.quotePath=false"); + assert_has_config_arg(&args, "protocol.ext.allow=never"); + assert_has_config_arg(&args, "protocol.file.allow=user"); + assert_has_config_arg(&args, "core.fsmonitor=false"); + assert_has_config_arg(&args, "core.sshCommand=ssh"); + } + + fn assert_has_config_arg(args: &[String], value: &str) { + assert!(args + .windows(2) + .any(|pair| pair[0] == "-c" && pair[1] == value)); + } + + #[test] + fn test_init_repo_creates_git_directory() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + assert!(vault.join(".git").exists()); + } + + #[test] + fn test_init_repo_creates_initial_commit() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let log = git_command() + .args(["log", "--oneline"]) + .current_dir(&vault) + .output() + .unwrap(); + let log_str = String::from_utf8_lossy(&log.stdout); + assert!(log_str.contains("Initial vault setup")); + } + + #[test] + fn test_init_repo_creates_initial_commit_when_signing_is_misconfigured() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + + git_command() + .args(["init"]) + .current_dir(&vault) + .output() + .unwrap(); + git_command() + .args(["config", "commit.gpgsign", "true"]) + .current_dir(&vault) + .output() + .unwrap(); + git_command() + .args(["config", "gpg.program", "/missing/tolaria-test-gpg"]) + .current_dir(&vault) + .output() + .unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let log = git_command() + .args(["log", "--oneline"]) + .current_dir(&vault) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&log.stdout).contains("Initial vault setup")); + } + + #[test] + fn test_init_repo_stages_all_files() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(vault.join("sub")).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let status = git_command() + .args(["status", "--porcelain"]) + .current_dir(&vault) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&status.stdout).trim().is_empty(), + "All files should be committed" + ); + } + + #[test] + fn test_init_repo_creates_gitignore() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let gitignore = vault.join(".gitignore"); + assert!( + gitignore.exists(), + ".gitignore should be created by init_repo" + ); + let content = fs::read_to_string(&gitignore).unwrap(); + assert!( + content.contains(".DS_Store"), + ".gitignore should exclude .DS_Store" + ); + assert!( + content.contains(".laputa/settings.json"), + ".gitignore should exclude settings.json" + ); + // Cache is now stored outside the vault — no need for .gitignore entry + assert!( + !content.contains(".laputa-cache.json"), + ".gitignore should NOT contain .laputa-cache.json (cache is external)" + ); + } + + #[test] + fn test_init_repo_does_not_overwrite_existing_gitignore() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("new-vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("note.md"), "# Test\n").unwrap(); + fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let content = fs::read_to_string(vault.join(".gitignore")).unwrap(); + assert_eq!( + content, "custom-rule\n", + "existing .gitignore should not be overwritten" + ); + } + + #[test] + fn test_parse_github_repo_path_variants() { + let tokenized_url = format!( + "https://{}@github.com/owner/repo.git", + ["gho", "abc123"].join("_") + ); + for url in [ + "https://github.com/owner/repo.git", + "https://github.com/owner/repo", + "http://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + "git@github.com:owner/repo", + "ssh://git@github.com/owner/repo.git", + tokenized_url.as_str(), + ] { + assert_repo_path(url, Some("owner/repo")); + } + } + + #[test] + fn test_parse_github_repo_path_non_github() { + assert_repo_path("https://gitlab.com/owner/repo.git", None); + assert_repo_path("owner/repo", None); + } +} diff --git a/src-tauri/src/git/provider.rs b/src-tauri/src/git/provider.rs new file mode 100644 index 0000000..559caaf --- /dev/null +++ b/src-tauri/src/git/provider.rs @@ -0,0 +1,443 @@ +use serde::Serialize; +use std::ffi::OsString; +use std::process::{Command, Output}; + +use crate::settings::{normalize_git_provider, Settings}; + +pub(super) const NATIVE_PROVIDER: &str = "native"; +pub(super) const WSL_PROVIDER: &str = "wsl"; + +#[derive(Debug, Clone, Copy)] +struct ProbeIdentity { + provider: &'static str, + label: &'static str, +} + +const NATIVE_GIT_IDENTITY: ProbeIdentity = ProbeIdentity { + provider: NATIVE_PROVIDER, + label: "Native Git", +}; + +const WSL_GIT_IDENTITY: ProbeIdentity = ProbeIdentity { + provider: WSL_PROVIDER, + label: "WSL2 Git", +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum GitProviderSelection { + Native, + Wsl { distro: Option }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitProviderProbe { + pub provider: String, + pub label: String, + pub available: bool, + pub version: Option, + pub distro: Option, + pub path: Option, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitProviderStatus { + pub selected_provider: String, + pub selected_wsl_distro: Option, + pub native: GitProviderProbe, + pub wsl_distributions: Vec, +} + +impl GitProviderSelection { + pub(super) fn from_settings(settings: Option<&Settings>) -> Self { + let provider = + settings.and_then(|settings| normalize_git_provider(settings.git_provider.as_deref())); + + if provider.as_deref() == Some(WSL_PROVIDER) && wsl_supported_on_this_platform() { + return Self::Wsl { + distro: settings.and_then(|settings| settings.git_wsl_distro.clone()), + }; + } + + Self::Native + } + + pub(super) fn provider_id(&self) -> &'static str { + match self { + Self::Native => NATIVE_PROVIDER, + Self::Wsl { .. } => WSL_PROVIDER, + } + } +} + +pub(super) fn wsl_git_prefix_args(distro: Option<&str>) -> Vec { + let mut args = Vec::new(); + if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) { + args.push(OsString::from("--distribution")); + args.push(OsString::from(distro)); + } + args.push(OsString::from("--exec")); + args.push(OsString::from("git")); + args +} + +pub(super) fn selected_git_path_argument( + path: &str, + settings: Option<&Settings>, +) -> Result { + match GitProviderSelection::from_settings(settings) { + GitProviderSelection::Wsl { .. } => windows_path_to_wsl_path(path).ok_or_else(|| { + format!("The selected WSL Git provider cannot translate '{path}' to a WSL path.") + }), + GitProviderSelection::Native => Ok(path.to_string()), + } +} + +pub fn git_provider_status() -> GitProviderStatus { + let settings = crate::settings::get_settings().ok(); + let selection = GitProviderSelection::from_settings(settings.as_ref()); + + GitProviderStatus { + selected_provider: selection.provider_id().to_string(), + selected_wsl_distro: settings.and_then(|settings| settings.git_wsl_distro), + native: native_git_probe(), + wsl_distributions: wsl_git_probes(), + } +} + +pub fn test_git_provider( + provider: &str, + distro: Option<&str>, + vault_path: Option<&str>, +) -> GitProviderProbe { + match normalize_git_provider(Some(provider)).as_deref() { + Some(WSL_PROVIDER) => wsl_git_probe(distro, vault_path), + _ => native_git_probe(), + } +} + +fn native_git_probe() -> GitProviderProbe { + let output = Command::new("git").arg("--version").output(); + match output { + Ok(output) if output.status.success() => available_probe( + NATIVE_GIT_IDENTITY, + None, + None, + version_from_output(&output), + ), + Ok(output) => unavailable_probe(NATIVE_GIT_IDENTITY, None, native_failure_message(&output)), + Err(err) => unavailable_probe( + NATIVE_GIT_IDENTITY, + None, + format!("Native Git is unavailable: {err}"), + ), + } +} + +fn wsl_git_probes() -> Vec { + match wsl_distribution_names() { + Ok(distributions) if !distributions.is_empty() => distributions + .into_iter() + .map(|distro| wsl_git_probe(Some(&distro), None)) + .collect(), + Ok(_) => vec![unavailable_probe( + WSL_GIT_IDENTITY, + None, + "WSL is installed, but no distributions are configured.".to_string(), + )], + Err(message) => vec![unavailable_probe(WSL_GIT_IDENTITY, None, message)], + } +} + +fn wsl_git_probe(distro: Option<&str>, vault_path: Option<&str>) -> GitProviderProbe { + if !wsl_supported_on_this_platform() { + return unavailable_probe( + WSL_GIT_IDENTITY, + distro.map(ToOwned::to_owned), + "WSL2 Git is only available on Windows.".to_string(), + ); + } + + let translated_vault_path = match vault_path + .and_then(|path| (!path.trim().is_empty()).then(|| windows_path_to_wsl_path(path))) + { + Some(Some(path)) => Some(path), + Some(None) => { + return unavailable_probe( + WSL_GIT_IDENTITY, + distro.map(ToOwned::to_owned), + "The selected vault path cannot be translated to WSL.".to_string(), + ); + } + None => None, + }; + + let output = wsl_git_version_command(distro, translated_vault_path.as_deref()).output(); + match output { + Ok(output) if output.status.success() => available_probe( + WSL_GIT_IDENTITY, + distro.map(ToOwned::to_owned), + translated_vault_path, + version_from_output(&output), + ), + Ok(output) => unavailable_probe( + WSL_GIT_IDENTITY, + distro.map(ToOwned::to_owned), + native_failure_message(&output), + ), + Err(err) => unavailable_probe( + WSL_GIT_IDENTITY, + distro.map(ToOwned::to_owned), + format!("WSL2 Git is unavailable: {err}"), + ), + } +} + +fn available_probe( + identity: ProbeIdentity, + distro: Option, + path: Option, + version: Option, +) -> GitProviderProbe { + let message = version + .as_deref() + .map(|version| format!("{} is available: {version}", identity.label)) + .unwrap_or_else(|| format!("{} is available.", identity.label)); + + GitProviderProbe { + provider: identity.provider.to_string(), + label: identity.label.to_string(), + available: true, + version, + distro, + path, + message, + } +} + +fn unavailable_probe( + identity: ProbeIdentity, + distro: Option, + message: String, +) -> GitProviderProbe { + GitProviderProbe { + provider: identity.provider.to_string(), + label: identity.label.to_string(), + available: false, + version: None, + distro, + path: None, + message, + } +} + +fn native_failure_message(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + return stderr; + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return stdout; + } + + format!("Git exited with status {}", output.status) +} + +fn version_from_output(output: &Output) -> Option { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(target_os = "windows")] +fn wsl_supported_on_this_platform() -> bool { + true +} + +#[cfg(not(target_os = "windows"))] +fn wsl_supported_on_this_platform() -> bool { + false +} + +#[cfg(target_os = "windows")] +fn wsl_distribution_names() -> Result, String> { + let output = Command::new("wsl.exe") + .args(["--list", "--quiet"]) + .output() + .map_err(|err| format!("WSL is unavailable: {err}"))?; + + if !output.status.success() { + return Err(native_failure_message(&output)); + } + + Ok(parse_wsl_distribution_names(&output.stdout)) +} + +#[cfg(not(target_os = "windows"))] +fn wsl_distribution_names() -> Result, String> { + Err("WSL2 Git is only available on Windows.".to_string()) +} + +#[cfg(target_os = "windows")] +fn wsl_git_version_command(distro: Option<&str>, translated_vault_path: Option<&str>) -> Command { + let mut command = Command::new("wsl.exe"); + if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) { + command.args(["--distribution", distro]); + } + if let Some(path) = translated_vault_path { + command.args(["--cd", path]); + } + command.args(["--exec", "git", "--version"]); + command +} + +#[cfg(not(target_os = "windows"))] +fn wsl_git_version_command(_distro: Option<&str>, _translated_vault_path: Option<&str>) -> Command { + Command::new("wsl.exe") +} + +#[cfg(any(target_os = "windows", test))] +fn parse_wsl_distribution_names(output: &[u8]) -> Vec { + decode_wsl_output(output) + .lines() + .map(|line| line.trim().trim_end_matches('\r').to_string()) + .filter(|line| !line.is_empty()) + .collect() +} + +#[cfg(any(target_os = "windows", test))] +fn decode_wsl_output(output: &[u8]) -> String { + if output.len() >= 2 && output.chunks_exact(2).any(|chunk| chunk[1] == 0) { + let units = output + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + return String::from_utf16_lossy(&units).replace('\0', ""); + } + + String::from_utf8_lossy(output).replace('\0', "") +} + +fn windows_path_to_wsl_path(path: &str) -> Option { + let trimmed = path.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.starts_with('/') { + return Some(trimmed.to_string()); + } + + let normalized = trimmed.replace('\\', "/"); + if let Some(path) = drive_path_to_wsl_path(&normalized) { + return Some(path); + } + + wsl_unc_path_to_linux_path(&normalized) +} + +fn drive_path_to_wsl_path(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() < 3 { + return None; + } + if bytes[1] != b':' { + return None; + } + if bytes[2] != b'/' { + return None; + } + + let drive = bytes[0] as char; + if !drive.is_ascii_alphabetic() { + return None; + } + + Some(format!( + "/mnt/{}/{}", + drive.to_ascii_lowercase(), + &path[3..] + )) +} + +fn wsl_unc_path_to_linux_path(path: &str) -> Option { + for prefix in ["//wsl$/", "//wsl.localhost/"] { + if let Some(rest) = path.strip_prefix(prefix) { + let (_, linux_path) = rest.split_once('/')?; + return Some(format!("/{linux_path}")); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_utf8_wsl_distribution_names() { + assert_eq!( + parse_wsl_distribution_names(b"Ubuntu\r\nDebian\r\n"), + vec!["Ubuntu", "Debian"] + ); + } + + #[test] + fn parses_utf16_wsl_distribution_names() { + let encoded = "Ubuntu\r\nDebian\r\n" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + + assert_eq!( + parse_wsl_distribution_names(&encoded), + vec!["Ubuntu", "Debian"] + ); + } + + #[test] + fn translates_windows_drive_paths_for_wsl() { + assert_eq!( + windows_path_to_wsl_path(r"C:\Users\Luca\Vault").as_deref(), + Some("/mnt/c/Users/Luca/Vault") + ); + assert_eq!( + windows_path_to_wsl_path("D:/Work/Tolaria").as_deref(), + Some("/mnt/d/Work/Tolaria") + ); + } + + #[test] + fn translates_wsl_unc_paths_for_wsl() { + assert_eq!( + windows_path_to_wsl_path(r"\\wsl$\Ubuntu\home\luca\vault").as_deref(), + Some("/home/luca/vault") + ); + assert_eq!( + windows_path_to_wsl_path(r"\\wsl.localhost\Debian\var\repo").as_deref(), + Some("/var/repo") + ); + } + + #[test] + fn rejects_untranslatable_relative_paths() { + assert_eq!(windows_path_to_wsl_path("notes/vault"), None); + assert_eq!(windows_path_to_wsl_path(""), None); + } + + #[test] + fn builds_wsl_git_prefix_args() { + assert_eq!( + wsl_git_prefix_args(Some("Ubuntu")) + .into_iter() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(), + vec!["--distribution", "Ubuntu", "--exec", "git"] + ); + } +} diff --git a/src-tauri/src/git/pulse.rs b/src-tauri/src/git/pulse.rs new file mode 100644 index 0000000..374cb1a --- /dev/null +++ b/src-tauri/src/git/pulse.rs @@ -0,0 +1,599 @@ +use serde::Serialize; +use std::path::Path; + +use super::{git_command_at, parse_github_repo_path}; + +#[derive(Debug, Serialize, Clone)] +pub struct PulseFile { + pub path: String, + pub status: String, + pub title: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct PulseCommit { + pub hash: String, + #[serde(rename = "shortHash")] + pub short_hash: String, + pub message: String, + pub date: i64, + #[serde(rename = "githubUrl")] + pub github_url: Option, + pub files: Vec, + pub added: usize, + pub modified: usize, + pub deleted: usize, +} + +#[derive(Debug, Serialize, Clone)] +pub struct LastCommitInfo { + #[serde(rename = "shortHash")] + pub short_hash: String, + #[serde(rename = "commitUrl")] + pub commit_url: Option, +} + +#[derive(Clone, Copy)] +struct CommitHash<'a>(&'a str); + +#[derive(Clone, Copy)] +struct GitLogLine<'a>(&'a str); + +#[derive(Clone, Copy)] +struct GitLogOutput<'a>(&'a str); + +#[derive(Clone, Copy)] +struct GitStatusCode<'a>(&'a str); + +struct GitHubBaseUrl(String); + +#[derive(Clone, Copy, Eq, PartialEq)] +enum FileChangeStatus { + Added, + Modified, + Deleted, +} + +#[derive(Clone, Copy)] +struct VaultRelativePath<'a>(&'a str); + +impl<'a> CommitHash<'a> { + fn as_str(self) -> &'a str { + self.0 + } +} + +impl FileChangeStatus { + fn as_str(self) -> &'static str { + match self { + FileChangeStatus::Added => "added", + FileChangeStatus::Modified => "modified", + FileChangeStatus::Deleted => "deleted", + } + } +} + +impl GitHubBaseUrl { + fn commit_url(&self, hash: CommitHash<'_>) -> String { + format!("{}/commit/{}", self.0, hash.as_str()) + } +} + +impl<'a> GitLogLine<'a> { + fn as_str(self) -> &'a str { + self.0 + } + + fn is_empty(self) -> bool { + self.0.is_empty() + } +} + +impl<'a> GitLogOutput<'a> { + fn lines(self) -> impl Iterator> { + self.0.lines().map(GitLogLine) + } +} + +fn title_from_path(path: VaultRelativePath<'_>) -> String { + path.0 + .rsplit('/') + .next() + .unwrap_or(path.0) + .strip_suffix(".md") + .unwrap_or(path.0) + .replace('-', " ") +} + +fn parse_file_status(code: GitStatusCode<'_>) -> FileChangeStatus { + match code.0 { + "A" => FileChangeStatus::Added, + "M" => FileChangeStatus::Modified, + "D" => FileChangeStatus::Deleted, + _ => FileChangeStatus::Modified, + } +} + +/// Get the pulse (commit activity feed) for a vault, showing only .md file changes. +/// `skip` offsets into the commit list for pagination; `limit` caps how many to return. +pub fn get_vault_pulse( + vault_path: impl AsRef, + limit: usize, + skip: usize, +) -> Result, String> { + let vault = vault_path.as_ref(); + + if !vault.join(".git").exists() { + return Err("Not a git repository".to_string()); + } + + let limit_str = limit.to_string(); + let skip_str = skip.to_string(); + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args([ + "log", + "--name-status", + "--pretty=format:%H|%h|%s|%aI", + "--diff-filter=ADM", + "-n", + &limit_str, + "--skip", + &skip_str, + "--", + "*.md", + ]) + .output() + }) + .map_err(|e| format!("Failed to run git log: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("does not have any commits yet") { + return Ok(Vec::new()); + } + return Err(format!("git log failed: {}", stderr)); + } + + let github_base = get_github_base_url(vault); + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_pulse_output( + GitLogOutput(stdout.as_ref()), + github_base.as_ref(), + )) +} + +fn get_github_base_url(vault: &Path) -> Option { + let output = git_command_at(vault) + .and_then(|mut command| command.args(["remote", "get-url", "origin"]).output()) + .ok()?; + + if !output.status.success() { + return None; + } + + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let repo_path = parse_github_repo_path(&url)?; + Some(GitHubBaseUrl(format!("https://github.com/{}", repo_path))) +} + +fn parse_pulse_output( + stdout: GitLogOutput<'_>, + github_base: Option<&GitHubBaseUrl>, +) -> Vec { + let mut commits: Vec = Vec::new(); + let mut current: Option = None; + + for line in stdout.lines() { + if line.is_empty() { + continue; + } + + if is_commit_header(line) { + push_current_commit(&mut commits, &mut current); + current = parse_commit_header(line, github_base); + continue; + } + + if let Some(ref mut commit) = current { + add_file_change(commit, line); + } + } + + push_current_commit(&mut commits, &mut current); + + commits +} + +fn is_git_status_line(line: GitLogLine<'_>) -> bool { + let line = line.as_str(); + line.starts_with(|c: char| { + c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t') + }) +} + +fn is_commit_header(line: GitLogLine<'_>) -> bool { + line.as_str().contains('|') && !is_git_status_line(line) +} + +fn push_current_commit(commits: &mut Vec, current: &mut Option) { + if let Some(commit) = current.take() { + commits.push(commit); + } +} + +fn parse_commit_header( + line: GitLogLine<'_>, + github_base: Option<&GitHubBaseUrl>, +) -> Option { + let parts: Vec<&str> = line.as_str().splitn(4, '|').collect(); + if parts.len() != 4 { + return None; + } + + let hash = CommitHash(parts[0]); + let date = chrono::DateTime::parse_from_rfc3339(parts[3]) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + let github_url = github_base.map(|base| base.commit_url(hash)); + + Some(PulseCommit { + hash: hash.as_str().to_string(), + short_hash: parts[1].to_string(), + message: parts[2].to_string(), + date, + github_url, + files: Vec::new(), + added: 0, + modified: 0, + deleted: 0, + }) +} + +fn add_file_change(commit: &mut PulseCommit, line: GitLogLine<'_>) { + let file_parts: Vec<&str> = line.as_str().splitn(2, '\t').collect(); + if file_parts.len() != 2 { + return; + } + + let status = parse_file_status(GitStatusCode(file_parts[0].trim())); + let path = file_parts[1].trim(); + match status { + FileChangeStatus::Added => commit.added += 1, + FileChangeStatus::Deleted => commit.deleted += 1, + _ => commit.modified += 1, + } + commit.files.push(PulseFile { + path: path.to_string(), + status: status.as_str().to_string(), + title: title_from_path(VaultRelativePath(path)), + }); +} + +/// Get the last commit's short hash and a GitHub URL (if remote is GitHub). +pub fn get_last_commit_info( + vault_path: impl AsRef, +) -> Result, String> { + let vault = vault_path.as_ref(); + + let output = git_command_at(vault) + .and_then(|mut command| command.args(["log", "-1", "--format=%H|%h"]).output()) + .map_err(|e| format!("Failed to run git log: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("does not have any commits yet") { + return Ok(None); + } + return Err(format!("git log failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout.trim(); + if line.is_empty() { + return Ok(None); + } + + let parts: Vec<&str> = line.splitn(2, '|').collect(); + if parts.len() != 2 { + return Ok(None); + } + + let full_hash = parts[0]; + let short_hash = parts[1].to_string(); + + let commit_url = get_github_commit_url(vault, CommitHash(full_hash)); + + Ok(Some(LastCommitInfo { + short_hash, + commit_url, + })) +} + +/// Try to build a GitHub commit URL from the origin remote URL. +fn get_github_commit_url(vault: &Path, full_hash: CommitHash<'_>) -> Option { + get_github_base_url(vault).map(|base| base.commit_url(full_hash)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_commit; + use crate::git::tests::setup_git_repo; + use std::fs; + use std::process::Command; + use tempfile::TempDir; + + #[derive(Clone, Copy)] + enum GitHubRemote { + OwnerRepo, + LaputaVault, + } + + enum NoteRepoChange { + ConfigOnlyCommit, + NoteUpdateCommit, + } + + enum CommitUrlSource { + Pulse, + LastCommitInfo, + } + + impl GitHubRemote { + fn url(&self) -> &'static str { + match self { + GitHubRemote::OwnerRepo => "https://github.com/owner/repo.git", + GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault.git", + } + } + + fn commit_prefix(&self) -> &'static str { + match self { + GitHubRemote::OwnerRepo => "https://github.com/owner/repo/commit/", + GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault/commit/", + } + } + } + + impl NoteRepoChange { + fn apply(self, dir: &TempDir) { + let vault = dir.path(); + let vp = vault_path(dir); + + match self { + NoteRepoChange::ConfigOnlyCommit => { + fs::write(vault.join("config.json"), "{}").unwrap(); + git_commit(vp, "Add config").unwrap(); + } + NoteRepoChange::NoteUpdateCommit => { + fs::write(vault.join("note.md"), "# Updated\n").unwrap(); + git_commit(vp, "Update note").unwrap(); + } + } + } + } + + fn vault_path(dir: &TempDir) -> &str { + dir.path().to_str().unwrap() + } + + fn repo_with_committed_note() -> TempDir { + let dir = setup_git_repo(); + + fs::write(dir.path().join("note.md"), "# Note\n").unwrap(); + git_commit(vault_path(&dir), "Add note").unwrap(); + + dir + } + + fn add_origin_remote(vault: &Path, remote: GitHubRemote) { + Command::new("git") + .args(["remote", "add", "origin", remote.url()]) + .current_dir(vault) + .output() + .unwrap(); + } + + fn pulse_after_note_repo_change(change: NoteRepoChange) -> Vec { + let dir = repo_with_committed_note(); + change.apply(&dir); + + get_vault_pulse(vault_path(&dir), 30, 0).unwrap() + } + + fn commit_url_for(source: CommitUrlSource, remote: GitHubRemote) -> String { + let dir = repo_with_committed_note(); + add_origin_remote(dir.path(), remote); + + match source { + CommitUrlSource::Pulse => get_vault_pulse(vault_path(&dir), 30, 0).unwrap()[0] + .github_url + .clone() + .unwrap(), + CommitUrlSource::LastCommitInfo => get_last_commit_info(vault_path(&dir)) + .unwrap() + .unwrap() + .commit_url + .unwrap(), + } + } + + #[test] + fn test_get_vault_pulse_with_commits() { + let dir = repo_with_committed_note(); + let vault = dir.path(); + let vp = vault_path(&dir); + + fs::write(vault.join("project.md"), "# Project\n").unwrap(); + git_commit(vp, "Add project").unwrap(); + + let pulse = get_vault_pulse(vp, 30, 0).unwrap(); + + assert_eq!(pulse.len(), 2); + assert_eq!(pulse[0].message, "Add project"); + assert_eq!(pulse[1].message, "Add note"); + assert_eq!(pulse[0].files.len(), 1); + assert_eq!(pulse[0].files[0].path, "project.md"); + assert_eq!(pulse[0].files[0].status, "added"); + assert_eq!(pulse[0].added, 1); + assert_eq!(pulse[0].modified, 0); + assert!(!pulse[0].short_hash.is_empty()); + } + + #[test] + fn test_get_vault_pulse_no_git() { + let dir = TempDir::new().unwrap(); + let vp = dir.path().to_str().unwrap(); + + let result = get_vault_pulse(vp, 30, 0); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Not a git repository")); + } + + #[test] + fn test_get_vault_pulse_empty_repo() { + let dir = setup_git_repo(); + let vp = vault_path(&dir); + + let pulse = get_vault_pulse(vp, 30, 0).unwrap(); + assert!(pulse.is_empty()); + } + + #[test] + fn test_get_vault_pulse_only_md_files() { + let pulse = pulse_after_note_repo_change(NoteRepoChange::ConfigOnlyCommit); + + assert_eq!(pulse.len(), 1); + assert_eq!(pulse[0].files.len(), 1); + assert_eq!(pulse[0].files[0].path, "note.md"); + } + + #[test] + fn test_get_vault_pulse_respects_limit() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault_path(&dir); + + for i in 0..5 { + fs::write( + vault.join(format!("note{}.md", i)), + format!("# Note {}\n", i), + ) + .unwrap(); + git_commit(vp, &format!("Add note {}", i)).unwrap(); + } + + let pulse = get_vault_pulse(vp, 3, 0).unwrap(); + assert_eq!(pulse.len(), 3); + } + + #[test] + fn test_get_vault_pulse_modified_and_deleted() { + let pulse = pulse_after_note_repo_change(NoteRepoChange::NoteUpdateCommit); + + assert_eq!(pulse[0].message, "Update note"); + assert_eq!(pulse[0].files[0].status, "modified"); + assert_eq!(pulse[0].modified, 1); + } + + #[test] + fn test_get_vault_pulse_github_url() { + let remote = GitHubRemote::OwnerRepo; + let url = commit_url_for(CommitUrlSource::Pulse, remote); + + assert!(url.starts_with(remote.commit_prefix())); + } + + #[test] + fn test_get_vault_pulse_no_github_url_without_remote() { + let dir = repo_with_committed_note(); + let vp = vault_path(&dir); + + let pulse = get_vault_pulse(vp, 30, 0).unwrap(); + assert!(pulse[0].github_url.is_none()); + } + + #[test] + fn test_title_from_path() { + assert_eq!( + title_from_path(VaultRelativePath("note/my-project.md")), + "my project" + ); + assert_eq!(title_from_path(VaultRelativePath("simple.md")), "simple"); + assert_eq!( + title_from_path(VaultRelativePath("deep/nested/file.md")), + "file" + ); + } + + #[test] + fn test_parse_pulse_output_basic() { + let stdout = + "abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n"; + let commits = parse_pulse_output(GitLogOutput(stdout), None); + + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].message, "Add notes"); + assert_eq!(commits[0].files.len(), 2); + assert_eq!(commits[0].files[0].status, "added"); + assert_eq!(commits[0].files[1].status, "modified"); + assert_eq!(commits[0].added, 1); + assert_eq!(commits[0].modified, 1); + assert!(commits[0].github_url.is_none()); + } + + #[test] + fn test_parse_pulse_output_with_github() { + let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n"; + let base = GitHubBaseUrl("https://github.com/o/r".to_string()); + let commits = parse_pulse_output(GitLogOutput(stdout), Some(&base)); + + assert_eq!( + commits[0].github_url.as_deref(), + Some("https://github.com/o/r/commit/abc123") + ); + } + + #[test] + fn test_parse_pulse_output_multiple_commits() { + let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n"; + let commits = parse_pulse_output(GitLogOutput(stdout), None); + + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].message, "First"); + assert_eq!(commits[1].message, "Second"); + assert_eq!(commits[1].files.len(), 2); + assert_eq!(commits[1].deleted, 1); + } + + #[test] + fn test_get_last_commit_info_with_commit() { + let dir = repo_with_committed_note(); + let vp = vault_path(&dir); + + let info = get_last_commit_info(vp).unwrap(); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info.short_hash.len(), 7); + assert!(info.commit_url.is_none()); + } + + #[test] + fn test_get_last_commit_info_no_commits() { + let dir = setup_git_repo(); + let vp = vault_path(&dir); + + let info = get_last_commit_info(vp).unwrap(); + assert!(info.is_none()); + } + + #[test] + fn test_get_last_commit_info_with_github_remote() { + let remote = GitHubRemote::LaputaVault; + let url = commit_url_for(CommitUrlSource::LastCommitInfo, remote); + + assert!(url.starts_with(remote.commit_prefix())); + } +} diff --git a/src-tauri/src/git/remote.rs b/src-tauri/src/git/remote.rs new file mode 100644 index 0000000..bdf9a08 --- /dev/null +++ b/src-tauri/src/git/remote.rs @@ -0,0 +1,594 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use super::command::{git_output, stderr_text, stdout_text}; +use super::conflict::get_conflict_files; +use super::remote_config::has_configured_remote; +use super::upstream::{missing_upstream_message, sync_target}; + +const NO_REMOTE_STATUS: &str = "no_remote"; +const NO_REMOTE_MESSAGE: &str = "No remote configured"; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitPullResult { + pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error" + pub message: String, + #[serde(rename = "updatedFiles")] + pub updated_files: Vec, + #[serde(rename = "conflictFiles")] + pub conflict_files: Vec, +} + +/// Check whether the vault repo has at least one remote configured. +pub fn has_remote(vault_path: impl AsRef) -> Result { + let vault = vault_path.as_ref(); + has_configured_remote(vault) +} + +/// Pull latest changes from remote. Uses --no-rebase to merge. +/// Returns a structured result with status and affected files. +pub fn git_pull(vault_path: impl AsRef) -> Result { + let vault = vault_path.as_ref(); + + if !has_remote(vault)? { + return Ok(GitPullResult { + status: NO_REMOTE_STATUS.to_string(), + message: NO_REMOTE_MESSAGE.to_string(), + updated_files: vec![], + conflict_files: vec![], + }); + } + + let target = match sync_target(vault)? { + Some(target) => target, + None => { + return Ok(GitPullResult { + status: "error".to_string(), + message: missing_upstream_message(vault)?, + updated_files: vec![], + conflict_files: vec![], + }); + } + }; + + let output = git_output( + vault, + &["pull", "--no-rebase", &target.remote, &target.branch], + ) + .map_err(|e| format!("Failed to run git pull: {}", e))?; + + let stdout = stdout_text(&output); + let stderr = stderr_text(&output); + + if output.status.success() { + if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") { + return Ok(GitPullResult { + status: "up_to_date".to_string(), + message: "Already up to date".to_string(), + updated_files: vec![], + conflict_files: vec![], + }); + } + let updated = parse_updated_files(&stdout); + return Ok(GitPullResult { + status: "updated".to_string(), + message: format!("{} file(s) updated", updated.len()), + updated_files: updated, + conflict_files: vec![], + }); + } + + // Check for merge conflicts + let vault_text = vault.to_string_lossy(); + let conflicts = get_conflict_files(vault_text.as_ref()).unwrap_or_default(); + if !conflicts.is_empty() { + return Ok(GitPullResult { + status: "conflict".to_string(), + message: format!("Merge conflict in {} file(s)", conflicts.len()), + updated_files: vec![], + conflict_files: conflicts, + }); + } + + // Network error or other failure — report as error + let detail = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + Ok(GitPullResult { + status: "error".to_string(), + message: detail, + updated_files: vec![], + conflict_files: vec![], + }) +} + +/// Parse `git pull` output to extract updated file paths. +fn parse_updated_files(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + // Lines like " path/to/file.md | 5 ++-" in diffstat + if trimmed.contains('|') { + let path = trimmed.split('|').next()?.trim(); + if !path.is_empty() { + return Some(path.to_string()); + } + } + None + }) + .collect() +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitPushResult { + pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error" + pub message: String, +} + +#[derive(Clone, Copy)] +enum PushStatus { + Rejected, + AuthError, + NetworkError, + NoRemote, + Error, +} + +impl PushStatus { + fn as_str(self) -> &'static str { + match self { + Self::Rejected => "rejected", + Self::AuthError => "auth_error", + Self::NetworkError => "network_error", + Self::NoRemote => "no_remote", + Self::Error => "error", + } + } +} + +/// Classify a git push stderr message into a user-friendly status and message. +pub fn classify_push_error(stderr: impl AsRef) -> GitPushResult { + let stderr = stderr.as_ref(); + let lower = stderr.to_lowercase(); + + if is_rejected_push_error(&lower) { + return push_error( + PushStatus::Rejected, + "Push rejected: remote has new commits. Pull first, then push.", + ); + } + + if is_auth_push_error(&lower) { + return push_error( + PushStatus::AuthError, + "Push failed: authentication error. Check your credentials.", + ); + } + + if is_network_push_error(&lower) { + return push_error( + PushStatus::NetworkError, + "Push failed: network error. Check your connection and try again.", + ); + } + + if is_no_remote_push_error(&lower) { + return push_error(PushStatus::NoRemote, "No remote configured"); + } + + push_error( + PushStatus::Error, + format!("Push failed: {}", push_error_detail(stderr)), + ) +} + +fn push_error(status: PushStatus, message: impl Into) -> GitPushResult { + GitPushResult { + status: status.as_str().to_string(), + message: message.into(), + } +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +fn is_rejected_push_error(lower: impl AsRef) -> bool { + let lower = lower.as_ref(); + if contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"]) { + return true; + } + if !lower.contains("failed to push some refs") { + return false; + } + contains_any(lower, &["updates were rejected", "non-fast-forward"]) +} + +fn is_auth_push_error(lower: impl AsRef) -> bool { + contains_any( + lower.as_ref(), + &[ + "authentication failed", + "could not read username", + "permission denied", + "403", + "invalid credentials", + ], + ) +} + +fn is_network_push_error(lower: impl AsRef) -> bool { + contains_any( + lower.as_ref(), + &[ + "could not resolve host", + "unable to access", + "connection refused", + "network is unreachable", + "timed out", + ], + ) +} + +fn is_no_remote_push_error(lower: impl AsRef) -> bool { + contains_any( + lower.as_ref(), + &[ + "no configured push destination", + "does not appear to be a git repository", + "no such remote", + "no upstream branch", + ], + ) +} + +fn push_error_detail(stderr: &str) -> String { + let hint_line = stderr + .lines() + .find(|line| line.trim_start().starts_with("hint:")) + .map(|line| { + line.trim_start() + .strip_prefix("hint:") + .unwrap_or(line) + .trim() + }) + .unwrap_or("") + .to_string(); + + if hint_line.is_empty() { + stderr.trim().to_string() + } else { + hint_line + } +} + +/// Push to remote. +pub fn git_push(vault_path: impl AsRef) -> Result { + let vault = vault_path.as_ref(); + + if !has_remote(vault)? { + return Ok(GitPushResult { + status: NO_REMOTE_STATUS.to_string(), + message: NO_REMOTE_MESSAGE.to_string(), + }); + } + + let target = match sync_target(vault)? { + Some(target) => target, + None => { + return Ok(GitPushResult { + status: "error".to_string(), + message: missing_upstream_message(vault)?, + }); + } + }; + + let push_refspec = format!("HEAD:refs/heads/{}", target.branch); + let output = git_output(vault, &["push", &target.remote, &push_refspec]) + .map_err(|e| format!("Failed to run git push: {}", e))?; + + if !output.status.success() { + let stderr = stderr_text(&output); + return Ok(classify_push_error(&stderr)); + } + + Ok(GitPushResult { + status: "ok".to_string(), + message: "Pushed to remote".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::git_commit; + use crate::git::tests::{setup_git_repo, setup_remote_pair}; + use std::fs; + use tempfile::TempDir; + + struct RemotePair { + _bare: TempDir, + clone_a: TempDir, + clone_b: TempDir, + } + + impl RemotePair { + fn new() -> Self { + let (_bare, clone_a, clone_b) = setup_remote_pair(); + + Self { + _bare, + clone_a, + clone_b, + } + } + + fn seeded() -> Self { + let pair = Self::new(); + commit_default_note(pair.clone_a.path()); + git_push(pair.vault_a()).unwrap(); + pair + } + + fn vault_a(&self) -> &str { + path_text(self.clone_a.path()) + } + + fn vault_b(&self) -> &str { + path_text(self.clone_b.path()) + } + + fn sync_b(&self) { + git_pull(self.vault_b()).unwrap(); + } + + fn update_a_note(&self) { + fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap(); + git_commit(self.vault_a(), "update").unwrap(); + } + + fn push_a(&self) { + git_push(self.vault_a()).unwrap(); + } + } + + fn path_text(path: &Path) -> &str { + path.to_str().unwrap() + } + + fn local_repo_with_note() -> TempDir { + let dir = setup_git_repo(); + commit_default_note(dir.path()); + dir + } + + fn commit_default_note(vault_path: &Path) { + fs::write(vault_path.join("note.md"), "# Note\n").unwrap(); + git_commit(vault_path.to_str().unwrap(), "initial").unwrap(); + } + + #[test] + fn test_has_remote_returns_false_for_local_repo() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = path_text(vault); + + assert!(!has_remote(vp).unwrap()); + } + + #[test] + fn test_has_remote_returns_true_when_remote_exists() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = path_text(vault); + + git_command() + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(vault) + .output() + .unwrap(); + + assert!(has_remote(vp).unwrap()); + } + + #[test] + fn test_has_remote_ignores_name_only_remote_without_url() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = path_text(vault); + + git_command() + .args(["config", "remote.origin.prune", "true"]) + .current_dir(vault) + .output() + .unwrap(); + + let remote_names = git_command() + .args(["remote"]) + .current_dir(vault) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin")); + assert!(!has_remote(vp).unwrap()); + } + + #[test] + fn test_git_pull_no_remote_returns_no_remote() { + let dir = local_repo_with_note(); + let vp = path_text(dir.path()); + + let result = git_pull(vp).unwrap(); + assert_eq!(result.status, "no_remote"); + assert!(result.updated_files.is_empty()); + assert!(result.conflict_files.is_empty()); + } + + #[test] + fn test_git_pull_up_to_date() { + let pair = RemotePair::seeded(); + + let result = git_pull(pair.vault_a()).unwrap(); + assert_eq!(result.status, "up_to_date"); + } + + #[test] + fn test_git_pull_updated_files() { + let pair = RemotePair::seeded(); + pair.sync_b(); + pair.update_a_note(); + pair.push_a(); + + let result = git_pull(pair.vault_b()).unwrap(); + assert_eq!(result.status, "updated"); + assert!(result.conflict_files.is_empty()); + } + + #[test] + fn test_parse_updated_files_diffstat() { + let stdout = + " Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n"; + let files = parse_updated_files(stdout); + assert_eq!(files, vec!["note.md", "project/plan.md"]); + } + + #[test] + fn test_parse_updated_files_empty() { + let stdout = "Already up to date.\n"; + let files = parse_updated_files(stdout); + assert!(files.is_empty()); + } + + #[test] + fn test_classify_push_error_non_fast_forward() { + let stderr = r#"To github.com:user/repo.git + ! [rejected] main -> main (non-fast-forward) +error: failed to push some refs to 'github.com:user/repo.git' +hint: Updates were rejected because the remote contains work that you do not +hint: have locally."#; + let result = classify_push_error(stderr); + assert_eq!(result.status, "rejected"); + assert!(result.message.contains("Pull first")); + } + + #[test] + fn test_classify_push_error_fetch_first() { + let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n"; + let result = classify_push_error(stderr); + assert_eq!(result.status, "rejected"); + } + + #[test] + fn test_classify_push_error_auth_failure() { + let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403"; + let result = classify_push_error(stderr); + assert_eq!(result.status, "auth_error"); + assert!(result.message.contains("authentication")); + } + + #[test] + fn test_classify_push_error_network() { + let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com"; + let result = classify_push_error(stderr); + assert_eq!(result.status, "network_error"); + assert!(result.message.contains("network")); + } + + #[test] + fn test_classify_push_error_no_remote() { + let stderr = "fatal: No configured push destination."; + let result = classify_push_error(stderr); + assert_eq!(result.status, "no_remote"); + assert!(result.message.contains("No remote")); + } + + #[test] + fn test_classify_push_error_unknown() { + let stderr = "error: something unexpected happened\nhint: Try again later"; + let result = classify_push_error(stderr); + assert_eq!(result.status, "error"); + assert!(result.message.contains("Try again later")); + } + + #[test] + fn test_classify_push_error_unknown_no_hint() { + let stderr = "error: something totally weird"; + let result = classify_push_error(stderr); + assert_eq!(result.status, "error"); + assert!(result.message.contains("something totally weird")); + } + + #[test] + fn test_git_push_result_serialization() { + let result = GitPushResult { + status: "rejected".to_string(), + message: "Push rejected".to_string(), + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"rejected\"")); + let parsed: GitPushResult = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status, "rejected"); + } + + #[test] + fn test_git_push_success_returns_ok() { + let pair = RemotePair::new(); + + commit_default_note(pair.clone_a.path()); + let result = git_push(pair.vault_a()).unwrap(); + assert_eq!(result.status, "ok"); + } + + #[test] + fn test_git_push_no_remote_returns_no_remote() { + let dir = local_repo_with_note(); + let vp = path_text(dir.path()); + + let result = git_push(vp).unwrap(); + assert_eq!(result.status, "no_remote"); + } + + #[test] + fn test_git_push_rejected_returns_rejected() { + let pair = RemotePair::new(); + let vp_a = pair.vault_a(); + let vp_b = pair.vault_b(); + + // Both clones commit and push — second push should be rejected + fs::write(pair.clone_a.path().join("note.md"), "# A\n").unwrap(); + git_commit(vp_a, "from A").unwrap(); + git_push(vp_a).unwrap(); + + git_pull(vp_b).unwrap(); + fs::write(pair.clone_b.path().join("note.md"), "# B\n").unwrap(); + git_commit(vp_b, "from B").unwrap(); + git_push(vp_b).unwrap(); + + // Now A has a new commit but hasn't pulled B's changes + fs::write(pair.clone_a.path().join("other.md"), "# Other\n").unwrap(); + git_commit(vp_a, "from A again").unwrap(); + let result = git_push(vp_a).unwrap(); + assert_eq!(result.status, "rejected"); + assert!(result.message.contains("Pull first")); + } + + #[test] + fn test_git_pull_result_serialization() { + let result = GitPullResult { + status: "updated".to_string(), + message: "2 file(s) updated".to_string(), + updated_files: vec!["note.md".to_string()], + conflict_files: vec![], + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"updatedFiles\"")); + assert!(json.contains("\"conflictFiles\"")); + + let parsed: GitPullResult = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status, "updated"); + assert_eq!(parsed.updated_files.len(), 1); + } +} diff --git a/src-tauri/src/git/remote_branch_tests.rs b/src-tauri/src/git/remote_branch_tests.rs new file mode 100644 index 0000000..6000b75 --- /dev/null +++ b/src-tauri/src/git/remote_branch_tests.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::path::Path; + +use super::git_command; +use super::tests::setup_remote_pair; +use super::{git_commit, git_pull, git_push, git_remote_status}; +use tempfile::TempDir; + +struct RemotePair { + _bare: TempDir, + clone_a: TempDir, + clone_b: TempDir, +} + +impl RemotePair { + fn seeded() -> Self { + let (_bare, clone_a, clone_b) = setup_remote_pair(); + fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); + git_commit(path_text(clone_a.path()), "initial").unwrap(); + git_push(path_text(clone_a.path())).unwrap(); + Self { + _bare, + clone_a, + clone_b, + } + } + + fn vault_a(&self) -> &str { + path_text(self.clone_a.path()) + } + + fn vault_b(&self) -> &str { + path_text(self.clone_b.path()) + } +} + +fn path_text(path: &Path) -> &str { + path.to_str().unwrap() +} + +fn run_git(vault_path: &Path, args: &[&str]) { + let output = git_command() + .args(args) + .current_dir(vault_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn git_pull_and_push_use_configured_upstream_branch() { + let pair = RemotePair::seeded(); + + run_git(pair.clone_a.path(), &["checkout", "-b", "local-draft"]); + run_git( + pair.clone_a.path(), + &["push", "-u", "origin", "HEAD:refs/heads/review-target"], + ); + run_git(pair.clone_b.path(), &["fetch", "origin"]); + run_git( + pair.clone_b.path(), + &["checkout", "-b", "review-copy", "origin/review-target"], + ); + + fs::write( + pair.clone_a.path().join("branch-note.md"), + "# Branch note\n", + ) + .unwrap(); + git_commit(pair.vault_a(), "branch update").unwrap(); + + let push = git_push(pair.vault_a()).unwrap(); + assert_eq!(push.status, "ok"); + + let pull = git_pull(pair.vault_b()).unwrap(); + assert_eq!(pull.status, "updated"); + assert_eq!( + fs::read_to_string(pair.clone_b.path().join("branch-note.md")).unwrap(), + "# Branch note\n" + ); +} + +#[test] +fn git_remote_status_reports_missing_upstream() { + let pair = RemotePair::seeded(); + run_git(pair.clone_a.path(), &["checkout", "-b", "local-only"]); + + let status = git_remote_status(pair.vault_a()).unwrap(); + assert!(status.has_remote); + assert_eq!(status.branch, "local-only"); + assert!(!status.has_upstream); + assert_eq!(status.upstream, None); + + let pull = git_pull(pair.vault_a()).unwrap(); + assert_eq!(pull.status, "error"); + assert!(pull.message.contains("No upstream branch configured")); + + let push = git_push(pair.vault_a()).unwrap(); + assert_eq!(push.status, "error"); + assert!(push.message.contains("No upstream branch configured")); +} diff --git a/src-tauri/src/git/remote_config.rs b/src-tauri/src/git/remote_config.rs new file mode 100644 index 0000000..2655632 --- /dev/null +++ b/src-tauri/src/git/remote_config.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use super::command::{git_output, stderr_or_failure, stdout_lines}; + +const DEFAULT_FETCH_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*"; +const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$"; +const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url"; +const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConfiguredRemote { + name: String, + url: String, +} + +pub(super) fn has_configured_remote(vault: &Path) -> Result { + Ok(!list_configured_remotes(vault)?.is_empty()) +} + +pub(super) fn list_configured_remotes(vault: &Path) -> Result, String> { + Ok(list_configured_remote_urls(vault)? + .into_iter() + .map(|remote| remote.name) + .collect()) +} + +pub(super) fn primary_remote_url(vault: &Path) -> Result, String> { + let remotes = list_configured_remote_urls(vault)?; + Ok(remotes + .iter() + .find(|remote| remote.name == "origin") + .or_else(|| remotes.first()) + .map(|remote| remote.url.clone())) +} + +fn list_configured_remote_urls(vault: &Path) -> Result, String> { + let output = git_output( + vault, + &["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN], + ) + .map_err(|e| format!("Failed to inspect git remotes: {e}"))?; + + if output.status.code() == Some(1) { + return Ok(Vec::new()); + } + if !output.status.success() { + return Err(stderr_or_failure("git config --get-regexp", &output)); + } + + Ok(stdout_lines(&output) + .into_iter() + .filter_map(|line| remote_from_url_config(&line)) + .collect()) +} + +fn remote_from_url_config(line: &str) -> Option { + let (key, value) = line.split_once(' ')?; + let url = value.trim(); + if url.is_empty() { + return None; + } + let name = key + .strip_prefix("remote.") + .and_then(|name| name.strip_suffix(".url")) + .filter(|name| !name.is_empty()) + .map(ToString::to_string)?; + + Some(ConfiguredRemote { + name, + url: url.to_string(), + }) +} + +pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> { + run_git_config(vault, ORIGIN_URL_CONFIG_KEY, remote_url)?; + run_git_config(vault, ORIGIN_FETCH_CONFIG_KEY, DEFAULT_FETCH_REFSPEC) +} + +fn run_git_config(vault: &Path, key: &str, value: &str) -> Result<(), String> { + let output = git_output(vault, &["config", "--local", "--replace-all", key, value]) + .map_err(|e| format!("Failed to run git config: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + Err(stderr_or_failure("git config", &output)) +} diff --git a/src-tauri/src/git/remote_status.rs b/src-tauri/src/git/remote_status.rs new file mode 100644 index 0000000..43860bd --- /dev/null +++ b/src-tauri/src/git/remote_status.rs @@ -0,0 +1,223 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use super::command::{git_output, git_output_result, stdout_text}; +use super::remote_config::has_configured_remote; +use super::upstream::{branch_label, sync_target}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitRemoteStatus { + pub branch: String, + pub ahead: u32, + pub behind: u32, + #[serde(rename = "hasRemote")] + pub has_remote: bool, + #[serde(rename = "hasUpstream")] + pub has_upstream: bool, + pub upstream: Option, +} + +/// Get the current branch name, and how many commits ahead/behind the upstream. +pub fn git_remote_status(vault_path: impl AsRef) -> Result { + let vault = vault_path.as_ref(); + let branch = branch_label(vault)?; + + if !has_configured_remote(vault)? { + return Ok(status_without_remote(branch)); + } + + // Fetch latest remote refs (silent, best-effort) + let _ = git_output(vault, &["fetch", "--quiet"]); + + let Some(target) = sync_target(vault)? else { + return Ok(status_without_upstream(branch)); + }; + + let output = git_output_result( + vault, + &[ + "rev-list", + "--left-right", + "--count", + &format!("HEAD...{}", target.display), + ], + )?; + + let (ahead, behind) = if output.status.success() { + parse_ahead_behind(&stdout_text(&output)) + } else { + (0, 0) + }; + + Ok(status_with_upstream(branch, target.display, ahead, behind)) +} + +fn status_without_remote(branch: String) -> GitRemoteStatus { + GitRemoteStatus { + branch, + ahead: 0, + behind: 0, + has_remote: false, + has_upstream: false, + upstream: None, + } +} + +fn status_without_upstream(branch: String) -> GitRemoteStatus { + GitRemoteStatus { + branch, + ahead: 0, + behind: 0, + has_remote: true, + has_upstream: false, + upstream: None, + } +} + +fn status_with_upstream( + branch: String, + upstream: String, + ahead: u32, + behind: u32, +) -> GitRemoteStatus { + GitRemoteStatus { + branch, + ahead, + behind, + has_remote: true, + has_upstream: true, + upstream: Some(upstream), + } +} + +fn parse_ahead_behind(stdout: &str) -> (u32, u32) { + let parts: Vec<&str> = stdout.split('\t').collect(); + let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + (ahead, behind) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::tests::{setup_git_repo, setup_remote_pair}; + use crate::git::{git_commit, git_pull, git_push}; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + struct RemotePair { + _bare: TempDir, + clone_a: TempDir, + clone_b: TempDir, + } + + impl RemotePair { + fn seeded() -> Self { + let (_bare, clone_a, clone_b) = setup_remote_pair(); + commit_default_note(clone_a.path()); + git_push(path_text(clone_a.path())).unwrap(); + Self { + _bare, + clone_a, + clone_b, + } + } + + fn vault_a(&self) -> &str { + path_text(self.clone_a.path()) + } + + fn sync_b(&self) { + git_pull(path_text(self.clone_b.path())).unwrap(); + } + + fn update_a_note(&self) { + fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap(); + git_commit(self.vault_a(), "update").unwrap(); + } + + fn update_b_note(&self) { + fs::write(self.clone_b.path().join("note.md"), "# B update\n").unwrap(); + git_commit(path_text(self.clone_b.path()), "from B").unwrap(); + } + + fn push_b(&self) { + git_push(path_text(self.clone_b.path())).unwrap(); + } + } + + fn path_text(path: &Path) -> &str { + path.to_str().unwrap() + } + + fn local_repo_with_note() -> TempDir { + let dir = setup_git_repo(); + commit_default_note(dir.path()); + dir + } + + fn commit_default_note(vault_path: &Path) { + fs::write(vault_path.join("note.md"), "# Note\n").unwrap(); + git_commit(path_text(vault_path), "initial").unwrap(); + } + + #[test] + fn git_remote_status_no_remote() { + let dir = local_repo_with_note(); + let status = git_remote_status(path_text(dir.path())).unwrap(); + assert!(!status.has_remote); + assert_eq!(status.ahead, 0); + assert_eq!(status.behind, 0); + } + + #[test] + fn git_remote_status_up_to_date() { + let pair = RemotePair::seeded(); + let status = git_remote_status(pair.vault_a()).unwrap(); + assert!(status.has_remote); + assert!(status.has_upstream); + assert_eq!(status.upstream.as_deref(), Some("origin/main")); + assert_eq!(status.ahead, 0); + assert_eq!(status.behind, 0); + } + + #[test] + fn git_remote_status_ahead() { + let pair = RemotePair::seeded(); + pair.update_a_note(); + let status = git_remote_status(pair.vault_a()).unwrap(); + assert_eq!(status.ahead, 1); + assert_eq!(status.behind, 0); + } + + #[test] + fn git_remote_status_behind() { + let pair = RemotePair::seeded(); + pair.sync_b(); + pair.update_b_note(); + pair.push_b(); + let status = git_remote_status(pair.vault_a()).unwrap(); + assert_eq!(status.behind, 1); + assert_eq!(status.ahead, 0); + } + + #[test] + fn git_remote_status_serialization() { + let status = GitRemoteStatus { + branch: "main".to_string(), + ahead: 2, + behind: 1, + has_remote: true, + has_upstream: true, + upstream: Some("origin/main".to_string()), + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"hasRemote\"")); + assert!(json.contains("\"hasUpstream\"")); + let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.branch, "main"); + assert_eq!(parsed.ahead, 2); + assert_eq!(parsed.upstream.as_deref(), Some("origin/main")); + } +} diff --git a/src-tauri/src/git/remote_url.rs b/src-tauri/src/git/remote_url.rs new file mode 100644 index 0000000..156837e --- /dev/null +++ b/src-tauri/src/git/remote_url.rs @@ -0,0 +1,132 @@ +const ALLOWED_REMOTE_URL_MESSAGE: &str = + "Repository URL must start with https://, http://, ssh://, or git@host:path."; +const HIERARCHICAL_REMOTE_SCHEMES: [&str; 3] = ["https://", "http://", "ssh://"]; + +pub(crate) fn validate_user_remote_url(remote_url: &str) -> Result<&str, String> { + let trimmed = remote_url.trim(); + + if let Some(message) = invalid_remote_url_message(trimmed) { + return Err(message.to_string()); + } + + if is_supported_remote_url(trimmed) { + return Ok(trimmed); + } + + Err(ALLOWED_REMOTE_URL_MESSAGE.to_string()) +} + +fn invalid_remote_url_message(remote_url: &str) -> Option<&'static str> { + if remote_url.is_empty() { + return Some("Enter a repository URL before continuing."); + } + + if remote_url.starts_with('-') { + return Some("Repository URL cannot start with '-'."); + } + + if contains_unsafe_url_character(remote_url) { + return Some(ALLOWED_REMOTE_URL_MESSAGE); + } + + None +} + +fn contains_unsafe_url_character(remote_url: &str) -> bool { + remote_url + .chars() + .any(|ch| ch.is_control() || ch.is_whitespace()) +} + +fn is_supported_remote_url(remote_url: &str) -> bool { + HIERARCHICAL_REMOTE_SCHEMES + .iter() + .any(|scheme| is_hierarchical_remote_url(remote_url, scheme)) + || is_git_scp_remote_url(remote_url) +} + +fn is_hierarchical_remote_url(url: &str, scheme: &str) -> bool { + let Some(rest) = strip_ascii_prefix(url, scheme) else { + return false; + }; + let Some((authority, path)) = rest.split_once('/') else { + return false; + }; + + has_valid_remote_host(authority) && !path.is_empty() +} + +fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .filter(|candidate| candidate.eq_ignore_ascii_case(prefix)) + .map(|_| &value[prefix.len()..]) +} + +fn is_git_scp_remote_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("git@") else { + return false; + }; + let Some((host, path)) = rest.split_once(':') else { + return false; + }; + + has_valid_remote_host(host) && !path.is_empty() +} + +fn has_valid_remote_host(authority: &str) -> bool { + let host = host_from_authority(authority); + + !host.is_empty() && !host.starts_with('-') +} + +fn host_from_authority(authority: &str) -> &str { + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + + host.split_once(':').map_or(host, |(host, _)| host) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_user_remote_url_accepts_supported_remote_forms() { + for url in [ + "https://github.com/refactoringhq/tolaria.git", + "http://git.example.test/org/repo.git", + "ssh://git@git.example.test/org/repo.git", + "git@github.com:refactoringhq/tolaria.git", + ] { + assert_eq!(validate_user_remote_url(url).unwrap(), url); + } + } + + #[test] + fn validate_user_remote_url_trims_supported_urls() { + assert_eq!( + validate_user_remote_url(" https://github.com/refactoringhq/tolaria.git ").unwrap(), + "https://github.com/refactoringhq/tolaria.git" + ); + } + + #[test] + fn validate_user_remote_url_rejects_dangerous_or_unsupported_inputs() { + for url in [ + "", + "--upload-pack=touch-pwned", + "ext::sh -c touch-pwned %0.git", + "file:///Users/luca/private.git", + "/Users/luca/private.git", + "github.com:refactoringhq/tolaria.git", + "git@-oProxyCommand=touch-pwned:repo.git", + "https://", + "ssh://git@example.com", + "https://github.com/refactoringhq/tolaria with space.git", + ] { + assert!(validate_user_remote_url(url).is_err(), "{url}"); + } + } +} diff --git a/src-tauri/src/git/status.rs b/src-tauri/src/git/status.rs new file mode 100644 index 0000000..f72ae91 --- /dev/null +++ b/src-tauri/src/git/status.rs @@ -0,0 +1,708 @@ +use super::git_command_at; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Debug, Serialize, Clone)] +pub struct ModifiedFile { + pub path: String, + #[serde(rename = "relativePath")] + pub relative_path: String, + pub status: String, + #[serde(rename = "addedLines")] + pub added_lines: Option, + #[serde(rename = "deletedLines")] + pub deleted_lines: Option, + pub binary: bool, +} + +#[derive(Debug, Clone, Copy, Default)] +struct DiffStats { + added_lines: Option, + deleted_lines: Option, + binary: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StatusEntry { + status_code: String, + relative_path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileChangeStatus { + Modified, + Added, + Deleted, + Untracked, + Renamed, +} + +impl FileChangeStatus { + fn from_code(status_code: &str) -> Self { + match status_code.trim() { + "A" => Self::Added, + "D" => Self::Deleted, + "??" => Self::Untracked, + "R" | "RM" => Self::Renamed, + _ => Self::Modified, + } + } + + fn label(self) -> &'static str { + match self { + Self::Added => "added", + Self::Deleted => "deleted", + Self::Untracked => "untracked", + Self::Renamed => "renamed", + Self::Modified => "modified", + } + } +} + +fn split_nul_fields(output: &[u8]) -> Vec { + output + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .map(|field| String::from_utf8_lossy(field).into_owned()) + .collect() +} + +fn status_has_source_path(status_code: &str) -> bool { + status_code.contains('R') || status_code.contains('C') +} + +fn parse_status_field(field: &str) -> Option { + if field.len() < 4 { + return None; + } + + Some(StatusEntry { + status_code: field[..2].to_string(), + relative_path: field[3..].to_string(), + }) +} + +fn parse_status_output(output: &[u8]) -> Vec { + let fields = split_nul_fields(output); + let mut entries = Vec::new(); + let mut index = 0; + + while index < fields.len() { + let Some(entry) = parse_status_field(&fields[index]) else { + index += 1; + continue; + }; + let has_source_path = status_has_source_path(&entry.status_code); + entries.push(entry); + index += if has_source_path { 2 } else { 1 }; + } + + entries +} + +fn parse_numstat_field(field: &str) -> Option { + field.parse().ok() +} + +fn parse_numstat_header(header: &str) -> Option<(Option, DiffStats)> { + let mut parts = header.splitn(3, '\t'); + let added = parts.next()?; + let deleted = parts.next()?; + let path = parts.next()?; + + let added_lines = parse_numstat_field(added); + let deleted_lines = parse_numstat_field(deleted); + let binary = added == "-" || deleted == "-"; + + Some(( + (!path.is_empty()).then(|| path.to_string()), + DiffStats { + added_lines, + deleted_lines, + binary, + }, + )) +} + +fn parse_numstat_output(output: &[u8]) -> HashMap { + let fields = split_nul_fields(output); + let mut stats = HashMap::new(); + let mut index = 0; + + while index < fields.len() { + let Some((path, diff_stats)) = parse_numstat_header(&fields[index]) else { + index += 1; + continue; + }; + + match path { + Some(path) => { + stats.insert(path, diff_stats); + index += 1; + } + None if index + 2 < fields.len() => { + stats.insert(fields[index + 2].clone(), diff_stats); + index += 3; + } + None => { + index += 1; + } + } + } + + stats +} + +fn repo_has_head(vault: &Path) -> Result { + let output = git_command_at(vault) + .and_then(|mut command| command.args(["rev-parse", "--verify", "HEAD"]).output()) + .map_err(|e| format!("Failed to run git rev-parse: {e}"))?; + + Ok(output.status.success()) +} + +fn load_diff_stats(vault: &Path) -> Result, String> { + if !repo_has_head(vault)? { + return Ok(HashMap::new()); + } + + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args(["diff", "--numstat", "-z", "--find-renames", "HEAD", "--"]) + .output() + }) + .map_err(|e| format!("Failed to run git diff --numstat: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git diff --numstat failed: {}", stderr.trim())); + } + + Ok(parse_numstat_output(&output.stdout)) +} + +fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats { + let full_path = vault.join(relative_path); + let added_lines = std::fs::read_to_string(full_path) + .ok() + .map(|content| content.lines().count()); + + DiffStats { + added_lines, + deleted_lines: None, + binary: false, + } +} + +fn resolve_diff_stats( + vault: &Path, + relative_path: &Path, + status: FileChangeStatus, + diff_stats: &HashMap, + include_stats: bool, +) -> DiffStats { + if !include_stats { + return DiffStats::default(); + } + + if status == FileChangeStatus::Untracked { + return count_worktree_lines(vault, relative_path); + } + + let key = relative_path.to_string_lossy(); + diff_stats.get(key.as_ref()).copied().unwrap_or_default() +} + +fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> { + for component in relative_path.components() { + if matches!(component, std::path::Component::ParentDir) { + return Err("File path is outside the vault".into()); + } + } + + if !abs.exists() { + return Ok(()); + } + + let canonical_vault = vault + .canonicalize() + .map_err(|e| format!("Cannot resolve vault path: {e}"))?; + let canonical_file = abs + .canonicalize() + .map_err(|e| format!("Cannot resolve file path: {e}"))?; + + if canonical_file.starts_with(&canonical_vault) { + Ok(()) + } else { + Err("File path is outside the vault".into()) + } +} + +fn load_file_status(vault: &Path, relative_path: &Path) -> Result { + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args(["status", "--porcelain", "--"]) + .arg(relative_path) + .output() + }) + .map_err(|e| format!("Failed to run git status: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .find(|line| line.len() >= 4) + .map(|line| line[..2].trim().to_string()) + .unwrap_or_default()) +} + +fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> { + let _ = git_command_at(vault).and_then(|mut command| { + command + .args(["reset", "HEAD", "--"]) + .arg(relative_path) + .output() + }); + + let checkout = git_command_at(vault) + .and_then(|mut command| command.args(["checkout", "--"]).arg(relative_path).output()) + .map_err(|e| format!("Failed to run git checkout: {e}"))?; + + if checkout.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&checkout.stderr); + Err(format!("git checkout failed: {}", stderr.trim())) +} + +/// Get list of modified/added/deleted files in the vault (uncommitted changes). +pub fn get_modified_files(vault_path: impl AsRef) -> Result, String> { + get_modified_files_impl(vault_path.as_ref(), false) +} + +/// Get list of modified/added/deleted files with line-level diff statistics. +pub fn get_modified_files_with_stats( + vault_path: impl AsRef, +) -> Result, String> { + get_modified_files_impl(vault_path.as_ref(), true) +} + +fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result, String> { + if !super::is_inside_work_tree(vault) { + return Ok(Vec::new()); + } + + let output = git_command_at(vault) + .and_then(|mut command| { + command + .args(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + .output() + }) + .map_err(|e| format!("Failed to run git status: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git status failed: {}", stderr.trim())); + } + + let diff_stats = if include_stats { + load_diff_stats(vault)? + } else { + HashMap::new() + }; + let files = parse_status_output(&output.stdout) + .into_iter() + .filter_map(|entry| { + // Only include markdown files + if !entry.relative_path.ends_with(".md") { + return None; + } + + let status = FileChangeStatus::from_code(&entry.status_code); + let full_path = vault + .join(&entry.relative_path) + .to_string_lossy() + .to_string(); + let stats = resolve_diff_stats( + vault, + Path::new(&entry.relative_path), + status, + &diff_stats, + include_stats, + ); + + Some(ModifiedFile { + path: full_path, + relative_path: entry.relative_path, + status: status.label().to_string(), + added_lines: stats.added_lines, + deleted_lines: stats.deleted_lines, + binary: stats.binary, + }) + }) + .collect(); + + Ok(files) +} + +/// Discard uncommitted changes to a single file. +/// +/// - **Modified / Deleted**: `git checkout -- ` restores the last committed version. +/// - **Untracked / Added**: the file is removed from disk. +/// +/// The `relative_path` must be relative to `vault_path` (the same format +/// returned by [`get_modified_files`]). +pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> { + let vault = Path::new(vault_path); + let relative = Path::new(relative_path); + let abs = vault.join(relative); + + ensure_path_within_vault(vault, relative, &abs)?; + let status_code = load_file_status(vault, relative)?; + + match status_code.as_str() { + "??" => { + std::fs::remove_file(&abs) + .map_err(|e| format!("Failed to delete untracked file: {e}"))?; + } + _ => { + restore_tracked_file(vault, relative)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::git_command; + use crate::git::git_commit; + use crate::git::tests::setup_git_repo; + use std::fs; + + fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) { + fs::write(vault.join(relative_path), content).unwrap(); + git_commit(vp, "initial").unwrap(); + } + + fn force_quoted_git_paths(vault: &Path) { + git_command() + .args(["config", "core.quotePath", "true"]) + .current_dir(vault) + .output() + .unwrap(); + } + + fn expect_modified_file(vp: &str, relative_path: &str, status: &str) -> ModifiedFile { + let modified = get_modified_files_with_stats(vp).unwrap(); + let file = modified + .iter() + .find(|file| file.relative_path == relative_path) + .unwrap_or_else(|| panic!("{relative_path} should be reported as {status}")); + + assert_eq!(file.status, status); + assert!(file.path.ends_with(relative_path)); + file.clone() + } + + fn expect_changed_file_after( + relative_path: &str, + status: &str, + change: impl FnOnce(&Path, &str), + ) -> ModifiedFile { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + change(vault, vp); + + expect_modified_file(vp, relative_path, status) + } + + #[test] + fn test_get_modified_files_returns_empty_for_gitless_folder() { + let dir = tempfile::TempDir::new().unwrap(); + fs::write(dir.path().join("note.md"), "# Note\n").unwrap(); + + assert!(get_modified_files(dir.path()).unwrap().is_empty()); + assert!(get_modified_files_with_stats(dir.path()) + .unwrap() + .is_empty()); + } + + #[test] + fn test_get_modified_files_with_stats() { + let dir = setup_git_repo(); + let vault = dir.path(); + + // Create and commit a file + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_command() + .args(["add", "note.md"]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["commit", "-m", "Add note"]) + .current_dir(vault) + .output() + .unwrap(); + + // Modify it + fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap(); + // Add an untracked file + fs::write(vault.join("new.md"), "# New\n").unwrap(); + + let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap(); + + assert!(modified.len() >= 2); + let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect(); + assert!(statuses.contains(&"modified")); + assert!(statuses.contains(&"untracked")); + + let modified_entry = modified + .iter() + .find(|file| file.relative_path == "note.md") + .unwrap(); + assert!(modified_entry.added_lines.is_some()); + assert!(!modified_entry.binary); + + let untracked_entry = modified + .iter() + .find(|file| file.relative_path == "new.md") + .unwrap(); + assert_eq!(untracked_entry.added_lines, Some(1)); + assert_eq!(untracked_entry.deleted_lines, None); + } + + #[test] + fn test_get_modified_files_omits_stats_by_default() { + let dir = setup_git_repo(); + let vault = dir.path(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_command() + .args(["add", "note.md"]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["commit", "-m", "Add note"]) + .current_dir(vault) + .output() + .unwrap(); + + fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap(); + fs::write(vault.join("new.md"), "# New\n").unwrap(); + + let modified = get_modified_files(vault.to_str().unwrap()).unwrap(); + + assert!(modified.len() >= 2); + assert!(modified.iter().all(|file| file.added_lines.is_none() + && file.deleted_lines.is_none() + && !file.binary)); + } + + #[test] + fn test_get_modified_files_untracked_in_subdirectory() { + let dir = setup_git_repo(); + let vault = dir.path(); + + // Create initial commit so git is initialized + fs::write(vault.join("init.md"), "# Init\n").unwrap(); + git_command() + .args(["add", "init.md"]) + .current_dir(vault) + .output() + .unwrap(); + git_command() + .args(["commit", "-m", "Initial"]) + .current_dir(vault) + .output() + .unwrap(); + + // Create a new untracked file in a subdirectory (simulates new note creation) + fs::create_dir_all(vault.join("note")).unwrap(); + fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap(); + + let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap(); + + assert_eq!(modified.len(), 1); + assert_eq!(modified[0].status, "untracked"); + assert_eq!(modified[0].relative_path, "note/brand-new.md"); + assert_eq!(modified[0].added_lines, Some(1)); + assert!( + modified[0].path.ends_with("/note/brand-new.md"), + "Full path should end with relative path: {}", + modified[0].path + ); + } + + #[test] + fn test_get_modified_files_preserves_chinese_markdown_path() { + let relative_path = "中文笔记.md"; + + let file = expect_changed_file_after(relative_path, "modified", |vault, vp| { + force_quoted_git_paths(vault); + write_and_commit_markdown(vault, vp, relative_path, "# 初始\n"); + fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap(); + }); + assert_eq!(file.added_lines, Some(2)); + } + + #[test] + fn test_get_modified_files_preserves_untracked_markdown_path_with_spaces() { + let relative_path = "test note.md"; + + let file = expect_changed_file_after(relative_path, "untracked", |vault, vp| { + write_and_commit_markdown(vault, vp, "init.md", "# Init\n"); + fs::write(vault.join(relative_path), "# Test\n").unwrap(); + }); + assert_eq!(file.added_lines, Some(1)); + } + + #[test] + fn test_get_modified_files_preserves_modified_markdown_path_with_spaces() { + let relative_path = "test note.md"; + + let file = expect_changed_file_after(relative_path, "modified", |vault, vp| { + write_and_commit_markdown(vault, vp, relative_path, "# Test\n"); + fs::write(vault.join(relative_path), "# Test\n\nUpdated\n").unwrap(); + }); + assert_eq!(file.added_lines, Some(2)); + } + + #[test] + fn test_get_modified_files_preserves_renamed_markdown_path_with_spaces() { + let relative_path = "test note.md"; + + let file = expect_changed_file_after(relative_path, "renamed", |vault, vp| { + write_and_commit_markdown(vault, vp, "alpha.md", "# Alpha\n"); + git_command() + .args(["mv", "alpha.md", relative_path]) + .current_dir(vault) + .output() + .unwrap(); + }); + assert_eq!(file.added_lines, Some(0)); + assert_eq!(file.deleted_lines, Some(0)); + } + + #[test] + fn test_commit_flow_modified_files_then_commit_clears() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + // Create and commit initial file + fs::write(vault.join("flow.md"), "# Original\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + // Modify the file on disk + fs::write(vault.join("flow.md"), "# Modified\n").unwrap(); + + // get_modified_files should detect the change + let modified = get_modified_files(vp).unwrap(); + assert!( + modified.iter().any(|f| f.relative_path == "flow.md"), + "Modified file should be detected after write" + ); + + // Commit the change + let result = git_commit(vp, "update flow").unwrap(); + assert!( + result.contains("1 file changed") || result.contains("flow.md"), + "Commit output should reference the changed file: {}", + result + ); + + // After commit, get_modified_files should return empty + let after = get_modified_files(vp).unwrap(); + assert!( + after.is_empty(), + "No modified files should remain after commit, found: {:?}", + after + ); + } + + #[test] + fn test_discard_modified_file() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + write_and_commit_markdown(vault, vp, "note.md", "# Original\n"); + + // Modify the file + fs::write(vault.join("note.md"), "# Changed\n").unwrap(); + assert_eq!(get_modified_files(vp).unwrap().len(), 1); + + // Discard + discard_file_changes(vp, "note.md").unwrap(); + + let content = fs::read_to_string(vault.join("note.md")).unwrap(); + assert_eq!(content, "# Original\n"); + assert!(get_modified_files(vp).unwrap().is_empty()); + } + + #[test] + fn test_discard_untracked_file() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + write_and_commit_markdown(vault, vp, "init.md", "# Init\n"); + + // Create an untracked file + fs::write(vault.join("new.md"), "# New\n").unwrap(); + assert!(vault.join("new.md").exists()); + + discard_file_changes(vp, "new.md").unwrap(); + + assert!(!vault.join("new.md").exists()); + assert!(get_modified_files(vp).unwrap().is_empty()); + } + + #[test] + fn test_discard_deleted_file() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + write_and_commit_markdown(vault, vp, "note.md", "# Original\n"); + + // Delete the file + fs::remove_file(vault.join("note.md")).unwrap(); + assert!(!vault.join("note.md").exists()); + + discard_file_changes(vp, "note.md").unwrap(); + + assert!(vault.join("note.md").exists()); + let content = fs::read_to_string(vault.join("note.md")).unwrap(); + assert_eq!(content, "# Original\n"); + } + + #[test] + fn test_discard_rejects_path_outside_vault() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + write_and_commit_markdown(vault, vp, "init.md", "# Init\n"); + + let result = discard_file_changes(vp, "../../../etc/passwd"); + assert!( + result.is_err(), + "Should reject path outside vault, got: {:?}", + result + ); + assert!( + result.unwrap_err().contains("outside the vault"), + "Error should mention 'outside the vault'" + ); + } +} diff --git a/src-tauri/src/git/upstream.rs b/src-tauri/src/git/upstream.rs new file mode 100644 index 0000000..c492051 --- /dev/null +++ b/src-tauri/src/git/upstream.rs @@ -0,0 +1,77 @@ +use std::path::Path; + +use super::command::{git_output, git_output_result, stdout_text}; + +const DETACHED_HEAD_BRANCH: &str = "Detached HEAD"; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct UpstreamTarget { + pub remote: String, + pub branch: String, + pub display: String, +} + +pub(crate) fn branch_label(vault: &Path) -> Result { + Ok(current_branch(vault)?.unwrap_or_else(|| DETACHED_HEAD_BRANCH.to_string())) +} + +pub(crate) fn missing_upstream_message(vault: &Path) -> Result { + let branch = branch_label(vault)?; + if branch == DETACHED_HEAD_BRANCH { + return Ok( + "This vault is in detached HEAD. Check out a branch and configure its upstream before syncing in Tolaria." + .to_string(), + ); + } + Ok(format!( + "No upstream branch configured for '{branch}'. Set a tracking branch with external Git tooling, then sync again in Tolaria." + )) +} + +pub(crate) fn sync_target(vault: &Path) -> Result, String> { + let Some(branch_name) = current_branch(vault)? else { + return Ok(None); + }; + let Some(remote) = config_value(vault, &format!("branch.{branch_name}.remote"))? else { + return Ok(None); + }; + let Some(merge_ref) = config_value(vault, &format!("branch.{branch_name}.merge"))? else { + return Ok(None); + }; + let branch = merge_ref + .strip_prefix("refs/heads/") + .unwrap_or(merge_ref.as_str()) + .to_string(); + if remote.is_empty() || branch.is_empty() { + return Ok(None); + } + Ok(Some(UpstreamTarget { + display: format!("{remote}/{branch}"), + remote, + branch, + })) +} + +fn current_branch(vault: &Path) -> Result, String> { + let output = git_output(vault, &["branch", "--show-current"]) + .map_err(|e| format!("Failed to get branch: {}", e))?; + let branch = stdout_text(&output); + if branch.is_empty() { + Ok(None) + } else { + Ok(Some(branch)) + } +} + +fn config_value(vault: &Path, key: &str) -> Result, String> { + let output = git_output_result(vault, &["config", "--get", key])?; + if !output.status.success() { + return Ok(None); + } + let value = stdout_text(&output); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} diff --git a/src-tauri/src/hermes_cli.rs b/src-tauri/src/hermes_cli.rs new file mode 100644 index 0000000..7a4eb27 --- /dev/null +++ b/src-tauri/src/hermes_cli.rs @@ -0,0 +1,209 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::cli_agent_runtime::{AgentStreamRequest, LineStreamProcess}; +use std::path::Path; +use std::process::{Command, Stdio}; + +pub fn check_cli() -> AiAgentAvailability { + crate::hermes_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::hermes_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let prompt = + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()); + let command = build_hermes_command(binary, prompt, &request.vault_path)?; + crate::cli_agent_runtime::run_ai_agent_line_stream( + LineStreamProcess::new(command, "hermes", "hermes"), + emit, + format_hermes_error, + ) +} + +fn build_hermes_command( + binary: &Path, + prompt: String, + vault_path: &str, +) -> Result { + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .arg("chat") + .arg("--quiet") + .arg("--source") + .arg("tolaria") + .arg("-q") + .arg(prompt) + .current_dir(vault_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn format_hermes_error(stderr_output: &str, status: &str) -> String { + if is_auth_or_setup_error(stderr_output) { + return "Hermes Agent is not ready. Run `hermes setup`, choose a model with `hermes model`, then run `hermes doctor` in your terminal before retrying in Tolaria.".into(); + } + + let stderr = stderr_output.trim(); + if stderr.is_empty() { + format!("hermes exited with status {status}") + } else { + stderr.lines().take(3).collect::>().join("\n") + } +} + +fn is_auth_or_setup_error(stderr_output: &str) -> bool { + let lower = stderr_output.to_ascii_lowercase(); + [ + "auth", + "api key", + "login", + "model", + "provider", + "setup", + "token", + "unauthorized", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + use std::path::PathBuf; + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: Some("Use Tolaria conventions".into()), + vault_path, + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("hermes"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + #[test] + fn build_hermes_command_uses_quiet_chat_query() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("hermes"); + let command = build_hermes_command(&binary, "Prompt".into(), "/tmp/vault").unwrap(); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!( + args, + ["chat", "--quiet", "--source", "tolaria", "-q", "Prompt"] + ); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_hermes_stdout() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'Hello from Hermes' +printf '%s\n' 'Second line' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert!(session_id.starts_with("hermes-")); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id.starts_with("hermes-") + )); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Hello from Hermes\n" + ))); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::TextDelta { text } if text == "Second line\n" + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_hermes_setup_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'provider api key missing' >&2 +exit 2 +"#, + ); + + let mut events = Vec::new(); + run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("hermes setup") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn format_hermes_error_returns_status_for_empty_stderr() { + let result = format_hermes_error("", "1"); + + assert!(result.contains("status 1")); + } + + #[test] + fn strip_ansi_codes_removes_terminal_colors() { + assert_eq!( + crate::cli_agent_runtime::strip_ansi_codes("\x1b[32mHermes\x1b[0m"), + "Hermes" + ); + } +} diff --git a/src-tauri/src/hermes_discovery.rs b/src-tauri/src/hermes_discovery.rs new file mode 100644 index 0000000..6f34072 --- /dev/null +++ b/src-tauri/src/hermes_discovery.rs @@ -0,0 +1,87 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + crate::cli_agent_runtime::check_cli_availability(find_binary) +} + +pub(crate) fn find_binary() -> Result { + crate::cli_agent_runtime::find_cli_binary( + "hermes", + hermes_binary_candidates(), + "Hermes Agent", + "https://hermes-agent.nousresearch.com/docs/getting-started/quickstart", + ) +} + +fn hermes_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| hermes_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn hermes_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".local/bin/hermes"), + home.join(".local/bin/hermes.exe"), + home.join(".hermes/bin/hermes"), + home.join(".hermes/bin/hermes.exe"), + home.join(".hermes/hermes"), + home.join(".hermes/hermes.exe"), + home.join(".local/share/mise/shims/hermes"), + home.join(".local/share/mise/shims/hermes.exe"), + home.join(".asdf/shims/hermes"), + home.join(".asdf/shims/hermes.exe"), + home.join(".linuxbrew/bin/hermes"), + home.join("AppData/Local/hermes/hermes.exe"), + home.join("AppData/Local/hermes/bin/hermes.exe"), + home.join("AppData/Local/hermes/Scripts/hermes.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/hermes"), + PathBuf::from("/usr/local/bin/hermes"), + PathBuf::from("/opt/homebrew/bin/hermes"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = hermes_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/hermes"), + home.join(".hermes/bin/hermes"), + home.join(".local/share/mise/shims/hermes"), + home.join(".asdf/shims/hermes"), + PathBuf::from("/opt/homebrew/bin/hermes"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn binary_candidates_include_windows_native_installs() { + let home = PathBuf::from("C:/Users/alex"); + let candidates = hermes_binary_candidates_for_home(&home); + let expected = [ + home.join("AppData/Local/hermes/hermes.exe"), + home.join("AppData/Local/hermes/bin/hermes.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } +} diff --git a/src-tauri/src/kiro_cli.rs b/src-tauri/src/kiro_cli.rs new file mode 100644 index 0000000..e6648e4 --- /dev/null +++ b/src-tauri/src/kiro_cli.rs @@ -0,0 +1,198 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::cli_agent_runtime::{AgentStreamRequest, LineStreamProcess}; +use std::path::Path; +use std::process::Stdio; + +struct KiroMcpConfig<'a> { + vault_path: &'a str, + vault_paths: &'a [String], + mcp_server_path: &'a str, +} + +pub fn check_cli() -> AiAgentAvailability { + crate::kiro_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::kiro_discovery::find_binary()?; + ensure_mcp_config(&request)?; + let prompt = + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()); + let command = build_kiro_command(&binary, Path::new(&request.vault_path)); + crate::cli_agent_runtime::run_ai_agent_line_stream( + LineStreamProcess::new(command, "kiro-cli", "kiro").with_stdin(prompt), + emit, + format_kiro_error, + ) +} + +fn build_kiro_command(binary: &Path, vault_path: &Path) -> std::process::Command { + let mut command = crate::hidden_command(binary); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + command + .arg("chat") + .arg("--no-interactive") + .arg("--trust-all-tools") + .current_dir(vault_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + write_mcp_json(KiroMcpConfig { + vault_path: &request.vault_path, + vault_paths: &request.vault_paths, + mcp_server_path: &mcp_server_path, + }) +} + +fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> { + let config_dir = Path::new(config.vault_path).join(".kiro").join("settings"); + std::fs::create_dir_all(&config_dir) + .map_err(|e| format!("Failed to create .kiro/settings: {e}"))?; + + let config_path = config_dir.join("mcp.json"); + + let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| serde_json::json!({})); + + let servers = json_config + .as_object_mut() + .ok_or("Invalid mcp.json: not an object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + + let mut server = crate::cli_agent_runtime::tolaria_node_mcp_server( + config.mcp_server_path, + config.vault_path, + config.vault_paths, + true, + ); + server["disabled"] = serde_json::json!(false); + servers["tolaria"] = server; + + std::fs::write( + &config_path, + serde_json::to_string_pretty(&json_config) + .map_err(|e| format!("JSON serialize error: {e}"))?, + ) + .map_err(|e| format!("Failed to write mcp.json: {e}"))?; + + Ok(()) +} + +fn format_kiro_error(stderr_output: &str, status: &str) -> String { + if is_auth_error(stderr_output) { + return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in." + .into(); + } + if stderr_output.trim().is_empty() { + format!("kiro-cli exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +fn is_auth_error(stderr_output: &str) -> bool { + let lower = stderr_output.to_ascii_lowercase(); + ["auth", "login", "token"] + .iter() + .any(|needle| lower.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_codes_removes_terminal_colors() { + assert_eq!( + crate::cli_agent_runtime::strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"), + "> Hello! " + ); + assert_eq!( + crate::cli_agent_runtime::strip_ansi_codes("plain text"), + "plain text" + ); + } + + #[test] + fn format_kiro_error_detects_auth_errors() { + let result = format_kiro_error("Error: auth token expired", "1"); + assert!(result.contains("kiro-cli login")); + } + + #[test] + fn format_kiro_error_returns_status_for_empty_stderr() { + let result = format_kiro_error("", "1"); + assert!(result.contains("status 1")); + } + + #[test] + fn write_mcp_json_creates_config() { + let dir = tempfile::tempdir().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + write_mcp_json(KiroMcpConfig { + vault_path, + vault_paths: &["/other/vault".into(), vault_path.into()], + mcp_server_path: "/opt/mcp/index.js", + }) + .unwrap(); + + let config_path = dir.path().join(".kiro/settings/mcp.json"); + let content: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); + assert_eq!(content["mcpServers"]["tolaria"]["command"], "node"); + assert_eq!( + content["mcpServers"]["tolaria"]["args"][0], + "/opt/mcp/index.js" + ); + assert_eq!( + content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], + vault_path + ); + assert_eq!( + content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"], + serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap()) + ); + assert_eq!( + content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], + "9711" + ); + } + + #[test] + fn write_mcp_json_merges_preserving_existing_servers() { + let dir = tempfile::tempdir().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let config_dir = dir.path().join(".kiro/settings"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("mcp.json"), + r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#, + ) + .unwrap(); + + write_mcp_json(KiroMcpConfig { + vault_path, + vault_paths: &[], + mcp_server_path: "/new/index.js", + }) + .unwrap(); + + let content: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(), + ) + .unwrap(); + assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js"); + assert_eq!(content["mcpServers"]["other"]["command"], "python"); + } +} diff --git a/src-tauri/src/kiro_discovery.rs b/src-tauri/src/kiro_discovery.rs new file mode 100644 index 0000000..438a0eb --- /dev/null +++ b/src-tauri/src/kiro_discovery.rs @@ -0,0 +1,59 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + crate::cli_agent_runtime::check_cli_availability(find_binary) +} + +pub(crate) fn find_binary() -> Result { + crate::cli_agent_runtime::find_cli_binary( + "kiro-cli", + kiro_binary_candidates(), + "Kiro CLI", + "https://kiro.dev/docs/cli", + ) +} + +fn kiro_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| kiro_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn kiro_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".local/bin/kiro-cli"), + home.join(".kiro/bin/kiro-cli"), + home.join(".local/share/mise/shims/kiro-cli"), + home.join(".asdf/shims/kiro-cli"), + home.join(".npm-global/bin/kiro-cli"), + home.join(".npm/bin/kiro-cli"), + home.join(".bun/bin/kiro-cli"), + PathBuf::from("/usr/local/bin/kiro-cli"), + PathBuf::from("/opt/homebrew/bin/kiro-cli"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = kiro_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/kiro-cli"), + home.join(".kiro/bin/kiro-cli"), + home.join(".npm-global/bin/kiro-cli"), + PathBuf::from("/opt/homebrew/bin/kiro-cli"), + ]; + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..1a2c3f7 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,801 @@ +mod ai_agent_processes; +pub mod ai_agents; +mod ai_model_tools; +pub mod ai_models; +pub mod antigravity_cli; +mod antigravity_config; +mod antigravity_discovery; +mod app_config; +mod app_icon; +pub mod app_updater; +pub mod claude_cli; +mod claude_invocation; +mod cli_agent_runtime; +pub mod codex_cli; +mod commands; +pub mod copilot_cli; +mod copilot_discovery; +pub mod frontmatter; +pub mod git; +pub mod hermes_cli; +mod hermes_discovery; +pub mod kiro_cli; +mod kiro_discovery; +#[cfg(any(test, all(desktop, target_os = "linux")))] +mod linux_appimage; +pub mod mcp; +#[cfg(desktop)] +pub mod menu; +pub mod opencode_cli; +mod opencode_config; +mod opencode_discovery; +mod opencode_events; +pub mod pi_cli; +mod pi_config; +mod pi_discovery; +mod pi_events; +pub mod search; +pub mod settings; +pub mod telemetry; +pub mod vault; +pub mod vault_list; +pub mod vault_watcher; +#[cfg(desktop)] +mod window_state; + +use std::ffi::OsStr; +use std::process::Command; + +#[cfg(desktop)] +use std::path::{Path, PathBuf}; +#[cfg(desktop)] +use std::process::Child; +#[cfg(desktop)] +use std::sync::Mutex; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +pub(crate) fn hidden_command(program: impl AsRef) -> Command { + let mut command = Command::new(program); + suppress_windows_console(&mut command); + command +} + +#[cfg(windows)] +fn suppress_windows_console(command: &mut Command) { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn suppress_windows_console(_command: &mut Command) {} + +#[cfg(desktop)] +struct WsBridgeChild(Mutex>); + +#[cfg(desktop)] +struct AllowedAssetScopeRoots(Mutex>); + +#[cfg(desktop)] +fn log_startup_result(label: &str, result: Result) { + match result { + Ok(n) if n > 0 => log::info!("{}: {} files", label, n), + Err(e) => log::warn!("{}: {}", label, e), + _ => {} + } +} + +#[cfg(desktop)] +fn selected_mcp_bridge_vault_paths(vault_list: &vault_list::VaultList) -> Vec { + let mut paths = Vec::new(); + if let Some(active_vault) = vault_list + .active_vault + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + push_unique_mcp_bridge_vault_path(&mut paths, active_vault); + } + + for vault in &vault_list.vaults { + if vault.mounted == Some(false) { + continue; + } + push_unique_mcp_bridge_vault_path(&mut paths, &vault.path); + } + + paths +} + +#[cfg(desktop)] +fn push_unique_mcp_bridge_vault_path(paths: &mut Vec, path: &str) { + let trimmed = path.trim(); + if trimmed.is_empty() { + return; + } + let path = PathBuf::from(trimmed); + if paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + +#[cfg(desktop)] +fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result { + let resolved = std::fs::canonicalize(vault_path).map_err(|e| { + format!( + "MCP bridge vault is not available: {} ({e})", + vault_path.display() + ) + })?; + + if !resolved.is_dir() { + return Err(format!( + "MCP bridge vault is not available: {} is not a directory", + vault_path.display() + )); + } + + Ok(resolved) +} + +#[cfg(desktop)] +fn stop_ws_bridge_child(active_child: &mut Option) { + if let Some(mut child) = active_child.take() { + let _ = child.kill(); + let _ = child.wait(); + log::info!("ws-bridge child process stopped"); + } +} + +#[cfg(desktop)] +pub(crate) fn sync_ws_bridge_for_vault( + app_handle: &tauri::AppHandle, + vault_path: Option<&Path>, + active_vault_paths: &[PathBuf], +) -> Result<&'static str, String> { + use tauri::Manager; + + let state: tauri::State<'_, WsBridgeChild> = app_handle.state(); + let mut active_child = state + .0 + .lock() + .map_err(|_| "Failed to lock ws-bridge state".to_string())?; + + let Some(vault_path) = vault_path else { + stop_ws_bridge_child(&mut active_child); + return Ok("stopped"); + }; + + let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) { + Ok(path) => path, + Err(e) => { + stop_ws_bridge_child(&mut active_child); + return Err(e); + } + }; + + stop_ws_bridge_child(&mut active_child); + + let resolved_active_vault_paths = active_vault_paths + .iter() + .filter_map(|path| validate_mcp_bridge_vault_path(path).ok()) + .collect::>(); + let child = + mcp::spawn_ws_bridge_with_paths(&resolved_vault_path, &resolved_active_vault_paths)?; + + *active_child = Some(child); + Ok("started") +} + +fn spawn_background_task(thread_name: &'static str, task: F) +where + F: FnOnce() + Send + 'static, +{ + if let Err(e) = std::thread::Builder::new() + .name(thread_name.into()) + .spawn(task) + { + log::warn!("Failed to start {thread_name}: {e}"); + } +} + +/// Run startup housekeeping on the legacy default vault (migrate legacy frontmatter, seed configs). +#[cfg(desktop)] +fn run_startup_tasks_for_vault(vault_path: &Path) { + let vp_str = vault_path.to_str().unwrap_or_default(); + log_startup_result( + "Migrated is_a to type on startup", + vault::migrate_is_a_to_type(vp_str), + ); + // Migrate legacy config/agents.md -> root AGENTS.md (one-time, idempotent) + vault::migrate_agents_md(vp_str); + // Seed AGENTS.md and starter type definitions at vault root if missing + vault::seed_config_files(vp_str); +} + +#[cfg(desktop)] +fn spawn_startup_tasks_for_vault_with(vault_path: PathBuf, task: F) -> bool +where + F: FnOnce(PathBuf) + Send + 'static, +{ + if !vault_path.is_dir() { + return false; + } + + spawn_background_task("tolaria-startup-tasks", move || task(vault_path)); + true +} + +#[cfg(desktop)] +fn spawn_startup_tasks() { + let Some(vault_path) = dirs::home_dir().map(|h| h.join("Laputa")) else { + return; + }; + spawn_startup_tasks_for_vault_with(vault_path, |path| run_startup_tasks_for_vault(&path)); +} + +#[cfg(desktop)] +fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) { + let vault_paths = match vault_list::load_vault_list() { + Ok(vault_list) => selected_mcp_bridge_vault_paths(&vault_list), + Err(e) => { + log::warn!("Failed to load active vault for ws-bridge startup: {}", e); + Vec::new() + } + }; + + let Some(vault_path) = vault_paths.first() else { + log::info!("ws-bridge not started: no active vault selected"); + return; + }; + + if let Err(e) = sync_ws_bridge_for_vault(app_handle, Some(vault_path), &vault_paths) { + log::warn!("Failed to start ws-bridge: {}", e); + } +} + +#[cfg(desktop)] +fn spawn_initial_ws_bridge_sync(app: &tauri::App) { + let app_handle = app.handle().clone(); + spawn_background_task("tolaria-ws-bridge-startup", move || { + #[cfg(all(desktop, target_os = "linux"))] + if linux_appimage::is_running() { + let app_version = app_handle.package_info().version.to_string(); + if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) { + log::warn!("Failed to extract MCP server to stable path: {e}"); + } + } + + sync_ws_bridge_for_selected_vault(&app_handle); + }); +} + +fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box> { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + + app.handle().plugin(tauri_plugin_dialog::init())?; + Ok(()) +} + +#[cfg(desktop)] +fn focus_main_window(app_handle: &tauri::AppHandle) { + use tauri::Manager; + + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[cfg(desktop)] +fn with_desktop_entry_plugins(builder: tauri::Builder) -> tauri::Builder { + builder + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + focus_main_window(app); + })) + .plugin(tauri_plugin_deep_link::init()) +} + +#[cfg(desktop)] +fn setup_deep_link_runtime_registration( + _app: &mut tauri::App, +) -> Result<(), Box> { + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + use tauri_plugin_deep_link::DeepLinkExt; + + _app.deep_link().register_all()?; + } + + Ok(()) +} + +#[cfg(desktop)] +fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box> { + setup_macos_webview_shortcut_prevention(app)?; + setup_deep_link_runtime_registration(app)?; + app.handle() + .plugin(tauri_plugin_updater::Builder::new().build())?; + app.handle().plugin(tauri_plugin_process::init())?; + app.handle().plugin(tauri_plugin_opener::init())?; + if should_use_native_desktop_menu(std::env::consts::OS) { + menu::setup_menu(app)?; + } + setup_custom_window_chrome(app)?; + window_state::restore_main_window_state(app); + show_debug_main_window(app); + Ok(()) +} + +#[cfg(debug_assertions)] +fn show_debug_main_window(app: &mut tauri::App) { + use tauri::Manager; + + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.center(); + let _ = window.set_focus(); + } +} + +#[cfg(not(debug_assertions))] +fn show_debug_main_window(_app: &mut tauri::App) {} + +fn should_use_native_desktop_menu(target_os: &str) -> bool { + target_os == "macos" +} + +#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))] +fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box> { + use tauri::Manager; + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_decorations(false); + } + Ok(()) +} + +#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))] +fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box> { + Ok(()) +} + +#[cfg(any(test, all(desktop, target_os = "macos")))] +const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"]; +#[cfg(any(test, all(desktop, target_os = "macos")))] +const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"]; + +#[cfg(all(desktop, target_os = "macos"))] +fn setup_macos_webview_shortcut_prevention( + app: &mut tauri::App, +) -> Result<(), Box> { + use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey}; + use tauri_plugin_prevent_default::{Flags, KeyboardShortcut}; + + let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty()); + + // WKWebView can swallow some browser-reserved chords before our shared + // renderer shortcut handler sees them. Keep this list narrow and verify + // every addition with native QA. + for key in MACOS_WEBVIEW_RESERVED_COMMAND_KEYS { + builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey])); + } + for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS { + builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey])); + } + + app.handle().plugin(builder.build())?; + Ok(()) +} + +#[cfg(not(all(desktop, target_os = "macos")))] +fn setup_macos_webview_shortcut_prevention( + _app: &mut tauri::App, +) -> Result<(), Box> { + Ok(()) +} + +fn setup_app(app: &mut tauri::App) -> Result<(), Box> { + setup_common_plugins(app)?; + + #[cfg(desktop)] + setup_desktop_plugins(app)?; + + if telemetry::init_sentry_from_settings() { + log::info!("Sentry initialized (crash reporting enabled)"); + } + + #[cfg(desktop)] + { + spawn_startup_tasks(); + spawn_initial_ws_bridge_sync(app); + } + + Ok(()) +} + +#[cfg(desktop)] +fn vault_asset_scope_roots(vault_path: &Path) -> Result, String> { + let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| { + format!( + "Failed to resolve asset scope for {}: {e}", + vault_path.display() + ) + })?; + let mut roots = vec![canonical_vault_path.clone()]; + let requested_vault_path = vault_path.to_path_buf(); + if requested_vault_path != canonical_vault_path { + roots.push(requested_vault_path); + } + Ok(roots) +} + +#[cfg(desktop)] +fn missing_asset_scope_roots( + allowed_roots: &[PathBuf], + requested_roots: &[PathBuf], +) -> Vec { + requested_roots + .iter() + .filter(|root| !allowed_roots.contains(root)) + .cloned() + .collect() +} + +#[cfg(desktop)] +pub(crate) fn sync_vault_asset_scope( + app_handle: &tauri::AppHandle, + vault_path: &Path, +) -> Result<(), String> { + use tauri::Manager; + + let requested_roots = vault_asset_scope_roots(vault_path)?; + let scope = app_handle.asset_protocol_scope(); + let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state(); + let mut allowed_roots = state + .0 + .lock() + .map_err(|_| "Failed to lock asset scope state".to_string())?; + let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots); + + for root in &roots_to_allow { + scope + .allow_directory(root, true) + .map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?; + } + + allowed_roots.extend(roots_to_allow); + Ok(()) +} + +macro_rules! app_invoke_handler { + () => { + tauri::generate_handler![ + commands::list_vault, + commands::list_vault_folders, + commands::get_note_content, + commands::validate_note_content, + commands::create_note_content, + commands::save_note_content, + commands::update_frontmatter, + commands::delete_frontmatter_property, + commands::rename_note, + commands::rename_note_filename, + commands::move_note_to_folder, + commands::move_note_to_workspace, + commands::auto_rename_untitled, + commands::detect_renames, + commands::update_wikilinks_for_renames, + commands::get_file_history, + commands::get_modified_files, + commands::get_file_diff, + commands::get_file_diff_at_commit, + commands::get_vault_pulse, + commands::git_commit, + commands::git_author_identity, + commands::get_build_number, + commands::get_last_commit_info, + commands::git_pull, + commands::git_push, + commands::git_remote_status, + commands::git_file_url, + commands::git_provider_status, + commands::test_git_provider, + commands::git_add_remote, + commands::get_conflict_files, + commands::get_conflict_mode, + commands::git_resolve_conflict, + commands::git_commit_conflict_resolution, + commands::git_discard_file, + commands::is_git_repo, + commands::init_git_repo, + commands::check_claude_cli, + commands::get_ai_agents_status, + commands::get_agent_docs_path, + commands::get_vault_ai_guidance_status, + commands::restore_vault_ai_guidance, + commands::stream_claude_chat, + commands::stream_ai_agent, + commands::abort_ai_agent_stream, + commands::stream_ai_model, + commands::save_ai_model_provider_api_key, + commands::delete_ai_model_provider_api_key, + commands::test_ai_model_provider, + commands::reload_vault, + commands::reload_vault_entry, + commands::sync_vault_asset_scope_for_window, + commands::open_vault_file_external, + commands::sync_note_title, + commands::save_image, + commands::copy_image_to_vault, + commands::delete_note, + commands::batch_delete_notes, + commands::batch_delete_notes_async, + commands::migrate_is_a_to_type, + commands::create_vault_folder, + commands::rename_vault_folder, + commands::delete_vault_folder, + commands::batch_archive_notes, + commands::get_settings, + commands::get_ai_workspace_sessions, + commands::check_for_app_update, + commands::update_menu_state, + commands::update_app_icon, + commands::trigger_menu_command, + commands::update_current_window_min_size, + commands::perform_current_window_titlebar_double_click, + commands::save_settings, + commands::save_ai_workspace_sessions, + commands::download_and_install_app_update, + commands::load_vault_list, + commands::save_vault_list, + commands::git_clone::clone_git_repo, + commands::search_vault, + commands::create_empty_vault, + commands::create_getting_started_vault, + commands::check_vault_exists, + commands::get_default_vault_path, + commands::register_mcp_tools, + commands::remove_mcp_tools, + commands::check_mcp_status, + commands::get_mcp_config_snippet, + commands::get_opencode_mcp_config_snippet, + commands::copy_text_to_clipboard, + commands::read_text_from_clipboard, + commands::sync_mcp_bridge_vault, + commands::get_process_memory_snapshot, + commands::repair_vault, + commands::reinit_telemetry, + commands::should_use_external_media_preview, + commands::print_current_webview, + commands::can_export_current_webview_pdf, + commands::export_current_webview_pdf, + commands::resolve_sheet_external_formula_inputs, + commands::list_views, + commands::save_view_cmd, + commands::delete_view_cmd, + vault_watcher::start_vault_watcher, + vault_watcher::stop_vault_watcher + ] + }; +} + +fn with_invoke_handler(builder: tauri::Builder) -> tauri::Builder { + builder.invoke_handler(app_invoke_handler!()) +} + +#[cfg(desktop)] +fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) { + use tauri::Manager; + + window_state::handle_run_event(app_handle, event); + + if let tauri::RunEvent::Exit = event { + let state: tauri::State<'_, WsBridgeChild> = app_handle.state(); + let mut guard = state.0.lock().unwrap(); + stop_ws_bridge_child(&mut guard); + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[cfg(all(desktop, target_os = "linux"))] + linux_appimage::apply_startup_env_overrides(); + + let builder = tauri::Builder::default(); + + #[cfg(desktop)] + let builder = with_desktop_entry_plugins(builder); + + #[cfg(desktop)] + let builder = builder + .manage(WsBridgeChild(Mutex::new(None))) + .manage(AllowedAssetScopeRoots(Mutex::new(Vec::new()))) + .manage(window_state::MainWindowFrameState::default()) + .manage(vault_watcher::VaultWatcherState::new()); + + with_invoke_handler(builder) + .setup(setup_app) + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + #[cfg(desktop)] + handle_run_event(app_handle, &event); + }); +} + +#[cfg(test)] +mod tests { + use super::should_use_native_desktop_menu; + use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS; + use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS; + + #[cfg(desktop)] + use super::{ + missing_asset_scope_roots, selected_mcp_bridge_vault_paths, + spawn_startup_tasks_for_vault_with, validate_mcp_bridge_vault_path, + }; + #[cfg(desktop)] + use crate::vault_list::{VaultEntry, VaultList}; + #[cfg(desktop)] + use std::path::PathBuf; + + #[cfg(all(desktop, unix))] + use super::vault_asset_scope_roots; + + #[test] + fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() { + assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]); + assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]); + } + + #[cfg(desktop)] + #[test] + fn selected_mcp_bridge_vault_paths_puts_persisted_active_vault_first() { + let list = VaultList { + vaults: vec![ + VaultEntry { + label: "Secondary".to_string(), + path: "/tmp/Secondary Vault".to_string(), + mounted: Some(true), + ..VaultEntry::default() + }, + VaultEntry { + label: "Hidden".to_string(), + path: "/tmp/Hidden Vault".to_string(), + mounted: Some(false), + ..VaultEntry::default() + }, + VaultEntry { + label: "Selected".to_string(), + path: "/tmp/Selected Vault".to_string(), + mounted: Some(true), + ..VaultEntry::default() + }, + ], + active_vault: Some("/tmp/Selected Vault".to_string()), + default_workspace_path: None, + hidden_defaults: Vec::new(), + }; + + assert_eq!( + selected_mcp_bridge_vault_paths(&list), + vec![ + PathBuf::from("/tmp/Selected Vault"), + PathBuf::from("/tmp/Secondary Vault"), + ] + ); + } + + #[cfg(desktop)] + #[test] + fn selected_mcp_bridge_vault_paths_ignores_blank_active_vault() { + let list = VaultList { + vaults: Vec::new(), + active_vault: Some(" ".to_string()), + default_workspace_path: None, + hidden_defaults: Vec::new(), + }; + + assert!(selected_mcp_bridge_vault_paths(&list).is_empty()); + } + + #[cfg(desktop)] + #[test] + fn startup_tasks_skip_missing_legacy_vault() { + let missing_vault = tempfile::tempdir().unwrap().path().join("missing"); + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let called_from_task = called.clone(); + + let spawned = spawn_startup_tasks_for_vault_with(missing_vault, move |_| { + called_from_task.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + assert!(!spawned); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[cfg(desktop)] + #[test] + fn startup_tasks_run_in_background() { + let dir = tempfile::tempdir().unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + + let spawned = spawn_startup_tasks_for_vault_with(dir.path().to_path_buf(), move |_| { + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + }); + + assert!(spawned); + entered_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + release_tx.send(()).unwrap(); + } + + #[cfg(desktop)] + #[test] + fn validate_mcp_bridge_vault_path_requires_existing_directory() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("Vault With Spaces"); + std::fs::create_dir(&vault).unwrap(); + + let resolved = validate_mcp_bridge_vault_path(&vault).unwrap(); + assert_eq!(resolved, vault.canonicalize().unwrap()); + + let missing = dir.path().join("Missing Vault"); + let err = validate_mcp_bridge_vault_path(&missing).unwrap_err(); + assert!(err.contains("MCP bridge vault is not available")); + } + + #[cfg(all(desktop, unix))] + #[test] + fn vault_asset_scope_roots_include_requested_symlink_path() { + let dir = tempfile::tempdir().unwrap(); + let canonical_vault = dir.path().join("Getting Started"); + let symlinked_vault = dir.path().join("Symlinked Getting Started"); + std::fs::create_dir(&canonical_vault).unwrap(); + std::os::unix::fs::symlink(&canonical_vault, &symlinked_vault).unwrap(); + + let roots = vault_asset_scope_roots(&symlinked_vault).unwrap(); + + assert_eq!(roots[0], canonical_vault.canonicalize().unwrap()); + assert!(roots.contains(&symlinked_vault)); + } + + #[cfg(desktop)] + #[test] + fn missing_asset_scope_roots_keeps_previously_allowed_vaults() { + let vault_a = PathBuf::from("/vault-a"); + let vault_b = PathBuf::from("/vault-b"); + let allowed_roots = vec![vault_a.clone()]; + + assert_eq!( + missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_b)), + vec![vault_b] + ); + assert!( + missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty() + ); + } + + #[test] + fn native_desktop_menu_is_macos_only() { + assert!(should_use_native_desktop_menu("macos")); + assert!(!should_use_native_desktop_menu("windows")); + assert!(!should_use_native_desktop_menu("linux")); + } +} diff --git a/src-tauri/src/linux_appimage.rs b/src-tauri/src/linux_appimage.rs new file mode 100644 index 0000000..006ddef --- /dev/null +++ b/src-tauri/src/linux_appimage.rs @@ -0,0 +1,795 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct StartupEnvOverride { + key: &'static str, + value: &'static str, +} + +const WEBKIT_DISABLE_DMABUF_RENDERER_OVERRIDE: StartupEnvOverride = StartupEnvOverride { + key: "WEBKIT_DISABLE_DMABUF_RENDERER", + value: "1", +}; +const WEBKIT_DISABLE_COMPOSITING_MODE_OVERRIDE: StartupEnvOverride = StartupEnvOverride { + key: "WEBKIT_DISABLE_COMPOSITING_MODE", + value: "1", +}; + +const FCITX_GTK_IM_MODULE_OVERRIDE: StartupEnvOverride = StartupEnvOverride { + key: "GTK_IM_MODULE", + value: "fcitx", +}; +const FCITX_ENV_HINT_KEYS: [&str; 4] = [ + "XMODIFIERS", + "INPUT_METHOD", + "QT_IM_MODULE", + "SDL_IM_MODULE", +]; +const FCITX_GTK3_IM_MODULE_RELATIVE_PATH: &str = + "usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so"; +#[cfg(all(desktop, target_os = "linux"))] +const TOLARIA_FCITX_IMMODULES_CACHE_FILE: &str = "tolaria-appimage-fcitx5-immodules.cache"; +const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf"; +#[cfg(all(desktop, target_os = "linux"))] +const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf"; + +const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [ + "/usr/lib64/libwayland-client.so.0", + "/usr/lib64/libwayland-client.so", + "/lib64/libwayland-client.so.0", + "/usr/lib/x86_64-linux-gnu/libwayland-client.so.0", + "/lib/x86_64-linux-gnu/libwayland-client.so.0", + "/usr/lib/libwayland-client.so.0", + "/usr/lib/libwayland-client.so", +]; + +#[cfg(target_pointer_width = "64")] +const PROCESS_ELF_CLASS: u8 = 2; + +#[cfg(target_pointer_width = "32")] +const PROCESS_ELF_CLASS: u8 = 1; + +fn is_linux_appimage_launch(mut get_var: F) -> bool +where + F: FnMut(&str) -> Option, +{ + ["APPIMAGE", "APPDIR"] + .into_iter() + .any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty())) +} + +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) fn is_running() -> bool { + is_linux_appimage_launch(|key| std::env::var(key).ok()) +} + +fn webkit_rendering_overrides_with(get_var: &mut F) -> Vec +where + F: FnMut(&str) -> Option, +{ + if is_linux_appimage_launch(&mut *get_var) { + return vec![ + WEBKIT_DISABLE_DMABUF_RENDERER_OVERRIDE, + WEBKIT_DISABLE_COMPOSITING_MODE_OVERRIDE, + ]; + } + if is_wayland_session(&mut *get_var) { + return vec![WEBKIT_DISABLE_DMABUF_RENDERER_OVERRIDE]; + } + + Vec::new() +} + +fn has_non_empty_env(get_var: &mut F, key: &str) -> bool +where + F: FnMut(&str) -> Option, +{ + get_var(key).is_some_and(|value| !value.trim().is_empty()) +} + +fn has_env(get_var: &mut F, key: &str) -> bool +where + F: FnMut(&str) -> Option, +{ + get_var(key).is_some() +} + +fn has_explicit_fontconfig_env(get_var: &mut F) -> bool +where + F: FnMut(&str) -> Option, +{ + ["FONTCONFIG_FILE", "FONTCONFIG_PATH"] + .into_iter() + .any(|key| has_non_empty_env(get_var, key)) +} + +fn can_apply_colrv1_font_guard(get_var: &mut F) -> bool +where + F: FnMut(&str) -> Option, +{ + if !is_linux_appimage_launch(&mut *get_var) { + return false; + } + + !has_explicit_fontconfig_env(get_var) +} + +fn env_mentions_fcitx(value: &str) -> bool { + value.to_ascii_lowercase().contains("fcitx") +} + +fn has_fcitx_env_hint(get_var: &mut F) -> bool +where + F: FnMut(&str) -> Option, +{ + FCITX_ENV_HINT_KEYS + .into_iter() + .any(|key| get_var(key).is_some_and(|value| env_mentions_fcitx(&value))) +} + +fn non_empty_env(get_var: &mut F, key: &str) -> Option +where + F: FnMut(&str) -> Option, +{ + get_var(key).filter(|value| !value.trim().is_empty()) +} + +#[derive(Debug, PartialEq, Eq)] +struct FcitxGtkModuleFileOverride { + cache_path: std::path::PathBuf, + cache_contents: String, +} + +fn fcitx_immodules_cache_contents(module_path: &std::path::Path) -> String { + format!( + r#"# Tolaria AppImage GTK input method modules +"{}" +"fcitx" "Fcitx 5" "fcitx" "" "ja:ko:zh:*" +"#, + module_path.display() + ) +} + +fn should_use_bundled_fcitx_gtk_module(get_var: &mut F) -> bool +where + F: FnMut(&str) -> Option, +{ + if !is_linux_appimage_launch(&mut *get_var) { + return false; + } + if has_non_empty_env(get_var, "GTK_IM_MODULE_FILE") { + return false; + } + if let Some(gtk_im_module) = non_empty_env(get_var, "GTK_IM_MODULE") { + return env_mentions_fcitx(>k_im_module); + } + + has_fcitx_env_hint(get_var) +} + +fn fcitx_gtk_im_module_file_override_with( + get_var: &mut F, + mut module_exists: E, + cache_path: std::path::PathBuf, +) -> Option +where + F: FnMut(&str) -> Option, + E: FnMut(&std::path::Path) -> bool, +{ + if !should_use_bundled_fcitx_gtk_module(get_var) { + return None; + } + + let appdir = non_empty_env(get_var, "APPDIR")?; + let module_path = std::path::Path::new(appdir.trim()).join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH); + + module_exists(&module_path).then(|| FcitxGtkModuleFileOverride { + cache_path, + cache_contents: fcitx_immodules_cache_contents(&module_path), + }) +} + +fn fcitx_gtk_im_module_override_with(get_var: &mut F) -> Option +where + F: FnMut(&str) -> Option, +{ + if !is_linux_appimage_launch(&mut *get_var) { + return None; + } + if has_env(get_var, "GTK_IM_MODULE") { + return None; + } + if !has_fcitx_env_hint(get_var) { + return None; + } + + Some(FCITX_GTK_IM_MODULE_OVERRIDE) +} + +fn is_wayland_session(mut get_var: F) -> bool +where + F: FnMut(&str) -> Option, +{ + has_non_empty_env(&mut get_var, "WAYLAND_DISPLAY") + || get_var("XDG_SESSION_TYPE") + .is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland")) +} + +fn elf_library_matches_process(path: &std::path::Path) -> bool { + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + + let mut header = [0; 5]; + if std::io::Read::read_exact(&mut file, &mut header).is_err() { + return false; + } + + header[..4] == *b"\x7FELF" && header[4] == PROCESS_ELF_CLASS +} + +#[cfg(all(desktop, target_os = "linux"))] +fn wayland_preload_candidate_matches(path: &str) -> bool { + let path = std::path::Path::new(path); + + path.is_file() && elf_library_matches_process(path) +} + +fn wayland_client_preload_path_with( + mut get_var: F, + mut candidate_matches: E, +) -> Option<&'static str> +where + F: FnMut(&str) -> Option, + E: FnMut(&str) -> bool, +{ + if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) { + return None; + } + + if has_non_empty_env(&mut get_var, "LD_PRELOAD") + || get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1") + { + return None; + } + + WAYLAND_CLIENT_PRELOAD_CANDIDATES + .into_iter() + .find(|path| candidate_matches(path)) +} + +fn colrv1_emoji_font_path_with(mut get_var: F, mut match_emoji_font: M) -> Option +where + F: FnMut(&str) -> Option, + M: FnMut() -> Option, +{ + if !can_apply_colrv1_font_guard(&mut get_var) { + return None; + } + + let font_path = match_emoji_font()?; + + std::path::Path::new(font_path.trim()) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case(COLRV1_EMOJI_FONT_FILE)) + .then(|| font_path.trim().to_string()) +} + +fn escape_xml_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn colrv1_emoji_fontconfig_contents(rejected_font_path: &str) -> String { + format!( + r#" + + + /etc/fonts/fonts.conf + + + + + {} + + + + + +"#, + escape_xml_text(rejected_font_path) + ) +} + +fn startup_env_overrides_with(mut get_var: F) -> Vec +where + F: FnMut(&str) -> Option, +{ + let mut overrides: Vec<_> = webkit_rendering_overrides_with(&mut get_var) + .into_iter() + .filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key)) + .collect(); + + if let Some(env_override) = fcitx_gtk_im_module_override_with(&mut get_var) { + overrides.push(env_override); + } + + overrides +} + +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) fn apply_startup_env_overrides() { + apply_wayland_client_preload(); + apply_colrv1_emoji_font_guard(); + apply_fcitx_gtk_im_module_file(); + + for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) { + std::env::set_var(env_override.key, env_override.value); + } +} + +#[cfg(all(desktop, target_os = "linux"))] +fn apply_fcitx_gtk_im_module_file() { + let Some(cache_path) = fcitx_immodules_cache_file_path() else { + eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to resolve cache directory"); + return; + }; + let Some(env_override) = fcitx_gtk_im_module_file_override_with( + &mut |key| std::env::var(key).ok(), + std::path::Path::is_file, + cache_path, + ) else { + return; + }; + let Some(parent) = env_override.cache_path.parent() else { + return; + }; + + if let Err(error) = std::fs::create_dir_all(parent) { + eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to prepare cache ({error})"); + return; + } + + if let Err(error) = std::fs::write(&env_override.cache_path, env_override.cache_contents) { + eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to write cache ({error})"); + return; + } + + std::env::set_var("GTK_IM_MODULE_FILE", env_override.cache_path.as_os_str()); +} + +#[cfg(all(desktop, target_os = "linux"))] +fn apply_colrv1_emoji_font_guard() { + let Some(font_path) = + colrv1_emoji_font_path_with(|key| std::env::var(key).ok(), match_emoji_font_path) + else { + return; + }; + let Some(config_path) = colrv1_fontconfig_file_path() else { + eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to resolve cache directory"); + return; + }; + let Some(parent) = config_path.parent() else { + return; + }; + + if let Err(error) = std::fs::create_dir_all(parent) { + eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to prepare cache ({error})"); + return; + } + + if let Err(error) = std::fs::write(&config_path, colrv1_emoji_fontconfig_contents(&font_path)) { + eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to write config ({error})"); + return; + } + + std::env::set_var("FONTCONFIG_FILE", config_path.as_os_str()); +} + +#[cfg(all(desktop, target_os = "linux"))] +fn match_emoji_font_path() -> Option { + let output = std::process::Command::new("fc-match") + .args(["-f", "%{file}\n", "emoji"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn colrv1_fontconfig_file_path() -> Option { + let cache_dir = + dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?; + + Some( + cache_dir + .join("tolaria") + .join(TOLARIA_COLRV1_FONTCONFIG_FILE), + ) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn fcitx_immodules_cache_file_path() -> Option { + let cache_dir = + dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?; + + Some( + cache_dir + .join("tolaria") + .join(TOLARIA_FCITX_IMMODULES_CACHE_FILE), + ) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn launched_appimage_path() -> Result { + if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|value| !value.is_empty()) { + return Ok(std::path::PathBuf::from(appimage)); + } + + std::fs::read_link("/proc/self/exe") + .map_err(|e| format!("failed to resolve /proc/self/exe ({e})")) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn launched_process_args() -> Vec { + use std::os::unix::ffi::OsStringExt; + + let Ok(cmdline) = std::fs::read("/proc/self/cmdline") else { + return Vec::new(); + }; + + cmdline + .split(|byte| *byte == 0) + .filter(|arg| !arg.is_empty()) + .skip(1) + .map(|arg| std::ffi::OsString::from_vec(arg.to_vec())) + .collect() +} + +#[cfg(all(desktop, target_os = "linux"))] +fn apply_wayland_client_preload() { + use std::os::unix::process::CommandExt; + + let Some(preload_path) = wayland_client_preload_path_with( + |key| std::env::var(key).ok(), + wayland_preload_candidate_matches, + ) else { + return; + }; + + let exe = match launched_appimage_path() { + Ok(exe) => exe, + Err(e) => { + eprintln!("Tolaria AppImage Wayland preload skipped: {e}"); + return; + } + }; + + let error = std::process::Command::new(exe) + .args(launched_process_args()) + .env("LD_PRELOAD", preload_path) + .env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1") + .exec(); + eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})"); +} + +#[cfg(test)] +mod tests { + use super::{ + colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, elf_library_matches_process, + fcitx_gtk_im_module_file_override_with, startup_env_overrides_with, + wayland_client_preload_path_with, StartupEnvOverride, FCITX_GTK3_IM_MODULE_RELATIVE_PATH, + }; + + fn default_webkit_overrides() -> Vec { + vec![ + StartupEnvOverride { + key: "WEBKIT_DISABLE_DMABUF_RENDERER", + value: "1", + }, + StartupEnvOverride { + key: "WEBKIT_DISABLE_COMPOSITING_MODE", + value: "1", + }, + ] + } + + fn default_webkit_and_fcitx_overrides() -> Vec { + let mut overrides = default_webkit_overrides(); + overrides.push(StartupEnvOverride { + key: "GTK_IM_MODULE", + value: "fcitx", + }); + overrides + } + + fn appimage_wayland_fcitx_env(key: &str, gtk_im_module: Option<&str>) -> Option { + match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "WAYLAND_DISPLAY" => Some("wayland-1".to_string()), + "XMODIFIERS" => Some("@im=fcitx".to_string()), + "GTK_IM_MODULE" => gtk_im_module.map(ToOwned::to_owned), + _ => None, + } + } + + #[test] + fn startup_env_overrides_are_empty_outside_appimage_or_wayland_launches() { + let overrides = startup_env_overrides_with(|_| None); + + assert!(overrides.is_empty()); + } + + #[test] + fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimage_launches() { + let overrides = startup_env_overrides_with(|key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + _ => None, + }); + + assert_eq!(overrides, default_webkit_overrides()); + } + + #[test] + fn startup_env_overrides_keep_compositing_enabled_for_native_wayland_launches() { + let overrides = startup_env_overrides_with(|key| match key { + "XDG_SESSION_TYPE" => Some("wayland".to_string()), + _ => None, + }); + + assert_eq!( + overrides, + vec![StartupEnvOverride { + key: "WEBKIT_DISABLE_DMABUF_RENDERER", + value: "1", + }] + ); + } + + #[test] + fn startup_env_overrides_preserve_explicit_user_setting_per_variable() { + let overrides = startup_env_overrides_with(|key| match key { + "APPDIR" => Some("/tmp/.mount_Tolaria".to_string()), + "WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()), + _ => None, + }); + + assert_eq!( + overrides, + vec![StartupEnvOverride { + key: "WEBKIT_DISABLE_COMPOSITING_MODE", + value: "1", + }] + ); + } + + #[test] + fn startup_env_overrides_enable_fcitx_gtk_module_for_wayland_appimage() { + let overrides = startup_env_overrides_with(|key| appimage_wayland_fcitx_env(key, None)); + + assert_eq!(overrides, default_webkit_and_fcitx_overrides()); + } + + #[test] + fn startup_env_overrides_enable_fcitx_gtk_module_for_x11_appimage() { + let overrides = startup_env_overrides_with(|key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "XDG_SESSION_TYPE" => Some("x11".to_string()), + "XMODIFIERS" => Some("@im=fcitx".to_string()), + _ => None, + }); + + assert_eq!(overrides, default_webkit_and_fcitx_overrides()); + } + + #[test] + fn startup_env_overrides_preserve_explicit_gtk_im_module() { + let overrides = + startup_env_overrides_with(|key| appimage_wayland_fcitx_env(key, Some("wayland"))); + + assert_eq!(overrides, default_webkit_overrides()); + } + + #[test] + fn startup_env_overrides_leave_non_fcitx_wayland_appimage_input_unchanged() { + let overrides = startup_env_overrides_with(|key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "WAYLAND_DISPLAY" => Some("wayland-1".to_string()), + _ => None, + }); + + assert_eq!(overrides, default_webkit_overrides()); + } + + #[test] + fn fcitx_module_file_override_points_gtk_to_bundled_appimage_module() { + let appdir = std::path::Path::new("/tmp/.mount_Tolaria"); + let module_path = appdir.join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH); + let cache_path = std::path::PathBuf::from("/tmp/tolaria/immodules.cache"); + let env_override = fcitx_gtk_im_module_file_override_with( + &mut |key| match key { + "APPDIR" => Some(appdir.display().to_string()), + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "GTK_IM_MODULE" => Some("fcitx".to_string()), + "XDG_SESSION_TYPE" => Some("x11".to_string()), + _ => None, + }, + |path| path == module_path, + cache_path.clone(), + ) + .unwrap(); + + assert_eq!(env_override.cache_path, cache_path); + assert!(env_override + .cache_contents + .contains(&module_path.display().to_string())); + assert!(env_override + .cache_contents + .contains("\"fcitx\" \"Fcitx 5\"")); + } + + #[test] + fn fcitx_module_file_override_preserves_explicit_module_cache() { + let env_override = fcitx_gtk_im_module_file_override_with( + &mut |key| match key { + "APPDIR" => Some("/tmp/.mount_Tolaria".to_string()), + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "GTK_IM_MODULE" => Some("fcitx".to_string()), + "GTK_IM_MODULE_FILE" => Some("/tmp/custom-immodules.cache".to_string()), + _ => None, + }, + |_| true, + std::path::PathBuf::from("/tmp/tolaria/immodules.cache"), + ); + + assert_eq!(env_override, None); + } + + #[test] + fn colrv1_font_guard_targets_reported_appimage_emoji_font() { + let font_path = colrv1_emoji_font_path_with( + |key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + _ => None, + }, + || Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()), + ); + + assert_eq!( + font_path.as_deref(), + Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf") + ); + } + + #[test] + fn colrv1_font_guard_preserves_explicit_fontconfig_settings() { + let font_path = colrv1_emoji_font_path_with( + |key| match key { + "APPDIR" => Some("/tmp/.mount_Tolaria".to_string()), + "FONTCONFIG_FILE" => Some("/tmp/custom-fontconfig.conf".to_string()), + _ => None, + }, + || Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()), + ); + + assert_eq!(font_path, None); + } + + #[test] + fn colrv1_font_guard_ignores_other_emoji_fonts() { + let font_path = colrv1_emoji_font_path_with( + |key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + _ => None, + }, + || Some("/usr/share/fonts/noto/NotoColorEmoji.ttf".to_string()), + ); + + assert_eq!(font_path, None); + } + + #[test] + fn colrv1_fontconfig_includes_system_fonts_and_rejects_matched_file() { + let contents = colrv1_emoji_fontconfig_contents("/tmp/fonts/A&B/Noto-COLRv1.ttf"); + + assert!( + contents.contains("/etc/fonts/fonts.conf") + ); + assert!(contents.contains("")); + assert!(contents.contains("/tmp/fonts/A&B/Noto-COLRv1.ttf")); + } + + #[test] + fn wayland_preload_uses_first_available_system_library() { + let preload_path = wayland_client_preload_path_with( + |key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "XDG_SESSION_TYPE" => Some("wayland".to_string()), + _ => None, + }, + |path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0", + ); + + assert_eq!( + preload_path, + Some("/lib/x86_64-linux-gnu/libwayland-client.so.0") + ); + } + + #[test] + fn wayland_preload_prefers_fedora_lib64_over_usr_lib() { + let preload_path = wayland_client_preload_path_with( + |key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "XDG_SESSION_TYPE" => Some("wayland".to_string()), + _ => None, + }, + |path| { + path == "/usr/lib/libwayland-client.so.0" + || path == "/usr/lib64/libwayland-client.so.0" + }, + ); + + assert_eq!(preload_path, Some("/usr/lib64/libwayland-client.so.0")); + } + + #[test] + fn preload_library_rejects_wrong_elf_class() { + let dir = tempfile::tempdir().unwrap(); + let matching = dir.path().join("matching-libwayland-client.so.0"); + let mismatched = dir.path().join("mismatched-libwayland-client.so.0"); + let matching_class = if cfg!(target_pointer_width = "64") { + 2 + } else { + 1 + }; + let mismatched_class = if matching_class == 2 { 1 } else { 2 }; + + std::fs::write(&matching, [0x7F, b'E', b'L', b'F', matching_class]).unwrap(); + std::fs::write(&mismatched, [0x7F, b'E', b'L', b'F', mismatched_class]).unwrap(); + + assert!(elf_library_matches_process(&matching)); + assert!(!elf_library_matches_process(&mismatched)); + assert!(!elf_library_matches_process(&dir.path().join("missing.so"))); + } + + #[test] + fn wayland_preload_preserves_explicit_ld_preload() { + let preload_path = wayland_client_preload_path_with( + |key| match key { + "APPDIR" => Some("/tmp/.mount_Tolaria".to_string()), + "WAYLAND_DISPLAY" => Some("wayland-0".to_string()), + "LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()), + _ => None, + }, + |_| true, + ); + + assert_eq!(preload_path, None); + } + + #[test] + fn wayland_preload_is_empty_for_x11_sessions() { + let preload_path = wayland_client_preload_path_with( + |key| match key { + "APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()), + "XDG_SESSION_TYPE" => Some("x11".to_string()), + _ => None, + }, + |_| true, + ); + + assert_eq!(preload_path, None); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..312d1c8 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tolaria_lib::run(); +} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs new file mode 100644 index 0000000..c609c44 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -0,0 +1,1170 @@ +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::Child; + +#[cfg(any(test, all(desktop, target_os = "linux")))] +mod extraction; +mod opencode; +mod paths; +mod runtime; +mod subprocess; + +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) use extraction::extract_mcp_server_to_stable_dir; +pub(crate) use runtime::{find_mcp_runtime, find_node}; + +const MCP_SERVER_NAME: &str = "tolaria"; +const LEGACY_MCP_SERVER_NAME: &str = "laputa"; + +/// Status of the MCP server installation. +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum McpStatus { + /// MCP is registered in Claude config and server files exist. + Installed, + /// MCP server files or config are missing for the active vault. + NotInstalled, +} + +/// Resolve the path to `mcp-server/`. +/// +/// In dev mode, prefers `CARGO_MANIFEST_DIR` and falls back to runtime checkout ancestors. +/// In release mode, uses launcher roots plus bundle paths derived from the executable. +pub(crate) fn mcp_server_dir() -> Result { + let dev_path = build_time_dev_mcp_server_dir(); + let resource_roots = paths::runtime_resource_roots(); + let candidates = mcp_server_dir_candidates(&dev_path, &resource_roots); + if let Some(path) = candidates + .iter() + .find(|path| mcp_server_dir_has_files(path)) + { + return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone())); + } + + let searched = candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + Err(format!( + "mcp-server not found. Searched these paths: {searched}" + )) +} + +fn mcp_server_dir_for_registration() -> Result { + #[cfg(all(desktop, target_os = "linux"))] + if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() { + return Ok(stable_dir); + } + + mcp_server_dir() +} + +pub(crate) fn mcp_server_index_js_path_string() -> Result { + Ok(index_js_client_path(&mcp_server_dir()?)) +} + +fn index_js_client_path(server_dir: &Path) -> String { + paths::client_script_path(&server_dir.join("index.js")) +} + +fn build_time_dev_mcp_server_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("mcp-server") +} + +fn mcp_server_dir_candidates(dev_path: &Path, resource_roots: &[PathBuf]) -> Vec { + let current_dir = std::env::current_dir().ok(); + + mcp_server_dir_candidates_for(dev_path, resource_roots, current_dir.as_deref()) +} + +fn mcp_server_dir_candidates_for( + dev_path: &Path, + resource_roots: &[PathBuf], + current_dir: Option<&Path>, +) -> Vec { + let mut candidates = Vec::new(); + push_unique_path(&mut candidates, dev_path.to_path_buf()); + + for root in resource_roots { + push_resource_root_candidates(&mut candidates, root); + } + + for root in runtime_development_roots(current_dir) { + push_development_root_candidates(&mut candidates, &root); + } + + push_linux_package_candidates(&mut candidates, Path::new("/usr/local")); + push_linux_package_candidates(&mut candidates, Path::new("/usr")); + candidates +} + +fn push_resource_root_candidates(candidates: &mut Vec, root: &Path) { + push_unique_path(candidates, root.join("mcp-server")); + push_unique_path(candidates, root.join("resources").join("mcp-server")); + push_linux_package_candidates(candidates, root); +} + +fn push_development_root_candidates(candidates: &mut Vec, root: &Path) { + push_unique_path(candidates, root.join("mcp-server")); + push_unique_path(candidates, root.join("resources").join("mcp-server")); + push_unique_path( + candidates, + root.join("src-tauri").join("resources").join("mcp-server"), + ); +} + +fn runtime_development_roots(current_dir: Option<&Path>) -> Vec { + let mut roots = Vec::new(); + + if let Some(current_dir) = current_dir { + push_ancestor_paths(&mut roots, current_dir); + } + + roots +} + +fn push_ancestor_paths(paths: &mut Vec, start: &Path) { + for ancestor in start.ancestors().filter(|path| path.parent().is_some()) { + push_unique_path(paths, ancestor.to_path_buf()); + } +} + +fn push_linux_package_candidates(candidates: &mut Vec, root: &Path) { + for path in linux_package_mcp_server_dirs(root) { + push_unique_path(candidates, path); + } +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !path.as_os_str().is_empty() && !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +fn linux_package_mcp_server_dirs(root: &Path) -> Vec { + vec![ + root.join("Tolaria").join("mcp-server"), + root.join("Tolaria").join("resources").join("mcp-server"), + root.join("lib").join("Tolaria").join("mcp-server"), + root.join("lib") + .join("Tolaria") + .join("resources") + .join("mcp-server"), + root.join("lib").join("tolaria").join("mcp-server"), + root.join("lib") + .join("tolaria") + .join("resources") + .join("mcp-server"), + ] +} + +fn mcp_server_dir_has_files(path: &Path) -> bool { + path.join("index.js").is_file() && path.join("ws-bridge.js").is_file() +} + +/// Spawn the WebSocket bridge as a child process. +pub fn spawn_ws_bridge(vault_path: impl AsRef) -> Result { + spawn_ws_bridge_with_paths(vault_path, &[]) +} + +/// Spawn the WebSocket bridge with every active vault exposed to MCP tools. +pub fn spawn_ws_bridge_with_paths( + vault_path: impl AsRef, + vault_paths: &[PathBuf], +) -> Result { + let runtime = find_mcp_runtime()?; + let server_dir = mcp_server_dir()?; + let script = server_dir.join("ws-bridge.js"); + let vault_path = vault_path.as_ref(); + let active_vault_paths = active_vault_paths_json(vault_path, vault_paths); + + let mut command = subprocess::command(&runtime.binary); + let child = command + .arg(&script) + .env("VAULT_PATH", vault_path) + .env("VAULT_PATHS", active_vault_paths) + .env("WS_PORT", "9710") + .env("WS_UI_PORT", "9711") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?; + + log::info!( + "ws-bridge spawned (pid: {}, runtime: {:?}, vault: {})", + child.id(), + runtime.kind, + vault_path.display() + ); + Ok(child) +} + +fn active_vault_paths_json(vault_path: &Path, vault_paths: &[PathBuf]) -> String { + let mut paths = Vec::new(); + push_unique_bridge_vault_path(&mut paths, vault_path); + for path in vault_paths { + push_unique_bridge_vault_path(&mut paths, path); + } + serde_json::to_string(&paths).unwrap_or_else(|_| "[]".to_string()) +} + +fn push_unique_bridge_vault_path(paths: &mut Vec, path: &Path) { + let value = path.to_string_lossy().trim().to_string(); + if value.is_empty() || paths.iter().any(|existing| existing == &value) { + return; + } + paths.push(value); +} + +fn mcp_config_paths() -> Vec { + dirs::home_dir() + .map(|home| mcp_config_paths_for_home(&home)) + .unwrap_or_default() +} + +fn mcp_config_paths_for_home(home: &Path) -> Vec { + vec![ + home.join(".claude.json"), + home.join(".claude").join("mcp.json"), + home.join(".gemini").join("config").join("mcp_config.json"), + home.join(".cursor").join("mcp.json"), + home.join(".config").join("mcp").join("mcp.json"), + ] +} + +fn legacy_gemini_mcp_config_paths() -> Vec { + dirs::home_dir() + .map(|home| legacy_gemini_mcp_config_paths_for_home(&home)) + .unwrap_or_default() +} + +fn legacy_gemini_mcp_config_paths_for_home(home: &Path) -> Vec { + vec![home.join(".gemini").join("settings.json")] +} + +fn read_registered_mcp_entry(config_path: &Path) -> Option { + let raw = std::fs::read_to_string(config_path).ok()?; + let config: serde_json::Value = serde_json::from_str(&raw).ok()?; + config + .get("mcpServers") + .and_then(|value| value.as_object()) + .and_then(|servers| { + servers + .get(MCP_SERVER_NAME) + .or_else(|| servers.get(LEGACY_MCP_SERVER_NAME)) + }) + .cloned() +} + +fn entry_index_js_exists(entry: &serde_json::Value) -> bool { + entry["args"] + .as_array() + .and_then(|args| args.first()) + .and_then(|value| value.as_str()) + .is_some_and(|index_js| Path::new(index_js).exists()) +} + +fn entry_uses_stdio(entry: &serde_json::Value) -> bool { + entry["type"].as_str() == Some("stdio") +} + +fn entry_has_ui_port(entry: &serde_json::Value) -> bool { + entry["env"]["WS_UI_PORT"].as_str() == Some("9711") +} + +/// Build the durable external MCP server entry JSON for an index.js path. +fn build_mcp_entry(runtime_command: &str, index_js: &str) -> serde_json::Value { + let index_js = paths::client_script_path(Path::new(index_js)); + serde_json::json!({ + "type": "stdio", + "command": runtime_command, + "args": [index_js], + "env": { + "WS_UI_PORT": "9711" + } + }) +} + +fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result { + let mut servers = serde_json::Map::new(); + servers.insert(MCP_SERVER_NAME.to_string(), entry.clone()); + let config = serde_json::json!({ "mcpServers": servers }); + + serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize MCP config snippet: {e}")) +} + +/// Build the exact MCP config JSON users can copy into compatible tools. +pub fn mcp_config_snippet(vault_path: &str) -> Result { + let _ = vault_path; + let runtime = find_mcp_runtime().map_err(|e| { + format!( + "Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build MCP config: {e}" + ) + })?; + let server_dir = mcp_server_dir_for_registration()?; + let index_js = index_js_client_path(&server_dir); + let runtime_command = runtime.binary.to_string_lossy().into_owned(); + let entry = build_mcp_entry(&runtime_command, &index_js); + + build_mcp_config_snippet(&entry) +} + +/// Build the exact OpenCode MCP config JSON users can copy into opencode.json. +pub fn opencode_mcp_config_snippet(vault_path: &str) -> Result { + let _ = vault_path; + let runtime = find_mcp_runtime().map_err(|e| { + format!( + "Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build OpenCode MCP config: {e}" + ) + })?; + let server_dir = mcp_server_dir_for_registration()?; + let index_js = index_js_client_path(&server_dir); + let runtime_command = runtime.binary.to_string_lossy().into_owned(); + let entry = opencode::build_entry(&runtime_command, &index_js); + + opencode::build_config_snippet(&entry) +} + +/// Write MCP registration to a list of config file paths. +/// Returns "registered" on first registration, "updated" if already present. +fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String { + let mut status = "registered"; + for config_path in config_paths { + match upsert_mcp_config(config_path, entry) { + Ok(true) => status = "updated", + Ok(false) => {} + Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e), + } + } + status.to_string() +} + +/// Register Tolaria as an MCP server in external AI tool config files. +pub fn register_mcp(vault_path: &str) -> Result { + let _ = vault_path; + let runtime = find_mcp_runtime().map_err(|e| { + format!( + "Node.js 18+ or Bun 1+ is required on PATH before Tolaria can register MCP tools: {e}" + ) + })?; + let server_dir = mcp_server_dir_for_registration()?; + let index_js = index_js_client_path(&server_dir); + let runtime_command = runtime.binary.to_string_lossy().into_owned(); + + let entry = build_mcp_entry(&runtime_command, &index_js); + let opencode_entry = opencode::build_entry(&runtime_command, &index_js); + if let Some(config_path) = opencode::config_path() { + if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) { + log::warn!("Failed to update {}: {}", config_path.display(), e); + } + } + + let status = register_mcp_to_configs(&entry, &mcp_config_paths()); + let _ = remove_mcp_from_configs(&legacy_gemini_mcp_config_paths()); + Ok(status) +} + +/// Insert or update the Tolaria entry in an MCP config file. +fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; + } + + let mut config: serde_json::Value = if config_path.exists() { + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?; + serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))? + } else { + serde_json::json!({}) + }; + + let servers = config + .as_object_mut() + .ok_or("Config is not a JSON object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + + let servers = servers + .as_object_mut() + .ok_or("mcpServers is not a JSON object")?; + + let was_update = + servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some(); + servers.remove(LEGACY_MCP_SERVER_NAME); + servers.insert(MCP_SERVER_NAME.to_string(), entry.clone()); + + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(config_path, json) + .map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?; + + Ok(was_update) +} + +fn remove_mcp_from_configs(config_paths: &[PathBuf]) -> String { + let mut removed_any = false; + for config_path in config_paths { + match remove_mcp_from_config(config_path) { + Ok(true) => removed_any = true, + Ok(false) => {} + Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e), + } + } + + if removed_any { + "removed".to_string() + } else { + "already_absent".to_string() + } +} + +fn remove_mcp_from_config(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(false); + } + + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?; + let mut config: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?; + + let Some(config_object) = config.as_object_mut() else { + return Err("Config is not a JSON object".into()); + }; + + let Some(servers_value) = config_object.get_mut("mcpServers") else { + return Ok(false); + }; + + let Some(servers) = servers_value.as_object_mut() else { + return Err("mcpServers is not a JSON object".into()); + }; + + let removed_primary = servers.remove(MCP_SERVER_NAME).is_some(); + let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some(); + if !removed_primary && !removed_legacy { + return Ok(false); + } + + if servers.is_empty() { + config_object.remove("mcpServers"); + } + + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(config_path, json) + .map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?; + + Ok(true) +} + +fn mcp_removal_status(removed_configs: &[bool]) -> String { + if removed_configs.iter().any(|removed| *removed) { + "removed".to_string() + } else { + "already_absent".to_string() + } +} + +pub fn remove_mcp() -> String { + let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed"; + let removed_legacy_gemini = + remove_mcp_from_configs(&legacy_gemini_mcp_config_paths()) == "removed"; + let removed_opencode = opencode::config_path().is_some_and(|config_path| { + opencode::remove_config(&config_path).unwrap_or_else(|e| { + log::warn!("Failed to update {}: {}", config_path.display(), e); + false + }) + }); + + mcp_removal_status(&[removed_standard, removed_legacy_gemini, removed_opencode]) +} + +/// Check whether the MCP server is properly installed and registered. +/// +/// Returns `Installed` when the Tolaria entry exists for the active vault in +/// an external AI tool config and the referenced index.js file is present. +/// Otherwise returns `NotInstalled`. +pub fn check_mcp_status(vault_path: &str) -> McpStatus { + let _ = vault_path; + let installed_standard = mcp_config_paths().into_iter().any(|config_path| { + read_registered_mcp_entry(&config_path).is_some_and(|entry| { + entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry) + }) + }); + let installed_opencode = opencode::config_path().is_some_and(|config_path| { + opencode::read_registered_entry(&config_path) + .is_some_and(|entry| opencode::entry_is_installed(&entry)) + }); + + if installed_standard || installed_opencode { + McpStatus::Installed + } else { + McpStatus::NotInstalled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_config(config_path: &Path) -> serde_json::Value { + let raw = std::fs::read_to_string(config_path).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + fn temp_config_path(file_name: &str) -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join(file_name); + (tmp, config_path) + } + + fn write_config_json(config_path: &Path, config: serde_json::Value) { + std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap(); + } + + fn managed_server(index_js: &str) -> serde_json::Value { + serde_json::json!({ + "type": "stdio", + "command": "node", + "args": [index_js], + "env": { "WS_UI_PORT": "9711" } + }) + } + + fn test_mcp_entry(index_js: &str) -> serde_json::Value { + build_mcp_entry("node", index_js) + } + + fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) { + let servers = servers + .into_iter() + .map(|(name, server)| (name.to_string(), server)) + .collect::>(); + write_config_json(config_path, serde_json::json!({ "mcpServers": servers })); + } + + struct ExpectedMcpServer<'a> { + index_js: &'a str, + } + + fn assert_registered_tolaria_server( + config: &serde_json::Value, + expected: ExpectedMcpServer<'_>, + ) { + let server = &config["mcpServers"][MCP_SERVER_NAME]; + assert_eq!(server["args"][0], expected.index_js); + assert!(server["env"]["VAULT_PATH"].is_null()); + assert_eq!(server["env"]["WS_UI_PORT"], "9711"); + } + + fn write_index_js(dir: &Path) -> PathBuf { + let index_js = dir.join("index.js"); + std::fs::write(&index_js, "console.log('ok');").unwrap(); + index_js + } + + fn assert_candidates_include(candidates: &[PathBuf], expected: &[PathBuf]) { + for candidate in expected { + assert!( + candidates.contains(candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn build_mcp_entry_produces_correct_json() { + let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js"); + assert_eq!( + entry, + serde_json::json!({ + "type": "stdio", + "command": "/usr/local/bin/node", + "args": ["/path/to/index.js"], + "env": { + "WS_UI_PORT": "9711" + } + }) + ); + } + + #[test] + fn build_mcp_entry_strips_windows_extended_length_script_prefix() { + let entry = build_mcp_entry("node", r"\\?\D:\Tolaria\mcp-server\index.js"); + + assert_eq!(entry["args"][0], r"D:\Tolaria\mcp-server\index.js",); + } + + #[test] + fn build_mcp_config_snippet_wraps_tolaria_server_entry() { + let entry = test_mcp_entry("/path/to/index.js"); + let snippet = build_mcp_config_snippet(&entry).unwrap(); + let config: serde_json::Value = serde_json::from_str(&snippet).unwrap(); + + assert_eq!( + config["mcpServers"][MCP_SERVER_NAME]["args"][0], + "/path/to/index.js" + ); + assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null()); + } + + #[test] + fn mcp_server_dir_candidates_prefer_resource_root_before_linux_packages() { + let dev_path = Path::new("/repo/mcp-server"); + let resource_roots = vec![PathBuf::from( + "/Applications/Tolaria.app/Contents/Resources", + )]; + let candidates = mcp_server_dir_candidates(dev_path, &resource_roots); + + let resource_dir = PathBuf::from("/Applications/Tolaria.app/Contents/Resources/mcp-server"); + let linux_pos = candidates + .iter() + .position(|path| path == &PathBuf::from("/usr/local/Tolaria/mcp-server")) + .unwrap(); + + assert_eq!(candidates[0], dev_path); + assert_eq!(candidates[1], resource_dir); + assert!(1 < linux_pos); + } + + #[test] + fn mcp_server_dir_candidates_include_linux_package_resource_roots() { + let dev_path = Path::new("/repo/mcp-server"); + let resource_roots = vec![PathBuf::from("/opt/tolaria")]; + let candidates = mcp_server_dir_candidates(dev_path, &resource_roots); + let expected = vec![ + PathBuf::from("/opt/tolaria/Tolaria/mcp-server"), + PathBuf::from("/opt/tolaria/Tolaria/resources/mcp-server"), + PathBuf::from("/opt/tolaria/lib/Tolaria/mcp-server"), + PathBuf::from("/opt/tolaria/lib/Tolaria/resources/mcp-server"), + PathBuf::from("/opt/tolaria/lib/tolaria/mcp-server"), + PathBuf::from("/opt/tolaria/lib/tolaria/resources/mcp-server"), + PathBuf::from("/usr/local/Tolaria/mcp-server"), + PathBuf::from("/usr/local/Tolaria/resources/mcp-server"), + PathBuf::from("/usr/local/lib/Tolaria/mcp-server"), + PathBuf::from("/usr/local/lib/Tolaria/resources/mcp-server"), + PathBuf::from("/usr/local/lib/tolaria/mcp-server"), + PathBuf::from("/usr/local/lib/tolaria/resources/mcp-server"), + PathBuf::from("/usr/lib/Tolaria/mcp-server"), + PathBuf::from("/usr/lib/Tolaria/resources/mcp-server"), + PathBuf::from("/usr/lib/tolaria/mcp-server"), + PathBuf::from("/usr/lib/tolaria/resources/mcp-server"), + ]; + + assert_candidates_include(&candidates, &expected); + } + + #[test] + fn mcp_server_dir_candidates_include_deb_capitalized_lib_root() { + let dev_path = Path::new("/repo/mcp-server"); + let candidates = mcp_server_dir_candidates(dev_path, &[]); + + assert!(candidates.contains(&PathBuf::from("/usr/lib/Tolaria/mcp-server"))); + } + + #[test] + fn mcp_server_dir_candidates_include_linux_appimage_resource_root() { + let dev_path = Path::new("/repo/mcp-server"); + let resource_roots = vec![PathBuf::from("/tmp/.mount_tolaria/usr")]; + let candidates = mcp_server_dir_candidates(dev_path, &resource_roots); + + assert_candidates_include( + &candidates, + &[PathBuf::from( + "/tmp/.mount_tolaria/usr/lib/tolaria/resources/mcp-server", + )], + ); + } + + #[test] + fn mcp_server_dir_candidates_include_runtime_dev_roots_when_build_path_is_stale() { + let stale_dev_path = Path::new("/Users/runner/work/tolaria/tolaria/mcp-server"); + let current_dir = Path::new("/Users/luca/Workspace/tolaria"); + let candidates = mcp_server_dir_candidates_for(stale_dev_path, &[], Some(current_dir)); + + assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server"))); + assert!(candidates.contains(&PathBuf::from( + "/Users/luca/Workspace/tolaria/src-tauri/resources/mcp-server" + ))); + } + + #[test] + fn mcp_server_dir_candidates_include_macos_bundle_resources() { + let dev_path = Path::new("/repo/mcp-server"); + let resource_roots = vec![PathBuf::from( + "/Applications/Tolaria.app/Contents/Resources", + )]; + let candidates = mcp_server_dir_candidates_for(dev_path, &resource_roots, None); + + assert_candidates_include( + &candidates, + &[PathBuf::from( + "/Applications/Tolaria.app/Contents/Resources/mcp-server", + )], + ); + } + + #[test] + fn upsert_creates_new_config() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + let entry = test_mcp_entry("/test/index.js"); + + let was_update = upsert_mcp_config(&config_path, &entry).unwrap(); + assert!(!was_update); + + let config = read_config(&config_path); + assert_registered_tolaria_server( + &config, + ExpectedMcpServer { + index_js: "/test/index.js", + }, + ); + } + + #[test] + fn upsert_updates_existing_config() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + + let entry1 = test_mcp_entry("/test/index.js"); + upsert_mcp_config(&config_path, &entry1).unwrap(); + + let entry2 = test_mcp_entry("/test/index.js"); + let was_update = upsert_mcp_config(&config_path, &entry2).unwrap(); + assert!(was_update); + + let config = read_config(&config_path); + assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null()); + } + + #[test] + fn upsert_migrates_legacy_server_name() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + + let existing = serde_json::json!({ + "mcpServers": { + "laputa": { + "command": "node", + "args": ["/old/index.js"], + "env": { "VAULT_PATH": "/old" } + } + } + }); + std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap(); + + let entry = test_mcp_entry("/test/index.js"); + let was_update = upsert_mcp_config(&config_path, &entry).unwrap(); + assert!(was_update); + + let config = read_config(&config_path); + assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null()); + assert_eq!( + config["mcpServers"][MCP_SERVER_NAME]["args"][0], + "/test/index.js" + ); + } + + #[test] + fn upsert_preserves_other_servers() { + let (_tmp, config_path) = temp_config_path("mcp.json"); + write_mcp_servers_config( + &config_path, + vec![( + "other-server", + serde_json::json!({ "command": "other", "args": [] }), + )], + ); + + let entry = test_mcp_entry("/test/index.js"); + upsert_mcp_config(&config_path, &entry).unwrap(); + + let raw = std::fs::read_to_string(&config_path).unwrap(); + let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(config["mcpServers"]["other-server"].is_object()); + assert!(config["mcpServers"][MCP_SERVER_NAME].is_object()); + } + + #[test] + fn upsert_preserves_other_top_level_settings() { + let (_tmp, config_path) = temp_config_path(".claude.json"); + write_config_json( + &config_path, + serde_json::json!({ + "model": "sonnet", + "theme": "dark", + "mcpServers": { + "other-server": { "command": "other", "args": [] } + } + }), + ); + + let entry = test_mcp_entry("/test/index.js"); + upsert_mcp_config(&config_path, &entry).unwrap(); + + let config = read_config(&config_path); + assert_eq!( + ( + config["model"].as_str(), + config["theme"].as_str(), + config["mcpServers"]["other-server"].is_object(), + config["mcpServers"][MCP_SERVER_NAME].is_object(), + ), + (Some("sonnet"), Some("dark"), true, true) + ); + } + + #[test] + fn upsert_preserves_antigravity_mcp_config_fields() { + let (_tmp, config_path) = temp_config_path("mcp_config.json"); + write_config_json( + &config_path, + serde_json::json!({ + "theme": "GitHub", + "mcpServers": { + "other": { "command": "example" } + } + }), + ); + let entry = test_mcp_entry("/antigravity/index.js"); + + let was_update = upsert_mcp_config(&config_path, &entry).unwrap(); + let config = read_config(&config_path); + + assert!(!was_update); + assert_eq!(config["theme"], "GitHub"); + assert_eq!(config["mcpServers"]["other"]["command"], "example"); + assert_registered_tolaria_server( + &config, + ExpectedMcpServer { + index_js: "/antigravity/index.js", + }, + ); + } + + #[test] + fn upsert_creates_parent_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("nested").join("dir").join("mcp.json"); + let entry = test_mcp_entry("/test/index.js"); + + upsert_mcp_config(&config_path, &entry).unwrap(); + assert!(config_path.exists()); + } + + #[test] + fn register_mcp_to_configs_returns_registered_for_new() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("claude").join("mcp.json"); + let entry = test_mcp_entry("/test/index.js"); + + let status = register_mcp_to_configs(&entry, &[config]); + assert_eq!(status, "registered"); + } + + #[test] + fn register_mcp_to_configs_returns_updated_for_existing() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("mcp.json"); + let entry = test_mcp_entry("/test/index.js"); + + // First call + register_mcp_to_configs(&entry, std::slice::from_ref(&config)); + // Second call + let status = register_mcp_to_configs(&entry, &[config]); + assert_eq!(status, "updated"); + } + + #[test] + fn mcp_server_dir_resolves_in_dev() { + let dir = mcp_server_dir().unwrap(); + assert!(dir.join("ws-bridge.js").exists()); + assert!(dir.join("index.js").exists()); + assert!(dir.join("vault.js").exists()); + } + + #[test] + fn spawn_ws_bridge_starts_and_can_be_killed() { + let tmp = tempfile::tempdir().unwrap(); + let vault_path = tmp.path().to_str().unwrap(); + + let mut child = spawn_ws_bridge(vault_path).unwrap(); + assert!(child.id() > 0, "child process should have a valid PID"); + + // Clean up: kill the spawned process + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[test] + fn register_mcp_to_configs_writes_multiple_configs() { + let tmp = tempfile::tempdir().unwrap(); + let claude_user_cfg = tmp.path().join(".claude.json"); + let claude_cfg = tmp.path().join("claude").join("mcp.json"); + let antigravity_cfg = tmp + .path() + .join(".gemini") + .join("config") + .join("mcp_config.json"); + let cursor_cfg = tmp.path().join("cursor").join("mcp.json"); + let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json"); + let entry = test_mcp_entry("/test/index.js"); + + register_mcp_to_configs( + &entry, + &[ + claude_user_cfg.clone(), + claude_cfg.clone(), + antigravity_cfg.clone(), + cursor_cfg.clone(), + generic_cfg.clone(), + ], + ); + let config_paths = [ + &claude_user_cfg, + &claude_cfg, + &antigravity_cfg, + &cursor_cfg, + &generic_cfg, + ]; + + assert!(config_paths.iter().all(|config_path| config_path.exists())); + + let raw = std::fs::read_to_string(&claude_user_cfg).unwrap(); + let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_registered_tolaria_server( + &config, + ExpectedMcpServer { + index_js: "/test/index.js", + }, + ); + } + + #[test] + fn mcp_config_paths_for_home_includes_all_supported_config_paths() { + let home = Path::new("/Users/tester"); + let paths = mcp_config_paths_for_home(home); + + assert_eq!( + paths, + vec![ + home.join(".claude.json"), + home.join(".claude").join("mcp.json"), + home.join(".gemini").join("config").join("mcp_config.json"), + home.join(".cursor").join("mcp.json"), + home.join(".config").join("mcp").join("mcp.json"), + ] + ); + } + + #[test] + fn legacy_gemini_mcp_config_paths_for_home_points_to_settings_json() { + let home = Path::new("/Users/tester"); + let paths = legacy_gemini_mcp_config_paths_for_home(home); + + assert_eq!(paths, vec![home.join(".gemini").join("settings.json")]); + } + + #[test] + fn upsert_returns_error_for_invalid_json() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + std::fs::write(&config_path, "not valid json{{{{").unwrap(); + let entry = test_mcp_entry("/test/index.js"); + let result = upsert_mcp_config(&config_path, &entry); + assert!(result.is_err()); + } + + #[test] + fn register_mcp_to_configs_handles_empty_list() { + let entry = test_mcp_entry("/test/index.js"); + // Empty config list — function should return "registered" (no existing) + let status = register_mcp_to_configs(&entry, &[]); + // With empty config list, there were no updates, so status should be "registered" + assert_eq!(status, "registered"); + } + + #[test] + fn read_registered_mcp_entry_prefers_primary_then_uses_legacy_server_name() { + let (_tmp, config_path) = temp_config_path("mcp.json"); + write_mcp_servers_config( + &config_path, + vec![ + (MCP_SERVER_NAME, managed_server("/primary/index.js")), + (LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js")), + ], + ); + + let entry = read_registered_mcp_entry(&config_path).unwrap(); + assert_eq!(entry["args"][0], "/primary/index.js"); + + write_mcp_servers_config( + &config_path, + vec![(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js"))], + ); + let entry = read_registered_mcp_entry(&config_path).unwrap(); + assert_eq!(entry["args"][0], "/legacy/index.js"); + } + + #[test] + fn read_registered_mcp_entry_returns_none_for_invalid_or_missing_servers() { + let tmp = tempfile::tempdir().unwrap(); + let invalid_path = tmp.path().join("invalid.json"); + std::fs::write(&invalid_path, "{not json").unwrap(); + assert!(read_registered_mcp_entry(&invalid_path).is_none()); + + let empty_path = tmp.path().join("empty.json"); + let empty_config = serde_json::json!({ "other": {} }); + std::fs::write(&empty_path, serde_json::to_string(&empty_config).unwrap()).unwrap(); + assert!(read_registered_mcp_entry(&empty_path).is_none()); + + let missing_path = tmp.path().join("missing.json"); + assert!(read_registered_mcp_entry(&missing_path).is_none()); + } + + #[test] + fn entry_index_js_exists_requires_existing_first_arg() { + let tmp = tempfile::tempdir().unwrap(); + let index_js = tmp.path().join("index.js"); + std::fs::write(&index_js, "console.log('ok');").unwrap(); + + let existing = serde_json::json!({ + "args": [index_js.to_string_lossy()] + }); + assert!(entry_index_js_exists(&existing)); + + let missing = serde_json::json!({ + "args": [tmp.path().join("missing.js").to_string_lossy()] + }); + assert!(!entry_index_js_exists(&missing)); + + let no_args = serde_json::json!({}); + assert!(!entry_index_js_exists(&no_args)); + } + + #[test] + fn upsert_returns_error_for_non_object_config() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + std::fs::write(&config_path, "[]").unwrap(); + + let entry = test_mcp_entry("/test/index.js"); + let result = upsert_mcp_config(&config_path, &entry); + assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object"))); + } + + #[test] + fn upsert_returns_error_for_non_object_mcp_servers() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + let config = serde_json::json!({ + "mcpServers": [] + }); + std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + + let entry = test_mcp_entry("/test/index.js"); + let result = upsert_mcp_config(&config_path, &entry); + assert!( + matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object")) + ); + } + + #[test] + fn remove_mcp_from_config_removes_primary_and_legacy_entries() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + let config = serde_json::json!({ + "mcpServers": { + "tolaria": { "command": "node", "args": ["/index.js"] }, + "laputa": { "command": "node", "args": ["/legacy.js"] }, + "other-server": { "command": "other", "args": [] } + } + }); + std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + + let removed = remove_mcp_from_config(&config_path).unwrap(); + assert!(removed); + + let updated = read_config(&config_path); + assert!(updated["mcpServers"][MCP_SERVER_NAME].is_null()); + assert!(updated["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null()); + assert!(updated["mcpServers"]["other-server"].is_object()); + } + + #[test] + fn remove_mcp_from_configs_removes_legacy_gemini_settings_entry() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join(".gemini").join("settings.json"); + let config = serde_json::json!({ + "preferredEditor": "vim", + "mcpServers": { + "tolaria": { "command": "node", "args": ["/index.js"] } + } + }); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + + let status = remove_mcp_from_configs(std::slice::from_ref(&config_path)); + let updated = read_config(&config_path); + + assert_eq!(status, "removed"); + assert_eq!(updated["preferredEditor"], "vim"); + assert!(updated["mcpServers"].is_null()); + } + + #[test] + fn remove_mcp_from_config_returns_false_when_entry_missing() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + let config = serde_json::json!({ + "mcpServers": { + "other-server": { "command": "other", "args": [] } + } + }); + std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + + let removed = remove_mcp_from_config(&config_path).unwrap(); + assert!(!removed); + } + + #[test] + fn durable_entry_is_status_eligible_without_vault_env() { + let tmp = tempfile::tempdir().unwrap(); + let index_js = write_index_js(tmp.path()); + let config_path = tmp.path().join("mcp.json"); + let config = serde_json::json!({ + "mcpServers": { + "tolaria": { + "type": "stdio", + "command": "node", + "args": [index_js.to_string_lossy()], + "env": { "WS_UI_PORT": "9711" } + } + } + }); + std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + + let entry = read_registered_mcp_entry(&config_path).unwrap(); + assert!(entry_uses_stdio(&entry)); + assert!(entry_index_js_exists(&entry)); + assert!(entry_has_ui_port(&entry)); + } + + #[test] + fn mcp_status_serializes_to_snake_case() { + let json = serde_json::to_string(&McpStatus::Installed).unwrap(); + assert_eq!(json, r#""installed""#); + let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap(); + assert_eq!(json, r#""not_installed""#); + } +} diff --git a/src-tauri/src/mcp/extraction.rs b/src-tauri/src/mcp/extraction.rs new file mode 100644 index 0000000..bdfc608 --- /dev/null +++ b/src-tauri/src/mcp/extraction.rs @@ -0,0 +1,303 @@ +use std::fs; +#[cfg(all(desktop, target_os = "linux"))] +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +#[cfg(all(desktop, target_os = "linux"))] +use std::time::{Duration, Instant, SystemTime}; + +const VERSION_MARKER_FILE: &str = ".tolaria-version"; +#[cfg(all(desktop, target_os = "linux"))] +const LOCK_FILE: &str = "mcp-server.lock"; +const STAGING_DIR: &str = "mcp-server.staging"; +const BACKUP_DIR: &str = "mcp-server.previous"; +#[cfg(all(desktop, target_os = "linux"))] +const LOCK_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(all(desktop, target_os = "linux"))] +const STALE_LOCK_AFTER: Duration = Duration::from_secs(120); + +#[cfg(all(desktop, target_os = "linux"))] +pub(super) fn ready_stable_mcp_server_dir() -> Option { + let stable_dir = stable_mcp_server_dir().ok()?; + stable_mcp_server_dir_is_ready(&stable_dir).then_some(stable_dir) +} + +#[cfg(all(desktop, target_os = "linux"))] +pub(crate) fn extract_mcp_server_to_stable_dir(app_version: &str) -> Result { + let source_dir = super::mcp_server_dir()?; + let target_dir = stable_mcp_server_dir()?; + + if !needs_extraction(app_version, &target_dir) { + return Ok(target_dir); + } + + let _lock = ExtractionLock::acquire(&extraction_lock_path()?)?; + if !needs_extraction(app_version, &target_dir) { + return Ok(target_dir); + } + + replace_stable_server_dir(&source_dir, &target_dir, app_version)?; + Ok(target_dir) +} + +fn stable_mcp_server_dir() -> Result { + dirs::data_dir() + .map(|data_dir| data_dir.join("tolaria").join("mcp-server")) + .ok_or_else(|| "Unable to resolve data directory for stable MCP server path".to_string()) +} + +fn stable_mcp_server_dir_is_ready(dir: &Path) -> bool { + mcp_server_dir_has_files(dir) && read_version_marker(dir).is_some() +} + +fn mcp_server_dir_has_files(dir: &Path) -> bool { + dir.join("index.js").is_file() && dir.join("ws-bridge.js").is_file() +} + +fn needs_extraction(app_version: &str, target_dir: &Path) -> bool { + !mcp_server_dir_has_files(target_dir) + || read_version_marker(target_dir).as_deref() != Some(app_version) +} + +fn read_version_marker(dir: &Path) -> Option { + fs::read_to_string(dir.join(VERSION_MARKER_FILE)) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn write_version_marker(dir: &Path, app_version: &str) -> Result<(), String> { + let marker = dir.join(VERSION_MARKER_FILE); + fs::write(&marker, app_version) + .map_err(|e| format!("Failed to write version marker {}: {e}", marker.display())) +} + +#[cfg(all(desktop, target_os = "linux"))] +fn extraction_lock_path() -> Result { + let stable_dir = stable_mcp_server_dir()?; + stable_dir + .parent() + .map(|parent| parent.join(LOCK_FILE)) + .ok_or_else(|| { + format!( + "Stable MCP server path has no parent: {}", + stable_dir.display() + ) + }) +} + +fn replace_stable_server_dir( + source_dir: &Path, + target_dir: &Path, + app_version: &str, +) -> Result<(), String> { + let parent = target_dir.parent().ok_or_else(|| { + format!( + "Stable MCP server path has no parent: {}", + target_dir.display() + ) + })?; + let staging_dir = parent.join(STAGING_DIR); + let backup_dir = parent.join(BACKUP_DIR); + + remove_dir_if_exists(&staging_dir)?; + remove_dir_if_exists(&backup_dir)?; + copy_dir_all(source_dir, &staging_dir)?; + write_version_marker(&staging_dir, app_version)?; + swap_staging_into_place(&staging_dir, target_dir, &backup_dir)?; + Ok(()) +} + +fn swap_staging_into_place( + staging_dir: &Path, + target_dir: &Path, + backup_dir: &Path, +) -> Result<(), String> { + if target_dir.exists() { + fs::rename(target_dir, backup_dir) + .map_err(|e| format!("Failed to move stable MCP server aside: {e}"))?; + } + + if let Err(error) = fs::rename(staging_dir, target_dir) { + if backup_dir.exists() { + let _ = fs::rename(backup_dir, target_dir); + } + return Err(format!("Failed to activate stable MCP server: {error}")); + } + + remove_dir_if_exists(backup_dir) +} + +fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> { + fs::create_dir_all(target) + .map_err(|e| format!("Failed to create {}: {e}", target.display()))?; + + for entry in fs::read_dir(source).map_err(|e| { + format!( + "Failed to read MCP server directory {}: {e}", + source.display() + ) + })? { + let entry = entry.map_err(|e| format!("Failed to read MCP server entry: {e}"))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_all(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path).map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + source_path.display(), + target_path.display() + ) + })?; + } + } + + Ok(()) +} + +fn remove_dir_if_exists(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_dir_all(path) + .map_err(|e| format!("Failed to remove {}: {e}", path.display()))?; + } + Ok(()) +} + +#[cfg(all(desktop, target_os = "linux"))] +struct ExtractionLock { + path: PathBuf, +} + +#[cfg(all(desktop, target_os = "linux"))] +impl ExtractionLock { + fn acquire(path: &Path) -> Result { + let started = Instant::now(); + loop { + match Self::try_create(path) { + Ok(()) => { + return Ok(Self { + path: path.to_path_buf(), + }); + } + Err(error) if lock_is_stale(path) => { + let _ = fs::remove_file(path); + log::warn!("Removed stale MCP extraction lock after error: {error}"); + } + Err(error) if started.elapsed() >= LOCK_TIMEOUT => return Err(error), + Err(_) => std::thread::sleep(Duration::from_millis(50)), + } + } + } + + fn try_create(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create MCP extraction lock dir: {e}"))?; + } + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| format!("Failed to acquire MCP extraction lock: {e}"))?; + use std::io::Write; + writeln!(file, "{}", std::process::id()) + .map_err(|e| format!("Failed to write MCP extraction lock: {e}")) + } +} + +#[cfg(all(desktop, target_os = "linux"))] +impl Drop for ExtractionLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(all(desktop, target_os = "linux"))] +fn lock_is_stale(path: &Path) -> bool { + path.metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= STALE_LOCK_AFTER) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_server_dir(parent: &Path) -> PathBuf { + let dir = parent.join("server"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("index.js"), "console.log('index');").unwrap(); + fs::write(dir.join("ws-bridge.js"), "console.log('bridge');").unwrap(); + dir + } + + #[test] + fn stable_mcp_server_dir_uses_app_data_dir() { + let expected = dirs::data_dir() + .expect("data dir should exist") + .join("tolaria") + .join("mcp-server"); + + assert_eq!(stable_mcp_server_dir().unwrap(), expected); + } + + #[test] + fn stable_mcp_server_dir_requires_marker_and_files() { + let tmp = tempfile::tempdir().unwrap(); + let stable_dir = tmp.path().join("tolaria").join("mcp-server"); + + fs::create_dir_all(&stable_dir).unwrap(); + fs::write(stable_dir.join("index.js"), "").unwrap(); + fs::write(stable_dir.join("ws-bridge.js"), "").unwrap(); + assert!(!stable_mcp_server_dir_is_ready(&stable_dir)); + + write_version_marker(&stable_dir, "2026.5.14").unwrap(); + assert!(stable_mcp_server_dir_is_ready(&stable_dir)); + } + + #[test] + fn needs_extraction_tracks_version_marker() { + let target = tempfile::tempdir().unwrap(); + fs::write(target.path().join("index.js"), "").unwrap(); + fs::write(target.path().join("ws-bridge.js"), "").unwrap(); + + assert!(needs_extraction("2026.5.14", target.path())); + write_version_marker(target.path(), "2026.5.14").unwrap(); + assert!(!needs_extraction("2026.5.14", target.path())); + assert!(needs_extraction("2026.5.15", target.path())); + } + + #[test] + fn copy_dir_all_copies_nested_server_files() { + let tmp = tempfile::tempdir().unwrap(); + let source = create_server_dir(tmp.path()); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::write(source.join("nested").join("package.json"), "{}").unwrap(); + let target = tmp.path().join("target"); + + copy_dir_all(&source, &target).unwrap(); + + assert!(target.join("index.js").is_file()); + assert!(target.join("ws-bridge.js").is_file()); + assert!(target.join("nested").join("package.json").is_file()); + } + + #[test] + fn replace_stable_server_dir_swaps_versioned_copy() { + let tmp = tempfile::tempdir().unwrap(); + let source = create_server_dir(tmp.path()); + let target = tmp.path().join("tolaria").join("mcp-server"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("stale.txt"), "old").unwrap(); + + replace_stable_server_dir(&source, &target, "2026.5.14").unwrap(); + + assert!(target.join("index.js").is_file()); + assert!(!target.join("stale.txt").exists()); + assert_eq!(read_version_marker(&target), Some("2026.5.14".to_string())); + } +} diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs new file mode 100644 index 0000000..0a4957d --- /dev/null +++ b/src-tauri/src/mcp/opencode.rs @@ -0,0 +1,278 @@ +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value}; + +use super::{LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME}; + +const OPENCODE_MCP_KEY: &str = "mcp"; + +pub(super) fn config_path() -> Option { + dirs::config_dir().map(|config_dir| config_dir.join("opencode").join("opencode.json")) +} + +pub(super) fn build_entry(node_command: &str, index_js: &str) -> Value { + serde_json::json!({ + "type": "local", + "command": [node_command, index_js], + "enabled": true, + "environment": { + "WS_UI_PORT": "9711" + } + }) +} + +pub(super) fn build_config_snippet(entry: &Value) -> Result { + let mut servers = Map::new(); + servers.insert(MCP_SERVER_NAME.to_string(), entry.clone()); + let config = serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + OPENCODE_MCP_KEY: servers + }); + + serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize OpenCode MCP config snippet: {e}")) +} + +pub(super) fn upsert_config(config_path: &Path, entry: &Value) -> Result { + let mut config = read_config_or_empty(config_path)?; + let servers = ensure_servers_object(&mut config)?; + let was_update = + servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some(); + + servers.remove(LEGACY_MCP_SERVER_NAME); + servers.insert(MCP_SERVER_NAME.to_string(), entry.clone()); + write_config(config_path, &config)?; + Ok(was_update) +} + +pub(super) fn remove_config(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(false); + } + + let mut config = read_config_or_empty(config_path)?; + let Some(config_object) = config.as_object_mut() else { + return Err("Config is not a JSON object".into()); + }; + let Some(servers_value) = config_object.get_mut(OPENCODE_MCP_KEY) else { + return Ok(false); + }; + let Some(servers) = servers_value.as_object_mut() else { + return Err("mcp is not a JSON object".into()); + }; + + let removed_primary = servers.remove(MCP_SERVER_NAME).is_some(); + let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some(); + if !removed_primary && !removed_legacy { + return Ok(false); + } + + if servers.is_empty() { + config_object.remove(OPENCODE_MCP_KEY); + } + write_config(config_path, &config)?; + Ok(true) +} + +pub(super) fn read_registered_entry(config_path: &Path) -> Option { + let raw = std::fs::read_to_string(config_path).ok()?; + let config: Value = serde_json::from_str(&raw).ok()?; + config + .get(OPENCODE_MCP_KEY) + .and_then(Value::as_object) + .and_then(|servers| { + servers + .get(MCP_SERVER_NAME) + .or_else(|| servers.get(LEGACY_MCP_SERVER_NAME)) + }) + .cloned() +} + +pub(super) fn entry_is_installed(entry: &Value) -> bool { + entry["type"].as_str() == Some("local") + && entry["enabled"].as_bool() == Some(true) + && entry["environment"]["WS_UI_PORT"].as_str() == Some("9711") + && command_index_js_exists(entry) +} + +fn command_index_js_exists(entry: &Value) -> bool { + entry["command"] + .as_array() + .and_then(|command| command.get(1)) + .and_then(Value::as_str) + .is_some_and(|index_js| Path::new(index_js).exists()) +} + +fn read_config_or_empty(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(serde_json::json!({})); + } + + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?; + serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display())) +} + +fn ensure_servers_object(config: &mut Value) -> Result<&mut Map, String> { + let servers = config + .as_object_mut() + .ok_or("Config is not a JSON object")? + .entry(OPENCODE_MCP_KEY) + .or_insert_with(|| serde_json::json!({})); + + servers + .as_object_mut() + .ok_or_else(|| "mcp is not a JSON object".to_string()) +} + +fn write_config(config_path: &Path, config: &Value) -> Result<(), String> { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; + } + + let json = serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(config_path, json) + .map_err(|e| format!("Cannot write {}: {e}", config_path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_config(config_path: &Path) -> Value { + let raw = std::fs::read_to_string(config_path).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + fn write_config_json(config_path: &Path, config: Value) { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap(); + } + + #[test] + fn build_entry_uses_opencode_schema_without_vault_path() { + let entry = build_entry("node", "/app/mcp-server/index.js"); + + assert_eq!( + entry, + serde_json::json!({ + "type": "local", + "command": ["node", "/app/mcp-server/index.js"], + "enabled": true, + "environment": { + "WS_UI_PORT": "9711" + } + }) + ); + assert!(entry["environment"]["VAULT_PATH"].is_null()); + } + + #[test] + fn build_config_snippet_wraps_tolaria_entry_in_opencode_schema() { + let snippet = + build_config_snippet(&build_entry("node", "/app/mcp-server/index.js")).unwrap(); + let config: Value = serde_json::from_str(&snippet).unwrap(); + + assert_eq!( + config, + serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "tolaria": { + "type": "local", + "command": ["node", "/app/mcp-server/index.js"], + "enabled": true, + "environment": { + "WS_UI_PORT": "9711" + } + } + } + }) + ); + assert!(config["mcpServers"].is_null()); + } + + #[test] + fn upsert_config_preserves_other_opencode_settings() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "other": { "type": "local" } + } + }), + ); + + let was_update = upsert_config(&config_path, &build_entry("node", "/index.js")).unwrap(); + let config = read_config(&config_path); + + assert!(!was_update); + assert_eq!(config["$schema"], "https://opencode.ai/config.json"); + assert!(config["mcp"]["other"].is_object()); + assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/index.js"); + } + + #[test] + fn upsert_config_migrates_legacy_server_name() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "mcp": { + "laputa": { "type": "local", "command": ["node", "/old.js"] } + } + }), + ); + + let was_update = upsert_config(&config_path, &build_entry("node", "/new.js")).unwrap(); + let config = read_config(&config_path); + + assert!(was_update); + assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null()); + assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/new.js"); + } + + #[test] + fn remove_config_removes_primary_and_legacy_entries() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("opencode.json"); + write_config_json( + &config_path, + serde_json::json!({ + "mcp": { + "tolaria": { "type": "local" }, + "laputa": { "type": "local" }, + "other": { "type": "local" } + } + }), + ); + + assert!(remove_config(&config_path).unwrap()); + let config = read_config(&config_path); + assert!(config["mcp"][MCP_SERVER_NAME].is_null()); + assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null()); + assert!(config["mcp"]["other"].is_object()); + } + + #[test] + fn entry_is_installed_checks_opencode_shape_and_index_path() { + let tmp = tempfile::tempdir().unwrap(); + let index_js = tmp.path().join("index.js"); + std::fs::write(&index_js, "").unwrap(); + + let entry = build_entry("node", &index_js.to_string_lossy()); + assert!(entry_is_installed(&entry)); + + let missing = build_entry("node", &tmp.path().join("missing.js").to_string_lossy()); + assert!(!entry_is_installed(&missing)); + } +} diff --git a/src-tauri/src/mcp/paths.rs b/src-tauri/src/mcp/paths.rs new file mode 100644 index 0000000..f3d4b0a --- /dev/null +++ b/src-tauri/src/mcp/paths.rs @@ -0,0 +1,169 @@ +use std::borrow::Cow; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(super) fn runtime_resource_roots() -> Vec { + let local_app_data = if cfg!(windows) { + non_empty_env_path("LOCALAPPDATA") + } else { + None + }; + let current_exe = std::env::current_exe().ok(); + + runtime_resource_roots_for_env_and_exe( + non_empty_env_path("RESOURCEPATH"), + non_empty_env_path("APPDIR"), + local_app_data, + current_exe.as_deref(), + ) +} + +fn runtime_resource_roots_for_env_and_exe( + resource_path: Option, + appdir: Option, + local_app_data: Option, + current_exe: Option<&Path>, +) -> Vec { + let mut roots = Vec::new(); + + if let Some(resource_path) = resource_path { + push_resource_root(&mut roots, resource_path); + } + if let Some(current_exe) = current_exe { + push_current_exe_resource_roots(&mut roots, current_exe); + } + if let Some(appdir) = appdir { + push_resource_root(&mut roots, appdir.join("usr")); + push_resource_root(&mut roots, appdir.join("usr/lib/tolaria")); + push_resource_root(&mut roots, appdir.join("usr/lib/Tolaria")); + } + if let Some(local_app_data) = local_app_data { + push_resource_root(&mut roots, local_app_data.join("Tolaria")); + push_resource_root(&mut roots, local_app_data.join("tolaria")); + } + + roots +} + +fn push_current_exe_resource_roots(roots: &mut Vec, current_exe: &Path) { + let Some(exe_dir) = current_exe.parent() else { + return; + }; + + push_resource_root(roots, exe_dir.to_path_buf()); + push_resource_root(roots, exe_dir.join("resources")); + if let Some(resource_dir) = macos_app_resources_dir(current_exe) { + push_resource_root(roots, resource_dir); + } +} + +fn macos_app_resources_dir(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + if macos_dir.file_name() != Some(OsStr::new("MacOS")) { + return None; + } + + let contents_dir = macos_dir.parent()?; + if contents_dir.file_name() != Some(OsStr::new("Contents")) { + return None; + } + + let app_dir = contents_dir.parent()?; + if app_dir.extension() != Some(OsStr::new("app")) { + return None; + } + + Some(contents_dir.join("Resources")) +} + +fn push_resource_root(roots: &mut Vec, root: PathBuf) { + if !root.as_os_str().is_empty() && !roots.iter().any(|candidate| candidate == &root) { + roots.push(root); + } +} + +fn non_empty_env_path(key: &str) -> Option { + std::env::var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +pub(super) fn client_script_path(path: &Path) -> String { + strip_windows_verbatim_prefix(&path.to_string_lossy()).into_owned() +} + +fn strip_windows_verbatim_prefix(path: &str) -> Cow<'_, str> { + const VERBATIM_PREFIX: &str = r"\\?\"; + const VERBATIM_UNC_PREFIX: &str = r"\\?\UNC\"; + + if let Some(rest) = path.strip_prefix(VERBATIM_UNC_PREFIX) { + return Cow::Owned(format!(r"\\{rest}")); + } + + path.strip_prefix(VERBATIM_PREFIX) + .map(Cow::Borrowed) + .unwrap_or_else(|| Cow::Borrowed(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn includes_windows_install_locations() { + let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local"); + let install_dir = local_app_data.join("Tolaria"); + let roots = + runtime_resource_roots_for_env_and_exe(None, None, Some(local_app_data.clone()), None); + + assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1); + assert!(roots.contains(&local_app_data.join("tolaria"))); + + let candidates = + super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots); + assert!(candidates.contains(&install_dir.join("mcp-server"))); + } + + #[test] + fn includes_macos_app_bundle_resources_from_executable_path() { + let executable = PathBuf::from("/Applications/Tolaria.app/Contents/MacOS/Tolaria"); + let roots = runtime_resource_roots_for_env_and_exe(None, None, None, Some(&executable)); + + assert!(roots.contains(&PathBuf::from( + "/Applications/Tolaria.app/Contents/Resources" + ))); + + let candidates = + super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots); + assert!(candidates.contains(&PathBuf::from( + "/Applications/Tolaria.app/Contents/Resources/mcp-server" + ))); + } + + #[test] + fn client_script_path_strips_windows_extended_length_disk_prefix() { + let path = PathBuf::from(r"\\?\D:\Tolaria\mcp-server\index.js"); + + assert_eq!(client_script_path(&path), r"D:\Tolaria\mcp-server\index.js",); + } + + #[test] + fn client_script_path_strips_windows_extended_length_unc_prefix() { + let path = PathBuf::from(r"\\?\UNC\server\share\Tolaria\mcp-server\index.js"); + + assert_eq!( + client_script_path(&path), + r"\\server\share\Tolaria\mcp-server\index.js", + ); + } + + #[test] + fn client_script_path_preserves_normal_paths_with_spaces() { + let path = PathBuf::from(r"D:\Program Files\Tolaria\mcp-server\index.js"); + + assert_eq!( + client_script_path(&path), + r"D:\Program Files\Tolaria\mcp-server\index.js", + ); + } +} diff --git a/src-tauri/src/mcp/runtime.rs b/src-tauri/src/mcp/runtime.rs new file mode 100644 index 0000000..4948edb --- /dev/null +++ b/src-tauri/src/mcp/runtime.rs @@ -0,0 +1,512 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::subprocess; + +/// A resolved runtime that can execute the MCP server scripts. +#[derive(Debug, Clone)] +pub(crate) struct McpRuntime { + pub(crate) kind: McpRuntimeKind, + pub(crate) binary: PathBuf, +} + +/// Which JS runtime was selected for the MCP server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum McpRuntimeKind { + Node, + Bun, +} + +impl McpRuntimeKind { + fn binary_name(self) -> &'static str { + match self { + McpRuntimeKind::Node => node_binary_name(), + McpRuntimeKind::Bun => bun_binary_name(), + } + } +} + +/// Find any supported MCP runtime, preferring Node over Bun. +pub(crate) fn find_mcp_runtime() -> Result { + let mut last_error = None; + for kind in [McpRuntimeKind::Node, McpRuntimeKind::Bun] { + if let Some(binary) = try_runtime(kind, &mut last_error) { + return Ok(McpRuntime { kind, binary }); + } + } + Err(last_error.unwrap_or_else(|| { + "No supported MCP runtime found. Install Node.js 18+ or Bun 1+ and ensure it's on PATH." + .into() + })) +} + +/// Find the `node` binary specifically. Used by Codex/CLI agent shims that +/// require Node and cannot fall back to Bun. +pub(crate) fn find_node() -> Result { + let mut last_error = None; + if let Some(binary) = try_runtime(McpRuntimeKind::Node, &mut last_error) { + return Ok(binary); + } + Err(last_error.unwrap_or_else(|| { + format!( + "{} not found in PATH or common install locations", + McpRuntimeKind::Node.binary_name() + ) + })) +} + +fn try_runtime(kind: McpRuntimeKind, last_error: &mut Option) -> Option { + for path in runtime_binary_candidates(kind) { + match verify_runtime_version(kind, &path) { + Ok(()) => return Some(path), + Err(error) => *last_error = Some(error), + } + } + None +} + +fn runtime_binary_candidates(kind: McpRuntimeKind) -> Vec { + let command = kind.binary_name(); + let mut candidates = find_on_path(command); + candidates.extend(find_in_user_shell(command)); + candidates.extend(fallback_paths_for(kind)); + candidates +} + +fn fallback_paths_for(kind: McpRuntimeKind) -> Vec { + match kind { + McpRuntimeKind::Node => fallback_node_paths(), + McpRuntimeKind::Bun => fallback_bun_paths(), + } +} + +fn verify_runtime_version(kind: McpRuntimeKind, path: &Path) -> Result<(), String> { + match kind { + McpRuntimeKind::Node => verify_node_version(path), + McpRuntimeKind::Bun => verify_bun_version(path), + } +} + +fn find_on_path(command: &str) -> Vec { + lookup_command(command) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| lookup_paths(&output.stdout)) + .unwrap_or_default() +} + +fn find_in_user_shell(command: &str) -> Vec { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .filter_map(|shell| command_path_from_shell(&shell, command)) + .collect() +} + +fn lookup_paths(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect() +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + subprocess::command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) + }) +} + +fn verify_node_version(node: &Path) -> Result<(), String> { + let output = subprocess::command(node) + .arg("--version") + .output() + .map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?; + if !output.status.success() { + return Err(format!( + "{} --version failed; install Node.js 18+ and make it available on PATH", + node.display() + )); + } + + let raw_version = String::from_utf8_lossy(&output.stdout); + let Some(major) = node_major_version(&raw_version) else { + return Err(format!( + "Cannot parse Node.js version from '{}'", + raw_version.trim() + )); + }; + if major < 18 { + return Err(format!( + "Node.js 18+ is required for Tolaria MCP tools; found {}", + raw_version.trim() + )); + } + + Ok(()) +} + +fn node_major_version(version: &str) -> Option { + version + .trim() + .trim_start_matches('v') + .split('.') + .next() + .and_then(|major| major.parse().ok()) +} + +fn lookup_command(command: &str) -> Command { + #[cfg(windows)] + let mut cmd = subprocess::command("where.exe"); + #[cfg(not(windows))] + let mut cmd = subprocess::command("which"); + + cmd.arg(command); + cmd +} + +fn fallback_node_paths() -> Vec { + let mut candidates = vec![ + PathBuf::from("/opt/homebrew/bin/node"), + PathBuf::from("/usr/local/bin/node"), + ]; + + #[cfg(not(windows))] + candidates.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin/node")); + + #[cfg(windows)] + { + if let Some(program_files) = std::env::var_os("ProgramFiles") { + candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe")); + } + if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { + candidates.push( + PathBuf::from(program_files_x86) + .join("nodejs") + .join("node.exe"), + ); + } + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + candidates.push( + PathBuf::from(local_app_data) + .join("Programs") + .join("nodejs") + .join("node.exe"), + ); + } + } + + if let Some(home) = dirs::home_dir() { + candidates.extend(node_binary_candidates_for_home(&home)); + } + + candidates + .into_iter() + .filter(|path| path.is_file()) + .collect() +} + +fn node_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = vec![ + home.join(".local/share/mise/shims") + .join(node_binary_name()), + home.join(".mise").join("shims").join(node_binary_name()), + home.join(".asdf").join("shims").join(node_binary_name()), + home.join(".volta").join("bin").join(node_binary_name()), + home.join(".linuxbrew").join("bin").join(node_binary_name()), + ]; + + let nvm_dir = home.join(".nvm").join("versions").join("node"); + if let Ok(entries) = std::fs::read_dir(nvm_dir) { + let mut versions = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .collect::>(); + versions.sort(); + versions.reverse(); + candidates.extend( + versions + .into_iter() + .map(|version| version.join("bin").join("node")), + ); + } + + candidates +} + +fn node_binary_name() -> &'static str { + if cfg!(windows) { + "node.exe" + } else { + "node" + } +} + +fn fallback_bun_paths() -> Vec { + let mut candidates = vec![ + PathBuf::from("/opt/homebrew/bin/bun"), + PathBuf::from("/usr/local/bin/bun"), + ]; + + #[cfg(windows)] + { + if let Some(profile) = std::env::var_os("USERPROFILE") { + candidates.push( + PathBuf::from(profile) + .join(".bun") + .join("bin") + .join("bun.exe"), + ); + } + } + + if let Some(home) = dirs::home_dir() { + candidates.extend(bun_binary_candidates_for_home(&home)); + } + + candidates + .into_iter() + .filter(|path| path.is_file()) + .collect() +} + +fn bun_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".bun").join("bin").join(bun_binary_name()), + home.join(".local/share/mise/shims").join(bun_binary_name()), + home.join(".mise").join("shims").join(bun_binary_name()), + home.join(".asdf").join("shims").join(bun_binary_name()), + home.join(".proto").join("bin").join(bun_binary_name()), + ] +} + +fn bun_binary_name() -> &'static str { + if cfg!(windows) { + "bun.exe" + } else { + "bun" + } +} + +fn verify_bun_version(bun: &Path) -> Result<(), String> { + let output = subprocess::command(bun) + .arg("--version") + .output() + .map_err(|e| format!("Failed to run {} --version: {e}", bun.display()))?; + if !output.status.success() { + return Err(format!( + "{} --version failed; install Bun 1+ and make it available on PATH", + bun.display() + )); + } + + let raw_version = String::from_utf8_lossy(&output.stdout); + let Some(major) = node_major_version(&raw_version) else { + return Err(format!( + "Cannot parse Bun version from '{}'", + raw_version.trim() + )); + }; + if major < 1 { + return Err(format!( + "Bun 1+ is required for Tolaria MCP tools; found {}", + raw_version.trim() + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_candidates_include(candidates: &[PathBuf], expected: &[PathBuf]) { + for candidate in expected { + assert!( + candidates.contains(candidate), + "missing {}", + candidate.display() + ); + } + } + + fn assert_home_binary_candidates_include( + home: &Path, + candidates: &[PathBuf], + expected_relative_paths: &[&str], + ) { + let expected = expected_relative_paths + .iter() + .map(|relative| home.join(relative)) + .collect::>(); + assert_candidates_include(candidates, &expected); + } + + #[test] + fn lookup_paths_keep_non_empty_lines_in_order() { + let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n"; + assert_eq!( + lookup_paths(stdout), + vec![ + PathBuf::from("C:\\Program Files\\nodejs\\node.exe"), + PathBuf::from("C:\\Other\\node.exe"), + ] + ); + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-node"); + let node = dir.path().join("node"); + std::fs::write(&node, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), node.display()); + + assert_eq!(first_existing_path(&stdout), Some(node)); + } + + #[cfg(unix)] + #[test] + fn command_path_from_shell_finds_node_from_login_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let node = dir.path().join("node"); + std::fs::write(&node, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shell = dir.path().join("shell"); + std::fs::write( + &shell, + format!( + "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n", + node.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(command_path_from_shell(&shell, "node"), Some(node)); + } + + #[test] + fn node_major_version_accepts_current_node_output() { + assert_eq!(node_major_version("v24.13.1\n"), Some(24)); + assert_eq!(node_major_version("18.19.0"), Some(18)); + assert_eq!(node_major_version("not-node"), None); + } + + #[test] + fn home_binary_candidates_include_shell_managed_installs() { + let home = PathBuf::from("/Users/alex"); + let cases = [ + ( + node_binary_candidates_for_home(&home), + &[ + ".local/share/mise/shims/node", + ".asdf/shims/node", + ".volta/bin/node", + ".linuxbrew/bin/node", + ][..], + ), + ( + bun_binary_candidates_for_home(&home), + &[ + ".bun/bin/bun", + ".local/share/mise/shims/bun", + ".mise/shims/bun", + ".asdf/shims/bun", + ".proto/bin/bun", + ][..], + ), + ]; + + for (candidates, expected_paths) in cases { + assert_home_binary_candidates_include(&home, &candidates, expected_paths); + } + } + + #[test] + fn find_node_returns_valid_path() { + let node = find_node().unwrap(); + assert!(node.exists(), "node binary should exist at {:?}", node); + assert!( + node.to_string_lossy().contains("node"), + "path should contain 'node': {:?}", + node + ); + } + + #[test] + fn find_mcp_runtime_returns_valid_runtime() { + let runtime = find_mcp_runtime().unwrap(); + assert!( + runtime.binary.exists(), + "runtime binary should exist at {:?}", + runtime.binary + ); + let expected = match runtime.kind { + McpRuntimeKind::Node => "node", + McpRuntimeKind::Bun => "bun", + }; + assert!( + runtime.binary.to_string_lossy().contains(expected), + "path should contain '{expected}': {:?}", + runtime.binary + ); + } + + #[test] + fn verify_bun_version_accepts_real_bun_binary() { + let Ok(bun) = find_bun_for_test() else { + // Bun is optional on dev machines; skip when absent. + return; + }; + verify_bun_version(&bun).expect("installed bun should satisfy version requirement"); + } + + fn find_bun_for_test() -> Result { + let mut last_error = None; + if let Some(bin) = try_runtime(McpRuntimeKind::Bun, &mut last_error) { + return Ok(bin); + } + Err(last_error.unwrap_or_else(|| "bun not present in test environment".into())) + } +} diff --git a/src-tauri/src/mcp/subprocess.rs b/src-tauri/src/mcp/subprocess.rs new file mode 100644 index 0000000..8fd171c --- /dev/null +++ b/src-tauri/src/mcp/subprocess.rs @@ -0,0 +1,78 @@ +use std::ffi::OsStr; +use std::process::Command; + +#[cfg(any(test, all(desktop, target_os = "linux")))] +const APPIMAGE_ENV_REMOVALS: [&str; 3] = ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"]; + +pub(super) fn command(program: impl AsRef) -> Command { + let mut command = crate::hidden_command(program); + sanitize_appimage_env(&mut command); + command +} + +#[cfg(all(desktop, target_os = "linux"))] +fn sanitize_appimage_env(command: &mut Command) { + sanitize_appimage_env_for_launch(command, appimage_env_present()); +} + +#[cfg(not(all(desktop, target_os = "linux")))] +fn sanitize_appimage_env(_command: &mut Command) {} + +#[cfg(any(test, all(desktop, target_os = "linux")))] +fn sanitize_appimage_env_for_launch(command: &mut Command, is_appimage: bool) { + if !is_appimage { + return; + } + + for key in APPIMAGE_ENV_REMOVALS { + command.env_remove(key); + } +} + +#[cfg(all(desktop, target_os = "linux"))] +fn appimage_env_present() -> bool { + ["APPIMAGE", "APPDIR"] + .into_iter() + .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command_envs(command: &Command) -> std::collections::HashMap> { + command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().to_string(), + value.map(|entry| entry.to_string_lossy().to_string()), + ) + }) + .collect() + } + + #[test] + fn appimage_mcp_subprocesses_remove_loader_env() { + let mut command = crate::hidden_command("node"); + + sanitize_appimage_env_for_launch(&mut command, true); + + let envs = command_envs(&command); + for key in APPIMAGE_ENV_REMOVALS { + assert_eq!(envs.get(key), Some(&None)); + } + } + + #[test] + fn non_appimage_mcp_subprocesses_keep_parent_env_unmodified() { + let mut command = crate::hidden_command("node"); + + sanitize_appimage_env_for_launch(&mut command, false); + + let envs = command_envs(&command); + for key in APPIMAGE_ENV_REMOVALS { + assert!(!envs.contains_key(key)); + } + } +} diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs new file mode 100644 index 0000000..46b9850 --- /dev/null +++ b/src-tauri/src/menu.rs @@ -0,0 +1,683 @@ +#[cfg(not(target_os = "macos"))] +use crate::window_state::MAIN_WINDOW_LABEL; +use serde::{Deserialize, Deserializer}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashSet}, + error::Error, + sync::OnceLock, +}; +#[cfg(not(target_os = "macos"))] +use tauri::{menu::MenuEvent, Manager}; +use tauri::{ + menu::{ + MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder, + WINDOW_SUBMENU_ID, + }, + App, AppHandle, Emitter, +}; + +const APP_COMMAND_MANIFEST_JSON: &str = include_str!("../../src/shared/appCommandManifest.json"); +const NOTE_DEPENDENT_GROUP: &str = "noteDependent"; +const EDITOR_FIND_DEPENDENT_GROUP: &str = "editorFindDependent"; +const NOTE_LIST_SEARCH_DEPENDENT_GROUP: &str = "noteListSearchDependent"; +const RESTORE_DELETED_DEPENDENT_GROUP: &str = "restoreDeletedDependent"; +const GIT_COMMIT_DEPENDENT_GROUP: &str = "gitCommitDependent"; +const GIT_CONFLICT_DEPENDENT_GROUP: &str = "gitConflictDependent"; +const GIT_NO_REMOTE_DEPENDENT_GROUP: &str = "gitNoRemoteDependent"; + +type MenuResult = Result, Box>; +type AppSubmenuBuilder<'a> = SubmenuBuilder<'a, tauri::Wry, App>; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppCommandManifest { + commands: BTreeMap, + menus: Vec, + app_menu: Vec, + menu_state_groups: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct ManifestCommand { + id: String, + shortcut: Option, +} + +#[derive(Debug, Deserialize)] +struct ManifestShortcut { + accelerator: String, +} + +#[derive(Debug, Deserialize)] +struct ManifestMenuSection { + label: String, + items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind")] +enum ManifestMenuItem { + #[serde(rename = "separator")] + Separator, + #[serde(rename = "command")] + Command { + command: String, + id: Option, + label: PlatformLabel, + #[serde(default, deserialize_with = "deserialize_accelerator")] + accelerator: ManifestAccelerator, + enabled: Option, + }, + #[serde(rename = "menu-event")] + MenuEvent { + id: String, + label: PlatformLabel, + #[serde(default, deserialize_with = "deserialize_accelerator")] + accelerator: ManifestAccelerator, + enabled: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PlatformLabel { + Plain(String), + Platform { + macos: Option, + windows: Option, + linux: Option, + default: String, + }, +} + +#[derive(Debug, Default)] +enum ManifestAccelerator { + #[default] + Inherit, + Suppressed, + Explicit(String), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum MenuStateGroupReference { + Command { command: String }, + Id { id: String }, +} + +fn deserialize_accelerator<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|accelerator| match accelerator { + Some(accelerator) => ManifestAccelerator::Explicit(accelerator), + None => ManifestAccelerator::Suppressed, + }) +} + +impl PlatformLabel { + fn resolve(&self, target_os: &str) -> &str { + match self { + Self::Plain(label) => label.as_str(), + Self::Platform { + macos, + windows, + linux, + default, + } => match target_os { + "macos" => macos.as_deref().unwrap_or(default.as_str()), + "windows" => windows.as_deref().unwrap_or(default.as_str()), + "linux" => linux.as_deref().unwrap_or(default.as_str()), + _ => default.as_str(), + }, + } + } +} + +impl ManifestMenuItem { + fn command_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { command, .. } => manifest + .commands + .get(command) + .map(|command| command.id.as_str()), + Self::MenuEvent { id, .. } => Some(id.as_str()), + Self::Separator => None, + } + } + + fn menu_item_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { command, id, .. } => id.as_deref().or_else(|| { + manifest + .commands + .get(command) + .map(|command| command.id.as_str()) + }), + Self::MenuEvent { id, .. } => Some(id.as_str()), + Self::Separator => None, + } + } + + fn label(&self, target_os: &str) -> Option<&str> { + match self { + Self::Command { label, .. } | Self::MenuEvent { label, .. } => { + Some(label.resolve(target_os)) + } + Self::Separator => None, + } + } + + fn accelerator<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { + command, + accelerator, + .. + } => match accelerator { + ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()), + ManifestAccelerator::Suppressed => None, + ManifestAccelerator::Inherit => manifest + .commands + .get(command) + .and_then(|command| command.shortcut.as_ref()) + .map(|shortcut| shortcut.accelerator.as_str()), + }, + Self::MenuEvent { accelerator, .. } => match accelerator { + ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()), + ManifestAccelerator::Suppressed | ManifestAccelerator::Inherit => None, + }, + Self::Separator => None, + } + } + + fn enabled(&self) -> bool { + match self { + Self::Command { enabled, .. } | Self::MenuEvent { enabled, .. } => { + enabled.unwrap_or(true) + } + Self::Separator => true, + } + } +} + +static APP_COMMAND_MANIFEST: OnceLock = OnceLock::new(); +static CUSTOM_MENU_IDS: OnceLock> = OnceLock::new(); + +fn manifest() -> &'static AppCommandManifest { + APP_COMMAND_MANIFEST.get_or_init(|| { + serde_json::from_str(APP_COMMAND_MANIFEST_JSON) + .expect("shared app command manifest must be valid JSON") + }) +} + +fn manifest_menu_items() -> impl Iterator { + let manifest = manifest(); + manifest + .menus + .iter() + .flat_map(|section| section.items.iter()) + .chain(manifest.app_menu.iter()) +} + +fn custom_menu_ids() -> &'static HashSet { + CUSTOM_MENU_IDS.get_or_init(|| { + manifest_menu_items() + .filter_map(|item| item.menu_item_id(manifest())) + .map(str::to_owned) + .collect() + }) +} + +fn manifest_section(label: &str) -> Result<&'static ManifestMenuSection, Box> { + manifest() + .menus + .iter() + .find(|section| section.label == label) + .ok_or_else(|| format!("Missing menu section in command manifest: {label}").into()) +} + +fn app_menu_includes_services(target_os: &str) -> bool { + target_os == "macos" +} + +fn window_menu_event_handler_required(target_os: &str) -> bool { + target_os != "macos" +} + +fn native_window_menu_submenu_id(target_os: &str) -> Option<&'static str> { + if target_os == "macos" { + Some(WINDOW_SUBMENU_ID) + } else { + None + } +} + +fn native_menu_label(label: &str) -> Cow<'_, str> { + if label.contains('&') { + Cow::Owned(label.replace('&', "&&")) + } else { + Cow::Borrowed(label) + } +} + +fn build_manifest_menu_item( + app: &App, + item: &ManifestMenuItem, +) -> Result>, Box> { + let Some(id) = item.menu_item_id(manifest()) else { + return Ok(None); + }; + let Some(label) = item.label(std::env::consts::OS) else { + return Ok(None); + }; + let label = native_menu_label(label); + + let mut builder = MenuItemBuilder::new(label.as_ref()) + .id(id) + .enabled(item.enabled()); + if let Some(accelerator) = item.accelerator(manifest()) { + builder = builder.accelerator(accelerator); + } + Ok(Some(builder.build(app)?)) +} + +fn append_manifest_item<'a>( + app: &'a App, + builder: AppSubmenuBuilder<'a>, + item: &ManifestMenuItem, +) -> Result, Box> { + if matches!(item, ManifestMenuItem::Separator) { + return Ok(builder.separator()); + } + + let Some(item) = build_manifest_menu_item(app, item)? else { + return Ok(builder); + }; + Ok(builder.item(&item)) +} + +fn build_manifest_menu(app: &App, label: &str) -> MenuResult { + let section = manifest_section(label)?; + let mut builder = SubmenuBuilder::new(app, section.label.as_str()); + for item in §ion.items { + builder = append_manifest_item(app, builder, item)?; + } + Ok(builder.build()?) +} + +fn build_app_menu(app: &App) -> MenuResult { + let mut builder = SubmenuBuilder::new(app, "Tolaria").about(None).separator(); + + for item in &manifest().app_menu { + builder = append_manifest_item(app, builder, item)?; + } + + builder = builder.separator(); + + if app_menu_includes_services(std::env::consts::OS) { + builder = builder + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator(); + } + + Ok(builder.quit().build()?) +} + +fn build_file_menu(app: &App) -> MenuResult { + build_manifest_menu(app, "File") +} + +fn build_edit_menu(app: &App) -> MenuResult { + let section = manifest_section("Edit")?; + let mut items = section.items.iter(); + let mut builder = SubmenuBuilder::new(app, "Edit"); + + for item in items.by_ref() { + if matches!(item, ManifestMenuItem::Separator) { + break; + } + builder = append_manifest_item(app, builder, item)?; + } + + builder = builder.separator().cut().copy().paste(); + + if let Some(paste_plain_text) = items.next() { + builder = append_manifest_item(app, builder, paste_plain_text)?; + } + + builder = builder.separator().select_all().separator(); + + if matches!(items.clone().next(), Some(ManifestMenuItem::Separator)) { + items.next(); + } + + for item in items { + builder = append_manifest_item(app, builder, item)?; + } + + Ok(builder.build()?) +} + +fn build_view_menu(app: &App) -> MenuResult { + build_manifest_menu(app, "View") +} + +fn build_go_menu(app: &App) -> MenuResult { + build_manifest_menu(app, "Go") +} + +fn build_note_menu(app: &App) -> MenuResult { + build_manifest_menu(app, "Note") +} + +fn build_vault_menu(app: &App) -> MenuResult { + build_manifest_menu(app, "Vault") +} + +fn build_window_menu(app: &App) -> MenuResult { + let mut builder = SubmenuBuilder::new(app, "Window"); + if let Some(id) = native_window_menu_submenu_id(std::env::consts::OS) { + builder = builder.id(id); + } + + Ok(builder + .minimize() + .maximize() + .separator() + .close_window() + .build()?) +} + +pub fn setup_menu(app: &App) -> Result<(), Box> { + let app_menu = build_app_menu(app)?; + let file_menu = build_file_menu(app)?; + let edit_menu = build_edit_menu(app)?; + let view_menu = build_view_menu(app)?; + let go_menu = build_go_menu(app)?; + let note_menu = build_note_menu(app)?; + let vault_menu = build_vault_menu(app)?; + let window_menu = build_window_menu(app)?; + + let menu = MenuBuilder::new(app) + .item(&app_menu) + .item(&file_menu) + .item(&edit_menu) + .item(&view_menu) + .item(&go_menu) + .item(¬e_menu) + .item(&vault_menu) + .item(&window_menu) + .build()?; + + app.set_menu(menu)?; + + app.on_menu_event(|app_handle, event| { + let id = event.id().0.as_str(); + let _ = emit_custom_menu_event(app_handle, id); + }); + + register_window_menu_event_handler(app)?; + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn register_window_menu_event_handler(app: &App) -> Result<(), Box> { + debug_assert!(window_menu_event_handler_required(std::env::consts::OS)); + let window = app.get_webview_window(MAIN_WINDOW_LABEL).ok_or_else(|| { + format!("setup_menu: window '{MAIN_WINDOW_LABEL}' not found; menu events will not fire") + })?; + let app_handle = app.handle().clone(); + window.on_menu_event(move |_window, event: MenuEvent| { + let id = event.id().0.as_str(); + let _ = emit_custom_menu_event(&app_handle, id); + }); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn register_window_menu_event_handler(_app: &App) -> Result<(), Box> { + debug_assert!(!window_menu_event_handler_required(std::env::consts::OS)); + Ok(()) +} + +fn emitted_menu_event_id(id: &str) -> Option<&'static str> { + manifest_menu_items().find_map(|item| { + if item.menu_item_id(manifest()) == Some(id) { + item.command_id(manifest()) + } else { + None + } + }) +} + +pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> { + if !custom_menu_ids().contains(id) { + return Err(format!("Unknown custom menu event: {id}")); + } + let emitted_id = emitted_menu_event_id(id) + .ok_or_else(|| format!("Missing emitted command for custom menu event: {id}"))?; + app_handle + .emit("menu-event", emitted_id) + .map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}")) +} + +fn menu_state_group_ids(group_name: &str) -> Vec<&'static str> { + manifest() + .menu_state_groups + .get(group_name) + .into_iter() + .flatten() + .filter_map(|reference| match reference { + MenuStateGroupReference::Command { command } => manifest() + .commands + .get(command) + .map(|command| command.id.as_str()), + MenuStateGroupReference::Id { id } => Some(id.as_str()), + }) + .collect() +} + +fn set_items_enabled<'a>( + app_handle: &AppHandle, + ids: impl IntoIterator, + enabled: bool, +) { + let Some(menu) = app_handle.menu() else { + return; + }; + for id in ids { + if let Some(MenuItemKind::MenuItem(mi)) = menu.get(id) { + let _ = mi.set_enabled(enabled); + } + } +} + +fn set_menu_state_group_enabled(app_handle: &AppHandle, group_name: &str, enabled: bool) { + set_items_enabled(app_handle, menu_state_group_ids(group_name), enabled); +} + +/// Enable or disable menu items that depend on having an active note tab. +pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, NOTE_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on the editor being the active surface. +pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, EDITOR_FIND_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on the note list being the active surface. +pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on having uncommitted changes. +pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, GIT_COMMIT_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on having merge conflicts. +pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, GIT_CONFLICT_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on the active vault having no remote. +pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_GROUP, enabled); +} + +/// Enable or disable menu items that depend on a deleted note preview being active. +pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) { + set_menu_state_group_enabled(app_handle, RESTORE_DELETED_DEPENDENT_GROUP, enabled); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn menu_item_by_id(id: &str) -> &'static ManifestMenuItem { + manifest_menu_items() + .find(|item| item.menu_item_id(manifest()) == Some(id)) + .unwrap_or_else(|| panic!("missing menu item {id}")) + } + + #[test] + fn custom_ids_are_manifest_menu_item_ids() { + let expected: HashSet<_> = manifest_menu_items() + .filter_map(|item| item.menu_item_id(manifest())) + .map(str::to_owned) + .collect(); + + assert_eq!(custom_menu_ids(), &expected); + assert!(custom_menu_ids().contains("file-quick-open-alias")); + } + + #[test] + fn manifest_command_items_reference_known_commands() { + for item in manifest_menu_items() { + if let ManifestMenuItem::Command { command, .. } = item { + assert!( + manifest().commands.contains_key(command), + "menu item references missing command key {command}" + ); + } + } + } + + #[test] + fn overridden_menu_item_ids_emit_their_primary_command() { + assert_eq!( + emitted_menu_event_id("file-quick-open-alias"), + Some("file-quick-open") + ); + assert_eq!( + emitted_menu_event_id("edit-toggle-note-list-search"), + Some("edit-toggle-note-list-search") + ); + assert_eq!(emitted_menu_event_id("file-save"), Some("file-save")); + } + + #[test] + fn state_group_ids_are_manifest_menu_items() { + for (group, references) in &manifest().menu_state_groups { + for reference in references { + let id = match reference { + MenuStateGroupReference::Command { command } => manifest() + .commands + .get(command) + .map(|command| command.id.as_str()) + .unwrap_or_else(|| panic!("state group {group} references {command}")), + MenuStateGroupReference::Id { id } => id.as_str(), + }; + assert!( + custom_menu_ids().contains(id), + "state group {group} references non-menu item {id}" + ); + } + } + } + + #[test] + fn view_toggle_properties_keeps_renderer_owned_accelerator() { + let item = menu_item_by_id("view-toggle-properties"); + + assert_eq!(item.accelerator(manifest()), None); + } + + #[test] + fn view_menu_exposes_ai_panel_toggle() { + let view_menu = manifest_section("View").expect("view menu exists"); + let item = view_menu + .items + .iter() + .find(|item| item.command_id(manifest()) == Some("view-toggle-ai-chat")) + .expect("View menu exposes the AI panel toggle"); + + assert_eq!(item.menu_item_id(manifest()), Some("view-toggle-ai-chat")); + assert_eq!(item.label("macos"), Some("Toggle AI Panel")); + assert_eq!(item.accelerator(manifest()), Some("CmdOrCtrl+Shift+L")); + } + + #[test] + fn no_duplicate_custom_ids() { + let mut seen = HashSet::new(); + for id in manifest_menu_items().filter_map(|item| item.menu_item_id(manifest())) { + assert!(seen.insert(id), "duplicate custom ID: {id}"); + } + } + + #[test] + fn app_services_menu_is_macos_only() { + assert!(app_menu_includes_services("macos")); + assert!(!app_menu_includes_services("windows")); + assert!(!app_menu_includes_services("linux")); + } + + #[test] + fn window_menu_event_handler_is_required_off_macos() { + assert!(!window_menu_event_handler_required("macos")); + assert!(window_menu_event_handler_required("windows")); + assert!(window_menu_event_handler_required("linux")); + } + + #[test] + fn window_menu_uses_native_nsapp_integration_on_macos_only() { + assert_eq!( + native_window_menu_submenu_id("macos"), + Some(WINDOW_SUBMENU_ID) + ); + assert_eq!(native_window_menu_submenu_id("windows"), None); + assert_eq!(native_window_menu_submenu_id("linux"), None); + } + + #[test] + fn native_menu_labels_escape_literal_ampersands() { + assert_eq!(native_menu_label("Commit & Push"), "Commit && Push"); + assert_eq!( + native_menu_label("Research && Development"), + "Research &&&& Development" + ); + } + + #[test] + fn native_menu_labels_without_ampersands_are_unchanged() { + assert_eq!(native_menu_label("Pull from Remote"), "Pull from Remote"); + } + + #[test] + fn vault_commit_push_menu_label_is_native_menu_safe() { + let item = menu_item_by_id("vault-commit-push"); + let label = item.label("windows").expect("commit push label exists"); + + assert_eq!(label, "Commit & Push"); + assert_eq!(native_menu_label(label), "Commit && Push"); + assert_eq!(item.accelerator(manifest()), None); + } +} diff --git a/src-tauri/src/opencode_cli.rs b/src-tauri/src/opencode_cli.rs new file mode 100644 index 0000000..29287e1 --- /dev/null +++ b/src-tauri/src/opencode_cli.rs @@ -0,0 +1,121 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; +use std::path::Path; + +pub fn check_cli() -> AiAgentAvailability { + crate::opencode_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::opencode_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let command = crate::opencode_config::build_command(binary, &request)?; + crate::cli_agent_runtime::run_ai_agent_json_stream( + command, + "opencode", + emit, + crate::opencode_events::session_id, + crate::opencode_events::dispatch_event, + crate::opencode_events::format_error, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("opencode"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path, + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_opencode_json_events() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}' +printf '%s\n' '{"type":"message","text":"Done"}' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "open_1"); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "open_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_opencode_nonzero_exit_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}' +printf '%s\n' 'provider login required' >&2 +exit 3 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "open_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("provider configured") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs new file mode 100644 index 0000000..06cfc5f --- /dev/null +++ b/src-tauri/src/opencode_config.rs @@ -0,0 +1,418 @@ +use crate::ai_agents::AiAgentPermissionMode; +use crate::cli_agent_runtime::EnvName; +use crate::opencode_cli::AgentStreamRequest; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +const OPENCODE_SHELL_ENV_KEYS: [EnvName<'static>; 25] = [ + EnvName::trusted("OPENCODE_CONFIG"), + EnvName::trusted("OPENCODE_CONFIG_DIR"), + EnvName::trusted("ANTHROPIC_API_KEY"), + EnvName::trusted("ANTHROPIC_AUTH_TOKEN"), + EnvName::trusted("ANTHROPIC_BASE_URL"), + EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"), + EnvName::trusted("ANTHROPIC_MODEL"), + EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"), + EnvName::trusted("OPENAI_API_KEY"), + EnvName::trusted("OPENAI_BASE_URL"), + EnvName::trusted("OPENROUTER_API_KEY"), + EnvName::trusted("GEMINI_API_KEY"), + EnvName::trusted("GOOGLE_API_KEY"), + EnvName::trusted("GOOGLE_GENERATIVE_AI_API_KEY"), + EnvName::trusted("GROQ_API_KEY"), + EnvName::trusted("XAI_API_KEY"), + EnvName::trusted("MISTRAL_API_KEY"), + EnvName::trusted("COHERE_API_KEY"), + EnvName::trusted("DEEPSEEK_API_KEY"), + EnvName::trusted("AZURE_OPENAI_API_KEY"), + EnvName::trusted("AZURE_OPENAI_ENDPOINT"), + EnvName::trusted("AWS_ACCESS_KEY_ID"), + EnvName::trusted("AWS_SECRET_ACCESS_KEY"), + EnvName::trusted("AWS_SESSION_TOKEN"), + EnvName::trusted("AWS_REGION"), +]; + +pub(crate) fn build_command( + binary: &Path, + request: &AgentStreamRequest, +) -> Result { + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + apply_opencode_shell_env(&mut command, request); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .args(build_args()) + .arg(build_prompt(request)) + .env( + "OPENCODE_CONFIG_CONTENT", + build_config( + &request.vault_path, + &request.vault_paths, + request.permission_mode, + )?, + ) + .current_dir(&request.vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn build_args() -> Vec { + vec!["run".into(), "--format".into(), "json".into()] +} + +fn build_prompt(request: &AgentStreamRequest) -> String { + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) +} + +fn apply_opencode_shell_env(command: &mut std::process::Command, request: &AgentStreamRequest) { + let referenced_names = referenced_config_env_names(Path::new(&request.vault_path)); + let referenced_env_names = referenced_names + .iter() + .filter_map(|name| EnvName::new(name)) + .collect::>(); + let mut names = OPENCODE_SHELL_ENV_KEYS.to_vec(); + names.extend(referenced_env_names); + crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(command, &names); +} + +fn referenced_config_env_names(vault_path: &Path) -> BTreeSet { + let home = dirs::home_dir(); + let custom_config = crate::cli_agent_runtime::env_value_from_process_or_user_shell( + EnvName::trusted("OPENCODE_CONFIG"), + ); + let candidates = config_file_candidates(home.as_deref(), vault_path, custom_config.as_deref()); + referenced_config_env_names_from_files(&candidates) +} + +fn config_file_candidates( + home: Option<&Path>, + vault_path: &Path, + custom_config: Option<&str>, +) -> Vec { + let mut candidates = Vec::new(); + if let Some(home) = home { + candidates.extend([ + home.join(".config/opencode/config.json"), + home.join(".config/opencode/opencode.json"), + home.join(".config/opencode/opencode.jsonc"), + home.join(".opencode/opencode.json"), + home.join(".opencode/opencode.jsonc"), + ]); + } + if let Some(path) = custom_config.map(str::trim).filter(|path| !path.is_empty()) { + candidates.push(PathBuf::from(path)); + } + candidates.extend(project_config_candidates(vault_path)); + candidates +} + +fn project_config_candidates(vault_path: &Path) -> Vec { + let mut candidates = Vec::new(); + for ancestor in vault_path.ancestors() { + candidates.push(ancestor.join("opencode.json")); + candidates.push(ancestor.join("opencode.jsonc")); + if ancestor.join(".git").exists() { + break; + } + } + candidates +} + +fn referenced_config_env_names_from_files(paths: &[PathBuf]) -> BTreeSet { + let mut names = BTreeSet::new(); + for path in paths { + if let Ok(config) = std::fs::read_to_string(path) { + names.extend(referenced_env_names_in_config_text(&config)); + } + } + names +} + +fn referenced_env_names_in_config_text(config: &str) -> BTreeSet { + let mut names = BTreeSet::new(); + let mut remaining = config; + while let Some(start) = remaining.find("{env:") { + let after_prefix = &remaining[start + "{env:".len()..]; + let Some(end) = after_prefix.find('}') else { + break; + }; + if let Some(name) = EnvName::new(after_prefix[..end].trim()) { + names.insert(name.as_str().to_string()); + } + remaining = &after_prefix[end + 1..]; + } + names +} + +fn build_config( + vault_path: &str, + vault_paths: &[String], + permission_mode: AiAgentPermissionMode, +) -> Result { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths); + + serde_json::to_string(&serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "permission": permission_config(permission_mode), + "mcp": { + "tolaria": { + "type": "local", + "command": ["node", mcp_server_path], + "environment": { + "VAULT_PATH": vault_path, + "VAULT_PATHS": vault_paths + }, + "enabled": true + } + } + })) + .map_err(|error| format!("Failed to serialize opencode config: {error}")) +} + +fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value { + let bash_permission = match permission_mode { + AiAgentPermissionMode::Safe => "deny", + AiAgentPermissionMode::PowerUser => "allow", + }; + + serde_json::json!({ + "read": "allow", + "edit": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "external_directory": "deny", + "bash": bash_permission + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + + fn request() -> AgentStreamRequest { + AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe, + } + } + + #[test] + fn args_use_documented_safe_run_mode() { + let args = build_args(); + let forbidden_args = ( + args.contains(&"--dangerously-skip-permissions".to_string()), + args.contains(&"--dir".to_string()), + args.contains(&"--thinking".to_string()), + ); + + assert_eq!( + (args, forbidden_args), + ( + vec![ + "run".to_string(), + "--format".to_string(), + "json".to_string() + ], + (false, false, false), + ) + ); + } + + #[test] + fn command_sets_vault_cwd_and_mcp_config() { + let command = build_command(&PathBuf::from("opencode"), &request()).unwrap(); + let actual_args: Vec<&OsStr> = command.get_args().collect(); + let config_value = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT")) + .and_then(|(_, value)| value); + + assert_eq!( + ( + command.get_program(), + actual_args[0], + actual_args[1], + actual_args[2], + actual_args.last().copied(), + command.get_current_dir(), + config_value.is_some(), + ), + ( + OsStr::new("opencode"), + OsStr::new("run"), + OsStr::new("--format"), + OsStr::new("json"), + Some(OsStr::new("Rename the note")), + Some(Path::new("/tmp/vault")), + true, + ) + ); + } + + #[test] + fn command_provider_env_keys_cover_terminal_backed_opencode_configs() { + assert!(OPENCODE_SHELL_ENV_KEYS.contains(&EnvName::trusted("OPENCODE_CONFIG"))); + assert!(OPENCODE_SHELL_ENV_KEYS.contains(&EnvName::trusted("OPENAI_API_KEY"))); + assert!(OPENCODE_SHELL_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY"))); + assert!(OPENCODE_SHELL_ENV_KEYS.contains(&EnvName::trusted("GEMINI_API_KEY"))); + } + + #[test] + fn config_file_candidates_follow_opencode_precedence_locations() { + let home = Path::new("/Users/alex"); + let vault = Path::new("/Users/alex/project"); + let candidates = + config_file_candidates(Some(home), vault, Some("/Users/alex/custom/opencode.json")); + + assert!(candidates.contains(&home.join(".config/opencode/config.json"))); + assert!(candidates.contains(&home.join(".config/opencode/opencode.json"))); + assert!(candidates.contains(&PathBuf::from("/Users/alex/custom/opencode.json"))); + assert!(candidates.contains(&vault.join("opencode.json"))); + assert!(candidates.contains(&vault.join("opencode.jsonc"))); + } + + #[test] + fn referenced_env_names_are_read_from_opencode_config_files() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("opencode.json"); + std::fs::write( + &config, + r#"{ + "provider": { + "anthropic": { "options": { "apiKey": "{env:ANTHROPIC_API_KEY}" } }, + "custom": { "options": { "apiKey": "{env:TOLARIA_CUSTOM_KEY}" } }, + "bad": "{env:bad-name}" + } + }"#, + ) + .unwrap(); + + let names = referenced_config_env_names_from_files(&[config]); + + assert_eq!( + names, + BTreeSet::from([ + "ANTHROPIC_API_KEY".to_string(), + "TOLARIA_CUSTOM_KEY".to_string(), + ]) + ); + } + + #[test] + fn command_avoids_windows_cmd_shim_for_run_args() { + let dir = tempfile::tempdir().unwrap(); + let shim = dir.path().join("opencode.cmd"); + let script = dir + .path() + .join("node_modules") + .join("opencode") + .join("bin") + .join("opencode.js"); + std::fs::create_dir_all(script.parent().unwrap()).unwrap(); + std::fs::write(&script, "console.log('opencode')\n").unwrap(); + std::fs::write( + &shim, + r#"@ECHO off +"%_prog%" "%~dp0\node_modules\opencode\bin\opencode.js" %* +"#, + ) + .unwrap(); + + let command = build_command(&shim, &request()).unwrap(); + let actual_args = command.get_args().collect::>(); + + assert_ne!( + command.get_program(), + shim.as_os_str(), + "OpenCode npm .cmd shims cannot be spawned directly on Windows" + ); + assert_eq!( + ( + actual_args.first().copied(), + actual_args.iter().any(|arg| *arg == OsStr::new("run")), + actual_args.iter().any(|arg| *arg == OsStr::new("json")), + actual_args + .iter() + .any(|arg| *arg == OsStr::new("Rename the note")), + ), + (Some(script.as_os_str()), true, true, true) + ); + } + + #[test] + fn config_includes_permissions_and_tolaria_mcp_server() { + if let Ok(config) = build_config( + "/tmp/vault", + &[], + crate::ai_agents::AiAgentPermissionMode::Safe, + ) { + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!( + ( + json["permission"]["edit"].as_str(), + json["permission"]["external_directory"].as_str(), + json["permission"]["bash"].as_str(), + json["mcp"]["tolaria"]["type"].as_str(), + json["mcp"]["tolaria"]["command"][0].as_str(), + json["mcp"]["tolaria"]["environment"]["VAULT_PATH"].as_str(), + json["mcp"]["tolaria"]["command"][1] + .as_str() + .is_some_and(|path| path.ends_with("index.js")), + ), + ( + Some("allow"), + Some("deny"), + Some("deny"), + Some("local"), + Some("node"), + Some("/tmp/vault"), + true, + ) + ); + assert_eq!( + json["mcp"]["tolaria"]["environment"]["VAULT_PATHS"], + r#"["/tmp/vault"]"# + ); + assert!(json["mcp"]["tolaria"]["command"][1] + .as_str() + .unwrap() + .ends_with("index.js")); + } + } + + #[test] + fn power_user_config_allows_bash_but_keeps_external_directories_denied() { + if let Ok(config) = build_config( + "/tmp/vault", + &[], + crate::ai_agents::AiAgentPermissionMode::PowerUser, + ) { + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(json["permission"]["bash"], "allow"); + assert_eq!(json["permission"]["external_directory"], "deny"); + } + } + + #[test] + fn prompt_keeps_system_prompt_first() { + let prompt = build_prompt(&AgentStreamRequest { + system_prompt: Some("Be concise".into()), + ..request() + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } +} diff --git a/src-tauri/src/opencode_discovery.rs b/src-tauri/src/opencode_discovery.rs new file mode 100644 index 0000000..068178f --- /dev/null +++ b/src-tauri/src/opencode_discovery.rs @@ -0,0 +1,262 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + match find_binary() { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +pub(crate) fn find_binary() -> Result { + if let Some(binary) = find_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + opencode_binary_candidates(), + "OpenCode CLI", + )? { + return Ok(binary); + } + + Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into()) +} + +fn find_binary_on_path() -> Option { + crate::hidden_command(path_lookup_command()) + .arg("opencode") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + +fn find_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, "opencode")) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + first_existing_path_for_platform(stdout, cfg!(windows)) +} + +fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { + let mut paths = stdout.lines().filter_map(existing_path); + if windows { + return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path)); + } + + paths.next() +} + +fn existing_path(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) +} + +fn opencode_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| opencode_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn opencode_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".local/bin/opencode"), + home.join(".local/bin/opencode.cmd"), + home.join(".local/bin/opencode.exe"), + home.join(".opencode/bin/opencode"), + home.join(".opencode/bin/opencode.cmd"), + home.join(".opencode/bin/opencode.exe"), + home.join(".local/share/mise/shims/opencode"), + home.join(".local/share/mise/shims/opencode.cmd"), + home.join(".local/share/mise/shims/opencode.exe"), + home.join(".asdf/shims/opencode"), + home.join(".asdf/shims/opencode.cmd"), + home.join(".asdf/shims/opencode.exe"), + home.join(".npm-global/bin/opencode"), + home.join(".npm-global/bin/opencode.cmd"), + home.join(".npm-global/bin/opencode.exe"), + home.join(".npm/bin/opencode"), + home.join(".npm/bin/opencode.cmd"), + home.join(".npm/bin/opencode.exe"), + home.join(".bun/bin/opencode"), + home.join(".bun/bin/opencode.cmd"), + home.join(".bun/bin/opencode.exe"), + home.join(".linuxbrew/bin/opencode"), + home.join("AppData/Roaming/npm/opencode.cmd"), + home.join("AppData/Roaming/npm/opencode.exe"), + home.join("AppData/Local/pnpm/opencode.cmd"), + home.join("AppData/Local/pnpm/opencode.exe"), + home.join("scoop/shims/opencode.cmd"), + home.join("scoop/shims/opencode.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"), + PathBuf::from("/usr/local/bin/opencode"), + PathBuf::from("/opt/homebrew/bin/opencode"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_local_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/opencode"), + home.join(".opencode/bin/opencode"), + home.join(".local/share/mise/shims/opencode"), + home.join(".asdf/shims/opencode"), + home.join(".npm-global/bin/opencode"), + home.join(".bun/bin/opencode"), + PathBuf::from("/opt/homebrew/bin/opencode"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".linuxbrew/bin/opencode"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn binary_candidates_include_windows_npm_and_toolchain_shims() { + let home = PathBuf::from(r"C:\Users\alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/opencode.cmd"), + home.join(".opencode/bin/opencode.cmd"), + home.join(".local/share/mise/shims/opencode.cmd"), + home.join(".asdf/shims/opencode.cmd"), + home.join(".npm-global/bin/opencode.cmd"), + home.join(".npm-global/bin/opencode.exe"), + home.join(".npm/bin/opencode.cmd"), + home.join(".npm/bin/opencode.exe"), + home.join(".bun/bin/opencode.cmd"), + home.join("AppData/Roaming/npm/opencode.cmd"), + home.join("AppData/Roaming/npm/opencode.exe"), + home.join("AppData/Local/pnpm/opencode.cmd"), + home.join("AppData/Local/pnpm/opencode.exe"), + home.join("scoop/shims/opencode.cmd"), + home.join("scoop/shims/opencode.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn path_lookup_command_matches_current_platform() { + let expected = if cfg!(windows) { "where" } else { "which" }; + + assert_eq!(path_lookup_command(), expected); + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-opencode"); + let opencode = dir.path().join("opencode"); + std::fs::write(&opencode, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display()); + + assert_eq!(first_existing_path(&stdout), Some(opencode)); + } + + #[test] + fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() { + let dir = tempfile::tempdir().unwrap(); + let shell_script = dir.path().join("opencode"); + let cmd_shim = dir.path().join("opencode.cmd"); + std::fs::write(&shell_script, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd_shim, "@ECHO off\n").unwrap(); + + let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display()); + + assert_eq!( + first_existing_path_for_platform(&stdout, true), + Some(cmd_shim) + ); + } +} diff --git a/src-tauri/src/opencode_events.rs b/src-tauri/src/opencode_events.rs new file mode 100644 index 0000000..db467b6 --- /dev/null +++ b/src-tauri/src/opencode_events.rs @@ -0,0 +1,185 @@ +use crate::ai_agents::AiAgentStreamEvent; + +#[cfg(test)] +pub(crate) fn parse_line( + line: Result, + emit: &mut F, +) -> Option +where + F: FnMut(AiAgentStreamEvent), +{ + crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit) +} + +pub(crate) fn dispatch_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if json["type"].as_str() == Some("session") { + emit_session_event(json, emit); + } + + match event_type(json) { + "message" | "text" => emit_text(json, emit), + "reasoning" => emit_reasoning(json, emit), + "tool_use" | "tool" => emit_tool_start(json, emit), + "tool_result" | "tool_done" => emit_tool_done(json, emit), + "error" => emit_error_event(json, emit), + _ => {} + } +} + +fn event_type(json: &serde_json::Value) -> &str { + let direct = json["type"].as_str().unwrap_or_default(); + match direct { + "session" | "message" | "text" | "reasoning" | "tool_use" | "tool" | "tool_result" + | "tool_done" | "error" => direct, + _ => json["part"]["type"].as_str().unwrap_or(direct), + } +} + +pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> { + json["sessionID"] + .as_str() + .or_else(|| json["session_id"].as_str()) + .or_else(|| json["session"]["id"].as_str()) +} + +pub(crate) fn format_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if is_auth_error(&lower) { + return "OpenCode CLI is not authenticated or has no provider configured. Run `opencode auth login` or configure a provider in OpenCode, then retry.".into(); + } + + if stderr_output.trim().is_empty() { + format!("opencode exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +fn emit_session_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(session_id) = session_id(json) { + emit(AiAgentStreamEvent::Init { + session_id: session_id.to_string(), + }); + } +} + +fn emit_text(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(text) = text_value(json) { + emit(AiAgentStreamEvent::TextDelta { + text: text.to_string(), + }); + } +} + +fn emit_reasoning(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(text) = text_value(json) { + emit(AiAgentStreamEvent::ThinkingDelta { + text: text.to_string(), + }); + } +} + +fn emit_tool_start(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_id = tool_id(json).unwrap_or("tool").to_string(); + let tool_name = tool_name(json).unwrap_or("tool").to_string(); + let input = tool_input(json); + + emit(AiAgentStreamEvent::ToolStart { + tool_name, + tool_id, + input, + }); +} + +fn emit_tool_done(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_id = tool_id(json).unwrap_or("tool").to_string(); + let output = tool_output(json); + + emit(AiAgentStreamEvent::ToolDone { tool_id, output }); +} + +fn emit_error_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(message) = message_value(json) { + emit(AiAgentStreamEvent::Error { + message: message.to_string(), + }); + } +} + +fn tool_id(json: &serde_json::Value) -> Option<&str> { + first_string_field( + json, + &["id", "toolID", "tool_id", "toolCallID", "toolCallId"], + ) +} + +fn text_value(json: &serde_json::Value) -> Option<&str> { + first_string_field(json, &["text", "content", "message"]) +} + +fn message_value(json: &serde_json::Value) -> Option<&str> { + first_string_field(json, &["message", "error", "text"]) +} + +fn tool_name(json: &serde_json::Value) -> Option<&str> { + first_string_field(json, &["name", "tool", "toolName"]) +} + +fn tool_input(json: &serde_json::Value) -> Option { + first_json_field(json, &["input", "args"]).map(|input| input.to_string()) +} + +fn tool_output(json: &serde_json::Value) -> Option { + first_json_field(json, &["output", "result"]).map(display_json_value) +} + +fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str())) +} + +fn first_json_field<'a>( + json: &'a serde_json::Value, + keys: &[&str], +) -> Option<&'a serde_json::Value> { + keys.iter() + .find_map(|key| json.get(*key).or_else(|| json["part"].get(*key))) +} + +fn display_json_value(value: &serde_json::Value) -> String { + value + .as_str() + .map(|output| output.to_string()) + .unwrap_or_else(|| value.to_string()) +} + +fn is_auth_error(lower: &str) -> bool { + ["auth", "login", "sign in", "api key", "provider"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +#[cfg(test)] +#[path = "opencode_events_tests.rs"] +mod tests; diff --git a/src-tauri/src/opencode_events_tests.rs b/src-tauri/src/opencode_events_tests.rs new file mode 100644 index 0000000..5400258 --- /dev/null +++ b/src-tauri/src/opencode_events_tests.rs @@ -0,0 +1,237 @@ +use super::*; + +fn dispatch_events(events: impl IntoIterator) -> Vec { + let mut mapped = Vec::new(); + + for event in events { + dispatch_event(&event, &mut |event| mapped.push(event)); + } + + mapped +} + +struct ToolExpectation<'a> { + tool_id: &'a str, + tool_name: &'a str, + input: Option<&'a str>, + output: Option<&'a str>, +} + +fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { + tool_name: actual_name, + tool_id: actual_id, + input: actual_input, + } if actual_name == expected.tool_name + && actual_id == expected.tool_id + && actual_input.as_deref() == expected.input + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { + tool_id: actual_id, + output: actual_output, + } if actual_id == expected.tool_id && actual_output.as_deref() == expected.output + )); +} + +#[test] +fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + let mut events = Vec::new(); + + let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| { + events.push(event) + }); + let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event)); + let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event)); + + assert!(read_error.is_none()); + assert!(blank.is_none()); + assert!(invalid.is_none()); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message.contains("broken pipe") + )); +} + +#[test] +fn dispatch_maps_session_reasoning_and_text() { + let mut events = Vec::new(); + let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" }); + let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" }); + let text = serde_json::json!({ "type": "message", "text": "Done" }); + + for event in [started, reasoning, text] { + dispatch_event(&event, &mut |event| events.push(event)); + } + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "ses_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" + )); + assert!(matches!( + &events[2], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); +} + +#[test] +fn dispatch_maps_part_backed_reasoning_and_text() { + let mut events = Vec::new(); + let reasoning = serde_json::json!({ + "type": "reasoning", + "part": { "type": "reasoning", "text": "Checking links" } + }); + let text = serde_json::json!({ + "type": "text", + "part": { "type": "text", "text": "Done from OpenCode" } + }); + + for event in [reasoning, text] { + dispatch_event(&event, &mut |event| events.push(event)); + } + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode" + )); +} + +#[test] +fn dispatch_maps_final_text_after_tool_part_wrappers() { + let events = dispatch_events([ + serde_json::json!({ + "type": "part", + "part": { + "id": "prt_tool_1", + "type": "tool", + "tool": "webfetch", + "input": { "url": "https://example.com" } + } + }), + serde_json::json!({ + "type": "part", + "part": { + "id": "prt_text_1", + "type": "text", + "text": "Final answer after tool output." + } + }), + serde_json::json!({ + "type": "part", + "part": { + "id": "prt_finish_1", + "type": "step-finish", + "reason": "stop" + } + }), + ]); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { + tool_name, + tool_id, + input, + } if tool_name == "webfetch" + && tool_id == "prt_tool_1" + && input.as_deref() == Some(r#"{"url":"https://example.com"}"#) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Final answer after tool output." + )); + assert_eq!(events.len(), 2); +} + +#[test] +fn dispatch_maps_tool_events() { + let direct = dispatch_events([ + serde_json::json!({ + "type": "tool_use", + "id": "tool_1", + "name": "read", + "input": { "path": "Note.md" } + }), + serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }), + ]); + let part_backed = dispatch_events([ + serde_json::json!({ + "type": "tool_use", + "part": { + "id": "prt_tool_1", + "tool": "read", + "input": { "path": "Note.md" } + } + }), + serde_json::json!({ + "type": "tool_result", + "part": { + "id": "prt_tool_1", + "output": "ok" + } + }), + ]); + + assert_tool_pair( + &direct, + ToolExpectation { + tool_id: "tool_1", + tool_name: "read", + input: Some(r#"{"path":"Note.md"}"#), + output: Some("ok"), + }, + ); + assert_tool_pair( + &part_backed, + ToolExpectation { + tool_id: "prt_tool_1", + tool_name: "read", + input: Some(r#"{"path":"Note.md"}"#), + output: Some("ok"), + }, + ); +} + +#[test] +fn dispatch_maps_error_events() { + let mut events = Vec::new(); + let error = serde_json::json!({ "type": "error", "message": "provider failed" }); + + dispatch_event(&error, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message == "provider failed" + )); +} + +#[test] +fn format_error_explains_missing_auth_or_provider_setup() { + let message = format_error( + "provider auth failed: please login".into(), + "exit status: 1".into(), + ); + + assert!(message.contains("OpenCode CLI is not authenticated")); + assert!(message.contains("opencode auth login")); +} + +#[test] +fn format_error_uses_status_or_first_stderr_lines() { + let empty = format_error(String::new(), "exit status: 2".into()); + let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into()); + + assert_eq!(empty, "opencode exited with status exit status: 2"); + assert_eq!(truncated, "line 1\nline 2\nline 3"); +} diff --git a/src-tauri/src/pi_cli.rs b/src-tauri/src/pi_cli.rs new file mode 100644 index 0000000..f5aa296 --- /dev/null +++ b/src-tauri/src/pi_cli.rs @@ -0,0 +1,159 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; +use std::path::Path; + +pub fn check_cli() -> AiAgentAvailability { + crate::pi_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::pi_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let agent_dir = tempfile::Builder::new() + .prefix("tolaria-pi-agent-") + .tempdir() + .map_err(|error| format!("Failed to create Pi config directory: {error}"))?; + let command = crate::pi_config::build_command(binary, &request, agent_dir.path())?; + crate::cli_agent_runtime::run_ai_agent_json_stream_with_success_check( + command, + "pi", + emit, + crate::pi_events::session_id, + crate::pi_events::dispatch_event, + crate::pi_events::format_error, + |run| { + (run.parsed_json_lines == 0) + .then(|| crate::pi_events::format_empty_success(&run.diagnostic_output())) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("pi"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path, + vault_paths: Vec::new(), + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_pi_json_events() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","id":"pi_1"}' +printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "pi_1"); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "pi_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_pi_nonzero_exit_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","id":"pi_1"}' +printf '%s\n' 'api key login required' >&2 +exit 4 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "pi_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not authenticated") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_success_without_pi_events() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' 'npm warn exec installing pi-mcp-adapter' +printf '%s\n' 'pi completed without json output' >&2 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, ""); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } + if message.contains(r#""key":"ai.error.pi.emptyOutputWithDiagnostic""#) + && message.contains("npm warn exec") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/pi_config.rs b/src-tauri/src/pi_config.rs new file mode 100644 index 0000000..b99cdb3 --- /dev/null +++ b/src-tauri/src/pi_config.rs @@ -0,0 +1,558 @@ +use crate::ai_agents::AiAgentPermissionMode; +use crate::pi_cli::AgentStreamRequest; +use serde_json::{Map, Value}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +pub(crate) fn build_command( + binary: &Path, + request: &AgentStreamRequest, + agent_dir: &Path, +) -> Result { + prepare_agent_dir(agent_dir)?; + write_mcp_config( + agent_dir, + &request.vault_path, + &request.vault_paths, + request.permission_mode, + )?; + + let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?; + let mut command = crate::hidden_command(&target.program); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); + if let Some(first_arg) = target.first_arg { + command.arg(first_arg); + } + command + .args(build_args()) + .arg(build_prompt(request)) + .env("PI_CODING_AGENT_DIR", agent_dir) + .current_dir(&request.vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn prepare_agent_dir(agent_dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(agent_dir) + .map_err(|error| format!("Failed to create Pi agent directory: {error}"))?; + let Some(source_dir) = source_agent_dir() else { + return Ok(()); + }; + + seed_agent_dir(&source_dir, agent_dir) +} + +fn source_agent_dir() -> Option { + std::env::var_os("PI_CODING_AGENT_DIR") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .or_else(default_agent_dir) +} + +fn default_agent_dir() -> Option { + dirs::home_dir().map(|home| home.join(".pi").join("agent")) +} + +fn seed_agent_dir(source_dir: &Path, agent_dir: &Path) -> Result<(), String> { + if !source_dir.is_dir() || same_directory(source_dir, agent_dir) { + return Ok(()); + } + + let entries = match std::fs::read_dir(source_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to read Pi agent directory at {}: {error}", + source_dir.display() + )); + } + }; + + for entry in entries { + let entry = entry.map_err(|error| format!("Failed to read Pi agent file: {error}"))?; + let target = agent_dir.join(entry.file_name()); + copy_agent_entry(&entry.path(), &target)?; + } + + Ok(()) +} + +fn same_directory(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn copy_agent_entry(source: &Path, target: &Path) -> Result<(), String> { + let metadata = match std::fs::metadata(source) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to inspect Pi agent config at {}: {error}", + source.display() + )); + } + }; + + if metadata.is_dir() { + seed_agent_dir(source, target) + } else if metadata.is_file() { + copy_agent_file(source, target, metadata) + } else { + Ok(()) + } +} + +fn copy_agent_file( + source: &Path, + target: &Path, + metadata: std::fs::Metadata, +) -> Result<(), String> { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create Pi agent config parent at {}: {error}", + parent.display() + ) + })?; + } + match std::fs::copy(source, target) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to copy Pi agent config from {} to {}: {error}", + source.display(), + target.display() + )); + } + } + std::fs::set_permissions(target, metadata.permissions()).map_err(|error| { + format!( + "Failed to preserve Pi agent config permissions at {}: {error}", + target.display() + ) + }) +} + +fn build_args() -> Vec { + vec![ + "--mode".into(), + "json".into(), + "--no-session".into(), + "--extension".into(), + "npm:pi-mcp-adapter".into(), + ] +} + +fn build_prompt(request: &AgentStreamRequest) -> String { + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) +} + +fn write_mcp_config( + agent_dir: &Path, + vault_path: &str, + vault_paths: &[String], + permission_mode: AiAgentPermissionMode, +) -> Result<(), String> { + std::fs::create_dir_all(agent_dir) + .map_err(|error| format!("Failed to create Pi agent directory: {error}"))?; + let config_path = agent_dir.join("mcp.json"); + let config = build_mcp_config_from_base( + read_mcp_config(&config_path)?, + vault_path, + vault_paths, + permission_mode, + )?; + std::fs::write(agent_dir.join("mcp.json"), config) + .map_err(|error| format!("Failed to write Pi MCP config: {error}")) +} + +fn read_mcp_config(config_path: &Path) -> Result { + match std::fs::read_to_string(config_path) { + Ok(contents) if contents.trim().is_empty() => Ok(Value::Object(Map::new())), + Ok(contents) => serde_json::from_str(&contents) + .map_err(|error| format!("Failed to parse existing Pi MCP config: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Value::Object(Map::new())), + Err(error) => Err(format!("Failed to read existing Pi MCP config: {error}")), + } +} + +#[cfg(test)] +fn build_mcp_config( + vault_path: &str, + vault_paths: &[String], + permission_mode: AiAgentPermissionMode, +) -> Result { + build_mcp_config_from_base( + Value::Object(Map::new()), + vault_path, + vault_paths, + permission_mode, + ) +} + +fn build_mcp_config_from_base( + mut config: Value, + vault_path: &str, + vault_paths: &[String], + _permission_mode: AiAgentPermissionMode, +) -> Result { + let mcp_server = tolaria_mcp_server_config(vault_path, vault_paths)?; + let root = ensure_object(&mut config); + let settings = ensure_child_object(root, "settings"); + settings.insert("toolPrefix".into(), Value::String("none".into())); + settings.insert("idleTimeout".into(), Value::Number(10.into())); + let servers = ensure_child_object(root, "mcpServers"); + servers.insert("tolaria".into(), mcp_server); + + serde_json::to_string(&config) + .map_err(|error| format!("Failed to serialize Pi MCP config: {error}")) +} + +fn tolaria_mcp_server_config(vault_path: &str, vault_paths: &[String]) -> Result { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths); + + Ok(serde_json::json!({ + "command": "node", + "args": [mcp_server_path], + "env": { + "VAULT_PATH": vault_path, + "VAULT_PATHS": vault_paths, + "WS_UI_PORT": "9711" + }, + "lifecycle": "lazy", + "directTools": true + })) +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + match value { + Value::Object(object) => object, + _ => unreachable!("value was normalized to an object"), + } +} + +fn ensure_child_object<'a>( + object: &'a mut Map, + key: &str, +) -> &'a mut Map { + let value = object + .entry(key.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + ensure_object(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::path::PathBuf; + use std::sync::Mutex; + + static PI_AGENT_ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + fn request() -> AgentStreamRequest { + AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + vault_paths: Vec::new(), + permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe, + } + } + + #[test] + fn args_use_documented_json_mode_with_mcp_adapter() { + let args = build_args(); + + assert_pi_json_mode_args(&args); + assert!(args.contains(&"npm:pi-mcp-adapter".to_string())); + assert!(!args.contains(&"--no-tools".to_string())); + } + + fn assert_pi_json_mode_args(args: &[String]) { + assert_eq!( + ( + args.first().map(String::as_str), + args.get(1).map(String::as_str), + args.iter().any(|arg| arg == "--no-session"), + args.iter().any(|arg| arg == "--extension"), + ), + (Some("--mode"), Some("json"), true, true) + ); + } + + #[test] + fn command_sets_vault_cwd_closed_stdin_and_config_dir() { + let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap(); + let source_agent_dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path()); + let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap(); + let actual_args: Vec<&OsStr> = command.get_args().collect(); + let config_dir = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("PI_CODING_AGENT_DIR")) + .and_then(|(_, value)| value); + + assert_command_identity(&command, &actual_args); + assert_command_vault_scope(&command, config_dir, agent_dir.path()); + } + + fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) { + assert_eq!( + ( + command.get_program(), + actual_args.first().copied(), + actual_args.get(1).copied(), + actual_args.last().copied(), + ), + ( + OsStr::new("pi"), + Some(OsStr::new("--mode")), + Some(OsStr::new("json")), + Some(OsStr::new("Rename the note")), + ) + ); + } + + fn assert_command_vault_scope( + command: &std::process::Command, + config_dir: Option<&OsStr>, + agent_dir: &Path, + ) { + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + assert_eq!(config_dir, Some(agent_dir.as_os_str())); + assert!(agent_dir.join("mcp.json").exists()); + } + + #[test] + fn command_avoids_windows_cmd_shim_for_prompt_args() { + let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap(); + let source_agent_dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path()); + let shim = agent_dir.path().join("pi.cmd"); + let launcher = agent_dir + .path() + .join("node_modules") + .join("@withpi") + .join("pi") + .join("bin") + .join("pi.exe"); + std::fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + std::fs::write(&launcher, "native pi launcher").unwrap(); + std::fs::write( + &shim, + r#"@ECHO off +"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %* +"#, + ) + .unwrap(); + + let command = build_command(&shim, &request(), agent_dir.path()).unwrap(); + let actual_args = command.get_args().collect::>(); + + assert_eq!( + ( + command.get_program() != shim.as_os_str(), + command.get_program(), + actual_args.first().copied(), + actual_args.last().copied(), + ), + ( + true, + launcher.as_os_str(), + Some(OsStr::new("--mode")), + Some(OsStr::new("Rename the note")), + ), + "Pi npm .cmd shims cannot be spawned directly on Windows" + ); + } + + #[test] + fn command_seeds_temp_agent_dir_from_existing_pi_config() { + let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap(); + let source_agent_dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + write_existing_pi_config(source_agent_dir.path()); + let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path()); + + let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap(); + let config_dir = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("PI_CODING_AGENT_DIR")) + .and_then(|(_, value)| value) + .unwrap(); + + assert_eq!(config_dir, agent_dir.path().as_os_str()); + assert_seeded_pi_config_files(agent_dir.path()); + assert_seeded_pi_mcp_config(read_mcp_config_value(agent_dir.path())); + } + + fn write_existing_pi_config(source_agent_dir: &Path) { + std::fs::write( + source_agent_dir.join("auth.json"), + r#"{"openai":{"type":"api_key","key":"OPENAI_API_KEY"}}"#, + ) + .unwrap(); + std::fs::write( + source_agent_dir.join("settings.json"), + r#"{"defaultProvider":"openai","defaultModel":"gpt-5.1","settingsOnly":true}"#, + ) + .unwrap(); + std::fs::write( + source_agent_dir.join("mcp.json"), + r#"{"imports":["codex"],"mcpServers":{"personal":{"command":"personal-mcp"}}}"#, + ) + .unwrap(); + } + + fn assert_seeded_pi_config_files(agent_dir: &Path) { + assert_eq!( + std::fs::read_to_string(agent_dir.join("auth.json")).unwrap(), + r#"{"openai":{"type":"api_key","key":"OPENAI_API_KEY"}}"# + ); + assert_eq!( + std::fs::read_to_string(agent_dir.join("settings.json")).unwrap(), + r#"{"defaultProvider":"openai","defaultModel":"gpt-5.1","settingsOnly":true}"# + ); + } + + fn read_mcp_config_value(agent_dir: &Path) -> serde_json::Value { + serde_json::from_str(&std::fs::read_to_string(agent_dir.join("mcp.json")).unwrap()).unwrap() + } + + fn assert_seeded_pi_mcp_config(mcp: serde_json::Value) { + assert_eq!(mcp["imports"][0], "codex"); + assert_eq!(mcp["mcpServers"]["personal"]["command"], "personal-mcp"); + assert_eq!( + mcp["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], + "/tmp/vault" + ); + } + + #[test] + fn stale_pi_agent_config_entry_is_ignored() { + let source_agent_dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let stale_source = source_agent_dir.path().join("stale.json"); + let target = agent_dir.path().join("stale.json"); + + copy_agent_entry(&stale_source, &target).unwrap(); + + assert!(!target.exists()); + } + + #[test] + fn mcp_config_includes_tolaria_server_for_active_vault() { + if let Ok(config) = build_mcp_config( + "/tmp/vault", + &[], + crate::ai_agents::AiAgentPermissionMode::Safe, + ) { + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_base_mcp_config(&json); + assert_tolaria_mcp_env(&json); + assert_tolaria_mcp_args(&json); + } + } + + fn assert_base_mcp_config(json: &serde_json::Value) { + assert_eq!( + ( + &json["settings"]["toolPrefix"], + &json["mcpServers"]["tolaria"]["command"], + &json["mcpServers"]["tolaria"]["lifecycle"], + &json["mcpServers"]["tolaria"]["directTools"], + ), + ( + &serde_json::json!("none"), + &serde_json::json!("node"), + &serde_json::json!("lazy"), + &serde_json::json!(true), + ) + ); + } + + fn assert_tolaria_mcp_env(json: &serde_json::Value) { + assert_eq!( + json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], + "/tmp/vault" + ); + assert_eq!( + json["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"], + r#"["/tmp/vault"]"# + ); + assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711"); + } + + fn assert_tolaria_mcp_args(json: &serde_json::Value) { + assert!(json["mcpServers"]["tolaria"]["args"][0] + .as_str() + .unwrap() + .ends_with("index.js")); + } + + #[test] + fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() { + let safe = build_mcp_config( + "/tmp/vault", + &[], + crate::ai_agents::AiAgentPermissionMode::Safe, + ) + .unwrap(); + let power = build_mcp_config( + "/tmp/vault", + &[], + crate::ai_agents::AiAgentPermissionMode::PowerUser, + ) + .unwrap(); + + assert_eq!(safe, power); + } + + #[test] + fn prompt_keeps_system_prompt_first() { + let prompt = build_prompt(&AgentStreamRequest { + system_prompt: Some("Be concise".into()), + ..request() + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } +} diff --git a/src-tauri/src/pi_discovery.rs b/src-tauri/src/pi_discovery.rs new file mode 100644 index 0000000..cbe3fc5 --- /dev/null +++ b/src-tauri/src/pi_discovery.rs @@ -0,0 +1,403 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + match find_binary() { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +pub(crate) fn find_binary() -> Result { + if let Some(binary) = find_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + pi_binary_candidates(), + "Pi CLI", + )? { + return Ok(binary); + } + + Err("Pi CLI not found. Install it: https://pi.dev".into()) +} + +fn find_binary_on_path() -> Option { + crate::hidden_command(path_lookup_command()) + .arg("pi") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + +fn find_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, "pi")) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + ["-lc", "-lic"].into_iter().find_map(|flags| { + crate::hidden_command(shell) + .arg(flags) + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) + }) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + first_existing_path_for_platform(stdout, cfg!(windows)) +} + +fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { + stdout.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) { + return None; + } + candidate.exists().then_some(candidate) + }) +} + +fn pi_binary_candidates() -> Vec { + let mut candidates = pi_binary_candidates_from_env(); + + if let Some(home) = dirs::home_dir() { + candidates.extend(pi_binary_candidates_for_home(&home)); + } + + candidates.extend(pi_global_binary_candidates()); + candidates +} + +fn pi_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = pi_nvm_binary_candidates_for_home(home); + candidates.extend([ + home.join(".local/bin/pi"), + home.join(".local/bin/pi.exe"), + home.join(".pi/bin/pi"), + home.join(".pi/bin/pi.exe"), + home.join(".local/share/mise/shims/pi"), + home.join(".local/share/mise/shims/pi.exe"), + home.join(".asdf/shims/pi"), + home.join(".asdf/shims/pi.exe"), + home.join(".volta/bin/pi"), + home.join(".volta/bin/pi.cmd"), + home.join(".volta/bin/pi.exe"), + home.join(".npm-global/bin/pi"), + home.join(".npm-global/bin/pi.cmd"), + home.join(".npm-global/bin/pi.exe"), + home.join(".npm/bin/pi"), + home.join(".npm/bin/pi.cmd"), + home.join(".npm/bin/pi.exe"), + home.join(".local/share/pnpm/pi"), + home.join(".local/share/pnpm/pi.cmd"), + home.join(".local/share/pnpm/pi.exe"), + home.join("Library/pnpm/pi"), + home.join("Library/pnpm/pi.cmd"), + home.join("Library/pnpm/pi.exe"), + home.join(".bun/bin/pi"), + home.join(".bun/bin/pi.exe"), + home.join(".linuxbrew/bin/pi"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Local/pnpm/pi.cmd"), + home.join("AppData/Local/pnpm/pi.exe"), + home.join("scoop/shims/pi.exe"), + ]); + candidates +} + +fn pi_global_binary_candidates() -> Vec { + vec![ + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/pi"), + PathBuf::from("/usr/local/bin/pi"), + PathBuf::from("/opt/homebrew/bin/pi"), + ] +} + +fn pi_binary_candidates_from_env() -> Vec { + let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from); + let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from); + let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from); + let pnpm_home = std::env::var_os("PNPM_HOME").map(PathBuf::from); + + pi_binary_candidates_from_prefixes( + nvm_bin, + npm_config_prefix, + npm_config_prefix_upper, + pnpm_home, + ) +} + +fn pi_binary_candidates_from_prefixes( + nvm_bin: Option, + npm_config_prefix: Option, + npm_config_prefix_upper: Option, + pnpm_home: Option, +) -> Vec { + let mut candidates = Vec::new(); + + if let Some(path) = nvm_bin.filter(|path| !path.as_os_str().is_empty()) { + candidates.push(path.join("pi")); + } + if let Some(path) = npm_config_prefix.filter(|path| !path.as_os_str().is_empty()) { + candidates.push(path.join("bin/pi")); + } + if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) { + candidates.push(path.join("bin/pi")); + } + if let Some(path) = pnpm_home.filter(|path| !path.as_os_str().is_empty()) { + candidates.push(path.join("pi")); + } + + candidates +} + +fn pi_nvm_binary_candidates_for_home(home: &Path) -> Vec { + let versions_dir = home.join(".nvm/versions/node"); + let mut version_dirs = match std::fs::read_dir(versions_dir) { + Ok(entries) => entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(), + Err(_) => return Vec::new(), + }; + + version_dirs.sort_by(|left, right| right.file_name().cmp(&left.file_name())); + version_dirs + .into_iter() + .map(|version_dir| version_dir.join("bin/pi")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_local_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = pi_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/pi"), + home.join(".pi/bin/pi"), + home.join(".local/share/mise/shims/pi"), + home.join(".asdf/shims/pi"), + home.join(".volta/bin/pi"), + home.join(".npm-global/bin/pi"), + home.join(".local/share/pnpm/pi"), + home.join("Library/pnpm/pi"), + home.join(".bun/bin/pi"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let home_candidates = pi_binary_candidates_for_home(&home); + let global_candidates = pi_global_binary_candidates(); + let expected_home = home.join(".linuxbrew/bin/pi"); + let expected_global = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/pi"); + + assert!( + home_candidates.contains(&expected_home), + "missing {}", + expected_home.display() + ); + assert!( + global_candidates.contains(&expected_global), + "missing {}", + expected_global.display() + ); + } + + #[test] + fn binary_candidates_include_windows_npm_and_toolchain_shims() { + let home = PathBuf::from(r"C:\Users\alex"); + let candidates = pi_binary_candidates_for_home(&home); + let expected = [ + home.join(".npm-global/bin/pi.cmd"), + home.join(".npm-global/bin/pi.exe"), + home.join(".npm/bin/pi.cmd"), + home.join(".npm/bin/pi.exe"), + home.join(".local/share/pnpm/pi.cmd"), + home.join(".local/share/pnpm/pi.exe"), + home.join("Library/pnpm/pi.cmd"), + home.join("Library/pnpm/pi.exe"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Local/pnpm/pi.cmd"), + home.join("AppData/Local/pnpm/pi.exe"), + home.join("scoop/shims/pi.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn path_lookup_command_matches_current_platform() { + let expected = if cfg!(windows) { "where" } else { "which" }; + + assert_eq!(path_lookup_command(), expected); + } + + #[test] + fn binary_candidates_include_nvm_node_version_installs() { + let home = tempfile::tempdir().unwrap(); + let pi = home.path().join(".nvm/versions/node/v22.20.0/bin/pi"); + + std::fs::create_dir_all(pi.parent().unwrap()).unwrap(); + std::fs::write(&pi, "#!/bin/sh\n").unwrap(); + + let candidates = pi_binary_candidates_for_home(home.path()); + + assert!( + candidates.contains(&pi), + "missing nvm candidate {}", + pi.display() + ); + } + + #[test] + fn binary_candidates_include_static_global_fallbacks() { + let candidates = pi_global_binary_candidates(); + + assert!(candidates.contains(&PathBuf::from("/usr/local/bin/pi"))); + assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/pi"))); + } + + #[test] + fn binary_candidates_include_env_provided_npm_and_nvm_prefixes() { + let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin"); + let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global"); + let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm"); + let pnpm_home = PathBuf::from("/Users/alex/Library/pnpm"); + + let candidates = pi_binary_candidates_from_prefixes( + Some(nvm_bin.clone()), + Some(npm_config_prefix.clone()), + Some(npm_config_prefix_upper.clone()), + Some(pnpm_home.clone()), + ); + + assert_eq!( + candidates, + vec![ + nvm_bin.join("pi"), + npm_config_prefix.join("bin/pi"), + npm_config_prefix_upper.join("bin/pi"), + pnpm_home.join("pi"), + ] + ); + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-pi"); + let pi = dir.path().join("pi"); + std::fs::write(&pi, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), pi.display()); + + assert_eq!(first_existing_path(&stdout), Some(pi)); + } + + #[test] + fn first_existing_windows_path_skips_extensionless_npm_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let wrapper = dir.path().join("pi"); + let shim = dir.path().join("pi.cmd"); + std::fs::write(&wrapper, "#!/bin/sh\n").unwrap(); + std::fs::write(&shim, "@ECHO off\n").unwrap(); + let stdout = format!("{}\n{}\n", wrapper.display(), shim.display()); + + assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim)); + } + + #[cfg(unix)] + #[test] + fn command_path_from_shell_finds_pi_from_interactive_login_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let pi = dir.path().join("pi"); + std::fs::write(&pi, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shell = dir.path().join("shell"); + std::fs::write( + &shell, + format!( + "#!/bin/sh\nif [ \"$1\" = \"-lic\" ]; then echo '{}'; fi\n", + pi.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(command_path_from_shell(&shell, "pi"), Some(pi)); + } +} diff --git a/src-tauri/src/pi_events.rs b/src-tauri/src/pi_events.rs new file mode 100644 index 0000000..fb7bcdf --- /dev/null +++ b/src-tauri/src/pi_events.rs @@ -0,0 +1,168 @@ +use crate::ai_agents::AiAgentStreamEvent; + +const LOCALIZED_ERROR_PREFIX: &str = "tolaria:i18n-error:"; +const PI_EMPTY_OUTPUT_KEY: &str = "ai.error.pi.emptyOutput"; +const PI_EMPTY_OUTPUT_WITH_DIAGNOSTIC_KEY: &str = "ai.error.pi.emptyOutputWithDiagnostic"; + +#[cfg(test)] +pub(crate) fn parse_line( + line: Result, + emit: &mut F, +) -> Option +where + F: FnMut(AiAgentStreamEvent), +{ + crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit) +} + +pub(crate) fn dispatch_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if json["type"].as_str() == Some("session") { + emit_session_event(json, emit); + } + + match json["type"].as_str().unwrap_or_default() { + "message_update" => emit_message_update(json, emit), + "tool_execution_start" => emit_tool_start(json, emit), + "tool_execution_end" => emit_tool_done(json, emit), + "error" => emit_error_event(json, emit), + _ => {} + } +} + +pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> { + json["id"] + .as_str() + .or_else(|| json["session_id"].as_str()) + .or_else(|| json["session"]["id"].as_str()) +} + +pub(crate) fn format_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if is_auth_error(&lower) { + return "Pi CLI is not authenticated. Run `pi /login` in your terminal or configure a provider API key, then retry.".into(); + } + + if stderr_output.trim().is_empty() { + format!("pi exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +pub(crate) fn format_empty_success(diagnostic_output: &str) -> String { + if diagnostic_output.is_empty() { + return localized_error(PI_EMPTY_OUTPUT_KEY, serde_json::json!({})); + } + + localized_error( + PI_EMPTY_OUTPUT_WITH_DIAGNOSTIC_KEY, + serde_json::json!({ "diagnostic_output": diagnostic_output }), + ) +} + +fn localized_error(key: &str, values: serde_json::Value) -> String { + let payload = serde_json::json!({ + "key": key, + "values": values, + }); + format!("{LOCALIZED_ERROR_PREFIX}{payload}") +} + +fn emit_session_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(session_id) = session_id(json) { + emit(AiAgentStreamEvent::Init { + session_id: session_id.to_string(), + }); + } +} + +fn emit_message_update(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let event = &json["assistantMessageEvent"]; + match event["type"].as_str().unwrap_or_default() { + "text_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::TextDelta { text }), + "thinking_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::ThinkingDelta { + text, + }), + _ => {} + } +} + +fn emit_delta( + json: &serde_json::Value, + emit: &mut F, + build: impl FnOnce(String) -> AiAgentStreamEvent, +) where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(delta) = json["delta"].as_str() { + emit(build(delta.to_string())); + } +} + +fn emit_tool_start(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + emit(AiAgentStreamEvent::ToolStart { + tool_name: tool_name(json), + tool_id: tool_id(json), + input: json.get("args").map(|args| args.to_string()), + }); +} + +fn emit_tool_done(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + emit(AiAgentStreamEvent::ToolDone { + tool_id: tool_id(json), + output: json.get("result").map(|result| result.to_string()), + }); +} + +fn emit_error_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(message) = message_value(json) { + emit(AiAgentStreamEvent::Error { + message: message.to_string(), + }); + } +} + +fn tool_name(json: &serde_json::Value) -> String { + json["toolName"].as_str().unwrap_or("tool").to_string() +} + +fn tool_id(json: &serde_json::Value) -> String { + json["toolCallId"].as_str().unwrap_or("tool").to_string() +} + +fn message_value(json: &serde_json::Value) -> Option<&str> { + json["message"] + .as_str() + .or_else(|| json["error"].as_str()) + .or_else(|| json["text"].as_str()) +} + +fn is_auth_error(lower: &str) -> bool { + [ + "auth", "login", "sign in", "api key", "api.key", "provider", "401", + ] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +#[cfg(test)] +#[path = "pi_events_tests.rs"] +mod tests; diff --git a/src-tauri/src/pi_events_tests.rs b/src-tauri/src/pi_events_tests.rs new file mode 100644 index 0000000..c5c8bf4 --- /dev/null +++ b/src-tauri/src/pi_events_tests.rs @@ -0,0 +1,127 @@ +use super::*; + +#[test] +fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + let mut events = Vec::new(); + + let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| { + events.push(event) + }); + let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event)); + let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event)); + + assert!(read_error.is_none()); + assert!(blank.is_none()); + assert!(invalid.is_none()); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message.contains("broken pipe") + )); +} + +#[test] +fn dispatch_maps_session_thinking_and_text() { + let mut events = Vec::new(); + let started = serde_json::json!({ "type": "session", "id": "pi-session" }); + let thinking = serde_json::json!({ + "type": "message_update", + "assistantMessageEvent": { "type": "thinking_delta", "delta": "Checking links" } + }); + let text = serde_json::json!({ + "type": "message_update", + "assistantMessageEvent": { "type": "text_delta", "delta": "Done" } + }); + + for event in [started, thinking, text] { + dispatch_event(&event, &mut |event| events.push(event)); + } + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "pi-session" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" + )); + assert!(matches!( + &events[2], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); +} + +#[test] +fn dispatch_maps_tool_events() { + let mut events = Vec::new(); + let tool_start = serde_json::json!({ + "type": "tool_execution_start", + "toolCallId": "tool_1", + "toolName": "search_notes", + "args": { "query": "today" } + }); + let tool_done = serde_json::json!({ + "type": "tool_execution_end", + "toolCallId": "tool_1", + "toolName": "search_notes", + "result": { "ok": true } + }); + + dispatch_event(&tool_start, &mut |event| events.push(event)); + dispatch_event(&tool_done, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } + if tool_name == "search_notes" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"query":"today"}"#) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "tool_1" && output.as_deref() == Some(r#"{"ok":true}"#) + )); +} + +#[test] +fn dispatch_maps_error_events() { + let mut events = Vec::new(); + let error = serde_json::json!({ "type": "error", "message": "provider failed" }); + + dispatch_event(&error, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message == "provider failed" + )); +} + +#[test] +fn format_error_explains_missing_auth_or_provider_setup() { + let message = format_error( + "provider auth failed: api key required".into(), + "exit status: 1".into(), + ); + + assert!(message.contains("Pi CLI is not authenticated")); + assert!(message.contains("pi /login")); +} + +#[test] +fn format_error_uses_status_or_first_stderr_lines() { + let empty = format_error(String::new(), "exit status: 2".into()); + let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into()); + + assert_eq!(empty, "pi exited with status exit status: 2"); + assert_eq!(truncated, "line 1\nline 2\nline 3"); +} + +#[test] +fn format_empty_success_returns_localized_error_markers() { + let empty = format_empty_success(""); + let diagnostic = format_empty_success("npm warn exec installing pi-mcp-adapter"); + + assert!(empty.starts_with("tolaria:i18n-error:")); + assert!(empty.contains(r#""key":"ai.error.pi.emptyOutput""#)); + assert!(diagnostic.contains(r#""key":"ai.error.pi.emptyOutputWithDiagnostic""#)); + assert!(diagnostic.contains("npm warn exec installing pi-mcp-adapter")); + assert!(!diagnostic.contains("Pi CLI exited without agent output")); +} diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs new file mode 100644 index 0000000..d3c29f1 --- /dev/null +++ b/src-tauri/src/search.rs @@ -0,0 +1,446 @@ +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use walkdir::WalkDir; + +#[derive(Debug, Serialize, Clone)] +pub struct SearchResult { + pub title: String, + pub path: String, + pub snippet: String, + pub score: f64, + pub note_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub results: Vec, + pub elapsed_ms: u64, + pub query: String, + pub mode: String, +} + +pub struct SearchOptions<'a> { + pub vault_path: &'a str, + pub query: &'a str, + pub mode: &'a str, + pub limit: usize, + pub hide_gitignored_files: bool, + pub exclude_frontmatter: bool, +} + +struct Utf8Boundary<'a> { + text: &'a str, +} + +struct SnippetRequest<'a> { + content: &'a str, + query_lower: &'a str, +} + +struct MatchScoreRequest<'a> { + title_lower: &'a str, + content_lower: &'a str, + query_lower: &'a str, +} + +impl Utf8Boundary<'_> { + fn floor(&self, index: usize) -> usize { + let mut boundary = index.min(self.text.len()); + while boundary > 0 && !self.text.is_char_boundary(boundary) { + boundary -= 1; + } + boundary + } + + fn lower_to_source(&self, lower_index: usize) -> usize { + let mut lowered_len = 0; + for (source_index, ch) in self.text.char_indices() { + if lowered_len >= lower_index { + return source_index; + } + lowered_len += ch.to_lowercase().map(|c| c.len_utf8()).sum::(); + if lowered_len > lower_index { + return source_index; + } + } + self.text.len() + } +} + +impl SnippetRequest<'_> { + fn extract(&self) -> String { + let content_lower = self.content.to_lowercase(); + let lower_pos = match content_lower.find(self.query_lower) { + Some(p) => p, + None => return String::new(), + }; + let content_boundary = Utf8Boundary { text: self.content }; + let pos = content_boundary.lower_to_source(lower_pos); + let start = self.content[..pos] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or_else(|| content_boundary.floor(pos.saturating_sub(60))); + let end = self.content[pos..] + .find('\n') + .map(|i| pos + i) + .unwrap_or_else(|| content_boundary.floor((pos + 120).min(self.content.len()))); + let snippet = &self.content[start..end]; + if snippet.len() > 200 { + let end = Utf8Boundary { text: snippet }.floor(200); + format!("{}…", &snippet[..end]) + } else { + snippet.to_string() + } + } +} + +impl MatchScoreRequest<'_> { + fn score(&self) -> f64 { + let title_exact = self.title_lower.contains(self.query_lower); + let title_word = self + .title_lower + .split_whitespace() + .any(|word| word == self.query_lower); + let content_count = self.content_lower.matches(self.query_lower).count(); + + let mut score = 0.0; + if title_word { + score += 10.0; + } else if title_exact { + score += 5.0; + } + score += (content_count as f64).min(20.0) * 0.5; + score + } +} + +pub fn search_vault( + vault_path: &str, + query: &str, + _mode: &str, + limit: usize, +) -> Result { + search_vault_with_options(SearchOptions { + vault_path, + query, + mode: _mode, + limit, + hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(), + exclude_frontmatter: false, + }) +} + +fn strip_frontmatter(content: &str) -> &str { + let Some(rest) = content.strip_prefix("---") else { + return content; + }; + + match rest.find("\n---") { + Some(end) => rest[end + 4..].trim_start(), + None => content, + } +} + +fn searchable_content(content: &str, exclude_frontmatter: bool) -> &str { + if exclude_frontmatter { + strip_frontmatter(content) + } else { + content + } +} + +fn is_markdown_search_candidate(vault_dir: &Path, path: &Path) -> bool { + if !path.extension().is_some_and(|ext| ext == "md") { + return false; + } + + let vault_relative_path = path.strip_prefix(vault_dir).unwrap_or(path); + !vault_relative_path + .components() + .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) +} + +fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec { + let paths = WalkDir::new(vault_dir) + .into_iter() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.into_path()) + .filter(|path| is_markdown_search_candidate(vault_dir, path)) + .collect::>(); + + crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files) +} + +pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result { + let start = Instant::now(); + let query_lower = options.query.to_lowercase(); + let vault_dir = Path::new(options.vault_path); + + let mut results: Vec = Vec::new(); + + for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) { + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + + let searchable_content = searchable_content(&content, options.exclude_frontmatter); + let content_lower = searchable_content.to_lowercase(); + let filename = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let title = crate::vault::derive_markdown_title_from_content(&content, filename); + let title_lower = title.to_lowercase(); + + if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) { + continue; + } + + let score = MatchScoreRequest { + title_lower: &title_lower, + content_lower: &content_lower, + query_lower: &query_lower, + } + .score(); + let snippet = SnippetRequest { + content: searchable_content, + query_lower: &query_lower, + } + .extract(); + let full_path = path.to_string_lossy().to_string(); + + results.push(SearchResult { + title, + path: full_path, + snippet, + score, + note_type: None, + }); + } + + results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + results.truncate(options.limit); + + let elapsed_ms = start.elapsed().as_millis() as u64; + + Ok(SearchResponse { + results, + elapsed_ms, + query: options.query.to_string(), + mode: options.mode.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::Builder; + + fn init_git_repo(root: &Path) { + crate::hidden_command("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + } + + macro_rules! snippet { + ($content:expr, $query_lower:expr) => { + SnippetRequest { + content: $content, + query_lower: $query_lower, + } + .extract() + }; + } + + macro_rules! match_score { + ($title_lower:expr, $content_lower:expr, $query_lower:expr) => { + MatchScoreRequest { + title_lower: $title_lower, + content_lower: $content_lower, + query_lower: $query_lower, + } + .score() + }; + } + + #[test] + fn test_extract_snippet_basic() { + let content = "line one\nline with keyword here\nline three"; + let snippet = snippet!(content, "keyword"); + assert!(snippet.contains("keyword")); + } + + #[test] + fn test_extract_snippet_no_match() { + let snippet = snippet!("nothing here", "missing"); + assert!(snippet.is_empty()); + } + + #[test] + fn test_score_match_title_word() { + let score = match_score!("my keyword", "", "keyword"); + assert!(score >= 10.0); + } + + #[test] + fn test_score_match_content_only() { + let score = match_score!("unrelated", "some keyword text keyword", "keyword"); + assert!(score > 0.0); + assert!(score < 10.0); + } + + #[test] + fn test_extract_snippet_long() { + let long_line = "a".repeat(300); + let content = format!("start\n{}keyword{}\nend", long_line, long_line); + let snippet = snippet!(&content, "keyword"); + assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8) + } + + #[test] + fn test_extract_snippet_multibyte_context_start() { + let prefix = format!("{}a", "한".repeat(21)); + let content = format!("{prefix}needle after multibyte prefix"); + + let snippet = snippet!(&content, "needle"); + + assert!(snippet.contains("needle")); + assert!(snippet.is_char_boundary(snippet.len())); + } + + #[test] + fn test_extract_snippet_multibyte_context_end() { + let content = format!("x{}", "한".repeat(50)); + + let snippet = snippet!(&content, "x"); + + assert!(snippet.starts_with('x')); + assert!(snippet.is_char_boundary(snippet.len())); + } + + #[test] + fn test_extract_snippet_multibyte_truncation() { + let content = format!("key {}\n", "한".repeat(100)); + + let snippet = snippet!(&content, "key"); + + assert!(snippet.starts_with("key")); + assert!(snippet.ends_with('…')); + assert!(snippet.is_char_boundary(snippet.len())); + } + + #[test] + fn test_extract_snippet_maps_expanded_lowercase_to_source_boundary() { + let content = "İstanbul needle"; + + let snippet = snippet!(content, "i"); + + assert!(snippet.starts_with("İstanbul")); + assert!(snippet.is_char_boundary(snippet.len())); + } + + #[test] + fn test_search_vault_uses_h1_for_result_title() { + let dir = Builder::new() + .prefix("search-vault-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + let note_path = dir.path().join("legacy-name.md"); + fs::write( + ¬e_path, + "# Updated Display Title\n\nThe body contains keyword for search.", + ) + .unwrap(); + + let response = + search_vault(dir.path().to_str().unwrap(), "keyword", "keyword", 10).unwrap(); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].title, "Updated Display Title"); + } + + #[test] + fn test_search_vault_hides_gitignored_notes_when_enabled() { + let dir = Builder::new() + .prefix("search-gitignored-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + init_git_repo(dir.path()); + fs::create_dir_all(dir.path().join("ignored")).unwrap(); + fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap(); + fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap(); + fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap(); + + let hidden = search_vault_with_options(SearchOptions { + vault_path: dir.path().to_str().unwrap(), + query: "needle", + mode: "keyword", + limit: 10, + hide_gitignored_files: true, + exclude_frontmatter: false, + }) + .unwrap(); + let shown = search_vault_with_options(SearchOptions { + vault_path: dir.path().to_str().unwrap(), + query: "needle", + mode: "keyword", + limit: 10, + hide_gitignored_files: false, + exclude_frontmatter: false, + }) + .unwrap(); + + assert_eq!(hidden.results.len(), 1); + assert_eq!(hidden.results[0].title, "Visible"); + assert_eq!(shown.results.len(), 2); + } + + #[test] + fn test_search_vault_can_exclude_frontmatter_from_content_matches() { + let dir = Builder::new() + .prefix("search-frontmatter-scope-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + fs::write( + dir.path().join("frontmatter-only.md"), + [ + "---", + "Owner: hidden-frontmatter-keyword", + "---", + "", + "# Public Body", + "", + "The note body deliberately omits the hidden property token.", + ] + .join("\n"), + ) + .unwrap(); + fs::write( + dir.path().join("body-match.md"), + "# Body Match\n\nBody includes hidden-frontmatter-keyword here.", + ) + .unwrap(); + + let response = search_vault_with_options(SearchOptions { + vault_path: dir.path().to_str().unwrap(), + query: "hidden-frontmatter-keyword", + mode: "keyword", + limit: 10, + hide_gitignored_files: false, + exclude_frontmatter: true, + }) + .unwrap(); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].title, "Body Match"); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..917f8aa --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,965 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::ai_models::{normalize_ai_model_providers, AiModelProvider}; + +const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &[ + "claude_code", + "codex", + "copilot", + "opencode", + "pi", + "antigravity", + "kiro", + "hermes", +]; +pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true; +const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"]; +const SUPPORTED_DATE_DISPLAY_FORMATS: &[&str] = &["us", "european", "friendly", "iso"]; +const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[ + ("en", "en"), + ("en-us", "en"), + ("en-gb", "en"), + ("en-ca", "en"), + ("en-au", "en"), + ("it", "it-IT"), + ("it-it", "it-IT"), + ("fr", "fr-FR"), + ("fr-fr", "fr-FR"), + ("de", "de-DE"), + ("de-de", "de-DE"), + ("ru", "ru-RU"), + ("ru-ru", "ru-RU"), + ("es-es", "es-ES"), + ("pt-br", "pt-BR"), + ("pt-pt", "pt-PT"), + ("es-419", "es-419"), + ("es-ar", "es-419"), + ("es-bo", "es-419"), + ("es-cl", "es-419"), + ("es-co", "es-419"), + ("es-cr", "es-419"), + ("es-cu", "es-419"), + ("es-do", "es-419"), + ("es-ec", "es-419"), + ("es-gt", "es-419"), + ("es-hn", "es-419"), + ("es-mx", "es-419"), + ("es-ni", "es-419"), + ("es-pa", "es-419"), + ("es-pe", "es-419"), + ("es-pr", "es-419"), + ("es-py", "es-419"), + ("es-sv", "es-419"), + ("es-us", "es-419"), + ("es-uy", "es-419"), + ("es-ve", "es-419"), + ("zh", "zh-CN"), + ("zh-cn", "zh-CN"), + ("zh-hans", "zh-CN"), + ("zh-sg", "zh-CN"), + ("zh-tw", "zh-TW"), + ("zh-hant", "zh-TW"), + ("zh-hk", "zh-TW"), + ("zh-mo", "zh-TW"), + ("ja", "ja-JP"), + ("ja-jp", "ja-JP"), + ("ko", "ko-KR"), + ("ko-kr", "ko-KR"), + ("vi", "vi"), + ("vi-vn", "vi"), + ("pl", "pl-PL"), + ("pl-pl", "pl-PL"), + ("be", "be-BY"), + ("be-by", "be-BY"), + ("be-latn", "be-Latn"), + ("id", "id-ID"), + ("id-id", "id-ID"), + ("sk", "sk-SK"), + ("sk-sk", "sk-SK"), +]; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct AiWorkspaceConversationSetting { + pub archived: Option, + pub id: String, + pub target_id: Option, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Settings { + pub auto_pull_interval_minutes: Option, + pub git_enabled: Option, + pub git_path: Option, + pub git_provider: Option, + pub git_wsl_distro: Option, + pub autogit_enabled: Option, + pub autogit_idle_threshold_seconds: Option, + pub autogit_inactive_threshold_seconds: Option, + pub auto_advance_inbox_after_organize: Option, + pub telemetry_consent: Option, + pub crash_reporting_enabled: Option, + pub analytics_enabled: Option, + pub anonymous_id: Option, + pub release_channel: Option, + pub automatic_update_checks_enabled: Option, + pub theme_mode: Option, + pub ui_language: Option, + pub date_display_format: Option, + pub note_width_mode: Option, + pub sidebar_type_pluralization_enabled: Option, + pub initial_h1_auto_rename_enabled: Option, + pub ai_features_enabled: Option, + pub default_ai_agent: Option, + pub default_ai_target: Option, + pub ai_model_providers: Option>, + pub ai_workspace_conversations: Option>, + pub hide_gitignored_files: Option, + pub all_notes_show_pdfs: Option, + pub all_notes_show_images: Option, + pub all_notes_show_unsupported: Option, + pub multi_workspace_enabled: Option, +} + +fn normalize_optional_string(value: Option) -> Option { + value + .map(|candidate| candidate.trim().to_string()) + .filter(|candidate| !candidate.is_empty()) +} + +fn normalize_optional_positive_u32(value: Option) -> Option { + value.filter(|candidate| *candidate > 0) +} + +pub fn normalize_release_channel(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(channel) if channel == "alpha" => Some(channel), + _ => None, + } +} + +pub fn effective_release_channel(value: Option<&str>) -> &'static str { + if normalize_release_channel(value).is_some() { + "alpha" + } else { + "stable" + } +} + +pub fn normalize_default_ai_agent(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(agent) if agent == "gemini" => Some("antigravity".to_string()), + Some(agent) if SUPPORTED_DEFAULT_AI_AGENTS.contains(&agent.as_str()) => Some(agent), + _ => None, + } +} + +pub fn normalize_theme_mode(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(mode) if mode == "light" || mode == "dark" || mode == "system" => Some(mode), + _ => None, + } +} + +pub fn normalize_note_width_mode(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode), + _ => None, + } +} + +pub fn normalize_date_display_format(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(format) if SUPPORTED_DATE_DISPLAY_FORMATS.contains(&format.as_str()) => Some(format), + _ => None, + } +} + +pub fn should_hide_gitignored_files(settings: &Settings) -> bool { + settings + .hide_gitignored_files + .unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES) +} + +pub fn normalize_git_provider(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(provider) if provider == "native" || provider == "wsl" => Some(provider), + _ => None, + } +} + +pub fn hide_gitignored_files_enabled() -> bool { + get_settings() + .map(|settings| should_hide_gitignored_files(&settings)) + .unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES) +} + +fn canonical_language_code(value: &str) -> Option { + let code = value.trim().replace('_', "-").to_ascii_lowercase(); + if code.is_empty() { + None + } else { + Some(code) + } +} + +pub fn normalize_ui_language(value: Option<&str>) -> Option { + let language = canonical_language_code(value?)?; + SUPPORTED_UI_LANGUAGE_ALIASES + .iter() + .find_map(|(alias, canonical)| (*alias == language).then(|| (*canonical).to_string())) +} + +fn normalize_settings(settings: Settings) -> Settings { + Settings { + auto_pull_interval_minutes: settings.auto_pull_interval_minutes, + git_enabled: settings.git_enabled, + git_path: normalize_optional_string(settings.git_path), + git_provider: normalize_git_provider(settings.git_provider.as_deref()), + git_wsl_distro: normalize_optional_string(settings.git_wsl_distro), + autogit_enabled: settings.autogit_enabled, + autogit_idle_threshold_seconds: normalize_optional_positive_u32( + settings.autogit_idle_threshold_seconds, + ), + autogit_inactive_threshold_seconds: normalize_optional_positive_u32( + settings.autogit_inactive_threshold_seconds, + ), + auto_advance_inbox_after_organize: settings.auto_advance_inbox_after_organize, + telemetry_consent: settings.telemetry_consent, + crash_reporting_enabled: settings.crash_reporting_enabled, + analytics_enabled: settings.analytics_enabled, + anonymous_id: normalize_optional_string(settings.anonymous_id), + release_channel: normalize_release_channel(settings.release_channel.as_deref()), + automatic_update_checks_enabled: settings.automatic_update_checks_enabled, + theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()), + ui_language: normalize_ui_language(settings.ui_language.as_deref()), + date_display_format: normalize_date_display_format(settings.date_display_format.as_deref()), + note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()), + sidebar_type_pluralization_enabled: settings.sidebar_type_pluralization_enabled, + initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled, + ai_features_enabled: settings.ai_features_enabled, + default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), + default_ai_target: normalize_optional_string(settings.default_ai_target), + ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers), + ai_workspace_conversations: normalize_ai_workspace_conversations( + settings.ai_workspace_conversations, + ), + hide_gitignored_files: settings.hide_gitignored_files, + all_notes_show_pdfs: settings.all_notes_show_pdfs, + all_notes_show_images: settings.all_notes_show_images, + all_notes_show_unsupported: settings.all_notes_show_unsupported, + multi_workspace_enabled: settings.multi_workspace_enabled, + } +} + +fn normalize_ai_workspace_conversations( + conversations: Option>, +) -> Option> { + let normalized: Vec = conversations + .unwrap_or_default() + .into_iter() + .filter_map(|conversation| { + let id = conversation.id.trim().to_string(); + let title = conversation.title.trim().to_string(); + if id.is_empty() || title.is_empty() { + return None; + } + + Some(AiWorkspaceConversationSetting { + archived: conversation.archived, + id, + target_id: normalize_optional_string(conversation.target_id), + title, + }) + }) + .take(100) + .collect(); + + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +pub(crate) fn preferred_app_config_path(file_name: &str) -> Result { + crate::app_config::preferred_app_config_path(file_name) +} + +fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result { + crate::app_config::resolve_existing_or_preferred_app_config_path(file_name) +} + +fn settings_path() -> Result { + resolve_existing_or_preferred_app_config_path("settings.json") +} + +fn get_settings_at(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Settings::default()); + } + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read settings: {}", e))?; + let settings = + serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))?; + Ok(normalize_settings(settings)) +} + +fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + let cleaned = normalize_settings(settings); + + let json = serde_json::to_string_pretty(&cleaned) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + fs::write(path, json).map_err(|e| format!("Failed to write settings: {}", e)) +} + +pub fn get_settings() -> Result { + get_settings_at(&settings_path()?) +} + +pub fn save_settings(settings: Settings) -> Result<(), String> { + save_settings_at(&preferred_app_config_path("settings.json")?, settings) +} + +fn ai_workspace_sessions_path() -> Result { + resolve_existing_or_preferred_app_config_path("ai-workspace-sessions.json") +} + +fn get_ai_workspace_sessions_at(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(serde_json::json!({})); + } + + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read AI workspace sessions: {}", e))?; + let sessions: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse AI workspace sessions: {}", e))?; + if sessions.is_object() { + Ok(sessions) + } else { + Ok(serde_json::json!({})) + } +} + +fn save_ai_workspace_sessions_at( + path: &PathBuf, + sessions: serde_json::Value, +) -> Result<(), String> { + if !sessions.is_object() { + return Err("AI workspace sessions must be a JSON object".to_string()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + let json = serde_json::to_string_pretty(&sessions) + .map_err(|e| format!("Failed to serialize AI workspace sessions: {}", e))?; + fs::write(path, json).map_err(|e| format!("Failed to write AI workspace sessions: {}", e)) +} + +pub fn get_ai_workspace_sessions() -> Result { + get_ai_workspace_sessions_at(&ai_workspace_sessions_path()?) +} + +pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> { + save_ai_workspace_sessions_at( + &preferred_app_config_path("ai-workspace-sessions.json")?, + sessions, + ) +} + +fn last_vault_file() -> Result { + resolve_existing_or_preferred_app_config_path("last-vault.txt") +} + +fn get_last_vault_at(path: &PathBuf) -> Option { + fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + fs::write(path, vault_path.trim()) + .map_err(|e| format!("Failed to write last vault path: {}", e)) +} + +pub fn get_last_vault() -> Option { + last_vault_file().ok().and_then(|p| get_last_vault_at(&p)) +} + +pub fn set_last_vault(vault_path: &str) -> Result<(), String> { + set_last_vault_at(&preferred_app_config_path("last-vault.txt")?, vault_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_empty_settings(settings: &Settings) { + assert_eq!(settings, &Settings::default()); + } + + /// Helper: save settings to a temp file and reload them. + fn save_and_reload(settings: Settings) -> Settings { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("settings.json"); + save_settings_at(&path, settings).unwrap(); + get_settings_at(&path).unwrap() + } + + fn create_last_vault_path(path_parts: &[&str]) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().unwrap(); + let path = path_parts + .iter() + .fold(dir.path().to_path_buf(), |acc, part| acc.join(part)); + (dir, path) + } + + fn write_and_assert_last_vault(path: &PathBuf, value: &str) { + set_last_vault_at(path, value).unwrap(); + assert_eq!(get_last_vault_at(path).as_deref(), Some(value)); + } + + #[test] + fn test_default_settings_all_none() { + assert_empty_settings(&Settings::default()); + } + + #[test] + fn test_settings_json_roundtrip() { + let settings = Settings { + auto_pull_interval_minutes: Some(10), + git_enabled: Some(false), + git_path: Some("/opt/homebrew/bin/git".to_string()), + git_provider: Some("wsl".to_string()), + git_wsl_distro: Some("Ubuntu".to_string()), + autogit_enabled: Some(true), + autogit_idle_threshold_seconds: Some(90), + autogit_inactive_threshold_seconds: Some(30), + auto_advance_inbox_after_organize: Some(true), + telemetry_consent: Some(true), + crash_reporting_enabled: Some(true), + analytics_enabled: Some(false), + anonymous_id: Some("abc-123-uuid".to_string()), + release_channel: Some("alpha".to_string()), + automatic_update_checks_enabled: Some(false), + theme_mode: Some("dark".to_string()), + ui_language: Some("zh-Hans".to_string()), + date_display_format: Some("iso".to_string()), + note_width_mode: Some("wide".to_string()), + sidebar_type_pluralization_enabled: Some(false), + initial_h1_auto_rename_enabled: Some(false), + ai_features_enabled: Some(false), + default_ai_agent: Some("codex".to_string()), + default_ai_target: Some("agent:codex".to_string()), + ai_model_providers: None, + ai_workspace_conversations: None, + hide_gitignored_files: Some(false), + multi_workspace_enabled: Some(true), + all_notes_show_pdfs: Some(true), + all_notes_show_images: Some(true), + all_notes_show_unsupported: Some(false), + }; + let json = serde_json::to_string(&settings).unwrap(); + let parsed: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, settings); + } + + #[test] + fn test_get_settings_returns_default_for_missing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + let result = get_settings_at(&path).unwrap(); + assert!(result.auto_pull_interval_minutes.is_none()); + } + + #[test] + fn test_save_and_load_preserves_values() { + let loaded = save_and_reload(Settings { + auto_pull_interval_minutes: Some(10), + git_enabled: Some(false), + autogit_enabled: Some(true), + autogit_idle_threshold_seconds: Some(90), + autogit_inactive_threshold_seconds: Some(30), + auto_advance_inbox_after_organize: Some(true), + release_channel: Some("alpha".to_string()), + automatic_update_checks_enabled: Some(false), + theme_mode: Some("dark".to_string()), + ui_language: Some("zh-Hans".to_string()), + date_display_format: Some("european".to_string()), + note_width_mode: Some("wide".to_string()), + sidebar_type_pluralization_enabled: Some(false), + initial_h1_auto_rename_enabled: Some(false), + ai_features_enabled: Some(false), + default_ai_agent: Some("codex".to_string()), + hide_gitignored_files: Some(false), + multi_workspace_enabled: Some(true), + all_notes_show_pdfs: Some(true), + all_notes_show_images: Some(false), + all_notes_show_unsupported: Some(true), + ..Default::default() + }); + assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); + assert_eq!(loaded.git_enabled, Some(false)); + assert_eq!(loaded.autogit_enabled, Some(true)); + assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90)); + assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30)); + assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true)); + assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.automatic_update_checks_enabled, Some(false)); + assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); + assert_eq!(loaded.ui_language.as_deref(), Some("zh-CN")); + assert_eq!(loaded.date_display_format.as_deref(), Some("european")); + assert_eq!(loaded.note_width_mode.as_deref(), Some("wide")); + assert_eq!(loaded.sidebar_type_pluralization_enabled, Some(false)); + assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); + assert_eq!(loaded.ai_features_enabled, Some(false)); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); + assert_eq!(loaded.hide_gitignored_files, Some(false)); + assert_eq!(loaded.multi_workspace_enabled, Some(true)); + assert_eq!(loaded.all_notes_show_pdfs, Some(true)); + assert_eq!(loaded.all_notes_show_images, Some(false)); + assert_eq!(loaded.all_notes_show_unsupported, Some(true)); + } + + #[test] + fn test_gitignored_files_are_hidden_by_default() { + assert!(should_hide_gitignored_files(&Settings::default())); + assert!(should_hide_gitignored_files(&Settings { + hide_gitignored_files: Some(true), + ..Default::default() + })); + assert!(!should_hide_gitignored_files(&Settings { + hide_gitignored_files: Some(false), + ..Default::default() + })); + } + + #[test] + fn test_git_provider_settings_are_normalized() { + let loaded = save_and_reload(Settings { + git_provider: Some(" WSL ".to_string()), + git_wsl_distro: Some(" Ubuntu-24.04 ".to_string()), + ..Default::default() + }); + assert_eq!(loaded.git_provider.as_deref(), Some("wsl")); + assert_eq!(loaded.git_wsl_distro.as_deref(), Some("Ubuntu-24.04")); + + let invalid = save_and_reload(Settings { + git_provider: Some("portable".to_string()), + git_wsl_distro: Some(" ".to_string()), + ..Default::default() + }); + assert!(invalid.git_provider.is_none()); + assert!(invalid.git_wsl_distro.is_none()); + } + + #[test] + fn test_save_trims_whitespace() { + let loaded = save_and_reload(Settings { + anonymous_id: Some(" test-uuid ".to_string()), + git_path: Some(" /opt/homebrew/bin/git ".to_string()), + git_provider: Some(" native ".to_string()), + git_wsl_distro: Some(" Ubuntu ".to_string()), + release_channel: Some(" alpha ".to_string()), + theme_mode: Some(" dark ".to_string()), + ui_language: Some(" zh-cn ".to_string()), + date_display_format: Some(" ISO ".to_string()), + note_width_mode: Some(" WIDE ".to_string()), + default_ai_agent: Some(" codex ".to_string()), + ..Default::default() + }); + assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); + assert_eq!(loaded.git_path.as_deref(), Some("/opt/homebrew/bin/git")); + assert_eq!(loaded.git_provider.as_deref(), Some("native")); + assert_eq!(loaded.git_wsl_distro.as_deref(), Some("Ubuntu")); + assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); + assert_eq!(loaded.ui_language.as_deref(), Some("zh-CN")); + assert_eq!(loaded.date_display_format.as_deref(), Some("iso")); + assert_eq!(loaded.note_width_mode.as_deref(), Some("wide")); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); + } + + #[test] + fn test_save_filters_empty_and_whitespace_only() { + let loaded = save_and_reload(Settings { + release_channel: Some("".to_string()), + ..Default::default() + }); + assert!(loaded.release_channel.is_none()); + } + + #[test] + fn test_non_positive_autogit_thresholds_are_filtered() { + let loaded = save_and_reload(Settings { + autogit_idle_threshold_seconds: Some(0), + autogit_inactive_threshold_seconds: Some(0), + ..Default::default() + }); + assert!(loaded.autogit_idle_threshold_seconds.is_none()); + assert!(loaded.autogit_inactive_threshold_seconds.is_none()); + } + + #[test] + fn test_non_alpha_release_channels_normalize_to_stable() { + let loaded = save_and_reload(Settings { + release_channel: Some("beta".to_string()), + ..Default::default() + }); + assert!(loaded.release_channel.is_none()); + } + + #[test] + fn test_invalid_default_ai_agent_is_filtered() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("cursor".to_string()), + ..Default::default() + }); + assert!(loaded.default_ai_agent.is_none()); + } + + #[test] + fn test_opencode_default_ai_agent_is_preserved() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("opencode".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("opencode")); + } + + #[test] + fn test_copilot_default_ai_agent_is_preserved() { + let loaded = normalize_settings(Settings { + default_ai_agent: Some("copilot".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("copilot")); + } + + #[test] + fn test_pi_default_ai_agent_is_preserved() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("pi".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("pi")); + } + + #[test] + fn test_antigravity_default_ai_agent_is_preserved() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("antigravity".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("antigravity")); + } + + #[test] + fn test_legacy_gemini_default_ai_agent_migrates_to_antigravity() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("gemini".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("antigravity")); + } + + #[test] + fn test_hermes_default_ai_agent_is_preserved() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("hermes".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("hermes")); + } + + #[test] + fn test_system_theme_mode_is_preserved() { + let loaded = save_and_reload(Settings { + theme_mode: Some("system".to_string()), + ..Default::default() + }); + assert_eq!(loaded.theme_mode.as_deref(), Some("system")); + } + + #[test] + fn test_invalid_theme_mode_is_filtered() { + let loaded = save_and_reload(Settings { + theme_mode: Some("sepia".to_string()), + ..Default::default() + }); + assert!(loaded.theme_mode.is_none()); + } + + #[test] + fn test_invalid_note_width_mode_is_filtered() { + let loaded = save_and_reload(Settings { + note_width_mode: Some("expanded".to_string()), + ..Default::default() + }); + assert!(loaded.note_width_mode.is_none()); + } + + #[test] + fn test_invalid_date_display_format_is_filtered() { + let loaded = save_and_reload(Settings { + date_display_format: Some("relative".to_string()), + ..Default::default() + }); + assert!(loaded.date_display_format.is_none()); + } + + #[test] + fn test_invalid_ui_language_is_filtered() { + let loaded = save_and_reload(Settings { + ui_language: Some("xx-ZZ".to_string()), + ..Default::default() + }); + assert!(loaded.ui_language.is_none()); + } + + #[test] + fn test_supported_ui_languages_are_saved_and_reloaded() { + let expected_languages = [ + ("it-IT", "it-IT"), + ("fr-FR", "fr-FR"), + ("de-DE", "de-DE"), + ("ru-RU", "ru-RU"), + ("es-ES", "es-ES"), + ("pt-BR", "pt-BR"), + ("pt-PT", "pt-PT"), + ("es-419", "es-419"), + ("zh-CN", "zh-CN"), + ("zh-TW", "zh-TW"), + ("ja-JP", "ja-JP"), + ("ko-KR", "ko-KR"), + ("vi", "vi"), + ("pl-PL", "pl-PL"), + ("be-BY", "be-BY"), + ("be-Latn", "be-Latn"), + ("id-ID", "id-ID"), + ("sk-SK", "sk-SK"), + ]; + + for (input, expected) in expected_languages { + let loaded = save_and_reload(Settings { + ui_language: Some(input.to_string()), + ..Default::default() + }); + assert_eq!(loaded.ui_language.as_deref(), Some(expected)); + } + } + + #[test] + fn test_ui_language_aliases_are_canonicalized() { + assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en")); + assert_eq!( + normalize_ui_language(Some("zh_CN")).as_deref(), + Some("zh-CN") + ); + assert_eq!( + normalize_ui_language(Some("zh-Hant")).as_deref(), + Some("zh-TW") + ); + assert_eq!(normalize_ui_language(Some("pl")).as_deref(), Some("pl-PL")); + assert_eq!(normalize_ui_language(Some("be")).as_deref(), Some("be-BY")); + assert_eq!( + normalize_ui_language(Some("be-latn")).as_deref(), + Some("be-Latn") + ); + assert_eq!(normalize_ui_language(Some("id")).as_deref(), Some("id-ID")); + assert_eq!(normalize_ui_language(Some("sk")).as_deref(), Some("sk-SK")); + } + + #[test] + fn test_get_settings_normalizes_legacy_beta_channel() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, r#"{"release_channel":"beta"}"#).unwrap(); + + let loaded = get_settings_at(&path).unwrap(); + assert!(loaded.release_channel.is_none()); + } + + #[test] + fn test_save_creates_parent_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("dir").join("settings.json"); + + save_settings_at( + &path, + Settings { + anonymous_id: Some("test-uuid".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert!(path.exists()); + assert_eq!( + get_settings_at(&path).unwrap().anonymous_id.as_deref(), + Some("test-uuid") + ); + } + + #[test] + fn test_get_settings_malformed_json() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("bad.json"); + fs::write(&path, "not valid json{{{").unwrap(); + + let err = get_settings_at(&path).unwrap_err(); + assert!(err.contains("Failed to parse settings")); + } + + #[test] + fn test_telemetry_fields_roundtrip() { + let loaded = save_and_reload(Settings { + telemetry_consent: Some(true), + crash_reporting_enabled: Some(true), + analytics_enabled: Some(false), + anonymous_id: Some("test-uuid-v4".to_string()), + ..Default::default() + }); + assert_eq!( + loaded, + Settings { + telemetry_consent: Some(true), + crash_reporting_enabled: Some(true), + analytics_enabled: Some(false), + anonymous_id: Some("test-uuid-v4".to_string()), + ..Default::default() + } + ); + } + + #[test] + fn test_old_settings_json_missing_telemetry_fields() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("settings.json"); + // Simulate an old settings.json that still contains removed GitHub auth fields. + let legacy_token = ["gho", "test"].join("_"); + let legacy_settings = serde_json::json!({ + "github_token": legacy_token, + "github_username": "lucaong", + }); + fs::write(&path, legacy_settings.to_string()).unwrap(); + let loaded = get_settings_at(&path).unwrap(); + assert_empty_settings(&loaded); + } + + #[test] + fn test_settings_path_returns_ok() { + let result = settings_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + let path = path.to_str().unwrap(); + assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app")); + } + + #[test] + fn test_preferred_settings_path_uses_tolaria_namespace() { + let result = preferred_app_config_path("settings.json"); + assert!(result.is_ok()); + assert!(result + .unwrap() + .to_str() + .unwrap() + .contains("com.tolaria.app")); + } + + #[test] + fn test_ai_workspace_sessions_roundtrip() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("ai-workspace-sessions.json"); + let sessions = serde_json::json!({ + "chat-1": { + "messages": [ + { + "userMessage": "Hello", + "actions": [], + "response": "Hi" + } + ], + "status": "done" + } + }); + + save_ai_workspace_sessions_at(&path, sessions.clone()).unwrap(); + + assert_eq!(get_ai_workspace_sessions_at(&path).unwrap(), sessions); + } + + #[test] + fn test_ai_workspace_sessions_missing_file_returns_empty_object() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("ai-workspace-sessions.json"); + + assert_eq!( + get_ai_workspace_sessions_at(&path).unwrap(), + serde_json::json!({}) + ); + } + + #[test] + fn test_ai_workspace_sessions_rejects_non_object_payload() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("ai-workspace-sessions.json"); + + assert!(save_ai_workspace_sessions_at(&path, serde_json::json!([])).is_err()); + } + + #[test] + fn test_get_last_vault_returns_none_for_missing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("last-vault.txt"); + assert!(get_last_vault_at(&path).is_none()); + } + + #[test] + fn test_set_and_get_last_vault_roundtrip() { + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/MyVault"); + } + + #[test] + fn test_set_last_vault_trims_whitespace() { + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/Vault"); + } + + #[test] + fn test_get_last_vault_returns_none_for_empty_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("last-vault.txt"); + fs::write(&path, " \n ").unwrap(); + assert!(get_last_vault_at(&path).is_none()); + } + + #[test] + fn test_set_last_vault_creates_parent_directories() { + let (_dir, path) = create_last_vault_path(&["nested", "dir", "last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/Vault"); + assert!(path.exists()); + } + + #[test] + fn test_set_last_vault_overwrites_previous() { + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/OldVault"); + write_and_assert_last_vault(&path, "/Users/test/NewVault"); + } +} diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..838635b --- /dev/null +++ b/src-tauri/src/telemetry.rs @@ -0,0 +1,225 @@ +use crate::settings; +use chrono::NaiveDate; +use regex::Regex; +use std::borrow::Cow; +use std::sync::Mutex; + +/// Global Sentry guard — must live for the duration of the app. +static SENTRY_GUARD: Mutex> = Mutex::new(None); + +/// Scrub absolute file paths from a string to prevent vault content leakage. +fn scrub_paths(input: &str) -> String { + let re = Regex::new(r"(?:/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+").unwrap(); + re.replace_all(input, "").to_string() +} + +fn normalize_embedded_env(raw: Option<&str>) -> Option { + let value = raw?.trim(); + if value.is_empty() { + return None; + } + + let unwrapped = match (value.chars().next(), value.chars().last()) { + (Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => { + value[1..value.len() - 1].trim() + } + _ => value, + }; + + if unwrapped.is_empty() { + None + } else { + Some(unwrapped.to_string()) + } +} + +fn normalize_http_like_value(value: &str) -> String { + if value.contains("://") { + value.to_string() + } else { + format!("https://{value}") + } +} + +fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option { + let normalized = normalize_embedded_env(raw)?; + normalize_http_like_value(&normalized).parse().ok() +} + +fn is_stable_calendar_release(version: &str) -> bool { + let mut parts = version.split('.'); + let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else { + return false; + }; + + if parts.next().is_some() { + return false; + } + + let (Ok(year), Ok(month), Ok(day)) = ( + year.parse::(), + month.parse::(), + day.parse::(), + ) else { + return false; + }; + + NaiveDate::from_ymd_opt(year, month, day).is_some() +} + +fn sentry_release_for_version(version: &'static str) -> Option> { + is_stable_calendar_release(version).then(|| version.into()) +} + +fn sentry_release_kind(version: &str) -> &'static str { + if is_stable_calendar_release(version) { + "stable" + } else if version.contains('-') { + "prerelease" + } else { + "internal" + } +} + +/// Initialize Sentry if the user has opted in to crash reporting. +/// Returns `true` if Sentry was initialized, `false` if skipped. +pub fn init_sentry_from_settings() -> bool { + let settings = match settings::get_settings() { + Ok(s) => s, + Err(_) => return false, + }; + + if settings.crash_reporting_enabled != Some(true) { + return false; + } + + let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else { + return false; + }; + + let anonymous_id = settings.anonymous_id.unwrap_or_default(); + let build_version = env!("CARGO_PKG_VERSION"); + let guard = sentry::init(sentry::ClientOptions { + dsn: Some(dsn), + release: sentry_release_for_version(build_version), + send_default_pii: false, + before_send: Some(std::sync::Arc::new(|mut event| { + if let Some(ref mut msg) = event.message { + *msg = scrub_paths(msg); + } + Some(event) + })), + ..Default::default() + }); + + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry::User { + id: Some(anonymous_id), + ..Default::default() + })); + scope.set_tag("tolaria.build_version", build_version); + scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version)); + }); + + *SENTRY_GUARD.lock().unwrap() = Some(guard); + true +} + +/// Tear down and reinitialize Sentry (called when user changes settings). +pub fn reinit_sentry() { + // Drop old guard first + *SENTRY_GUARD.lock().unwrap() = None; + // Re-read settings and optionally re-init + init_sentry_from_settings(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scrub_paths_unix() { + assert_eq!( + scrub_paths("Error at /Users/luca/Laputa/note.md"), + "Error at " + ); + } + + #[test] + fn test_scrub_paths_windows() { + assert_eq!( + scrub_paths("Error at C:\\Users\\test\\doc.md"), + "Error at " + ); + } + + #[test] + fn test_scrub_paths_no_paths() { + assert_eq!(scrub_paths("Normal error message"), "Normal error message"); + } + + #[test] + fn test_normalize_embedded_env_trims_wrapping_quotes() { + assert_eq!( + normalize_embedded_env(Some(" \"value\" ")).as_deref(), + Some("value") + ); + assert_eq!( + normalize_embedded_env(Some(" 'value' ")).as_deref(), + Some("value") + ); + } + + #[test] + fn test_normalize_embedded_env_drops_blank_values() { + assert_eq!(normalize_embedded_env(Some(" ")), None); + assert_eq!(normalize_embedded_env(None), None); + } + + #[test] + fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() { + let parsed = + parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" ")); + assert!(parsed.is_some()); + } + + #[test] + fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() { + let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1")); + assert!(parsed.is_some()); + } + + #[test] + fn test_parse_embedded_sentry_dsn_rejects_invalid_value() { + let parsed = parse_embedded_sentry_dsn(Some("not a dsn")); + assert!(parsed.is_none()); + } + + #[test] + fn test_init_sentry_returns_false_without_dsn() { + // Without SENTRY_DSN env var set at compile time, init should return false + assert!(!init_sentry_from_settings()); + } + + #[test] + fn test_sentry_release_for_stable_calendar_version() { + assert!(sentry_release_for_version("2026.4.28").is_some()); + } + + #[test] + fn test_sentry_release_for_alpha_version_is_none() { + assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none()); + } + + #[test] + fn test_sentry_release_for_local_dev_version_is_none() { + assert!(sentry_release_for_version("0.1.0").is_none()); + } + + #[test] + fn test_sentry_release_kind_classifies_versions() { + assert_eq!(sentry_release_kind("2026.4.28"), "stable"); + assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease"); + assert_eq!(sentry_release_kind("0.1.0"), "internal"); + } +} diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs new file mode 100644 index 0000000..b4e67a1 --- /dev/null +++ b/src-tauri/src/vault/cache.rs @@ -0,0 +1,1476 @@ +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::hash::{Hash, Hasher}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use uuid::Uuid; + +use crate::git::{get_all_file_dates, GitDates}; +use std::collections::HashMap; + +use super::path_identity::{ + normalize_path_for_identity, push_unique_relative_path, relative_path_key, + vault_relative_path_string, +}; +use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry}; + +// --- Vault Cache --- + +/// Bump this when VaultEntry fields change to force a full rescan. +/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items) +/// v14: preserve scalar-array custom frontmatter properties in VaultEntry +const CACHE_VERSION: u32 = 14; +const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct VaultCache { + #[serde(default = "default_cache_version")] + version: u32, + /// The vault path when the cache was written. Used to detect stale caches + /// from a different machine or a moved vault directory. + #[serde(default)] + vault_path: String, + commit_hash: String, + entries: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CacheFileFingerprint { + byte_len: usize, + content_hash: u64, +} + +#[derive(Debug)] +struct LoadedCache { + cache: VaultCache, + fingerprint: CacheFileFingerprint, +} + +#[derive(Debug)] +enum CacheLoadState { + Missing, + Loaded(LoadedCache), + Invalid(String), + Unreadable(String), +} + +#[derive(Debug, Eq, PartialEq)] +enum CacheWriteOutcome { + Replaced, + SkippedConcurrentUpdate, + SkippedActiveWriter, +} + +struct CacheWriteLock { + path: PathBuf, +} + +impl Drop for CacheWriteLock { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + if error.kind() != ErrorKind::NotFound { + log::warn!( + "Failed to release cache write lock {}: {}", + self.path.display(), + error + ); + } + } + } +} + +fn default_cache_version() -> u32 { + 1 +} + +/// Compute a deterministic hex hash of the vault path for use as cache filename. +fn vault_path_hash(vault: &Path) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + normalize_path_for_identity(&vault.to_string_lossy()).hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// Return the cache directory. Override with `LAPUTA_CACHE_DIR` env var (for tests). +fn cache_dir() -> PathBuf { + if let Ok(dir) = std::env::var("LAPUTA_CACHE_DIR") { + return PathBuf::from(dir); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".laputa") + .join("cache") +} + +fn cache_path(vault: &Path) -> PathBuf { + cache_dir().join(format!("{}.json", vault_path_hash(vault))) +} + +fn cache_lock_path(vault: &Path) -> PathBuf { + cache_path(vault).with_extension("lock") +} + +fn cache_temp_path(final_path: &Path) -> PathBuf { + let file_name = final_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("cache.json"); + final_path.with_file_name(format!("{file_name}.{}.tmp", Uuid::new_v4())) +} + +/// Legacy cache path inside the vault directory (pre-migration). +fn legacy_cache_path(vault: &Path) -> PathBuf { + vault.join(".laputa-cache.json") +} + +fn git_head_hash(vault: &Path) -> Option { + run_git(vault, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string()) +} + +/// Run a git command in the given directory and return stdout if successful. +fn run_git(vault: &Path, args: &[&str]) -> Option { + let output = crate::git::git_command_at(vault) + .and_then(|mut command| command.args(args).output()) + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Parse a git status porcelain line into (status_code, file_path). +fn parse_porcelain_line(line: &str) -> Option<(&str, String)> { + if line.len() < 3 { + return None; + } + Some((&line[..2], line[3..].trim().to_string())) +} + +fn push_changed_path_prefer_existing(paths: &mut Vec, vault: &Path, path: &str) { + let normalized = super::path_identity::normalize_relative_path(path); + if normalized.is_empty() || super::path_identity::has_hidden_segment(&normalized) { + return; + } + + let key = relative_path_key(&normalized); + if let Some(existing_index) = paths + .iter() + .position(|existing| relative_path_key(existing) == key) + { + let existing_path = vault.join(&paths[existing_index]); + let candidate_path = vault.join(&normalized); + if !existing_path.is_file() && candidate_path.is_file() { + paths[existing_index] = normalized; + } + return; + } + + paths.push(normalized); +} + +/// Extract file paths from git diff --name-only output. +/// Includes all non-hidden files (not just .md) so the cache picks up +/// view files (.yml), binary assets, etc. +fn collect_paths_from_diff(vault: &Path, stdout: &str) -> Vec { + let mut paths = Vec::new(); + for line in stdout.lines() { + push_changed_path_prefer_existing(&mut paths, vault, line); + } + paths +} + +/// Extract file paths from git status --porcelain output. +/// Includes all non-hidden files so incremental cache updates cover +/// every file type the vault scanner recognises. +fn collect_paths_from_porcelain(stdout: &str) -> Vec { + let mut paths = Vec::new(); + for (_, path) in stdout.lines().filter_map(parse_porcelain_line) { + push_unique_relative_path(&mut paths, path); + } + paths +} + +fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec { + let diff_arg = format!("{}..{}", from_hash, to_hash); + let mut files = run_git(vault, &["diff", &diff_arg, "--name-only"]) + .map(|s| collect_paths_from_diff(vault, &s)) + .unwrap_or_default(); + + // Include uncommitted changes (modified, staged, and untracked files). + let uncommitted = git_uncommitted_files(vault); + + for path in uncommitted.into_iter() { + push_unique_relative_path(&mut files, path); + } + + files +} + +fn git_uncommitted_files(vault: &Path) -> Vec { + // Modified/staged tracked files from git status --porcelain + let mut files: Vec = run_git(vault, &["status", "--porcelain"]) + .map(|s| collect_paths_from_porcelain(&s)) + .unwrap_or_default(); + + // Untracked files via ls-files (lists individual files, not just directories). + // git status --porcelain shows `?? dir/` for new directories, hiding individual + // files inside — ls-files resolves them so the cache picks up all new files. + let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"]) + .map(|s| { + let mut paths = Vec::new(); + for line in s.lines() { + push_unique_relative_path(&mut paths, line); + } + paths + }) + .unwrap_or_default(); + + for path in untracked { + push_unique_relative_path(&mut files, path); + } + + files +} + +fn cache_fingerprint(bytes: &[u8]) -> CacheFileFingerprint { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + CacheFileFingerprint { + byte_len: bytes.len(), + content_hash: hasher.finish(), + } +} + +fn read_cache_bytes(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "Failed to read cache {}: {}", + path.display(), + error + )), + } +} + +fn read_cache_fingerprint(path: &Path) -> Result, String> { + Ok(read_cache_bytes(path)?.map(|bytes| cache_fingerprint(&bytes))) +} + +fn load_cache(vault: &Path) -> CacheLoadState { + let path = cache_path(vault); + let Some(bytes) = (match read_cache_bytes(&path) { + Ok(bytes) => bytes, + Err(error) => return CacheLoadState::Unreadable(error), + }) else { + return CacheLoadState::Missing; + }; + + let fingerprint = cache_fingerprint(&bytes); + match serde_json::from_slice(&bytes) { + Ok(cache) => CacheLoadState::Loaded(LoadedCache { cache, fingerprint }), + Err(error) => CacheLoadState::Invalid(format!( + "Failed to parse cache {}: {}", + path.display(), + error + )), + } +} + +fn lock_is_stale(lock_path: &Path) -> bool { + fs::metadata(lock_path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.elapsed().ok()) + .map(|elapsed| elapsed > Duration::from_secs(CACHE_WRITE_LOCK_STALE_SECS)) + .unwrap_or(false) +} + +fn ensure_cache_parent_dir(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create cache directory {}: {}", + parent.display(), + error + ) + })?; + } + Ok(()) +} + +fn initialize_cache_write_lock( + mut file: fs::File, + lock_path: &Path, +) -> Result { + let pid = std::process::id().to_string(); + if let Err(error) = file.write_all(pid.as_bytes()).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(lock_path); + return Err(format!( + "Failed to initialize cache write lock {}: {}", + lock_path.display(), + error + )); + } + Ok(CacheWriteLock { + path: lock_path.to_path_buf(), + }) +} + +fn try_create_cache_write_lock(lock_path: &Path) -> Result, String> { + match OpenOptions::new() + .write(true) + .create_new(true) + .open(lock_path) + { + Ok(file) => initialize_cache_write_lock(file, lock_path).map(Some), + Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(None), + Err(error) => Err(format!( + "Failed to acquire cache write lock {}: {}", + lock_path.display(), + error + )), + } +} + +fn remove_stale_cache_write_lock(lock_path: &Path) -> Result { + if !lock_is_stale(lock_path) { + return Ok(false); + } + + log::warn!("Removing stale cache write lock {}", lock_path.display()); + match fs::remove_file(lock_path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(true), + Err(error) => Err(format!( + "Failed to remove stale cache write lock {}: {}", + lock_path.display(), + error + )), + } +} + +fn acquire_cache_write_lock(lock_path: &Path) -> Result, String> { + ensure_cache_parent_dir(lock_path)?; + if let Some(lock) = try_create_cache_write_lock(lock_path)? { + return Ok(Some(lock)); + } + if !remove_stale_cache_write_lock(lock_path)? { + return Ok(None); + } + try_create_cache_write_lock(lock_path) +} + +fn remove_cache_file(path: &Path, reason: &str) { + if let Err(error) = fs::remove_file(path) { + if error.kind() != ErrorKind::NotFound { + log::warn!("Failed to remove {reason} {}: {}", path.display(), error); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<(), String> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + fs::File::open(parent) + .and_then(|dir| dir.sync_all()) + .map_err(|error| { + format!( + "Failed to sync cache directory {}: {}", + parent.display(), + error + ) + }) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +/// Replace the cache file using a temp file + rename, but only if the on-disk +/// cache still matches the version we loaded earlier. +fn write_cache( + vault: &Path, + cache: &VaultCache, + expected_previous: Option, +) -> Result { + let final_path = cache_path(vault); + let lock_path = cache_lock_path(vault); + let Some(_lock) = acquire_cache_write_lock(&lock_path)? else { + return Ok(CacheWriteOutcome::SkippedActiveWriter); + }; + + let current_fingerprint = read_cache_fingerprint(&final_path)?; + let still_matches_loaded_state = match expected_previous.as_ref() { + Some(expected) => current_fingerprint.as_ref() == Some(expected), + None => current_fingerprint.is_none(), + }; + if !still_matches_loaded_state { + return Ok(CacheWriteOutcome::SkippedConcurrentUpdate); + } + + ensure_cache_parent_dir(&final_path)?; + + let data = serde_json::to_vec(cache).map_err(|error| { + format!( + "Failed to serialize cache {}: {}", + final_path.display(), + error + ) + })?; + let tmp_path = cache_temp_path(&final_path); + let mut tmp_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .map_err(|error| { + format!( + "Failed to create temp cache file {}: {}", + tmp_path.display(), + error + ) + })?; + + if let Err(error) = tmp_file.write_all(&data).and_then(|_| tmp_file.sync_all()) { + remove_cache_file(&tmp_path, "temp cache file"); + return Err(format!( + "Failed to flush temp cache file {}: {}", + tmp_path.display(), + error + )); + } + drop(tmp_file); + + if let Err(error) = fs::rename(&tmp_path, &final_path) { + remove_cache_file(&tmp_path, "temp cache file"); + return Err(format!( + "Failed to replace cache {}: {}", + final_path.display(), + error + )); + } + + if let Err(error) = sync_parent_directory(&final_path) { + log::warn!("{error}"); + } + + Ok(CacheWriteOutcome::Replaced) +} + +/// Normalize an absolute path to a relative path for comparison with git output. +fn to_relative_path(abs_path: &str, vault: &Path) -> String { + vault_relative_path_string(vault, Path::new(abs_path)) + .unwrap_or_else(|_| normalize_path_for_identity(abs_path)) +} + +fn to_relative_path_key(abs_path: &str, vault: &Path) -> String { + relative_path_key(&to_relative_path(abs_path, vault)) +} + +/// Parse files from a list of relative paths, skipping any that don't exist. +/// Dispatches to the appropriate parser based on file extension. +fn parse_files_at( + vault: &Path, + rel_paths: &[String], + git_dates: &HashMap, +) -> Vec { + rel_paths + .iter() + .filter_map(|rel| { + let abs = vault.join(rel); + if abs.is_file() { + let dates = git_dates + .get(rel.as_str()) + .map(|d| (d.modified_at, d.created_at)); + if is_md_file(&abs) { + parse_md_file(&abs, dates).ok() + } else { + parse_non_md_file(&abs, dates).ok() + } + } else { + None + } + }) + .collect() +} + +/// Copy legacy cache data to the new external location via temp file + rename. +fn copy_legacy_cache_to(legacy: &Path, dest: &Path) { + if let Some(parent) = dest.parent() { + let _ = fs::create_dir_all(parent); + } + let tmp_path = dest.with_extension("tmp"); + if let Ok(data) = fs::read_to_string(legacy) { + if fs::write(&tmp_path, &data).is_ok() { + let _ = fs::rename(&tmp_path, dest); + } + } +} + +/// Migrate legacy cache from inside the vault to the new external location. +/// Also removes the legacy file from git tracking if present. +fn migrate_legacy_cache(vault: &Path) { + let legacy = legacy_cache_path(vault); + if !legacy.exists() { + return; + } + + let new_path = cache_path(vault); + if !new_path.exists() { + copy_legacy_cache_to(&legacy, &new_path); + } + + // Remove legacy file from git tracking if present + let _ = crate::hidden_command("git") + .args([ + "rm", + "--cached", + "--quiet", + "--ignore-unmatch", + ".laputa-cache.json", + ]) + .current_dir(vault) + .output(); + + // Delete the legacy file from disk + let _ = fs::remove_file(&legacy); +} + +/// Remove entries for files that no longer exist on disk and deduplicate +/// by case-folded relative path (handles case-insensitive filesystems like macOS APFS). +/// Returns `true` if any entries were removed. +fn prune_stale_entries(vault: &Path, entries: &mut Vec) -> bool { + let before = entries.len(); + // Remove entries whose files no longer exist on disk + entries.retain(|e| std::path::Path::new(&e.path).is_file()); + // Deduplicate by case-folded relative path + let mut seen = std::collections::HashSet::new(); + entries.retain(|e| { + let rel = to_relative_path_key(&e.path, vault); + seen.insert(rel) + }); + entries.len() != before +} + +/// Sort entries by modified_at descending and write the cache. +fn finalize_and_cache( + vault: &Path, + mut entries: Vec, + hash: String, + expected_previous: Option, +) -> Vec { + prune_stale_entries(vault, &mut entries); + entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at)); + let outcome = write_cache( + vault, + &VaultCache { + version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), + commit_hash: hash, + entries: entries.clone(), + }, + expected_previous, + ); + match outcome { + Ok(CacheWriteOutcome::Replaced) => {} + Ok(CacheWriteOutcome::SkippedConcurrentUpdate) => log::info!( + "Skipped replacing cache {} because another scan refreshed it first", + cache_path(vault).display() + ), + Ok(CacheWriteOutcome::SkippedActiveWriter) => log::info!( + "Skipped replacing cache {} because another writer is active", + cache_path(vault).display() + ), + Err(error) => log::warn!("{error}"), + } + entries +} + +/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files). +/// Always prunes stale entries even when git reports no changes, so that files +/// deleted outside git (e.g., via Finder) are removed from the cache on vault open. +fn update_same_commit( + vault: &Path, + loaded_cache: LoadedCache, + git_dates: &HashMap, +) -> Vec { + let LoadedCache { cache, fingerprint } = loaded_cache; + let changed = git_uncommitted_files(vault); + let mut entries = cache.entries; + if !changed.is_empty() { + let changed_set: std::collections::HashSet = + changed.iter().map(|path| relative_path_key(path)).collect(); + entries.retain(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault))); + entries.extend(parse_files_at(vault, &changed, git_dates)); + } + // Always finalize: prune_stale_entries inside finalize_and_cache removes + // entries for files deleted outside git (e.g., via Finder or another app). + finalize_and_cache(vault, entries, cache.commit_hash, Some(fingerprint)) +} + +/// Handle different-commit cache: incremental update via git diff. +fn update_different_commit( + vault: &Path, + loaded_cache: LoadedCache, + current_hash: String, + git_dates: &HashMap, +) -> Vec { + let LoadedCache { cache, fingerprint } = loaded_cache; + let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash); + let changed_set: std::collections::HashSet = changed_files + .iter() + .map(|path| relative_path_key(path)) + .collect(); + + let mut entries: Vec = cache + .entries + .into_iter() + .filter(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault))) + .collect(); + entries.extend(parse_files_at(vault, &changed_files, git_dates)); + + finalize_and_cache(vault, entries, current_hash, Some(fingerprint)) +} + +fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool { + let current_vault_str = normalize_path_for_identity(&vault_path.to_string_lossy()); + cache.version != CACHE_VERSION + || (!cache.vault_path.is_empty() + && normalize_path_for_identity(&cache.vault_path) != current_vault_str) +} + +fn scan_and_cache_full( + vault_path: &Path, + git_dates: &HashMap, + current_hash: String, + expected_previous: Option, +) -> Result, String> { + let entries = scan_vault(vault_path, git_dates)?; + Ok(finalize_and_cache( + vault_path, + entries, + current_hash, + expected_previous, + )) +} + +/// Delete the cache file for a vault, forcing a full rescan on the next +/// call to `scan_vault_cached`. Used by the `reload_vault` command so that +/// explicit user-triggered reloads always read from the filesystem. +pub fn invalidate_cache(vault_path: &Path) { + let path = cache_path(vault_path); + remove_cache_file(&path, "cache file"); +} + +/// Scan vault with incremental caching via git. +/// Falls back to full scan if cache is missing/corrupt or git is unavailable. +pub fn scan_vault_cached(vault_path: &Path) -> Result, String> { + if !vault_path.exists() || !vault_path.is_dir() { + return Err(format!( + "Vault path does not exist or is not a directory: {}", + vault_path.display() + )); + } + + // Migrate legacy in-vault cache to external location on first run + migrate_legacy_cache(vault_path); + + let current_hash = match git_head_hash(vault_path) { + Some(h) => h, + None => return scan_vault(vault_path, &HashMap::new()), + }; + + // Build git dates map once — used by all code paths below + let git_dates = get_all_file_dates(vault_path); + + match load_cache(vault_path) { + CacheLoadState::Missing => {} + CacheLoadState::Unreadable(error) => log::warn!("{error}"), + CacheLoadState::Invalid(error) => { + log::warn!("{error}"); + remove_cache_file(&cache_path(vault_path), "invalid cache file"); + } + CacheLoadState::Loaded(loaded_cache) => { + if cache_requires_full_rescan(&loaded_cache.cache, vault_path) { + return scan_and_cache_full( + vault_path, + &git_dates, + current_hash, + Some(loaded_cache.fingerprint), + ); + } + return if loaded_cache.cache.commit_hash == current_hash { + Ok(update_same_commit(vault_path, loaded_cache, &git_dates)) + } else { + Ok(update_different_commit( + vault_path, + loaded_cache, + current_hash, + &git_dates, + )) + }; + } + } + + // No cache — full scan and write cache + scan_and_cache_full(vault_path, &git_dates, current_hash, None) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Serialize all cache tests that mutate the LAPUTA_CACHE_DIR env var. + /// `std::env::set_var` is process-global, so parallel tests would race. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Set up a temporary cache directory for test isolation. + /// Caller MUST hold `ENV_LOCK` for the duration of the test. + fn set_test_cache_dir(dir: &Path) { + std::env::set_var("LAPUTA_CACHE_DIR", dir.to_string_lossy().as_ref()); + } + + fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + } + + fn init_git_repo(vault: &Path) { + crate::hidden_command("git") + .args(["init"]) + .current_dir(vault) + .output() + .unwrap(); + crate::hidden_command("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(vault) + .output() + .unwrap(); + crate::hidden_command("git") + .args(["config", "user.name", "Test"]) + .current_dir(vault) + .output() + .unwrap(); + } + + /// Common setup: acquire env lock, create temp cache dir + git-initialised vault. + /// Returns (lock_guard, cache_tmpdir, vault_tmpdir) — keep all alive for the test. + fn setup_git_vault() -> (std::sync::MutexGuard<'static, ()>, TempDir, TempDir) { + let lock = ENV_LOCK.lock().unwrap(); + let cache_tmp = TempDir::new().unwrap(); + set_test_cache_dir(cache_tmp.path()); + let vault_tmp = TempDir::new().unwrap(); + init_git_repo(vault_tmp.path()); + (lock, cache_tmp, vault_tmp) + } + + fn git_add_commit(vault: &Path, msg: &str) { + crate::hidden_command("git") + .args(["add", "."]) + .current_dir(vault) + .output() + .unwrap(); + crate::hidden_command("git") + .args(["commit", "-m", msg]) + .current_dir(vault) + .output() + .unwrap(); + } + + fn force_quoted_git_paths(vault: &Path) { + crate::hidden_command("git") + .args(["config", "core.quotePath", "true"]) + .current_dir(vault) + .output() + .unwrap(); + } + + #[test] + fn test_cache_path_is_outside_vault() { + let _lock = ENV_LOCK.lock().unwrap(); + let cache_dir = TempDir::new().unwrap(); + set_test_cache_dir(cache_dir.path()); + + let vault = Path::new("/Users/test/MyVault"); + let path = cache_path(vault); + + // Cache must NOT be inside the vault + assert!( + !path.starts_with(vault), + "cache path must be outside the vault, got: {}", + path.display() + ); + // Cache must be under the cache directory + assert!( + path.starts_with(cache_dir.path()), + "cache path must be under cache dir, got: {}", + path.display() + ); + // Must end with .json + assert_eq!(path.extension().unwrap(), "json"); + } + + #[test] + fn test_vault_path_hash_is_deterministic() { + let hash1 = vault_path_hash(Path::new("/Users/test/MyVault")); + let hash2 = vault_path_hash(Path::new("/Users/test/MyVault")); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_to_relative_path_normalizes_aliases_and_separators() { + assert_eq!( + to_relative_path( + "/tmp/tolaria-vault/projects\\active.md", + Path::new("/private/tmp/tolaria-vault") + ), + "projects/active.md" + ); + } + + #[test] + fn test_different_vaults_get_different_hashes() { + let hash1 = vault_path_hash(Path::new("/Users/test/Vault1")); + let hash2 = vault_path_hash(Path::new("/Users/test/Vault2")); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_cache_write_no_tmp_file_left() { + let _lock = ENV_LOCK.lock().unwrap(); + let cache_dir = TempDir::new().unwrap(); + set_test_cache_dir(cache_dir.path()); + + let vault_dir = TempDir::new().unwrap(); + let vault = vault_dir.path(); + + let cache = VaultCache { + version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), + commit_hash: "abc123".to_string(), + entries: vec![], + }; + + write_cache(vault, &cache, None).unwrap(); + + // Final file should exist + let final_path = cache_path(vault); + assert!(final_path.exists(), "cache file must exist after write"); + + // Tmp files should NOT remain beside the cache file + let tmp_count = fs::read_dir(cache_dir.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp")) + .count(); + assert_eq!(tmp_count, 0, "cache write must not leave tmp files behind"); + + // Content must be valid JSON + let data = fs::read_to_string(&final_path).unwrap(); + let loaded: VaultCache = serde_json::from_str(&data).unwrap(); + assert_eq!(loaded.commit_hash, "abc123"); + } + + #[test] + fn test_legacy_cache_migration() { + let (_lock, _cache_tmp, vault_dir) = setup_git_vault(); + let vault = vault_dir.path(); + + // Create a legacy cache file inside the vault + let legacy = legacy_cache_path(vault); + let cache = VaultCache { + version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), + commit_hash: "old123".to_string(), + entries: vec![], + }; + fs::write(&legacy, serde_json::to_string(&cache).unwrap()).unwrap(); + + // Run migration + migrate_legacy_cache(vault); + + // New cache file should exist with migrated data + let new_path = cache_path(vault); + assert!(new_path.exists(), "migrated cache must exist"); + let data = fs::read_to_string(&new_path).unwrap(); + let loaded: VaultCache = serde_json::from_str(&data).unwrap(); + assert_eq!(loaded.commit_hash, "old123"); + + // Legacy file should be deleted + assert!(!legacy.exists(), "legacy cache file must be removed"); + } + + #[test] + fn test_scan_vault_cached_no_git() { + let _lock = ENV_LOCK.lock().unwrap(); + let cache_dir = TempDir::new().unwrap(); + set_test_cache_dir(cache_dir.path()); + + // Without git, scan_vault_cached falls back to scan_vault + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "note.md", "# Note\n\nContent here."); + + let entries = scan_vault_cached(dir.path()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].title, "Note"); + assert_eq!(entries[0].snippet, "Content here."); + } + + #[test] + fn test_scan_vault_cached_with_git() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "# Note\n\nFirst version."); + git_add_commit(vault, "init"); + + // First call: full scan, writes cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!(cache_path(vault).exists()); + + // Cache must NOT be inside the vault + assert!( + !cache_path(vault).starts_with(vault), + "cache must be outside the vault" + ); + + // Second call: uses cache (same HEAD) + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert_eq!(entries2[0].title, "Note"); + } + + #[test] + fn test_scan_vault_cached_invalidates_stale_vault_path() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "# Note\n\nContent."); + git_add_commit(vault, "init"); + + // Build cache normally + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0] + .path + .starts_with(vault.to_string_lossy().as_ref()), + "Entry path should start with vault path" + ); + + // Tamper with cache to simulate a clone from a different machine + let cache_file = cache_path(vault); + let cache_data = fs::read_to_string(&cache_file).unwrap(); + let tampered = cache_data.replace( + vault.to_string_lossy().as_ref(), + "/Users/other-machine/OtherVault", + ); + fs::write(&cache_file, tampered).unwrap(); + + // Rescanning should invalidate the stale cache and produce correct paths + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert!( + entries2[0] + .path + .starts_with(vault.to_string_lossy().as_ref()), + "After stale-cache invalidation, paths should use the current vault path, got: {}", + entries2[0].path + ); + } + + #[test] + fn test_scan_vault_cached_incremental_different_commit() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "first.md", "# First\n\nFirst note."); + git_add_commit(vault, "first"); + + // Build cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Add a second file and commit + create_test_file(vault, "second.md", "# Second\n\nSecond note."); + git_add_commit(vault, "second"); + + // Incremental update: cache has old commit, new commit adds second.md + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 2); + let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); + assert!(titles.contains(&"First")); + assert!(titles.contains(&"Second")); + } + + #[test] + fn test_update_same_commit_picks_up_modified_file() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + // Commit a type note without sidebar label + create_test_file(vault, "news.md", "---\ntype: Type\n---\n# News\n"); + git_add_commit(vault, "init"); + + // Prime the cache (same commit hash) + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].sidebar_label, None); + + // User edits the type note to add sidebar label (uncommitted) + create_test_file( + vault, + "news.md", + "---\ntype: Type\nsidebar label: News\n---\n# News\n", + ); + + // Reload with same git HEAD — must pick up the modification + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert_eq!( + entries2[0].sidebar_label, + Some("News".to_string()), + "sidebarLabel must reflect the uncommitted edit" + ); + } + + #[test] + fn test_git_uncommitted_files_preserves_chinese_markdown_path() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + let relative_path = "中文笔记.md"; + + force_quoted_git_paths(vault); + create_test_file(vault, relative_path, "# 初始\n"); + git_add_commit(vault, "init"); + create_test_file(vault, relative_path, "# 初始\n\n更新\n"); + + let changed = git_uncommitted_files(vault); + + assert_eq!(changed, vec![relative_path.to_string()]); + } + + #[test] + fn test_update_same_commit_new_file_still_added() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "existing.md", "# Existing\n"); + git_add_commit(vault, "init"); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Create new untracked file + create_test_file(vault, "new-note.md", "# New Note\n"); + + // Cache still same commit — new untracked file must appear + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 2); + let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); + assert!(titles.contains(&"Existing")); + assert!(titles.contains(&"New Note")); + } + + #[test] + fn test_update_same_commit_new_files_in_new_subdirectory() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file( + vault, + "existing.md", + "---\ntitle: Existing\n---\n# Existing\n", + ); + git_add_commit(vault, "init"); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Create files in a new protected subdirectory (simulates asset creation) + create_test_file( + vault, + "assets/default-theme.md", + "---\ntitle: Default Theme\nIs A: Theme\n---\n# Default Theme\n", + ); + create_test_file( + vault, + "assets/dark-theme.md", + "---\ntitle: Dark Theme\nIs A: Theme\n---\n# Dark Theme\n", + ); + + // Cache same commit — files in new subdirectory must appear + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!( + entries2.len(), + 3, + "must pick up files in new untracked subdirectory" + ); + let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); + assert!(titles.contains(&"Existing")); + assert!(titles.contains(&"Default Theme")); + assert!(titles.contains(&"Dark Theme")); + } + + #[test] + fn test_update_same_commit_visible_removed_from_type_note() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + // Commit a type note with visible: false + create_test_file( + vault, + "topic.md", + "---\ntype: Type\nvisible: false\n---\n# Topic\n", + ); + git_add_commit(vault, "init"); + + // Prime the cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].visible, + Some(false), + "visible must be false initially" + ); + + // User removes visible field (uncommitted edit) + create_test_file(vault, "topic.md", "---\ntype: Type\n---\n# Topic\n"); + + // Reload — must reflect the removal (visible defaults to None) + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert_eq!( + entries2[0].visible, None, + "visible must be None after removing the field" + ); + } + + #[test] + fn test_deleted_file_removed_from_cache_on_rescan() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "keep.md", "# Keep\n\nStays."); + create_test_file(vault, "remove.md", "# Remove\n\nGoes away."); + git_add_commit(vault, "init"); + + // Prime cache with both files + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 2); + + // Delete file via filesystem (simulates Finder delete) + fs::remove_file(vault.join("remove.md")).unwrap(); + // Also stage the deletion so git status is clean for this file + crate::hidden_command("git") + .args(["add", "remove.md"]) + .current_dir(vault) + .output() + .unwrap(); + + // Rescan — deleted file must be pruned + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1, "deleted file must be pruned on rescan"); + assert_eq!(entries2[0].title, "Keep"); + } + + #[test] + fn test_deleted_untracked_file_removed_from_cache() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "tracked.md", "# Tracked\n\nCommitted."); + git_add_commit(vault, "init"); + + // Create untracked file and prime cache + create_test_file(vault, "temp.md", "# Temp\n\nUntracked."); + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 2); + + // Delete the untracked file via filesystem + fs::remove_file(vault.join("temp.md")).unwrap(); + + // Rescan — untracked deleted file must be pruned + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!( + entries2.len(), + 1, + "deleted untracked file must be pruned on rescan" + ); + assert_eq!(entries2[0].title, "Tracked"); + } + + #[test] + fn test_case_rename_no_duplicates() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "Note.md", "# Note\n\nOriginal case."); + git_add_commit(vault, "init"); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Simulate case-only rename on case-insensitive FS: delete old, create new + fs::remove_file(vault.join("Note.md")).unwrap(); + create_test_file(vault, "note.md", "# Note\n\nRenamed case."); + git_add_commit(vault, "rename"); + + // Rescan — must not have duplicates + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!( + entries2.len(), + 1, + "case-only rename must not create duplicates" + ); + } + + #[test] + fn test_invalidate_cache_deletes_cache_file() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "# Note\n\nContent."); + git_add_commit(vault, "init"); + + // Build cache + let _ = scan_vault_cached(vault).unwrap(); + assert!(cache_path(vault).exists(), "cache file must exist"); + + // Invalidate + invalidate_cache(vault); + assert!( + !cache_path(vault).exists(), + "cache file must be deleted after invalidation" + ); + } + + #[test] + fn test_invalidate_then_scan_forces_full_rescan() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "---\n_archived: false\n---\n# Note\n"); + git_add_commit(vault, "init"); + + // Build cache — note is not archived + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!(!entries[0].archived, "note must not be archived initially"); + + // Simulate archiving the note on disk (update frontmatter directly) + create_test_file(vault, "note.md", "---\n_archived: true\n---\n# Note\n"); + // Stage the change so git sees it + git_add_commit(vault, "archive"); + + // Without invalidation, scan_vault_cached uses incremental update. + // With invalidation, it must do a full rescan from disk. + invalidate_cache(vault); + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!(entries2.len(), 1); + assert!( + entries2[0].archived, + "note must be archived after invalidate + rescan" + ); + } + + /// Integration test: a note with `Archived: Yes` (string, not boolean) + /// must be recognized as archived through the full cached vault load path. + /// This catches the scenario where a stale cache stores `archived: false` + /// and the cache version bump forces a correct re-parse. + #[test] + fn test_cached_vault_archived_yes_string() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file( + vault, + "archived-note.md", + "---\nArchived: Yes\n---\n# Old Note\n", + ); + git_add_commit(vault, "init"); + + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].archived, + "'Archived: Yes' must be parsed as true through the cached vault path" + ); + } + + /// Integration test: stale cache with old version is invalidated and + /// re-parses `Archived: Yes` correctly after cache version bump. + #[test] + fn test_stale_cache_version_forces_rescan_of_archived_yes() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "---\nArchived: Yes\n---\n# Note\n"); + git_add_commit(vault, "init"); + + let hash = git_head_hash(vault).unwrap(); + + // Simulate a stale cache written by old code that parsed Archived: Yes as false + let stale_entry = { + let mut e = parse_md_file(&vault.join("note.md"), None).unwrap(); + e.archived = false; // simulate old parser behavior + e + }; + let stale_cache = VaultCache { + version: CACHE_VERSION - 1, // old version + vault_path: vault.to_string_lossy().to_string(), + commit_hash: hash, + entries: vec![stale_entry], + }; + write_cache(vault, &stale_cache, None).unwrap(); + + // Load via cached path — stale version must trigger full rescan + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].archived, + "stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true" + ); + } + + #[test] + fn test_update_same_commit_picks_up_new_yml_file() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "# Note\n\nContent."); + git_add_commit(vault, "init"); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Create a new .yml view file (untracked, like save_view does) + create_test_file(vault, "views/my-view.yml", "name: My View\nfilters: []\n"); + + // Same commit — new .yml file must appear in entries + let entries2 = scan_vault_cached(vault).unwrap(); + assert!( + entries2.len() >= 2, + "new .yml file must be picked up by cache update, got {} entries", + entries2.len() + ); + assert!( + entries2.iter().any(|e| e.path.contains("my-view.yml")), + "entries must include the new .yml file" + ); + } + + #[test] + fn test_incremental_different_commit_picks_up_yml_file() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file(vault, "note.md", "# Note\n\nContent."); + git_add_commit(vault, "init"); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Add a .yml file and commit + create_test_file(vault, "views/my-view.yml", "name: My View\nfilters: []\n"); + git_add_commit(vault, "add view"); + + // Different commit — .yml file must appear in entries + let entries2 = scan_vault_cached(vault).unwrap(); + assert!( + entries2.iter().any(|e| e.path.contains("my-view.yml")), + "committed .yml file must be picked up by incremental cache update" + ); + } + + #[test] + fn test_load_cache_marks_invalid_json() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + fs::write(cache_path(vault), "{ not-json").unwrap(); + + let load = load_cache(vault); + assert!( + matches!(load, CacheLoadState::Invalid(_)), + "invalid cache JSON must be distinguished from a cache miss" + ); + } + + #[test] + fn test_write_cache_skips_overwriting_newer_cache() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + let original = VaultCache { + version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), + commit_hash: "original".to_string(), + entries: vec![], + }; + write_cache(vault, &original, None).unwrap(); + + let CacheLoadState::Loaded(loaded) = load_cache(vault) else { + panic!("expected original cache to load"); + }; + + let newer = VaultCache { + commit_hash: "newer".to_string(), + ..original + }; + write_cache(vault, &newer, Some(loaded.fingerprint.clone())).unwrap(); + + let stale = VaultCache { + commit_hash: "stale".to_string(), + ..newer + }; + let outcome = write_cache(vault, &stale, Some(loaded.fingerprint)).unwrap(); + assert_eq!(outcome, CacheWriteOutcome::SkippedConcurrentUpdate); + + let CacheLoadState::Loaded(final_cache) = load_cache(vault) else { + panic!("expected final cache to load"); + }; + assert_eq!(final_cache.cache.commit_hash, "newer"); + } + + #[test] + fn test_write_cache_skips_when_writer_lock_is_held() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + let lock_path = cache_lock_path(vault); + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&lock_path, "busy").unwrap(); + + let cache = VaultCache { + version: CACHE_VERSION, + vault_path: vault.to_string_lossy().to_string(), + commit_hash: "busy".to_string(), + entries: vec![], + }; + let outcome = write_cache(vault, &cache, None).unwrap(); + assert_eq!(outcome, CacheWriteOutcome::SkippedActiveWriter); + assert!( + !cache_path(vault).exists(), + "active writer lock must prevent a competing cache write" + ); + } +} diff --git a/src-tauri/src/vault/config_seed.rs b/src-tauri/src/vault/config_seed.rs new file mode 100644 index 0000000..9463c2a --- /dev/null +++ b/src-tauri/src/vault/config_seed.rs @@ -0,0 +1,870 @@ +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::getting_started::{agents_content_is_known_managed_template, AGENTS_MD}; + +/// Content for `type.md` — describes the generic Type metamodel for the vault. +const TYPE_TYPE_DEFINITION: &str = "\ +--- +type: Type +order: 0 +visible: false +--- + +# Type + +A Type defines shared metadata and defaults for a category of notes in this vault. + +## Common properties +- **Icon**: Sidebar icon for this type +- **Color**: Accent color for notes of this type +- **Order**: Sidebar ordering +- **Sidebar label**: Override the default plural label +- **Template**: Default body for new notes of this type +- **View**: Preferred note-list view for this type +"; + +/// Content for `note.md` — restores the default Note type definition when missing. +const NOTE_TYPE_DEFINITION: &str = "\ +--- +type: Type +--- + +# Note + +A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type. +"; + +const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md + +This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`. +"; + +const HEADING_CLAUDE_MD_SHIM: &str = "@AGENTS.md + +# CLAUDE.md + +This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`. +"; + +const CLAUDE_MD_SHIM: &str = "--- +type: Note +_organized: true +--- + +@AGENTS.md + +This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`. +"; + +const GEMINI_MD_SHIM: &str = "--- +type: Note +_organized: true +--- + +@AGENTS.md + +This file is only an Antigravity/Gemini compatibility shim. Keep shared agent instructions in `AGENTS.md`. +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AiGuidanceFileState { + Managed, + Missing, + Broken, + Custom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VaultAiGuidanceStatus { + pub agents_state: AiGuidanceFileState, + pub claude_state: AiGuidanceFileState, + pub gemini_state: AiGuidanceFileState, + pub can_restore: bool, +} + +struct GuidancePaths { + agents: PathBuf, + claude: PathBuf, + gemini: PathBuf, +} + +#[derive(Debug, Default)] +struct LegacyAgentsMigrationOutcome { + copied_to_root: bool, + removed_legacy: bool, +} + +/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written. +fn write_if_missing(path: &Path, content: &str) -> Result { + let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0); + if needs_write { + fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + } + Ok(needs_write) +} + +fn read_file_or_empty(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_default() +} + +fn agents_content_can_be_replaced(content: &str) -> bool { + content.trim().is_empty() + || content.contains("See config/agents.md") + || agents_content_is_known_managed_template(content) +} + +fn root_agents_can_be_replaced(path: &Path) -> bool { + !path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path)) +} + +fn matches_claude_shim(content: &str) -> bool { + let trimmed = content.trim(); + trimmed == "@AGENTS.md" + || trimmed == LEGACY_CLAUDE_MD_SHIM.trim() + || trimmed == HEADING_CLAUDE_MD_SHIM.trim() + || trimmed == CLAUDE_MD_SHIM.trim() +} + +fn matches_gemini_shim(content: &str) -> bool { + content.trim() == GEMINI_MD_SHIM.trim() +} + +fn claude_shim_can_be_replaced(path: &Path) -> bool { + !path.exists() || { + let content = read_file_or_empty(path); + content.trim().is_empty() || matches_claude_shim(&content) + } +} + +fn gemini_shim_can_be_replaced(path: &Path) -> bool { + !path.exists() || { + let content = read_file_or_empty(path); + content.trim().is_empty() || matches_gemini_shim(&content) + } +} + +fn sync_managed_file( + path: &Path, + content: &str, + can_replace: fn(&Path) -> bool, +) -> Result { + if !can_replace(path) { + return Ok(false); + } + + fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + Ok(true) +} + +fn strip_markdown_frontmatter(content: &str) -> &str { + let Some(line_ending) = resolve_frontmatter_line_ending(content) else { + return content; + }; + let after_open = &content[3 + line_ending.len()..]; + let close_marker = format!("{line_ending}---"); + let Some(close_index) = after_open.find(&close_marker) else { + return content; + }; + let after_close = &after_open[close_index + close_marker.len()..]; + after_close.strip_prefix(line_ending).unwrap_or(after_close) +} + +fn resolve_frontmatter_line_ending(content: &str) -> Option<&'static str> { + if content.starts_with("---\r\n") { + return Some("\r\n"); + } + content.starts_with("---\n").then_some("\n") +} + +fn guidance_content_has_body(content: &str) -> bool { + !strip_markdown_frontmatter(content).trim().is_empty() +} + +fn classify_guidance_file( + path: &Path, + matches_managed: fn(&str) -> bool, + can_replace: fn(&Path) -> bool, +) -> AiGuidanceFileState { + if !path.exists() { + return AiGuidanceFileState::Missing; + } + + let content = read_file_or_empty(path); + if matches_managed(&content) { + return AiGuidanceFileState::Managed; + } + + if !guidance_content_has_body(&content) { + return AiGuidanceFileState::Broken; + } + + if can_replace(path) { + return AiGuidanceFileState::Broken; + } + + AiGuidanceFileState::Custom +} + +fn guidance_paths(vault_path: &Path) -> GuidancePaths { + GuidancePaths { + agents: vault_path.join("AGENTS.md"), + claude: vault_path.join("CLAUDE.md"), + gemini: vault_path.join("GEMINI.md"), + } +} + +fn classify_agents_file(path: &Path) -> AiGuidanceFileState { + classify_guidance_file( + path, + |content| content == AGENTS_MD, + root_agents_can_be_replaced, + ) +} + +fn classify_claude_file(path: &Path) -> AiGuidanceFileState { + classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced) +} + +fn classify_gemini_file(path: &Path) -> AiGuidanceFileState { + classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced) +} + +fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool { + matches!( + state, + AiGuidanceFileState::Missing | AiGuidanceFileState::Broken + ) +} + +fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus { + let paths = guidance_paths(vault_path); + let agents_state = classify_agents_file(&paths.agents); + let claude_state = classify_claude_file(&paths.claude); + let gemini_state = classify_gemini_file(&paths.gemini); + + VaultAiGuidanceStatus { + agents_state, + claude_state, + gemini_state, + can_restore: guidance_file_needs_restore(agents_state) + || guidance_file_needs_restore(claude_state) + || guidance_file_needs_restore(gemini_state), + } +} + +fn sync_claude_shim_file(vault_path: &Path) -> Result { + let paths = guidance_paths(vault_path); + sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced) +} + +fn sync_gemini_shim_file(vault_path: &Path) -> Result { + let paths = guidance_paths(vault_path); + sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced) +} + +fn sync_required_ai_guidance_files(vault_path: &Path) -> Result { + let wrote_agents = sync_default_agents_file(vault_path)?; + let wrote_claude = sync_claude_shim_file(vault_path)?; + Ok(wrote_agents || wrote_claude) +} + +fn sync_all_ai_guidance_files(vault_path: &Path) -> Result { + let wrote_required = sync_required_ai_guidance_files(vault_path)?; + let wrote_gemini = sync_gemini_shim_file(vault_path)?; + Ok(wrote_required || wrote_gemini) +} + +fn migrate_legacy_agents_file( + root_agents: &Path, + config_agents: &Path, +) -> Result { + let mut outcome = LegacyAgentsMigrationOutcome::default(); + if !config_agents.exists() { + return Ok(outcome); + } + + let config_content = read_file_or_empty(config_agents); + if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) { + fs::write(root_agents, &config_content) + .map_err(|e| format!("Failed to write AGENTS.md: {e}"))?; + outcome.copied_to_root = true; + } + + fs::remove_file(config_agents) + .map_err(|e| format!("Failed to remove config/agents.md: {e}"))?; + outcome.removed_legacy = true; + + Ok(outcome) +} + +fn cleanup_empty_config_dir(vault: &Path) -> Result { + let config_dir = vault.join("config"); + if !config_dir.is_dir() { + return Ok(false); + } + + let is_empty = fs::read_dir(&config_dir) + .map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))? + .next() + .is_none(); + if !is_empty { + return Ok(false); + } + + fs::remove_dir(&config_dir) + .map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?; + Ok(true) +} + +pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result { + let paths = guidance_paths(vault_path); + sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced) +} + +pub fn get_ai_guidance_status( + vault_path: impl AsRef, +) -> Result { + Ok(build_ai_guidance_status(Path::new(vault_path.as_ref()))) +} + +pub fn restore_ai_guidance_files( + vault_path: impl AsRef, +) -> Result { + let vault_path = Path::new(vault_path.as_ref()); + sync_all_ai_guidance_files(vault_path)?; + Ok(build_ai_guidance_status(vault_path)) +} + +/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file). +/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows. +pub fn seed_config_files(vault_path: impl AsRef) { + let vault_path = Path::new(vault_path.as_ref()); + if sync_required_ai_guidance_files(vault_path).unwrap_or(false) { + log::info!("Seeded vault AI guidance files at vault root"); + } + + ensure_root_type_definitions(vault_path); +} + +fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) { + let path = vault_path.join(file_name); + let _ = write_if_missing(&path, content); +} + +/// Ensure the default root type definitions exist for opened/repaired vaults. +fn ensure_root_type_definitions(vault_path: &Path) { + ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION); + ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION); +} + +/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults. +/// +/// - If `config/agents.md` has real content and root `AGENTS.md` is missing/empty/stub: +/// move content to root, remove legacy file. +/// - If root `AGENTS.md` doesn't exist: write defaults. +/// - Cleans up empty `config/` directory after migration. +/// +/// Always idempotent and silent. +pub fn migrate_agents_md(vault_path: impl AsRef) { + let vault = Path::new(vault_path.as_ref()); + let root_agents = vault.join("AGENTS.md"); + let config_agents = vault.join("config").join("agents.md"); + + if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) { + if outcome.copied_to_root { + log::info!("Migrated config/agents.md content to root AGENTS.md"); + } + if outcome.removed_legacy { + log::info!("Removed legacy config/agents.md"); + } + } + + if cleanup_empty_config_dir(vault).unwrap_or(false) { + log::info!("Removed empty config/ directory"); + } + + let _ = sync_required_ai_guidance_files(vault); +} + +/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions. +/// Migrates legacy `config/agents.md` to root if present. +/// Called by the "Repair Vault" command. Returns a status message. +pub fn repair_config_files(vault_path: impl AsRef) -> Result { + let vault = Path::new(vault_path.as_ref()); + let root_agents = vault.join("AGENTS.md"); + let config_agents = vault.join("config").join("agents.md"); + + migrate_legacy_agents_file(&root_agents, &config_agents)?; + let _ = cleanup_empty_config_dir(vault)?; + sync_required_ai_guidance_files(vault)?; + + write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?; + write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?; + + Ok("Config files repaired".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_vault() -> (TempDir, PathBuf) { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + (dir, vault) + } + + fn config_dir(vault: &Path) -> PathBuf { + let dir = vault.join("config"); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_root_agents(vault: &Path, content: &str) { + fs::write(vault.join("AGENTS.md"), content).unwrap(); + } + + fn write_root_claude(vault: &Path, content: &str) { + fs::write(vault.join("CLAUDE.md"), content).unwrap(); + } + + fn write_root_gemini(vault: &Path, content: &str) { + fs::write(vault.join("GEMINI.md"), content).unwrap(); + } + + fn write_legacy_agents(vault: &Path, content: &str) { + fs::write(config_dir(vault).join("agents.md"), content).unwrap(); + } + + fn read_root_agents(vault: &Path) -> String { + fs::read_to_string(vault.join("AGENTS.md")).unwrap() + } + + fn read_root_claude(vault: &Path) -> String { + fs::read_to_string(vault.join("CLAUDE.md")).unwrap() + } + + fn read_root_gemini(vault: &Path) -> String { + fs::read_to_string(vault.join("GEMINI.md")).unwrap() + } + + type VaultOperation = fn(&Path); + + fn run_seed(vault: &Path) { + seed_config_files(vault.to_str().unwrap()); + } + + fn run_migrate(vault: &Path) { + migrate_agents_md(vault.to_str().unwrap()); + } + + fn run_repair(vault: &Path) { + repair_config_files(vault.to_str().unwrap()).unwrap(); + } + + fn run_with_agents( + run: VaultOperation, + root_agents: Option<&str>, + legacy_agents: Option<&str>, + ) -> (TempDir, PathBuf) { + let (dir, vault) = create_vault(); + if let Some(content) = root_agents { + write_root_agents(&vault, content); + } + if let Some(content) = legacy_agents { + write_legacy_agents(&vault, content); + } + + run(&vault); + (dir, vault) + } + + fn assert_preserves_custom_agents(run: VaultOperation) { + let (_dir, vault) = run_with_agents( + run, + Some("# Custom Agent Config\nMy custom instructions\n"), + None, + ); + + assert!( + read_root_agents(&vault).contains("Custom Agent Config"), + "must preserve existing content" + ); + } + + fn assert_preserves_edited_default_agents(run: VaultOperation) { + let edited_agents = AGENTS_MD.replacen( + "Store note type in the `type:` frontmatter field.", + "`type:` is the preferred type field. Tolaria still understands legacy aliases such as `Is A`.", + 1, + ); + let (_dir, vault) = run_with_agents(run, Some(&edited_agents), None); + + let content = read_root_agents(&vault); + assert!(content.contains("Tolaria still understands legacy aliases such as `Is A`.")); + assert!(!content.contains("Store note type in the `type:` frontmatter field.")); + } + + fn assert_legacy_agents_move_to_root( + run: VaultOperation, + legacy_agents: &str, + expected_root_text: &str, + expect_config_dir_removed: bool, + ) { + let (_dir, vault) = run_with_agents(run, None, Some(legacy_agents)); + + let config_dir = vault.join("config"); + let root_content = read_root_agents(&vault); + assert!(root_content.contains(expected_root_text)); + assert!(!config_dir.join("agents.md").exists()); + assert_eq!(config_dir.exists(), !expect_config_dir_removed); + } + + fn assert_stub_agents_are_replaced( + run: VaultOperation, + legacy_agents: &str, + expected_root_text: &str, + ) { + let (_dir, vault) = run_with_agents( + run, + Some("# Agent Instructions\nSee config/agents.md for vault instructions.\n"), + Some(legacy_agents), + ); + + let content = read_root_agents(&vault); + assert!(content.contains(expected_root_text)); + } + + fn assert_required_agents_file_seeded(vault: &Path) { + assert!(vault.join("AGENTS.md").exists()); + assert!(read_root_agents(vault).contains("Tolaria Vault")); + } + + fn assert_required_guidance_shims_seeded(vault: &Path) { + assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM); + assert!(!vault.join("GEMINI.md").exists()); + } + + fn assert_required_guidance_files_seeded(vault: &Path) { + assert_required_agents_file_seeded(vault); + assert_required_guidance_shims_seeded(vault); + } + + fn assert_root_type_definitions_seeded(vault: &Path) { + assert!(vault.join("type.md").exists()); + assert!(vault.join("note.md").exists()); + assert!(!vault.join("config").exists()); + } + + fn assert_type_definition_content(vault: &Path) { + let type_content = fs::read_to_string(vault.join("type.md")).unwrap(); + let note_content = fs::read_to_string(vault.join("note.md")).unwrap(); + assert_type_definition_body(&type_content); + assert_note_definition_body(¬e_content); + } + + fn assert_type_definition_body(type_content: &str) { + assert!(type_content.contains("type: Type")); + assert!(type_content.contains("# Type")); + assert!(type_content.contains("visible: false")); + } + + fn assert_note_definition_body(note_content: &str) { + assert!(note_content.contains("type: Type")); + assert!(note_content.contains("# Note")); + } + + #[test] + fn test_seed_config_files_creates_guidance_files_at_root() { + let (_dir, vault) = create_vault(); + + seed_config_files(vault.to_str().unwrap()); + + assert_required_guidance_files_seeded(&vault); + } + + #[test] + fn test_seed_config_files_creates_type_definitions() { + let (_dir, vault) = create_vault(); + + seed_config_files(vault.to_str().unwrap()); + + assert_root_type_definitions_seeded(&vault); + assert_type_definition_content(&vault); + } + + #[test] + fn test_seed_config_files_preserves_custom_agents() { + assert_preserves_custom_agents(run_seed); + } + + #[test] + fn test_seed_config_files_reseeds_empty() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, ""); + + seed_config_files(vault.to_str().unwrap()); + assert!(read_root_agents(&vault).contains("Tolaria Vault")); + } + + #[test] + fn test_seed_config_files_preserves_edited_stale_default_agents() { + let (_dir, vault) = create_vault(); + write_root_agents( + &vault, + "# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n", + ); + + seed_config_files(vault.to_str().unwrap()); + + let content = read_root_agents(&vault); + assert!(content.contains("Do not add `title:` frontmatter.")); + assert!(!content.contains("Use the first H1 as the note title.")); + } + + #[test] + fn test_seed_config_files_preserves_edited_default_agents() { + assert_preserves_edited_default_agents(run_seed); + } + + #[test] + fn test_seed_config_files_preserves_custom_claude() { + let (_dir, vault) = create_vault(); + write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n"); + + seed_config_files(vault.to_str().unwrap()); + + assert!(read_root_claude(&vault).contains("Custom Claude instructions")); + } + + #[test] + fn test_seed_config_files_refreshes_previous_managed_claude_shim() { + let (_dir, vault) = create_vault(); + write_root_claude(&vault, HEADING_CLAUDE_MD_SHIM); + + seed_config_files(vault.to_str().unwrap()); + + assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM); + } + + #[test] + fn test_migrate_agents_md_moves_config_to_root() { + assert_legacy_agents_move_to_root( + run_migrate, + "# My vault agent instructions\nCustom content\n", + "My vault agent instructions", + true, + ); + } + + #[test] + fn test_migrate_agents_md_preserves_existing_root() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, "# My root agent config\nDo not overwrite\n"); + write_legacy_agents(&vault, "Legacy content"); + + migrate_agents_md(vault.to_str().unwrap()); + + let config_dir = vault.join("config"); + let content = read_root_agents(&vault); + assert!(content.contains("My root agent config")); + assert!(!config_dir.join("agents.md").exists()); + } + + #[test] + fn test_migrate_agents_md_replaces_stub_with_config_content() { + assert_stub_agents_are_replaced( + run_migrate, + "# Real Agent Config\nImportant instructions\n", + "Real Agent Config", + ); + } + + #[test] + fn test_migrate_agents_md_idempotent_when_no_legacy() { + let (_dir, vault) = create_vault(); + + migrate_agents_md(vault.to_str().unwrap()); + + assert!(vault.join("AGENTS.md").exists()); + let root = read_root_agents(&vault); + assert!(root.contains("Tolaria Vault")); + assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM); + } + + #[test] + fn test_migrate_agents_md_keeps_nonempty_config_dir() { + let (_dir, vault) = create_vault(); + let config_dir = config_dir(&vault); + fs::write(config_dir.join("agents.md"), "Agent content").unwrap(); + fs::write(config_dir.join("other.md"), "Other file").unwrap(); + + migrate_agents_md(vault.to_str().unwrap()); + + assert!(config_dir.exists()); + assert!(config_dir.join("other.md").exists()); + assert!(!config_dir.join("agents.md").exists()); + } + + #[test] + fn test_repair_config_files_creates_all() { + let (_dir, vault) = create_vault(); + + let msg = repair_config_files(vault.to_str().unwrap()).unwrap(); + assert_eq!(msg, "Config files repaired"); + + assert_required_guidance_files_seeded(&vault); + assert_root_type_definitions_seeded(&vault); + assert_type_definition_content(&vault); + assert!(fs::read_to_string(vault.join("note.md")) + .unwrap() + .contains("general-purpose document")); + } + + #[test] + fn test_repair_config_files_preserves_custom_content() { + assert_preserves_custom_agents(run_repair); + } + + #[test] + fn test_repair_config_files_migrates_legacy_config() { + assert_legacy_agents_move_to_root( + run_repair, + "# My vault agent instructions\nCustom content\n", + "My vault agent instructions", + true, + ); + } + + #[test] + fn test_repair_config_files_replaces_stub_with_legacy() { + assert_stub_agents_are_replaced( + run_repair, + "# Real Instructions\nImportant stuff\n", + "Real Instructions", + ); + } + + #[test] + fn test_repair_config_files_preserves_edited_default_agents() { + assert_preserves_edited_default_agents(run_repair); + } + + #[test] + fn test_get_ai_guidance_status_reports_custom_and_repairable_files() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, "# Custom Agent Config\nHands off\n"); + write_root_claude(&vault, ""); + + let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap(); + assert_eq!( + status, + VaultAiGuidanceStatus { + agents_state: AiGuidanceFileState::Custom, + claude_state: AiGuidanceFileState::Broken, + gemini_state: AiGuidanceFileState::Missing, + can_restore: true, + } + ); + } + + #[test] + fn test_get_ai_guidance_status_treats_edited_agents_as_custom() { + let (_dir, vault) = create_vault(); + let edited_agents = AGENTS_MD.replacen("type: Note", "type: Vault Guidance", 1) + + "\n\n- Prefer the user's vault-specific naming conventions.\n"; + write_root_agents(&vault, &edited_agents); + write_root_claude(&vault, CLAUDE_MD_SHIM); + write_root_gemini(&vault, GEMINI_MD_SHIM); + + let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap(); + + assert_eq!( + status, + VaultAiGuidanceStatus { + agents_state: AiGuidanceFileState::Custom, + claude_state: AiGuidanceFileState::Managed, + gemini_state: AiGuidanceFileState::Managed, + can_restore: false, + } + ); + } + + #[test] + fn test_get_ai_guidance_status_reports_frontmatter_only_agents_as_broken() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, "---\ntype: Vault Guidance\n---\n"); + write_root_claude(&vault, CLAUDE_MD_SHIM); + write_root_gemini(&vault, GEMINI_MD_SHIM); + + let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap(); + + assert_eq!( + status, + VaultAiGuidanceStatus { + agents_state: AiGuidanceFileState::Broken, + claude_state: AiGuidanceFileState::Managed, + gemini_state: AiGuidanceFileState::Managed, + can_restore: true, + } + ); + } + + #[test] + fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, "# Custom Agent Config\nHands off\n"); + write_root_claude(&vault, ""); + + let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap(); + assert_eq!( + status, + VaultAiGuidanceStatus { + agents_state: AiGuidanceFileState::Custom, + claude_state: AiGuidanceFileState::Managed, + gemini_state: AiGuidanceFileState::Managed, + can_restore: false, + } + ); + assert!(read_root_agents(&vault).contains("Custom Agent Config")); + assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM); + assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM); + } + + #[test] + fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, ""); + + let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap(); + assert_eq!( + status, + VaultAiGuidanceStatus { + agents_state: AiGuidanceFileState::Managed, + claude_state: AiGuidanceFileState::Managed, + gemini_state: AiGuidanceFileState::Managed, + can_restore: false, + } + ); + } + + #[test] + fn test_restore_ai_guidance_files_preserves_custom_gemini() { + let (_dir, vault) = create_vault(); + write_root_agents(&vault, AGENTS_MD); + write_root_claude(&vault, CLAUDE_MD_SHIM); + write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n"); + + let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap(); + + assert_eq!(status.gemini_state, AiGuidanceFileState::Custom); + assert!(!status.can_restore); + assert!(read_root_gemini(&vault).contains("Custom Gemini instructions")); + } +} diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs new file mode 100644 index 0000000..9cb8282 --- /dev/null +++ b/src-tauri/src/vault/entry.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A node in the vault's folder tree. Only contains directories, not files. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FolderNode { + /// Folder name (last path component). + pub name: String, + /// Path relative to the vault root, using `/` separators (e.g. "projects/laputa"). + pub path: String, + /// Child folders (sorted alphabetically). + pub children: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct VaultEntry { + pub path: String, + pub filename: String, + pub title: String, + #[serde(rename = "isA")] + pub is_a: Option, + pub aliases: Vec, + #[serde(rename = "belongsTo")] + pub belongs_to: Vec, + #[serde(rename = "relatedTo")] + pub related_to: Vec, + pub status: Option, + pub archived: bool, + #[serde(rename = "modifiedAt")] + pub modified_at: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, + #[serde(rename = "fileSize")] + pub file_size: u64, + pub snippet: String, + /// Generic relationship fields: any frontmatter key whose value contains wikilinks. + /// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events"). + pub relationships: HashMap>, + /// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot". + pub icon: Option, + /// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange". + pub color: Option, + /// Display order for Type entries in sidebar (lower = higher). None = use default order. + pub order: Option, + /// Custom sidebar section label for Type entries, overriding auto-pluralization. + #[serde(rename = "sidebarLabel")] + pub sidebar_label: Option, + /// Markdown template for notes of this Type. When a new note is created + /// with this type, the template body is pre-filled after the frontmatter. + pub template: Option, + /// Default sort preference for the note list when viewing instances of this Type. + /// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc"). + pub sort: Option, + /// Default view mode for the note list when viewing instances of this Type. + /// Stored as a string: "all", "editor-list", or "editor-only". + pub view: Option, + /// Rich-editor width mode for this note. None means use the app default. + #[serde(rename = "noteWidth")] + pub note_width: Option, + /// Editor display mode for this note. None means the default text editor. + pub display: Option, + /// Whether this Type is visible in the sidebar. Defaults to true when absent. + pub visible: Option, + /// Whether this note has been explicitly organized (removed from Inbox). + pub organized: bool, + /// Whether this note is a user favorite (shown in FAVORITES sidebar section). + pub favorite: bool, + /// Display order within the FAVORITES section (lower = higher). + #[serde(rename = "favoriteIndex")] + pub favorite_index: Option, + /// Word count of the note body (excludes frontmatter and H1 title). + #[serde(rename = "wordCount")] + pub word_count: u32, + /// All wikilink targets found in the note body (excludes frontmatter). + /// Extracted from `[[target]]` and `[[target|display]]` patterns. + #[serde(rename = "outgoingLinks", default)] + pub outgoing_links: Vec, + /// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural). + /// Objects and arrays containing wikilinks are excluded. + #[serde(default)] + pub properties: HashMap, + /// Properties to display as chips in the note list for this Type's notes. + /// Configured via `_list_properties_display` in the type file's frontmatter. + #[serde(rename = "listPropertiesDisplay", default)] + pub list_properties_display: Vec, + /// Whether the note body has an H1 heading on the first non-empty line. + /// Used by the frontend to decide whether to show the TitleField. + #[serde(rename = "hasH1")] + pub has_h1: bool, + /// File kind: "markdown", "text", or "binary". + /// Determines how the frontend renders and opens the file. + #[serde(rename = "fileKind", default = "default_file_kind")] + pub file_kind: String, +} + +fn default_file_kind() -> String { + "markdown".to_string() +} diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs new file mode 100644 index 0000000..423da67 --- /dev/null +++ b/src-tauri/src/vault/file.rs @@ -0,0 +1,256 @@ +use std::borrow::Cow; +use std::fs; +use std::io::{Error, ErrorKind, Write}; +use std::path::Path; +use std::thread; +use std::time::{Duration, UNIX_EPOCH}; + +const SAVE_RETRY_DELAYS_MS: [u64; 4] = [25, 50, 100, 200]; + +/// Read file metadata (modified_at timestamp, created_at timestamp, file size). +/// Creation time is sourced from filesystem metadata (birthtime on macOS). +pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option, Option, u64), String> { + let metadata = + fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?; + let modified_at = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + let created_at = metadata + .created() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Ok((modified_at, created_at, metadata.len())) +} + +fn invalid_utf8_text_error(path: &Path) -> String { + format!("File is not valid UTF-8 text: {}", path.display()) +} + +fn is_invalid_platform_path_error(error: &Error) -> bool { + error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123) +} + +fn is_retryable_save_error(error: &Error) -> bool { + error.kind() == ErrorKind::PermissionDenied + || (cfg!(windows) && error.raw_os_error() == Some(5)) +} + +fn write_with_retry( + mut write_once: impl FnMut() -> Result<(), Error>, + mut wait_before_retry: impl FnMut(u64), +) -> Result<(), Error> { + for delay in SAVE_RETRY_DELAYS_MS { + match write_once() { + Ok(()) => return Ok(()), + Err(error) if is_retryable_save_error(&error) => wait_before_retry(delay), + Err(error) => return Err(error), + } + } + write_once() +} + +fn read_existing_note_bytes(path: &Path) -> Result, String> { + if !path.exists() { + return Err(format!("File does not exist: {}", path.display())); + } + if !path.is_file() { + return Err(format!("Path is not a file: {}", path.display())); + } + fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e)) +} + +struct RawNotePath<'a>(&'a str); + +impl<'a> RawNotePath<'a> { + fn is_windows_verbatim(&self) -> bool { + self.0.starts_with(r"\\?\") || self.0.starts_with(r"\??\") + } + + fn normalized_for_file_io(&self) -> Cow<'a, str> { + if !self.is_windows_verbatim() { + return Cow::Borrowed(self.0); + } + if !self.0.contains('/') { + return Cow::Borrowed(self.0); + } + Cow::Owned(self.0.replace('/', r"\")) + } +} + +#[derive(Clone, Copy)] +enum NoteIoOperation { + Save, + Create, +} + +#[derive(Clone, Copy)] +struct NotePathDisplay<'a> { + value: &'a str, +} + +impl<'a> NotePathDisplay<'a> { + fn new(value: &'a str) -> Self { + Self { value } + } +} + +impl NoteIoOperation { + fn verb(self) -> &'static str { + match self { + Self::Save => "save", + Self::Create => "create", + } + } +} + +fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String { + let verb = operation.verb(); + if is_invalid_platform_path_error(error) { + let path = path.value; + format!( + "Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}" + ) + } else { + let path = path.value; + format!("Failed to {verb} {path}: {error}") + } +} + +/// Read the content of a single note file. +pub fn get_note_content(path: &Path) -> Result { + let bytes = read_existing_note_bytes(path)?; + String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path)) +} + +/// Check whether a note still has the exact content the renderer cached. +pub fn note_content_matches(path: &Path, expected_content: &str) -> Result { + let bytes = read_existing_note_bytes(path)?; + Ok(bytes == expected_content.as_bytes()) +} + +fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { + let parent_missing = file_path.parent().is_some_and(|p| !p.exists()); + if parent_missing { + return Err(format!( + "Parent directory does not exist: {}", + file_path.parent().unwrap().display() + )); + } + let is_readonly = file_path.exists() + && file_path + .metadata() + .map(|m| m.permissions().readonly()) + .unwrap_or(false); + if is_readonly { + return Err(format!("File is read-only: {}", display_path)); + } + Ok(()) +} + +/// Write content to a note file. Creates parent directory if needed, validates path, +/// then writes content to disk. +pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { + let normalized_path = RawNotePath(path).normalized_for_file_io(); + let file_path = Path::new(normalized_path.as_ref()); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e) + })?; + } + } + validate_save_path(file_path, path)?; + write_with_retry( + || fs::write(file_path, content), + |delay| thread::sleep(Duration::from_millis(delay)), + ) + .map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)) +} + +/// Create a new note file without overwriting any existing file. +pub fn create_note_content(path: &str, content: &str) -> Result<(), String> { + let normalized_path = RawNotePath(path).normalized_for_file_io(); + let file_path = Path::new(normalized_path.as_ref()); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e) + })?; + } + } + validate_save_path(file_path, path)?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(file_path) + .map_err(|e| match e.kind() { + ErrorKind::AlreadyExists => format!("File already exists: {}", path), + _ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e), + })?; + file.write_all(content.as_bytes()) + .map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_windows_invalid_path_syntax_as_recoverable_save_error() { + let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md"; + let message = note_io_error( + NoteIoOperation::Save, + NotePathDisplay::new(path), + &Error::from_raw_os_error(123), + ); + + assert!(message.contains("path is invalid on this platform")); + assert!(message.contains("Rename the note or move it to a valid folder")); + assert!(!message.contains("os error 123")); + } + + #[test] + fn normalizes_extended_windows_paths_before_file_io() { + let path = r"\\?\C:\Users\alex\Documents\Tolaria/Getting Started/untitled-project.md"; + + assert_eq!( + RawNotePath(path).normalized_for_file_io(), + r"\\?\C:\Users\alex\Documents\Tolaria\Getting Started\untitled-project.md" + ); + } + + #[test] + fn retries_transient_access_denied_save_errors() { + let mut attempts = 0; + let mut delays = Vec::new(); + + write_with_retry( + || { + attempts += 1; + if attempts == 1 { + Err(Error::new(ErrorKind::PermissionDenied, "Access is denied")) + } else { + Ok(()) + } + }, + |delay| delays.push(delay), + ) + .unwrap(); + + assert_eq!(attempts, 2); + assert_eq!(delays, vec![25]); + } + + #[test] + fn note_content_matches_detects_external_edits() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "# Fresh\n").unwrap(); + + assert!(note_content_matches(&path, "# Fresh\n").unwrap()); + assert!(!note_content_matches(&path, "# Stale\n").unwrap()); + } +} diff --git a/src-tauri/src/vault/filename_rules.rs b/src-tauri/src/vault/filename_rules.rs new file mode 100644 index 0000000..adf05e3 --- /dev/null +++ b/src-tauri/src/vault/filename_rules.rs @@ -0,0 +1,101 @@ +const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> { + validate_portable_name_segment(stem, "Invalid filename") +} + +pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> { + validate_portable_name_segment(name, "Invalid folder name") +} + +pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> { + validate_portable_name_segment(stem, "Invalid view filename") +} + +fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> { + if is_invalid_portable_name_segment(value) { + return Err(message.to_string()); + } + + Ok(()) +} + +fn is_invalid_portable_name_segment(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() || matches!(trimmed, "." | "..") { + return true; + } + + if trimmed.ends_with('.') || trimmed.ends_with(' ') { + return true; + } + + if trimmed.chars().any(is_invalid_portable_name_char) { + return true; + } + + is_windows_reserved_device_name(trimmed) +} + +fn is_invalid_portable_name_char(ch: char) -> bool { + ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') +} + +fn is_windows_reserved_device_name(value: &str) -> bool { + let candidate = value + .split('.') + .next() + .unwrap_or(value) + .to_ascii_uppercase(); + WINDOWS_RESERVED_DEVICE_NAMES + .iter() + .any(|reserved| candidate == *reserved) +} + +#[cfg(test)] +mod tests { + use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem}; + + #[test] + fn accepts_portable_names() { + assert_eq!(validate_filename_stem("meeting-notes"), Ok(())); + assert_eq!(validate_filename_stem("draft.v2"), Ok(())); + assert_eq!(validate_folder_name("Projects"), Ok(())); + assert_eq!(validate_view_filename_stem("active-projects"), Ok(())); + } + + #[test] + fn rejects_reserved_windows_device_names() { + assert_eq!( + validate_filename_stem("con"), + Err("Invalid filename".to_string()) + ); + assert_eq!( + validate_folder_name("Lpt1"), + Err("Invalid folder name".to_string()) + ); + assert_eq!( + validate_view_filename_stem("aux"), + Err("Invalid view filename".to_string()) + ); + } + + #[test] + fn rejects_windows_invalid_characters_and_suffixes() { + assert_eq!( + validate_filename_stem("quarterly:plan"), + Err("Invalid filename".to_string()) + ); + assert_eq!( + validate_folder_name("Research?"), + Err("Invalid folder name".to_string()) + ); + assert_eq!( + validate_view_filename_stem("overview. "), + Err("Invalid view filename".to_string()) + ); + } +} diff --git a/src-tauri/src/vault/folders.rs b/src-tauri/src/vault/folders.rs new file mode 100644 index 0000000..4852f08 --- /dev/null +++ b/src-tauri/src/vault/folders.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use super::filename_rules::validate_folder_name; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FolderRenameResult { + pub old_path: String, + pub new_path: String, +} + +fn normalize_folder_name(next_name: &str) -> Result { + let trimmed = next_name.trim(); + if trimmed.is_empty() { + return Err("Folder name cannot be empty".to_string()); + } + validate_folder_name(trimmed)?; + Ok(trimmed.to_string()) +} + +fn ensure_relative_folder_path(folder_path: &str) -> Result { + let trimmed = folder_path.trim(); + if trimmed.is_empty() { + return Err("Folder path cannot be empty".to_string()); + } + + let relative = Path::new(trimmed); + if relative.is_absolute() { + return Err("Folder path must be relative to the vault root".to_string()); + } + if relative + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Folder path cannot escape the vault root".to_string()); + } + + Ok(relative.to_path_buf()) +} + +fn display_relative_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub fn rename_folder( + vault_path: &Path, + folder_path: &str, + next_name: &str, +) -> Result { + let relative_path = ensure_relative_folder_path(folder_path)?; + let normalized_name = normalize_folder_name(next_name)?; + let source_path = vault_path.join(&relative_path); + + if !source_path.exists() { + return Err(format!("Folder does not exist: {}", folder_path)); + } + if !source_path.is_dir() { + return Err(format!("Not a folder: {}", folder_path)); + } + + let current_name = source_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .ok_or_else(|| "Folder path cannot target the vault root".to_string())?; + + if current_name == normalized_name { + return Ok(FolderRenameResult { + old_path: display_relative_path(&relative_path), + new_path: display_relative_path(&relative_path), + }); + } + + let parent_relative = relative_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + let destination_relative = parent_relative.join(&normalized_name); + let destination_path = vault_path.join(&destination_relative); + + if destination_path.exists() { + return Err(format!( + "Folder '{}' already exists", + display_relative_path(&destination_relative) + )); + } + + fs::rename(&source_path, &destination_path) + .map_err(|error| format!("Failed to rename folder: {}", error))?; + + Ok(FolderRenameResult { + old_path: display_relative_path(&relative_path), + new_path: display_relative_path(&destination_relative), + }) +} + +pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result { + let relative_path = ensure_relative_folder_path(folder_path)?; + let target_path = vault_path.join(&relative_path); + + if !target_path.exists() { + return Err(format!("Folder does not exist: {}", folder_path)); + } + if !target_path.is_dir() { + return Err(format!("Not a folder: {}", folder_path)); + } + + fs::remove_dir_all(&target_path) + .map_err(|error| format!("Failed to delete folder: {}", error))?; + Ok(display_relative_path(&relative_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_folder(dir: &TempDir, relative: &str) -> PathBuf { + let path = dir.path().join(relative); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn rename_folder_updates_relative_destination() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects/laputa"); + + let result = rename_folder(dir.path(), "projects", "work").unwrap(); + + assert_eq!( + result, + FolderRenameResult { + old_path: "projects".to_string(), + new_path: "work".to_string(), + } + ); + assert!(dir.path().join("work/laputa").is_dir()); + assert!(!dir.path().join("projects").exists()); + } + + #[test] + fn rename_folder_rejects_duplicate_sibling() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects"); + make_folder(&dir, "areas"); + + let error = rename_folder(dir.path(), "projects", "areas").unwrap_err(); + + assert_eq!(error, "Folder 'areas' already exists"); + } + + #[test] + fn rename_folder_rejects_invalid_names() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects"); + + let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err(); + + assert_eq!(error, "Invalid folder name"); + } + + #[test] + fn rename_folder_rejects_windows_invalid_names() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects"); + + let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err(); + + assert_eq!(error, "Invalid folder name"); + } + + #[test] + fn delete_folder_removes_nested_contents() { + let dir = TempDir::new().unwrap(); + let nested = make_folder(&dir, "projects/laputa"); + fs::write(nested.join("note.md"), "# Note\n").unwrap(); + + let deleted_path = delete_folder(dir.path(), "projects").unwrap(); + + assert_eq!(deleted_path, "projects"); + assert!(!dir.path().join("projects").exists()); + } + + #[test] + fn delete_folder_rejects_missing_folder() { + let dir = TempDir::new().unwrap(); + + let error = delete_folder(dir.path(), "projects").unwrap_err(); + + assert_eq!(error, "Folder does not exist: projects"); + } +} diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs new file mode 100644 index 0000000..dd7919e --- /dev/null +++ b/src-tauri/src/vault/frontmatter.rs @@ -0,0 +1,546 @@ +use crate::frontmatter::keys::{canonical_known_frontmatter_key, FrontmatterKey}; +use crate::vault::parsing::contains_wikilink; +use serde::Deserialize; +use std::collections::HashMap; + +/// Intermediate struct to capture YAML frontmatter fields. +#[derive(Debug, Deserialize, Default)] +pub(crate) struct Frontmatter { + #[serde(default)] + pub title: Option, + #[serde(rename = "type", alias = "Is A", alias = "is_a")] + pub is_a: Option, + #[serde(default)] + pub aliases: Option, + #[serde( + rename = "_archived", + alias = "Archived", + alias = "archived", + default, + deserialize_with = "deserialize_bool_or_string" + )] + pub archived: Option, + #[serde(rename = "Status", alias = "status", default)] + pub status: Option, + #[serde(rename = "_icon", alias = "icon", default)] + pub icon: Option, + #[serde(default)] + pub color: Option, + #[serde(rename = "_order", alias = "order", default)] + pub order: Option, + #[serde( + rename = "_sidebar_label", + alias = "sidebar label", + alias = "sidebar_label", + default + )] + pub sidebar_label: Option, + #[serde(default)] + pub template: Option, + #[serde(rename = "_sort", alias = "sort", default)] + pub sort: Option, + #[serde(default)] + pub view: Option, + #[serde(rename = "_width", alias = "width", default)] + pub note_width: Option, + #[serde(rename = "_display", default)] + pub display: Option, + #[serde(default)] + pub visible: Option, + #[serde( + rename = "_organized", + default, + deserialize_with = "deserialize_bool_or_string" + )] + pub organized: Option, + #[serde( + rename = "_favorite", + default, + deserialize_with = "deserialize_bool_or_string" + )] + pub favorite: Option, + #[serde(rename = "_favorite_index", default)] + pub favorite_index: Option, + #[serde(rename = "_list_properties_display", default)] + pub list_properties_display: Option>, +} + +/// Custom deserializer for boolean fields that may arrive as strings. +/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we +/// need to accept both actual booleans and their string representations. +fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct BoolOrStringVisitor; + + impl<'de> de::Visitor<'de> for BoolOrStringVisitor { + type Value = Option; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a boolean or a string representing a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Some(v)) + } + + fn visit_str(self, v: &str) -> Result { + match v.to_lowercase().as_str() { + "true" | "yes" | "1" => Ok(Some(true)), + "false" | "no" | "0" | "" => Ok(Some(false)), + _ => Ok(Some(false)), + } + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Some(v != 0)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Some(v != 0)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(BoolOrStringVisitor) +} + +/// Handles YAML fields that can be either a single string or a list of strings. +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub(crate) enum StringOrList { + Single(String), + List(Vec), +} + +impl StringOrList { + pub fn into_vec(self) -> Vec { + match self { + StringOrList::Single(s) => vec![s], + StringOrList::List(v) => v, + } + } + + /// Normalize to a single scalar: unwrap single-element arrays, take first + /// element of multi-element arrays, return scalar unchanged, empty array → None. + pub fn into_scalar(self) -> Option { + match self { + StringOrList::Single(s) => Some(s), + StringOrList::List(mut v) => { + if v.is_empty() { + None + } else { + Some(v.swap_remove(0)) + } + } + } + } +} + +/// Sanitize a JSON value so that arrays of mixed types (strings + objects + nulls) +/// are flattened to arrays of strings. gray_matter mis-parses certain YAML patterns: +/// +/// 1. Unquoted colons in list items: `- Bitcoin: Net Unrealized` becomes +/// `{"Bitcoin": "Net Unrealized"}` instead of a plain string. +/// 2. Hash comments in list items: `- # Heading` becomes `Null` because +/// gray_matter treats `#` as a YAML comment. +/// +/// This sanitizer converts objects back to "key: value" strings and removes nulls, +/// preventing serde deserialization of the entire Frontmatter struct from failing. +fn sanitize_array_item(item: &serde_json::Value) -> Option { + match item { + serde_json::Value::Null => None, + serde_json::Value::Object(map) if !map.is_empty() => { + let parts: Vec = map + .iter() + .map(|(k, v)| match v { + serde_json::Value::String(s) => format!("{}: {}", k, s), + _ => format!("{}: {}", k, v), + }) + .collect(); + Some(serde_json::Value::String(parts.join(", "))) + } + other => Some(other.clone()), + } +} + +fn sanitize_value(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(arr) => { + let sanitized: Vec = + arr.iter().filter_map(sanitize_array_item).collect(); + serde_json::Value::Array(sanitized) + } + other => other.clone(), + } +} + +fn insert_known_frontmatter_value( + target: &mut serde_json::Map, + key: &str, + value: &serde_json::Value, + overwrite: bool, +) { + let Some(canonical_key) = canonical_known_frontmatter_key(FrontmatterKey::new(key)) else { + return; + }; + if overwrite || !target.contains_key(canonical_key) { + target.insert(canonical_key.to_string(), sanitize_value(value)); + } +} + +fn raw_frontmatter_keys(raw_content: &str) -> Vec { + RawFrontmatter(raw_content) + .extract_block() + .map(|raw| { + raw.lines() + .filter_map(|line| YamlLine(line).key().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn known_frontmatter_map( + data: &HashMap, + raw_content: &str, +) -> serde_json::Map { + let mut filtered = serde_json::Map::new(); + for key in raw_frontmatter_keys(raw_content) { + if let Some(value) = data.get(&key) { + insert_known_frontmatter_value(&mut filtered, &key, value, true); + } + } + for (key, value) in data { + insert_known_frontmatter_value(&mut filtered, key, value, false); + } + filtered +} + +/// Parse frontmatter from raw YAML data extracted by gray_matter. +fn parse_frontmatter(data: &HashMap, raw_content: &str) -> Frontmatter { + let filtered = known_frontmatter_map(data, raw_content); + let value = serde_json::Value::Object(filtered); + serde_json::from_value(value).unwrap_or_default() +} + +/// Extract all wikilink-containing fields from raw YAML frontmatter. +pub(crate) fn extract_relationships( + data: &HashMap, +) -> HashMap> { + let mut relationships = HashMap::new(); + + for (key, value) in data { + if FrontmatterKey::new(key).is_reserved() { + continue; + } + + let wikilinks = relationship_wikilinks(value); + if !wikilinks.is_empty() { + relationships.insert(key.clone(), wikilinks); + } + } + + relationships +} + +fn relationship_wikilinks(value: &serde_json::Value) -> Vec { + let mut wikilinks = Vec::new(); + collect_relationship_wikilinks(value, 0, &mut wikilinks); + wikilinks +} + +fn collect_relationship_wikilinks( + value: &serde_json::Value, + depth: usize, + wikilinks: &mut Vec, +) { + match value { + serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()), + serde_json::Value::Array(arr) => { + if let Some(link) = nested_flow_wikilink(arr, depth) { + wikilinks.push(link); + return; + } + for item in arr { + collect_relationship_wikilinks(item, depth + 1, wikilinks); + } + } + _ => {} + } +} + +fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option { + if depth == 0 { + return None; + } + match arr { + [serde_json::Value::String(target)] if !contains_wikilink(target) => { + Some(format!("[[{target}]]")) + } + _ => None, + } +} + +fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option { + let mut values = Vec::new(); + for item in arr { + let sanitized = sanitize_array_item(item)?; + match sanitized { + serde_json::Value::String(ref s) if contains_wikilink(s) => return None, + serde_json::Value::String(_) + | serde_json::Value::Number(_) + | serde_json::Value::Bool(_) => values.push(sanitized), + _ => return None, + } + } + + match values.as_slice() { + [single] => Some(single.clone()), + _ => Some(serde_json::Value::Array(values)), + } +} + +/// Extract custom scalar and scalar-array properties from raw YAML frontmatter. +pub(crate) fn extract_properties( + data: &HashMap, +) -> HashMap { + let mut properties = HashMap::new(); + + for (key, value) in data { + if FrontmatterKey::new(key).is_reserved() { + continue; + } + + match value { + serde_json::Value::Null => { + properties.insert(key.clone(), value.clone()); + } + serde_json::Value::String(s) if !contains_wikilink(s) => { + properties.insert(key.clone(), value.clone()); + } + serde_json::Value::Number(_) | serde_json::Value::Bool(_) => { + properties.insert(key.clone(), value.clone()); + } + serde_json::Value::Array(arr) => { + if let Some(value) = scalar_array_property_value(arr) { + properties.insert(key.clone(), value); + } + } + _ => {} + } + } + + properties +} + +/// Resolve `is_a` from frontmatter only. +pub(crate) fn resolve_is_a(fm_is_a: Option) -> Option { + fm_is_a.and_then(|a| a.into_vec().into_iter().next()) +} + +pub(crate) fn resolve_note_width(note_width: Option) -> Option { + match note_width + .and_then(StringOrList::into_scalar) + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(mode) if mode == "normal" || mode == "wide" => Some(mode), + _ => None, + } +} + +pub(crate) fn resolve_note_display(display: Option) -> Option { + match display + .and_then(StringOrList::into_scalar) + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(mode) if mode == "text" || mode == "sheet" => Some(mode), + _ => None, + } +} + +/// Convert gray_matter::Pod to serde_json::Value +fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { + match pod { + gray_matter::Pod::String(s) => serde_json::Value::String(s), + gray_matter::Pod::Integer(i) => serde_json::json!(i), + gray_matter::Pod::Float(f) => serde_json::json!(f), + gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b), + gray_matter::Pod::Array(arr) => { + serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect()) + } + gray_matter::Pod::Hash(map) => { + let obj: serde_json::Map = + map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect(); + serde_json::Value::Object(obj) + } + gray_matter::Pod::Null => serde_json::Value::Null, + } +} + +#[derive(Clone, Copy)] +struct YamlScalar<'a>(&'a str); + +impl<'a> YamlScalar<'a> { + /// Strip matching outer quotes (single or double) from a YAML scalar. + fn unquote(self) -> &'a str { + self.0 + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .or_else(|| { + self.0 + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + }) + .unwrap_or(self.0) + } + + /// Parse a scalar YAML value into a JSON value. + fn parse(self) -> serde_json::Value { + let trimmed = self.unquote(); + match trimmed.to_lowercase().as_str() { + "true" | "yes" => serde_json::Value::Bool(true), + "false" | "no" => serde_json::Value::Bool(false), + _ => trimmed + .parse::() + .map(|n| serde_json::json!(n)) + .unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())), + } + } +} + +#[derive(Clone, Copy)] +struct YamlLine<'a>(&'a str); + +impl<'a> YamlLine<'a> { + fn is_top_level(self) -> bool { + if self.0.is_empty() { + return false; + } + if self.0.starts_with(' ') { + return false; + } + !self.0.starts_with('\t') + } + + /// Return the key from a top-level `key:` or `"key":` YAML line. + /// Returns `None` for indented, blank, or non-key lines. + fn key(self) -> Option<&'a str> { + if !self.is_top_level() { + return None; + } + let (key, _) = self.0.split_once(':')?; + Some(key.trim().trim_matches('"').trim_matches('\'')) + } + + fn list_item(self) -> Option<&'a str> { + self.0.strip_prefix(" - ").map(str::trim) + } + + fn value_part(self) -> Option<&'a str> { + self.0.split_once(':').map(|(_, value)| value.trim()) + } +} + +#[derive(Clone, Copy)] +struct RawFrontmatter<'a>(&'a str); + +impl<'a> RawFrontmatter<'a> { + fn extract_block(self) -> Option<&'a str> { + let rest = self.0.strip_prefix("---")?; + let rest = rest + .strip_prefix('\n') + .or_else(|| rest.strip_prefix("\r\n"))?; + let end = rest.find("\n---")?; + Some(&rest[..end]) + } + + /// Fallback parser for when gray_matter fails to parse YAML (returns raw string). + /// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings, + /// and YAML lists. + fn parse_fallback(self) -> HashMap { + let mut map = HashMap::new(); + let mut list_key: Option = None; + let mut list_items: Vec = Vec::new(); + + for line in self.0.lines() { + let yaml_line = YamlLine(line); + + // Accumulate list items under the current key + if list_key.is_some() { + if let Some(item) = yaml_line.list_item() { + list_items.push(YamlScalar(item).parse()); + continue; + } + flush_list(&mut map, &mut list_key, &mut list_items); + } + + let Some(key) = yaml_line.key() else { + continue; + }; + let value_part = yaml_line.value_part().unwrap_or(""); + if value_part.is_empty() { + list_key = Some(key.to_string()); + } else { + map.insert(key.to_string(), YamlScalar(value_part).parse()); + } + } + + flush_list(&mut map, &mut list_key, &mut list_items); + map + } +} + +/// Flush a pending list accumulator into the map. +fn flush_list( + map: &mut HashMap, + key: &mut Option, + items: &mut Vec, +) { + if let Some(k) = key.take() { + if !items.is_empty() { + map.insert(k, serde_json::Value::Array(std::mem::take(items))); + } + } +} + +/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data. +/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports), +/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line +/// so that critical fields like Trashed, Archived, type are not silently lost. +pub(crate) fn extract_fm_and_rels( + data: Option, + raw_content: &str, +) -> ( + Frontmatter, + HashMap>, + HashMap, +) { + let json_map = match data { + Some(gray_matter::Pod::Hash(map)) => { + map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect() + } + _ => { + // gray_matter returned Null, String, or None — YAML parse failed. + // Fall back to line-by-line extraction from the raw frontmatter block. + match RawFrontmatter(raw_content).extract_block() { + Some(raw) => RawFrontmatter(raw).parse_fallback(), + None => return (Frontmatter::default(), HashMap::new(), HashMap::new()), + } + } + }; + ( + parse_frontmatter(&json_map, raw_content), + extract_relationships(&json_map), + extract_properties(&json_map), + ) +} diff --git a/src-tauri/src/vault/frontmatter_regression_tests.rs b/src-tauri/src/vault/frontmatter_regression_tests.rs new file mode 100644 index 0000000..25d975b --- /dev/null +++ b/src-tauri/src/vault/frontmatter_regression_tests.rs @@ -0,0 +1,188 @@ +use super::*; +use std::fs; +use std::io::Write; +use std::path::Path; +use tempfile::TempDir; + +fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); +} + +fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry { + create_test_file(dir.path(), name, content); + parse_md_file(&dir.path().join(name), None).unwrap() +} + +struct HiddenPropertyCase<'a> { + content: &'a str, + hidden_key: &'a str, +} + +fn assert_filtered_property_stays_hidden(case: HiddenPropertyCase<'_>) { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "test.md", case.content); + assert!(!entry.properties.contains_key(case.hidden_key)); + assert_eq!( + entry + .properties + .get("Company") + .and_then(|value| value.as_str()), + Some("Acme Corp") + ); +} + +struct SingleElementArrayCase<'a> { + note_type: &'a str, + key: &'a str, + value: &'a str, +} + +fn assert_single_element_array_property(case: SingleElementArrayCase<'_>) { + let dir = TempDir::new().unwrap(); + let content = format!( + "---\ntype: {}\n{}:\n - {}\n---\n# Test\n", + case.note_type, case.key, case.value + ); + let entry = parse_test_entry(&dir, "test.md", &content); + assert_eq!( + entry + .properties + .get(case.key) + .and_then(|property| property.as_str()), + Some(case.value) + ); + assert_eq!(entry.is_a, Some(case.note_type.to_string())); +} + +struct AliasRecoveryCase<'a> { + file_name: &'a str, + title: &'a str, + alias_item: &'a str, +} + +fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) { + let dir = TempDir::new().unwrap(); + let content = format!( + "---\ntype: Note\n_organized: true\naliases:\n - {}\n - Note\n---\n# {}\n", + case.alias_item, case.title + ); + let entry = parse_test_entry(&dir, case.file_name, &content); + assert_eq!(entry.is_a, Some("Note".to_string())); + assert!(entry.organized); +} + +#[test] +fn test_filtered_properties_stay_hidden() { + let cases = [HiddenPropertyCase { + content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n", + hidden_key: "Mentor", + }]; + + for case in cases { + assert_filtered_property_stays_hidden(case); + } +} + +#[test] +fn test_single_element_array_properties_unwrap_to_scalars() { + let cases = [ + SingleElementArrayCase { + note_type: "Responsibility", + key: "Owner", + value: "Luca", + }, + SingleElementArrayCase { + note_type: "Procedure", + key: "Cadence", + value: "Weekly", + }, + ]; + + for case in cases { + assert_single_element_array_property(case); + } +} + +#[test] +fn test_multi_element_array_properties_are_preserved() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "playlist.md", + "---\ntags:\n - blues\n - chicago\n---\n# Playlist\n", + ); + + assert_eq!( + entry.properties.get("tags"), + Some(&serde_json::json!(["blues", "chicago"])) + ); +} + +#[test] +fn test_blank_scalar_properties_are_preserved() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "book.md", + "---\ntype: Book\nstart date:\nrating: \n---\n# Book\n", + ); + + assert!(entry + .properties + .get("start date") + .is_some_and(|value| value.is_null())); + assert!(entry + .properties + .get("rating") + .is_some_and(|value| value.is_null())); +} + +#[test] +fn test_unquoted_wikilink_relationships_are_preserved() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "book.md", + "---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n", + ); + + assert_eq!( + entry.relationships.get("Mentor"), + Some(&vec!["[[person/alice]]".to_string()]) + ); +} + +#[test] +fn test_alias_parser_recovers_special_alias_items() { + let cases = [ + AliasRecoveryCase { + file_name: "colon-alias.md", + title: "Test", + alias_item: "Bitcoin: Net Unrealized Profit/Loss", + }, + AliasRecoveryCase { + file_name: "hash-alias.md", + title: "Title", + alias_item: "# Writing a Good CLAUDE.md", + }, + ]; + + for case in cases { + assert_alias_parser_recovers(case); + } +} + +#[test] +fn test_alias_collisions_keep_frontmatter_with_last_value_winning() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + + assert_eq!(entry.is_a, Some("Note".to_string())); + assert_eq!(entry.status, Some("Evergreened".to_string())); +} diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs new file mode 100644 index 0000000..495386a --- /dev/null +++ b/src-tauri/src/vault/getting_started.rs @@ -0,0 +1,870 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +/// Public starter vault cloned when the user chooses Getting Started. +pub const GETTING_STARTED_REPO_URL: &str = + "https://github.com/refactoringhq/tolaria-getting-started.git"; + +/// Default location for the Getting Started vault. +pub fn default_vault_path() -> Result { + documents_dir() + .map(|d| d.join("Getting Started")) + .ok_or_else(|| "Could not determine Documents directory".to_string()) +} + +fn documents_dir() -> Option { + dirs::document_dir().or_else(|| dirs::home_dir().map(|home| home.join("Documents"))) +} + +const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"]; +const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"]; + +/// Check whether a vault path exists on disk. +pub fn vault_exists(path: &str) -> bool { + let default_path = default_vault_path().ok(); + vault_exists_with_default_path(Path::new(path), default_path.as_deref()) +} + +fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool { + if !path.is_dir() { + return false; + } + + if !is_canonical_getting_started_path(path, default_path) { + return true; + } + + canonical_getting_started_vault_exists(path) +} + +fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool { + default_path.is_some_and(|candidate| candidate == path) +} + +fn canonical_getting_started_vault_exists(path: &Path) -> bool { + has_getting_started_config_files(path) && has_getting_started_template_marker(path) +} + +fn has_getting_started_config_files(path: &Path) -> bool { + GETTING_STARTED_REQUIRED_CONFIG_FILES + .iter() + .all(|file| path.join(file).is_file()) +} + +fn has_getting_started_template_marker(path: &Path) -> bool { + GETTING_STARTED_TEMPLATE_MARKERS + .iter() + .any(|file| path.join(file).is_file()) +} + +/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults +/// can still contain this exact text, so Tolaria treats it as managed content +/// that is safe to refresh automatically. +const STALE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault + +This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault - a folder of markdown files with YAML frontmatter forming a personal knowledge graph. + +Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring. + +## Core rules + +- One markdown note per file. +- The first H1 in the body is the note title. Do not add `title:` frontmatter. +- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`. +- Use wikilinks for note-to-note references, both in frontmatter and in the body. +- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change. + +## Notes + +```yaml +--- +type: Project +status: Active +belongs_to: + - "[[area-operations]]" +related_to: + - "[[goal-q2-launch]]" +--- + +# Q2 Launch Plan + +Body content in markdown. +``` + +Tolaria still understands some legacy aliases such as `Is A`, but prefer `type:` for new or edited notes. + +## Types + +Type definitions are regular notes stored in `type/`. Use `type: Type` in frontmatter: + +```yaml +--- +type: Type +icon: books +color: blue +order: 20 +sidebar label: Projects +--- + +# Project +``` + +Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`. + +## Relationships + +Any frontmatter property whose value is a wikilink is treated as a relationship. Common names include `belongs_to`, `related_to`, and `has`, but custom relationship names are valid too. + +## Wikilinks + +- `[[filename]]` or `[[Note Title]]` - link by filename or title +- `[[filename|display text]]` - with custom display text +- Works in frontmatter values and markdown body + +## Views + +Saved filters live in `views/` as `.view.json` files: + +```json +{ + "title": "Active Notes", + "filters": [ + {"property": "type", "operator": "equals", "value": "Note"}, + {"property": "status", "operator": "equals", "value": "Active"} + ], + "sort": {"property": "title", "direction": "asc"} +} +``` + +## Filenames + +Use kebab-case: `my-note-title.md`. One note per file. + +## What agents should do + +- Create and edit notes using the frontmatter and H1 conventions above. +- Create and edit type documents in `type/`. +- Add or modify relationships without breaking existing wikilinks. +- Create and edit saved views in `views/`. +- Update `AGENTS.md` only when the user asks for agent guidance changes. + +## What agents should avoid + +- Do not infer note type from folders other than the dedicated `type/` directory for type definitions. +- Do not silently overwrite an existing custom `AGENTS.md`. +- Do not rewrite installation-specific app config unless the user explicitly asks. +"##; + +/// Older Tolaria-managed AGENTS.md content from before the `type:` migration. +/// Existing vaults can still contain this exact text, so Tolaria treats it as +/// managed content that is safe to refresh automatically. +const PRE_TYPE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault + +This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph. + +## Note structure + +Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field. + +```yaml +--- +is_a: TypeName # the note's type (must match the title of a type file in the vault) +url: https://... # example property +belongs_to: "[[other-note]]" +related_to: + - "[[note-a]]" + - "[[note-b]]" +--- + +# Note Title + +Body content in markdown. +``` + +System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked. + +## Types + +A type is a note with `is_a: Type`. Type files live in the vault root: + +```yaml +--- +is_a: Type +_icon: books # Phosphor icon name in kebab-case +_color: "#8b5cf6" # hex color +--- + +# TypeName +``` + +To find what types exist: look for files with `is_a: Type` in the vault root. + +## Relationships + +Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically. + +Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid. + +## Wikilinks + +- `[[filename]]` or `[[Note Title]]` — link by filename or title +- `[[filename|display text]]` — with custom display text +- Works in frontmatter values and markdown body + +## Views + +Saved filters live in `views/` as `.view.json` files: + +```json +{ + "title": "Active Notes", + "filters": [ + {"property": "is_a", "operator": "equals", "value": "Note"}, + {"property": "status", "operator": "equals", "value": "Active"} + ], + "sort": {"property": "title", "direction": "asc"} +} +``` + +## Filenames + +Use kebab-case: `my-note-title.md`. One note per file. + +## What you can do + +- Create/edit notes with correct frontmatter and H1 title +- Create new type files +- Add or modify relationships +- Create/edit views in `views/` +- Edit `AGENTS.md` (this file) + +Do not modify app configuration files — those are local to each installation. +"##; + +const OUTDATED_AGENTS_MARKERS: [&str; 3] = [ + "# AGENTS.md — Tolaria Vault", + "Legacy `title:` frontmatter is still read as a fallback", + "Tolaria still understands legacy aliases such as `Is A`.", +]; + +const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter."; +const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views"; +const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"]; + +struct AgentsContent<'a>(&'a str); + +impl<'a> AgentsContent<'a> { + fn new(content: &'a str) -> Self { + Self(content) + } + + fn contains(&self, marker: &str) -> bool { + self.0.contains(marker) + } + + fn contains_all(&self, markers: &[&str]) -> bool { + markers.iter().all(|marker| self.contains(marker)) + } + + fn is_known_legacy_template(&self) -> bool { + self.0.trim().is_empty() + || self.0 == PRE_TYPE_AGENTS_MD + || self.0 == LEGACY_AGENTS_MD + || self.0 == STALE_AGENTS_MD + } + + fn has_stale_title_stub(&self) -> bool { + self.contains(STALE_TITLE_FRONTMATTER_MARKER) + } + + fn has_legacy_json_view_guidance(&self) -> bool { + self.contains(LEGACY_VIEWS_SECTION_MARKER) + && LEGACY_VIEW_FILE_MARKERS + .iter() + .any(|marker| self.contains(marker)) + } + + fn can_be_refreshed(&self) -> bool { + self.is_known_legacy_template() + || self.has_stale_title_stub() + || self.has_legacy_json_view_guidance() + || self.contains_all(&OUTDATED_AGENTS_MARKERS) + } +} + +pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool { + AgentsContent::new(content).can_be_refreshed() +} + +pub(super) fn agents_content_is_known_managed_template(content: &str) -> bool { + AgentsContent::new(content).is_known_legacy_template() +} + +/// Default AGENTS.md content — vault instructions for AI agents. +/// Describes Tolaria vault mechanics only; no user-specific structure. +/// The vault scanner will pick this up as a regular entry. +pub(super) const AGENTS_MD: &str = r##"--- +type: Note +_organized: true +--- + +# AGENTS.md — Tolaria Vault + +This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault. + +Keep this file focused on vault-specific conventions. For general Tolaria behavior, use the bundled Tolaria agent docs path provided by the app session context. + +## Core conventions + +- Notes are Markdown files. +- Use the first H1 as the note title. Tolaria uses this title in the note list, wikilinks, search, and other display surfaces. +- Store note type in the `type:` frontmatter field. +- Use wikilinks in body text and frontmatter fields to connect notes. +- Prefer types and relationships for organization. Folder structure is optional and should not be treated as the primary source of meaning. +- Tolaria reads notes recursively from all folders and stores new notes in the vault root by default. +- Saved views live in `views/*.yml`. +- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types. +- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change. + +## Notes + +```yaml +--- +type: Note +related_to: "[[tolaria]]" +status: Active +url: https://example.com +--- + +# Example note + +Body content in Markdown. +``` + +## Types + +Types are regular notes with `type: Type`. They define how notes of that type appear and which properties or relationships should be suggested for new notes. + +```yaml +--- +type: Type +_icon: rocket +_color: "#3b82f6" +_order: 0 +_list_properties_display: + - related_to +_sort: "property:onboarding:asc" +--- + +# Project +``` + +Empty properties and relationships in a type document become placeholders on new notes of that type. Values attached to properties in the type document become defaults for type instances. + +Useful type metadata includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys. + +## Relationships + +Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. Common relationship keys include `related_to`, `belongs_to`, and `has`, but custom relationship names are valid too. + +Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them. + +Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships. + +## Wikilinks + +- `[[filename]]` or `[[Note Title]]` for normal links +- `[[filename|display text]]` for custom display text +- Works in frontmatter values and Markdown body + +## Views + +Saved views live in `views/*.yml` and are written as YAML. Tolaria scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`. + +A view definition looks like this: + +```yaml +name: Active Projects +icon: null +color: null +sort: "property:onboarding:asc" +filters: + any: + - field: type + op: equals + value: Project + - field: related_to + op: contains + value: "[[tolaria]]" +``` + +View rules that matter when creating or editing files: +- `name` is required. `icon`, `color`, and `sort` are optional. +- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:`, for example `property:onboarding:asc`. +- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group. +- Each filter condition uses `field`, `op`, and usually `value`. +- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`. +- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`. +- `any_of` and `none_of` expect `value` to be a YAML list. +- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed. +- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`. +- Do not create JSON view files or `.view.json` filenames. + +## Filenames + +Use kebab-case: `my-note-title.md`. One note per file. + +## What agents should do + +- Create and edit notes using the frontmatter and H1 conventions above. +- Create and edit type documents when the user asks for note categories or defaults. +- Add or modify relationships without breaking existing wikilinks. +- Create and edit saved views in `views/`. +- Update `AGENTS.md` only when the user asks for vault-level guidance changes. +- Search the bundled Tolaria docs when the user asks how Tolaria works or when you need product behavior beyond these base conventions. +- Use Portent as the default best-practice model when the user asks how to improve, organize, or restructure the knowledge base. Combine Portent's types, relationships, and capture -> organize -> archive lifecycle with Tolaria's type documents, properties, Inbox, archive, and saved views. + +## What agents should avoid + +- Do not infer note type or meaning from folders. +- Do not treat files in `attachments/` as notes, types, or view definitions. +- Do not silently overwrite an existing custom `AGENTS.md`. +- Do not rewrite installation-specific app configuration unless the user explicitly asks. +"##; + +pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault + +This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph. + +## Note structure + +Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field. + +```yaml +--- +type: TypeName # the note's type (must match the title of a type file in the vault) +url: https://... # example property +belongs_to: "[[other-note]]" +related_to: + - "[[note-a]]" + - "[[note-b]]" +--- + +# Note Title + +Body content in markdown. +``` + +System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked. + +## Types + +A type is a note with `type: Type`. Type files live in the vault root: + +```yaml +--- +type: Type +_icon: books # Phosphor icon name in kebab-case +_color: "#8b5cf6" # hex color +--- + +# TypeName +``` + +To find what types exist: look for files with `type: Type` in the vault root. + +## Relationships + +Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically. + +Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid. + +## Wikilinks + +- `[[filename]]` or `[[Note Title]]` — link by filename or title +- `[[filename|display text]]` — with custom display text +- Works in frontmatter values and markdown body + +## Views + +Saved filters live in `views/` as `.view.json` files: + +```json +{ + "title": "Active Notes", + "filters": [ + {"property": "type", "operator": "equals", "value": "Note"}, + {"property": "status", "operator": "equals", "value": "Active"} + ], + "sort": {"property": "title", "direction": "asc"} +} +``` + +## Filenames + +Use kebab-case: `my-note-title.md`. One note per file. + +## What you can do + +- Create/edit notes with correct frontmatter and H1 title +- Create new type files +- Add or modify relationships +- Create/edit views in `views/` +- Edit `AGENTS.md` (this file) + +Do not modify app configuration files — those are local to each installation. +"##; + +/// Clone the public starter vault into the requested path. +pub fn create_getting_started_vault(target_path: &str) -> Result { + let vault_path = create_getting_started_vault_from_repo( + Path::new(target_path), + &getting_started_repo_url(), + )?; + Ok(vault_path.to_string_lossy().to_string()) +} + +fn create_getting_started_vault_from_repo( + target_path: &Path, + repo_url: &str, +) -> Result { + let target_path_str = target_path.to_string_lossy(); + if target_path_str.trim().is_empty() { + return Err("Target path is required".to_string()); + } + + crate::git::clone_repo(repo_url, &target_path_str)?; + let vault_path = canonical_vault_path(target_path)?; + crate::git::disconnect_all_remotes(path_to_utf8(&vault_path, "Vault path")?)?; + refresh_cloned_vault_config_files(&vault_path)?; + Ok(vault_path) +} + +fn getting_started_repo_url() -> String { + std::env::var("TOLARIA_GETTING_STARTED_REPO_URL") + .or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL")) + .unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string()) +} + +fn canonical_vault_path(target_path: &Path) -> Result { + target_path.canonicalize().map_err(|e| { + format!( + "Failed to resolve vault path '{}': {}", + target_path.display(), + e + ) + }) +} + +fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> { + path.to_str() + .ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display())) +} + +fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> { + let agents_path = vault_path.join("AGENTS.md"); + let refresh_agents = if !agents_path.exists() { + true + } else { + let content = fs::read_to_string(&agents_path) + .map_err(|e| format!("Failed to read {}: {e}", agents_path.display()))?; + agents_content_can_be_refreshed(&content) + }; + + if refresh_agents { + fs::write(&agents_path, AGENTS_MD) + .map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?; + } + + crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?; + + if !vault_has_pending_changes(vault_path)? { + return Ok(()); + } + + crate::git::ensure_author_config(vault_path)?; + crate::git::git_commit( + path_to_utf8(vault_path, "Vault path")?, + "Initialize Tolaria config files", + )?; + Ok(()) +} + +fn vault_has_pending_changes(vault_path: &Path) -> Result { + let output = crate::hidden_command("git") + .args(["status", "--porcelain"]) + .current_dir(vault_path) + .output() + .map_err(|e| format!("Failed to inspect cloned vault status: {e}"))?; + + if output.status.success() { + return Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()); + } + + Err(format!( + "git status failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::process::Command as StdCommand; + + fn init_source_repo(path: &Path, agents_content: Option<&str>) { + fs::create_dir_all(path.join("views")).unwrap(); + fs::write( + path.join("welcome.md"), + "# Welcome to Tolaria\n\nThis is the starter vault.\n", + ) + .unwrap(); + fs::write( + path.join("views").join("active-projects.yml"), + "title: Active Projects\nfilters: []\n", + ) + .unwrap(); + if let Some(content) = agents_content { + fs::write(path.join("AGENTS.md"), content).unwrap(); + } + + StdCommand::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "tolaria@app.local"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Tolaria App"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "Initial starter vault"]) + .current_dir(path) + .output() + .unwrap(); + } + + fn write_tolaria_config_files(path: &Path) { + fs::create_dir_all(path).unwrap(); + fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap(); + fs::write(path.join("type.md"), "# Type\n").unwrap(); + fs::write(path.join("note.md"), "# Note\n").unwrap(); + } + + fn assert_getting_started_vault_replaces_template(agents_content: &str) { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, Some(agents_content)); + + create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap(); + + let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap(); + assert_eq!(content, AGENTS_MD); + assert!(dest.join("type.md").exists()); + assert!(dest.join("note.md").exists()); + } + + #[test] + fn test_default_vault_path_appends_getting_started() { + let path = default_vault_path().unwrap(); + let path_str = path.to_string_lossy(); + assert!(path_str.ends_with("Getting Started")); + } + + #[test] + fn test_default_getting_started_repo_url_uses_tolaria_slug() { + assert_eq!( + GETTING_STARTED_REPO_URL, + "https://github.com/refactoringhq/tolaria-getting-started.git" + ); + } + + #[test] + fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() { + let dir = tempfile::TempDir::new().unwrap(); + let default_path = dir.path().join("Getting Started"); + + write_tolaria_config_files(&default_path); + + assert!(!vault_exists_with_default_path( + default_path.as_path(), + Some(default_path.as_path()) + )); + } + + #[test] + fn test_non_canonical_vault_path_stays_permissive() { + let dir = tempfile::TempDir::new().unwrap(); + let default_path = dir.path().join("Getting Started"); + let other_vault_path = dir.path().join("Existing Vault"); + + fs::create_dir_all(&other_vault_path).unwrap(); + + assert!(vault_exists_with_default_path( + other_vault_path.as_path(), + Some(default_path.as_path()) + )); + } + + #[test] + fn test_create_getting_started_vault_clones_repo() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, None); + + let result = + create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()) + .unwrap(); + + assert_eq!(result, dest.canonicalize().unwrap()); + assert!(dest.join("welcome.md").exists()); + assert!(dest.join("views").join("active-projects.yml").exists()); + assert!(dest.join(".git").exists()); + assert_eq!( + fs::read_to_string(dest.join("AGENTS.md")).unwrap(), + AGENTS_MD + ); + assert!(dest.join("type.md").exists()); + assert!(dest.join("note.md").exists()); + } + + #[test] + fn test_canonical_getting_started_path_accepts_cloned_starter_vault() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let default_path = dir.path().join("Getting Started"); + init_source_repo(&source, None); + + create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap()) + .unwrap(); + + assert!(vault_exists_with_default_path( + default_path.as_path(), + Some(default_path.as_path()) + )); + } + + #[test] + fn test_create_getting_started_vault_rejects_nonempty_destination() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, None); + fs::create_dir_all(&dest).unwrap(); + fs::write(dest.join("existing.md"), "# Existing\n").unwrap(); + + let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()) + .unwrap_err(); + + assert!(err.contains("already exists and is not empty")); + } + + #[test] + fn test_create_getting_started_vault_cleans_partial_clone_on_failure() { + let dir = tempfile::TempDir::new().unwrap(); + let missing_repo = dir.path().join("missing"); + let dest = dir.path().join("Getting Started"); + + let err = + create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap()) + .unwrap_err(); + + assert!(err.contains("git clone failed")); + assert!(!dest.exists()); + } + + #[test] + fn test_create_getting_started_vault_leaves_clean_worktree() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, None); + + create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap(); + + let output = StdCommand::new("git") + .args(["status", "--porcelain"]) + .current_dir(&dest) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); + } + + #[test] + fn test_create_getting_started_vault_removes_the_starter_remote() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, None); + + create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap(); + + assert!(!crate::git::has_remote(dest.to_str().unwrap()).unwrap()); + } + + #[test] + fn test_create_getting_started_vault_replaces_legacy_agents_template() { + assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD); + } + + #[test] + fn test_create_getting_started_vault_replaces_pre_type_agents_template() { + assert_getting_started_vault_replaces_template(PRE_TYPE_AGENTS_MD); + } + + #[test] + fn test_agents_refresh_detection_accepts_pre_type_managed_template() { + assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD)); + } + + #[test] + fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() { + let stale = r#"# AGENTS.md — Tolaria Vault + +## Views + +Saved filters live in `views/` as `.view.json` files: + +```json +{"title":"Active Notes"} +``` +"#; + assert!(agents_content_can_be_refreshed(stale)); + } + + #[test] + fn test_agents_template_matches_current_tolaria_vault_conventions() { + assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n")); + assert!(AGENTS_MD.contains("# AGENTS.md — Tolaria Vault")); + assert!(AGENTS_MD.contains("Use the first H1 as the note title.")); + assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field.")); + assert!(AGENTS_MD.contains("Tolaria reads notes recursively from all folders")); + assert!(AGENTS_MD.contains("Search the bundled Tolaria docs")); + assert!(AGENTS_MD.contains("attachments/")); + assert!(AGENTS_MD.contains("views/*.yml")); + assert!(AGENTS_MD.contains("option:direction")); + assert!(AGENTS_MD.contains("property:")); + assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.")); + assert!(AGENTS_MD.contains("Belongs to:")); + assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames.")); + assert!(!AGENTS_MD.contains("Laputa")); + assert!(!AGENTS_MD.contains("Is A")); + assert!(!AGENTS_MD.contains("is_a")); + assert!(!AGENTS_MD.contains("type definitions currently live")); + } +} diff --git a/src-tauri/src/vault/ignored.rs b/src-tauri/src/vault/ignored.rs new file mode 100644 index 0000000..c95bb66 --- /dev/null +++ b/src-tauri/src/vault/ignored.rs @@ -0,0 +1,370 @@ +use super::{FolderNode, VaultEntry}; +use std::collections::HashSet; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use walkdir::{DirEntry, WalkDir}; + +fn normalize_relative_path(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .trim_matches('/') + .to_string() +} + +fn stripped_relative_path(vault_path: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(vault_path).ok()?; + let normalized = normalize_relative_path(relative.to_string_lossy().as_ref()); + (!normalized.is_empty()).then_some(normalized) +} + +fn relative_path(vault_path: &Path, path: &Path) -> Option { + stripped_relative_path(vault_path, path).or_else(|| { + let canonical_vault_path = vault_path.canonicalize().ok()?; + let canonical_path = path.canonicalize().ok()?; + stripped_relative_path(&canonical_vault_path, &canonical_path) + }) +} + +fn should_descend_for_gitignore(entry: &DirEntry) -> bool { + entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git" +} + +fn has_gitignore_file(vault_path: &Path) -> bool { + if vault_path.join(".gitignore").is_file() { + return true; + } + + WalkDir::new(vault_path) + .follow_links(false) + .into_iter() + .filter_entry(should_descend_for_gitignore) + .filter_map(Result::ok) + .any(|entry| { + entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore" + }) +} + +fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option { + let mut child = crate::hidden_command("git") + .args(["check-ignore", "--no-index", "--stdin"]) + .current_dir(vault_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let mut stdin = child.stdin.take()?; + let paths = relative_paths.to_vec(); + let writer = std::thread::spawn(move || -> std::io::Result<()> { + for path in paths { + writeln!(stdin, "{path}")?; + } + Ok(()) + }); + + let output = child.wait_with_output().ok()?; + writer.join().ok()?.ok()?; + if output.status.success() || output.status.code() == Some(1) { + return Some(String::from_utf8_lossy(&output.stdout).to_string()); + } + None +} + +fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet { + if relative_paths.is_empty() || !has_gitignore_file(vault_path) { + return HashSet::new(); + } + + let mut candidates = relative_paths + .iter() + .map(|path| normalize_relative_path(path)) + .filter(|path| !path.is_empty()) + .collect::>(); + candidates.sort(); + candidates.dedup(); + + run_git_check_ignore(vault_path, &candidates) + .unwrap_or_default() + .lines() + .map(normalize_relative_path) + .filter(|path| !path.is_empty()) + .collect() +} + +fn filter_gitignored_items( + vault_path: &Path, + items: Vec, + hide_enabled: bool, + relative_for: impl Fn(&T) -> Option, +) -> Vec { + if !hide_enabled || items.is_empty() { + return items; + } + + let relative_paths = items.iter().filter_map(&relative_for).collect::>(); + let ignored = ignored_relative_paths(vault_path, &relative_paths); + if ignored.is_empty() { + return items; + } + + items + .into_iter() + .filter(|item| { + relative_for(item) + .map(|relative| !ignored.contains(&relative)) + .unwrap_or(true) + }) + .collect() +} + +pub fn filter_gitignored_paths( + vault_path: &Path, + paths: Vec, + hide_enabled: bool, +) -> Vec { + filter_gitignored_items(vault_path, paths, hide_enabled, |path| { + relative_path(vault_path, path) + }) +} + +pub fn filter_gitignored_entries( + vault_path: &Path, + entries: Vec, + hide_enabled: bool, +) -> Vec { + filter_gitignored_items(vault_path, entries, hide_enabled, |entry| { + relative_path(vault_path, Path::new(&entry.path)) + }) +} + +fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec) { + for node in nodes { + let relative = normalize_relative_path(&node.path); + if !relative.is_empty() { + queries.push(relative.clone()); + queries.push(format!("{relative}/")); + } + collect_folder_queries(&node.children, queries); + } +} + +fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet) -> bool { + if ignored.contains(relative_path) { + return true; + } + let mut current = relative_path; + while let Some((parent, _)) = current.rsplit_once('/') { + if ignored.contains(parent) { + return true; + } + current = parent; + } + false +} + +fn filter_folder_nodes(nodes: Vec, ignored: &HashSet) -> Vec { + nodes + .into_iter() + .filter_map(|mut node| { + let relative = normalize_relative_path(&node.path); + if path_or_parent_is_ignored(&relative, ignored) { + return None; + } + node.children = filter_folder_nodes(node.children, ignored); + Some(node) + }) + .collect() +} + +pub fn filter_gitignored_folders( + vault_path: &Path, + folders: Vec, + hide_enabled: bool, +) -> Vec { + if !hide_enabled || folders.is_empty() { + return folders; + } + + let mut queries = Vec::new(); + collect_folder_queries(&folders, &mut queries); + let ignored = ignored_relative_paths(vault_path, &queries); + if ignored.is_empty() { + return folders; + } + + filter_folder_nodes(folders, &ignored) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::mpsc; + use std::time::Duration; + use tempfile::TempDir; + + fn write_file(root: &Path, relative: &str, content: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + fn init_git_repo(root: &Path) { + crate::hidden_command("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + } + + fn entry(root: &Path, relative: &str) -> VaultEntry { + VaultEntry { + path: root.join(relative).to_string_lossy().to_string(), + filename: relative.rsplit('/').next().unwrap().to_string(), + title: relative.to_string(), + ..VaultEntry::default() + } + } + + fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec { + entries + .iter() + .map(|entry| relative_path(root, Path::new(&entry.path)).unwrap()) + .collect() + } + + #[test] + fn filters_ignored_entries_with_git_style_negation() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n"); + write_file(dir.path(), "visible.md", "# Visible\n"); + write_file(dir.path(), "ignored/hidden.md", "# Hidden\n"); + write_file(dir.path(), "ignored/keep.md", "# Keep\n"); + + let filtered = filter_gitignored_entries( + dir.path(), + vec![ + entry(dir.path(), "visible.md"), + entry(dir.path(), "ignored/hidden.md"), + entry(dir.path(), "ignored/keep.md"), + ], + true, + ); + assert_eq!( + entry_paths(dir.path(), &filtered), + vec!["visible.md", "ignored/keep.md"] + ); + } + + #[test] + fn keeps_ignored_entries_when_visibility_is_enabled() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + write_file(dir.path(), ".gitignore", "ignored/\n"); + + let entries = vec![entry(dir.path(), "ignored/hidden.md")]; + let filtered = filter_gitignored_entries(dir.path(), entries, false); + assert_eq!( + entry_paths(dir.path(), &filtered), + vec!["ignored/hidden.md"] + ); + } + + #[test] + fn filters_ignored_folder_trees() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + write_file(dir.path(), ".gitignore", "generated/\n"); + fs::create_dir_all(dir.path().join("generated/nested")).unwrap(); + fs::create_dir_all(dir.path().join("notes")).unwrap(); + + let folders = vec![ + FolderNode { + name: "generated".to_string(), + path: "generated".to_string(), + children: vec![FolderNode { + name: "nested".to_string(), + path: "generated/nested".to_string(), + children: vec![], + }], + }, + FolderNode { + name: "notes".to_string(), + path: "notes".to_string(), + children: vec![], + }, + ]; + + let filtered = filter_gitignored_folders(dir.path(), folders, true); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].path, "notes"); + } + + #[test] + fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + write_file(dir.path(), ".gitignore", "generated/\n"); + + let folders = (0..6_000) + .map(|index| FolderNode { + name: format!("package-{index}"), + path: format!("generated/package-{index}"), + children: vec![], + }) + .collect::>(); + let vault_path = dir.path().to_path_buf(); + let (sender, receiver) = mpsc::channel(); + + std::thread::spawn(move || { + let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true); + let _ = sender.send(filtered); + drop(dir); + }); + + let filtered = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("large gitignored folder filtering should not block on child stdout"); + assert!(filtered.is_empty()); + } + + #[test] + fn has_no_effect_without_gitignore_file() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + + let entries = vec![entry(dir.path(), "notes/local.md")]; + let filtered = filter_gitignored_entries(dir.path(), entries, true); + assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]); + } + + #[cfg(unix)] + #[test] + fn filters_entries_with_real_paths_when_vault_root_is_symlinked() { + let dir = TempDir::new().unwrap(); + let real_root = dir.path().join("real-vault"); + let symlink_root = dir.path().join("linked-vault"); + fs::create_dir_all(&real_root).unwrap(); + std::os::unix::fs::symlink(&real_root, &symlink_root).unwrap(); + init_git_repo(&symlink_root); + write_file(&real_root, ".gitignore", "tmp/\n"); + write_file(&real_root, "visible.md", "# Visible\n"); + write_file(&real_root, "tmp/hidden.md", "# Hidden\n"); + + let filtered = filter_gitignored_entries( + &symlink_root, + vec![ + entry(&real_root, "visible.md"), + entry(&real_root, "tmp/hidden.md"), + ], + true, + ); + + assert_eq!(entry_paths(&real_root, &filtered), vec!["visible.md"]); + } +} diff --git a/src-tauri/src/vault/image.rs b/src-tauri/src/vault/image.rs new file mode 100644 index 0000000..c3493fd --- /dev/null +++ b/src-tauri/src/vault/image.rs @@ -0,0 +1,196 @@ +use std::fs; +use std::path::Path; +use std::time::UNIX_EPOCH; + +/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore). +fn is_safe_filename_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '.' | '-' | '_') +} + +/// Sanitize a filename by replacing unsafe characters with underscores. +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if is_safe_filename_char(c) { c } else { '_' }) + .collect() +} + +/// Image file extensions considered valid for drag-drop import. +const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"]; + +/// Prepare the attachments directory and generate a unique target path. +fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result { + let attachments_dir = Path::new(vault_path).join("attachments"); + fs::create_dir_all(&attachments_dir) + .map_err(|e| format!("Failed to create attachments directory: {}", e))?; + + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename)); + Ok(attachments_dir.join(unique_name)) +} + +/// Save an uploaded image to the vault's attachments directory. +/// Returns the absolute path to the saved file. +pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result { + use base64::Engine; + + let target_path = prepare_attachment_path(vault_path, filename)?; + + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("Invalid base64 data: {}", e))?; + + fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?; + + Ok(target_path.to_string_lossy().to_string()) +} + +/// Copy an image file from a source path into the vault's attachments directory. +/// Used for Tauri native drag-drop which provides absolute file paths. +/// Returns the absolute path to the saved file. +pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result { + let source = Path::new(source_path); + if !source.exists() { + return Err(format!("Source file does not exist: {}", source_path)); + } + + let ext = source + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!("Not a supported image format: {}", source_path)); + } + + let filename = source + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("image"); + let target_path = prepare_attachment_path(vault_path, filename)?; + + fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?; + + Ok(target_path.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_sanitize_filename_safe_chars() { + assert_eq!(sanitize_filename("photo.png"), "photo.png"); + assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg"); + } + + #[test] + fn test_sanitize_filename_unsafe_chars() { + assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png"); + assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png"); + } + + #[test] + fn test_save_image_creates_file() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data"); + + let result = save_image(vault_path, "test.png", &data); + assert!(result.is_ok()); + + let saved_path = result.unwrap(); + assert!(std::path::Path::new(&saved_path).exists()); + assert!(saved_path.contains("attachments")); + assert!(saved_path.contains("test.png")); + + let content = fs::read(&saved_path).unwrap(); + assert_eq!(content, b"fake image data"); + } + + #[test] + fn test_save_image_creates_attachments_dir() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let attachments = dir.path().join("attachments"); + assert!(!attachments.exists()); + + let data = base64::engine::general_purpose::STANDARD.encode(b"test"); + save_image(vault_path, "img.png", &data).unwrap(); + assert!(attachments.exists()); + } + + #[test] + fn test_save_image_invalid_base64() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let result = save_image(vault_path, "test.png", "not-valid-base64!!!"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid base64")); + } + + #[test] + fn test_copy_image_to_vault_success() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + // Create a source image file + let source_path = dir.path().join("source.png"); + fs::write(&source_path, b"fake png data").unwrap(); + + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_ok()); + + let saved_path = result.unwrap(); + assert!(std::path::Path::new(&saved_path).exists()); + assert!(saved_path.contains("attachments")); + assert!(saved_path.contains("source.png")); + + let content = fs::read(&saved_path).unwrap(); + assert_eq!(content, b"fake png data"); + } + + #[test] + fn test_copy_image_to_vault_nonexistent_source() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn test_copy_image_to_vault_rejects_non_image() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let source_path = dir.path().join("document.pdf"); + fs::write(&source_path, b"fake pdf").unwrap(); + + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Not a supported image")); + } + + #[test] + fn test_copy_image_to_vault_accepts_all_extensions() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] { + let source_path = dir.path().join(format!("img.{}", ext)); + fs::write(&source_path, b"data").unwrap(); + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_ok(), "failed for extension: {}", ext); + } + } +} diff --git a/src-tauri/src/vault/migration.rs b/src-tauri/src/vault/migration.rs new file mode 100644 index 0000000..e53db73 --- /dev/null +++ b/src-tauri/src/vault/migration.rs @@ -0,0 +1,250 @@ +use std::fs; +use std::path::Path; +use walkdir::WalkDir; + +const LEGACY_IS_A_PREFIXES: [&str; 4] = ["is_a:", "\"Is A\":", "'Is A':", "Is A:"]; + +fn has_legacy_is_a(fm_content: &str) -> bool { + fm_content.lines().any(|line| { + let t = line.trim_start(); + LEGACY_IS_A_PREFIXES + .iter() + .any(|prefix| t.starts_with(prefix)) + }) +} + +/// Extract the value from a legacy `is_a` / `Is A` line. +fn extract_is_a_value(line: &str) -> Option<&str> { + let t = line.trim_start(); + for prefix in &LEGACY_IS_A_PREFIXES { + if let Some(rest) = t.strip_prefix(prefix) { + let v = rest.trim(); + return Some(v); + } + } + None +} + +fn frontmatter_content(content: &str) -> Option<(usize, &str)> { + if !content.starts_with("---\n") { + return None; + } + let fm_end = content[4..].find("\n---")? + 4; + Some((fm_end, &content[4..fm_end])) +} + +fn has_type_field(fm_content: &str) -> bool { + fm_content + .lines() + .any(|line| line.trim_start().starts_with("type:")) +} + +fn migrated_frontmatter_lines(fm_content: &str) -> Option> { + if !has_legacy_is_a(fm_content) { + return None; + } + + let mut new_lines = Vec::new(); + let mut is_a_value = None; + + for line in fm_content.lines() { + match extract_is_a_value(line) { + Some(value) => is_a_value = Some(value.to_string()), + None => new_lines.push(line.to_string()), + } + } + + let value = is_a_value?; + if !has_type_field(fm_content) { + new_lines.insert(0, format!("type: {}", value)); + } + + Some(new_lines) +} + +/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`. +/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed. +fn migrate_file_is_a_to_type(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + + let Some((fm_end, fm_content)) = frontmatter_content(&content) else { + return Ok(false); + }; + let Some(new_lines) = migrated_frontmatter_lines(fm_content) else { + return Ok(false); + }; + + let rest = &content[fm_end + 4..]; + let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest); + + fs::write(path, &new_content) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + + Ok(true) +} + +/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`. +/// Returns the number of files migrated. +pub fn migrate_is_a_to_type(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + if !vault.exists() || !vault.is_dir() { + return Err(format!( + "Vault path does not exist or is not a directory: {}", + vault_path + )); + } + + let mut migrated = 0; + for entry in WalkDir::new(vault) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) { + continue; + } + + match migrate_file_is_a_to_type(path) { + Ok(true) => { + log::info!("Migrated is_a → type: {}", path.display()); + migrated += 1; + } + Ok(false) => {} + Err(e) => { + log::warn!("Failed to migrate {}: {}", path.display(), e); + } + } + } + + Ok(migrated) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn assert_file_migration_result(content: &str, expected: bool) { + let tmp = tempdir().unwrap(); + let path = write_file(tmp.path(), "note.md", content); + + let result = migrate_file_is_a_to_type(&path).unwrap(); + + assert_eq!(result, expected); + } + + // --- has_legacy_is_a --- + + #[test] + fn test_has_legacy_is_a_detects_is_a_colon() { + assert!(has_legacy_is_a("is_a: Person\nname: Alice")); + } + + #[test] + fn test_has_legacy_is_a_detects_quoted_is_a() { + assert!(has_legacy_is_a("\"Is A\": Note\nname: Test")); + } + + #[test] + fn test_has_legacy_is_a_detects_bare_is_a() { + assert!(has_legacy_is_a("Is A: Topic\n")); + } + + #[test] + fn test_has_legacy_is_a_returns_false_for_clean_frontmatter() { + assert!(!has_legacy_is_a("type: Person\nname: Alice")); + } + + // --- extract_is_a_value --- + + #[test] + fn test_extract_is_a_value_from_is_a_colon() { + assert_eq!(extract_is_a_value("is_a: Person"), Some("Person")); + } + + #[test] + fn test_extract_is_a_value_from_quoted() { + assert_eq!(extract_is_a_value("\"Is A\": Note"), Some("Note")); + } + + #[test] + fn test_extract_is_a_value_returns_none_for_unrelated_line() { + assert_eq!(extract_is_a_value("name: Alice"), None); + } + + // --- migrate_file_is_a_to_type --- + + #[test] + fn test_migrate_file_adds_type_and_removes_is_a() { + let tmp = tempdir().unwrap(); + let path = write_file( + tmp.path(), + "note.md", + "---\nis_a: Person\nname: Alice\n---\n# Alice\n", + ); + let result = migrate_file_is_a_to_type(&path).unwrap(); + assert!(result, "file should be migrated"); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Person"), "should have type field"); + assert!(!content.contains("is_a:"), "should not have is_a field"); + } + + #[test] + fn test_migrate_file_skips_when_no_frontmatter() { + assert_file_migration_result("# Just a heading\nNo frontmatter here.\n", false); + } + + #[test] + fn test_migrate_file_skips_when_already_has_type() { + assert_file_migration_result("---\ntype: Person\nname: Alice\n---\n# Alice\n", false); + } + + #[test] + fn test_migrate_file_skips_when_no_is_a_field() { + assert_file_migration_result("---\nname: Alice\ndate: 2024-01-01\n---\n# Alice\n", false); + } + + // --- migrate_is_a_to_type (public function) --- + + #[test] + fn test_migrate_vault_returns_count_of_migrated_files() { + let tmp = tempdir().unwrap(); + write_file( + tmp.path(), + "note1.md", + "---\nis_a: Person\nname: Alice\n---\n", + ); + write_file(tmp.path(), "note2.md", "---\nis_a: Topic\nname: AI\n---\n"); + write_file( + tmp.path(), + "note3.md", + "---\ntype: Event\nname: Conf\n---\n", + ); + let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(count, 2, "should migrate exactly 2 files"); + } + + #[test] + fn test_migrate_vault_returns_error_for_nonexistent_path() { + let result = migrate_is_a_to_type("/tmp/this-path-does-not-exist-laputa-test"); + assert!(result.is_err()); + } + + #[test] + fn test_migrate_vault_ignores_non_markdown_files() { + let tmp = tempdir().unwrap(); + write_file(tmp.path(), "image.png", "not a markdown file"); + write_file(tmp.path(), "data.json", "{\"is_a\": \"test\"}"); + let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(count, 0, "non-markdown files should be ignored"); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs new file mode 100644 index 0000000..7ee105f --- /dev/null +++ b/src-tauri/src/vault/mod.rs @@ -0,0 +1,512 @@ +mod cache; +mod config_seed; +mod entry; +mod file; +pub(crate) mod filename_rules; +mod folders; +mod frontmatter; +mod getting_started; +mod ignored; +mod image; +mod migration; +mod parsing; +pub(crate) mod path_identity; +mod rename; +mod rename_transaction; +mod title_sync; +mod trash; +mod type_templates; +mod view_date_filters; +mod view_migration; +mod view_relationships; +#[cfg(test)] +mod view_tests; +mod view_value_conversions; +mod views; + +pub use cache::{invalidate_cache, scan_vault_cached}; +pub use config_seed::{ + get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files, + seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus, +}; +pub use entry::{FolderNode, VaultEntry}; +pub use file::{create_note_content, get_note_content, note_content_matches, save_note_content}; +pub use folders::{delete_folder, rename_folder, FolderRenameResult}; +pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; +pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths}; +pub use image::{copy_image_to_vault, save_image}; +pub use migration::migrate_is_a_to_type; +pub use rename::{ + auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note, + rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename, + MoveNoteToFolderRequest, MoveNoteToWorkspaceRequest, RenameNoteFilenameRequest, + RenameNoteRequest, RenameResult, +}; +pub use title_sync::{sync_title_on_open, SyncAction}; +pub use trash::{batch_delete_notes, delete_note}; +pub use views::{ + delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode, + FilterOp, ViewDefinition, ViewFile, +}; + +use file::read_file_metadata; +use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_display, resolve_note_width}; +use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title}; +use type_templates::TypeTemplateSource; + +use gray_matter::engine::YAML; +use gray_matter::Matter; +use std::fs; +use std::path::Path; +use walkdir::WalkDir; + +fn preferred_relationship_refs( + relationships: &std::collections::HashMap>, + canonical_key: &str, + legacy_key: &str, +) -> Vec { + relationships + .get(canonical_key) + .cloned() + .or_else(|| relationships.get(legacy_key).cloned()) + .unwrap_or_default() +} + +pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String { + let matter = Matter::::new(); + let parsed = matter.parse(content); + let (frontmatter, _, _) = extract_fm_and_rels(parsed.data, content); + extract_title(frontmatter.title.as_deref(), content, filename) +} + +fn resolve_entry_dates( + fs_modified: Option, + fs_created: Option, + git_dates: Option<(u64, u64)>, +) -> (Option, Option) { + match git_dates { + Some((git_modified, git_created)) => { + let modified_at = Some(fs_modified.map_or(git_modified, |fs| fs.max(git_modified))); + (modified_at, Some(git_created)) + } + None => (fs_modified, fs_created), + } +} + +/// Parse a single markdown file into a VaultEntry. +/// +/// If `git_dates` is provided, `created_at` comes from git history while +/// `modified_at` uses the newer of the latest git touch and the current +/// filesystem modified time. Pass `None` to use filesystem dates only +/// (appropriate for non-git vaults). +pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + + let matter = Matter::::new(); + let parsed = matter.parse(&content); + let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content); + + let title = derive_markdown_title_from_content(&content, &filename); + let has_h1 = parsing::extract_h1_title(&content).is_some(); + let snippet = extract_snippet(&content); + let word_count = count_body_words(&content); + let outgoing_links = extract_outgoing_links(&parsed.content); + let (fs_modified, fs_created, file_size) = read_file_metadata(path)?; + let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates); + let is_a = resolve_is_a(frontmatter.is_a); + let template = TypeTemplateSource { + explicit_template: frontmatter + .template + .map(|value| value.into_scalar().unwrap_or_default()), + is_a: is_a.as_deref(), + title: &title, + body: &parsed.content, + } + .resolve(); + + // Add "Type" relationship: isA becomes a navigable link to the type document. + // Skip for type documents themselves (isA == "Type") to avoid self-referential links. + if let Some(ref type_name) = is_a { + if type_name != "Type" { + let type_link = if type_name.starts_with("[[") && type_name.ends_with("]]") { + type_name.clone() + } else { + format!("[[{}]]", type_name.to_lowercase()) + }; + relationships.insert("Type".to_string(), vec![type_link]); + } + } + + let belongs_to = preferred_relationship_refs(&relationships, "belongs_to", "Belongs to"); + let related_to = preferred_relationship_refs(&relationships, "related_to", "Related to"); + + Ok(VaultEntry { + path: path.to_string_lossy().to_string(), + filename, + title, + is_a, + snippet, + relationships, + aliases: frontmatter + .aliases + .map(|a| a.into_vec()) + .unwrap_or_default(), + belongs_to, + related_to, + status: frontmatter.status.and_then(|v| v.into_scalar()), + archived: frontmatter.archived.unwrap_or(false), + modified_at, + created_at, + file_size, + icon: frontmatter.icon.and_then(|v| v.into_scalar()), + color: frontmatter.color.and_then(|v| v.into_scalar()), + order: frontmatter.order, + sidebar_label: frontmatter.sidebar_label.and_then(|v| v.into_scalar()), + template, + sort: frontmatter.sort.and_then(|v| v.into_scalar()), + view: frontmatter.view.and_then(|v| v.into_scalar()), + note_width: resolve_note_width(frontmatter.note_width), + display: resolve_note_display(frontmatter.display), + visible: frontmatter.visible, + organized: frontmatter.organized.unwrap_or(false), + favorite: frontmatter.favorite.unwrap_or(false), + favorite_index: frontmatter.favorite_index, + list_properties_display: frontmatter.list_properties_display.unwrap_or_default(), + word_count, + outgoing_links, + properties, + has_h1, + file_kind: "markdown".to_string(), + }) +} + +/// Parse a non-markdown file into a minimal VaultEntry. +/// Uses filename as title, except for `.yml` files where the YAML `name` field is used. +pub(crate) fn parse_non_md_file( + path: &Path, + git_dates: Option<(u64, u64)>, +) -> Result { + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let (fs_modified, fs_created, file_size) = read_file_metadata(path)?; + let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates); + let file_kind = classify_file_kind(path).to_string(); + let title = extract_yml_name(path).unwrap_or_else(|| filename.clone()); + + Ok(VaultEntry { + path: path.to_string_lossy().to_string(), + filename: filename.clone(), + title, + file_kind, + modified_at, + created_at, + file_size, + ..VaultEntry::default() + }) +} + +/// For `.yml` files, try to extract the `name` field from the YAML content. +fn extract_yml_name(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + if ext != "yml" && ext != "yaml" { + return None; + } + let content = std::fs::read_to_string(path).ok()?; + let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?; + mapping.get("name")?.as_str().map(|s| s.to_string()) +} + +/// Re-read a single file from disk and return a fresh VaultEntry. +/// Uses filesystem dates (no git lookup) since the file was likely just saved. +pub fn reload_entry(path: &Path) -> Result { + if !path.exists() { + return Err(format!("File does not exist: {}", path.display())); + } + if is_md_file(path) { + parse_md_file(path, None) + } else { + parse_non_md_file(path, None) + } +} + +/// Directories hidden from user-facing vault scans. +const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"]; +/// Keep type definitions in their dedicated sidebar section instead of the generic folder tree. +const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["type"]; + +fn is_hidden_dir(name: &str) -> bool { + name.starts_with('.') || HIDDEN_DIRS.contains(&name) +} + +fn is_folder_tree_hidden_dir(name: &str) -> bool { + is_hidden_dir(name) || FOLDER_TREE_EXCLUDED_DIRS.contains(&name) +} + +pub(crate) fn is_md_file(path: &Path) -> bool { + path.is_file() && path.extension().is_some_and(|ext| ext == "md") +} + +/// Extensions recognized as editable text files (opened in raw editor). +const TEXT_EXTENSIONS: &[&str] = &[ + "yml", + "yaml", + "json", + "txt", + "toml", + "csv", + "xml", + "html", + "htm", + "css", + "scss", + "less", + "ts", + "tsx", + "js", + "jsx", + "py", + "rs", + "sh", + "bash", + "zsh", + "fish", + "rb", + "go", + "java", + "kt", + "c", + "cpp", + "h", + "hpp", + "swift", + "lua", + "sql", + "graphql", + "env", + "ini", + "cfg", + "conf", + "properties", + "makefile", + "dockerfile", + "gitignore", + "editorconfig", + "mdx", + "svelte", + "vue", + "astro", + "tf", + "hcl", + "nix", + "zig", + "hs", + "ml", + "ex", + "exs", + "erl", + "clj", + "lisp", + "el", + "vim", + "r", + "jl", + "ps1", + "bat", + "cmd", +]; + +/// Classify a file extension into "markdown", "text", or "binary". +pub(crate) fn classify_file_kind(path: &Path) -> &'static str { + let ext = match path.extension() { + Some(e) => e.to_string_lossy().to_lowercase(), + None => { + // Files without extension: check if name itself is a known text file + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + return if [ + "makefile", + "dockerfile", + "rakefile", + "gemfile", + "procfile", + "brewfile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".env", + ] + .contains(&name.as_str()) + { + "text" + } else { + "binary" + }; + } + }; + if ext == "md" || ext == "markdown" { + "markdown" + } else if TEXT_EXTENSIONS.contains(&ext.as_str()) { + "text" + } else { + "binary" + } +} + +use crate::git::GitDates; +use std::collections::HashMap; + +fn lookup_git_dates( + path: &Path, + vault_path: &Path, + git_dates: &HashMap, +) -> Option<(u64, u64)> { + let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?; + git_dates.get(&rel).map(|d| (d.modified_at, d.created_at)) +} + +fn try_parse_file( + path: &Path, + vault_path: &Path, + git_dates: &HashMap, + entries: &mut Vec, +) { + let dates = lookup_git_dates(path, vault_path, git_dates); + let result = if is_md_file(path) { + parse_md_file(path, dates) + } else { + parse_non_md_file(path, dates) + }; + match result { + Ok(vault_entry) => entries.push(vault_entry), + Err(e) => log::warn!("Skipping file: {}", e), + } +} + +/// Scan all files in the vault, including subdirectories. +/// Hidden directories (starting with `.`) are excluded. +fn scan_all_files( + vault_path: &Path, + git_dates: &HashMap, + entries: &mut Vec, +) { + let walker = WalkDir::new(vault_path) + .follow_links(true) + .into_iter() + .filter_entry(|e| { + if e.file_type().is_dir() { + let name = e.file_name().to_string_lossy(); + // Skip the vault root itself (depth 0) — we only filter subdirs + if e.depth() == 0 { + return true; + } + return !is_hidden_dir(&name); + } + true + }); + for entry in walker.filter_map(|e| e.ok()) { + if entry.path().is_file() { + // Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store + let fname = entry.file_name().to_string_lossy(); + if fname.starts_with('.') { + continue; + } + try_parse_file(entry.path(), vault_path, git_dates, entries); + } + } +} + +/// Scan a directory recursively for all files and return VaultEntry for each. +/// Pass an empty map for `git_dates` to use filesystem dates only. +pub fn scan_vault( + vault_path: &Path, + git_dates: &HashMap, +) -> Result, String> { + if !vault_path.exists() { + return Err(format!( + "Vault path does not exist: {}", + vault_path.display() + )); + } + if !vault_path.is_dir() { + return Err(format!( + "Vault path is not a directory: {}", + vault_path.display() + )); + } + + if let Err(err) = rename::recover_pending_rename_transactions(vault_path) { + log::warn!( + "Failed to recover pending rename transactions in {}: {}", + vault_path.display(), + err + ); + } + + let mut entries = Vec::new(); + scan_all_files(vault_path, git_dates, &mut entries); + + entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at)); + Ok(entries) +} + +/// Build a tree of user-created folders in the vault. +pub fn scan_vault_folders(vault_path: &Path) -> Result, String> { + if !vault_path.is_dir() { + return Err(format!("Not a directory: {}", vault_path.display())); + } + fn build_tree(dir: &Path, vault_root: &Path) -> Vec { + let mut nodes: Vec = Vec::new(); + let entries = match fs::read_dir(dir) { + Ok(d) => d, + Err(_) => return nodes, + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if is_folder_tree_hidden_dir(&name) { + continue; + } + let rel_path = path_identity::vault_relative_path_string(vault_root, &path) + .unwrap_or_else(|_| { + path_identity::normalize_path_for_identity(&path.to_string_lossy()) + }); + let children = build_tree(&path, vault_root); + nodes.push(FolderNode { + name, + path: rel_path, + children, + }); + } + nodes.sort_by_key(|node| node.name.to_lowercase()); + nodes + } + Ok(build_tree(vault_path, vault_path)) +} + +#[cfg(test)] +#[path = "frontmatter_regression_tests.rs"] +mod frontmatter_regression_tests; +#[cfg(test)] +#[path = "modified_dates_tests.rs"] +mod modified_dates_tests; +#[cfg(test)] +#[path = "relationship_key_tests.rs"] +mod relationship_key_tests; +#[cfg(test)] +#[path = "system_metadata_tests.rs"] +mod system_metadata_tests; +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs new file mode 100644 index 0000000..d27a453 --- /dev/null +++ b/src-tauri/src/vault/mod_tests.rs @@ -0,0 +1,45 @@ +use super::*; +use std::fs; +use std::io::Write; +use std::path::Path; +use tempfile::TempDir; + +pub(super) fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); +} + +pub(super) fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry { + create_test_file(dir.path(), name, content); + parse_md_file(&dir.path().join(name), None).unwrap() +} + +#[path = "mod_tests/archival_metadata.rs"] +mod archival_metadata; +#[path = "mod_tests/basics.rs"] +mod basics; +#[path = "mod_tests/complex_frontmatter.rs"] +mod complex_frontmatter; +#[path = "mod_tests/display_metadata.rs"] +mod display_metadata; +#[path = "mod_tests/folder_and_file_kind.rs"] +mod folder_and_file_kind; +#[path = "mod_tests/journal_type_visibility.rs"] +mod journal_type_visibility; +#[path = "mod_tests/real_vault_consistency.rs"] +mod real_vault_consistency; +#[path = "mod_tests/relationships.rs"] +mod relationships; +#[path = "mod_tests/scan_and_file_access.rs"] +mod scan_and_file_access; +#[path = "mod_tests/type_and_links.rs"] +mod type_and_links; + +// Frontmatter update/delete tests are in frontmatter.rs +// save_image tests are in vault/image.rs +// purge_trash tests are in vault/trash.rs +// rename_note tests are in vault/rename.rs diff --git a/src-tauri/src/vault/mod_tests/archival_metadata.rs b/src-tauri/src/vault/mod_tests/archival_metadata.rs new file mode 100644 index 0000000..a354ea4 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/archival_metadata.rs @@ -0,0 +1,217 @@ +use super::*; + +fn parse_archived_entry(file_name: &str, content: &str) -> VaultEntry { + let dir = TempDir::new().unwrap(); + parse_test_entry(&dir, file_name, content) +} + +#[test] +fn test_parse_archived_truthy_aliases() { + let cases = [ + ( + "old-quarter.md", + "---\narchived: true\n---\n# Old Quarter\n", + "lowercase archived alias must be parsed", + ), + ( + "old-quarter-2.md", + "---\nArchived: true\n---\n# Old Quarter\n", + "titlecase archived alias must be parsed", + ), + ( + "old.md", + "---\nArchived: Yes\n---\n# Old\n", + "Archived: Yes must be parsed as true", + ), + ( + "old2.md", + "---\narchived: yes\n---\n# Old\n", + "archived: yes must be parsed as true", + ), + ( + "old3.md", + "---\nArchived: YES\n---\n# Old\n", + "Archived: YES must be parsed as true", + ), + ( + "old-new.md", + "---\n_archived: true\n---\n# Old\n", + "_archived canonical key must be parsed", + ), + ]; + + for (file_name, content, message) in cases { + let entry = parse_archived_entry(file_name, content); + assert!(entry.archived, "{message}"); + } +} + +#[test] +fn test_parse_archived_falsy_inputs_and_absence() { + let cases = [ + ( + "active2.md", + "---\nArchived: No\n---\n# Active\n", + "Archived: No must be parsed as false", + ), + ( + "active3.md", + "---\nArchived: \"false\"\n---\n# Active\n", + "Archived: false must be parsed as false", + ), + ( + "active4.md", + "---\nArchived: 0\n---\n# Active\n", + "Archived: 0 must be parsed as false", + ), + ( + "active5.md", + "---\nIs A: Note\n---\n# Active\n", + "absent archived must default to false", + ), + ]; + + for (file_name, content, message) in cases { + let entry = parse_archived_entry(file_name, content); + assert!(!entry.archived, "{message}"); + } +} + +#[test] +fn test_parse_favorite_fields() { + let favorite = parse_archived_entry( + "fav.md", + "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n", + ); + assert!(favorite.favorite); + assert_eq!(favorite.favorite_index, Some(3)); + + let not_favorite = parse_archived_entry("not-fav.md", "---\ntype: Note\n---\n# Not Fav\n"); + assert!(!not_favorite.favorite); + assert_eq!(not_favorite.favorite_index, None); +} + +#[test] +fn test_parse_visible_values() { + let cases = [ + ( + "journal.md", + "---\ntype: Type\nvisible: false\n---\n# Journal\n", + Some(false), + ), + ( + "project.md", + "---\ntype: Type\nvisible: true\n---\n# Project\n", + Some(true), + ), + ("missing.md", "---\ntype: Type\n---\n# Project\n", None), + ]; + + for (file_name, content, expected) in cases { + let entry = parse_archived_entry(file_name, content); + assert_eq!( + entry.visible, expected, + "unexpected visible value for {file_name}" + ); + } +} + +#[test] +fn test_archived_true_with_extra_non_string_fields() { + let entry = parse_archived_entry( + "archived-extra.md", + "---\nArchived: true\norder: 5\n---\n# Archived Note\n", + ); + assert!(entry.archived); +} + +#[test] +fn test_fallback_parser_extracts_archived_from_malformed_yaml() { + let dir = TempDir::new().unwrap(); + let frontmatter = [ + "---", + "type: Essay", + "Notes:", + " - \"[[slug|{\"Broken\": 'quotes'}]]\"", + "Archived: true", + "---", + "", + "# Archived Essay", + ]; + let content = frontmatter.join("\n"); + create_test_file(dir.path(), "archived-essay.md", &content); + let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap(); + assert!(entry.archived); + assert_eq!(entry.is_a, Some("Essay".to_string())); +} + +#[test] +fn test_visible_not_in_relationships_or_properties() { + let entry = parse_archived_entry( + "journal.md", + "---\ntype: Type\nvisible: false\n---\n# Journal\n", + ); + assert!(!entry.relationships.contains_key("visible")); + assert!(!entry.properties.contains_key("visible")); +} + +#[test] +fn test_roundtrip_type_aliases_parse_correctly() { + let cases = [ + ("quarter/q1.md", "---\ntype: Quarter\n---\n# Q1 2026\n"), + ("quarter/q1-is-a.md", "---\nIs A: Quarter\n---\n# Q1 2026\n"), + ( + "quarter/q1-snake.md", + "---\nis_a: Quarter\n---\n# Q1 2026\n", + ), + ]; + + for (file_name, content) in cases { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, file_name, content); + assert_eq!(entry.is_a, Some("Quarter".to_string())); + } +} + +#[test] +fn test_string_or_list_normalization_keeps_type_and_scalar_fields() { + let single_status = parse_archived_entry( + "test.md", + "---\ntype: Project\nStatus:\n - Active\n---\n# Test\n", + ); + assert_eq!(single_status.status, Some("Active".to_string())); + assert_eq!(single_status.is_a, Some("Project".to_string())); + + let scalar_fields = parse_archived_entry( + "scalar.md", + "---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n", + ); + assert_eq!( + scalar_fields + .properties + .get("Owner") + .and_then(|value| value.as_str()), + Some("Luca") + ); + assert_eq!( + scalar_fields + .properties + .get("Cadence") + .and_then(|value| value.as_str()), + Some("Daily") + ); + assert_eq!(scalar_fields.status, Some("Done".to_string())); + + let absent_fields = parse_archived_entry("absent.md", "---\ntype: Note\n---\n# Test\n"); + assert_eq!(absent_fields.status, None); +} + +#[test] +fn test_array_field_does_not_break_type_detection() { + let entry = parse_archived_entry( + "array-fields.md", + "---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n", + ); + assert_eq!(entry.is_a, Some("Responsibility".to_string())); + assert_eq!(entry.status, Some("Active".to_string())); +} diff --git a/src-tauri/src/vault/mod_tests/basics.rs b/src-tauri/src/vault/mod_tests/basics.rs new file mode 100644 index 0000000..7c3ac88 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/basics.rs @@ -0,0 +1,150 @@ +use super::*; + +const FULL_FM_CONTENT: &str = "---\ntitle: Laputa Project\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n"; + +#[test] +fn test_reload_entry_returns_fresh_data() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "my-note.md", + "---\ntitle: My Note\nStatus: Active\n---\n# My Note\n\nOriginal.", + ); + let entry = reload_entry(&dir.path().join("my-note.md")).unwrap(); + assert_eq!(entry.title, "My Note"); + assert_eq!(entry.status, Some("Active".to_string())); + + create_test_file( + dir.path(), + "my-note.md", + "---\ntitle: My Note\nStatus: Done\n---\n# My Note\n\nUpdated.", + ); + let fresh = reload_entry(&dir.path().join("my-note.md")).unwrap(); + assert_eq!(fresh.status, Some("Done".to_string())); +} + +#[test] +fn test_reload_entry_nonexistent_file() { + let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); +} + +#[test] +fn test_parse_full_frontmatter_identity() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT); + assert_eq!(entry.title, "Laputa Project"); + assert_eq!(entry.is_a, Some("Project".to_string())); + assert_eq!(entry.filename, "laputa.md"); +} + +#[test] +fn test_parse_full_frontmatter_lists() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT); + assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]); + assert!(!entry.relationships.contains_key("Belongs to")); + assert!(!entry.relationships.contains_key("Related to")); +} + +#[test] +fn test_parse_full_frontmatter_scalars() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT); + assert_eq!(entry.status, Some("Active".to_string())); + assert_eq!( + entry + .properties + .get("Owner") + .and_then(|value| value.as_str()), + Some("Luca") + ); + assert_eq!( + entry + .properties + .get("Cadence") + .and_then(|value| value.as_str()), + Some("Weekly") + ); +} + +#[test] +fn test_parse_empty_frontmatter() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "just-a-title.md", + "---\n---\n# Just a Title\n\nNo frontmatter fields.", + ); + assert_eq!( + ( + entry.title.as_str(), + entry.aliases.is_empty(), + entry.belongs_to.is_empty(), + entry.status.as_deref(), + ), + ("Just a Title", true, true, None) + ); +} + +#[test] +fn test_parse_no_frontmatter() { + let dir = TempDir::new().unwrap(); + let content = "# A Note Without Frontmatter\n\nJust markdown."; + create_test_file(dir.path(), "a-note-without-frontmatter.md", content); + + let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap(); + assert_eq!(entry.title, "A Note Without Frontmatter"); +} + +#[test] +fn test_parse_single_string_aliases() { + let dir = TempDir::new().unwrap(); + let content = "---\naliases: SingleAlias\n---\n# Test\n"; + create_test_file(dir.path(), "single-alias.md", content); + + let entry = parse_md_file(&dir.path().join("single-alias.md"), None).unwrap(); + assert_eq!(entry.aliases, vec!["SingleAlias"]); +} + +#[test] +fn test_parse_malformed_yaml() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n"; + create_test_file(dir.path(), "malformed.md", content); + + let entry = parse_md_file(&dir.path().join("malformed.md"), None); + assert!(entry.is_ok()); +} + +#[test] +fn test_parse_md_file_has_snippet() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet."; + create_test_file(dir.path(), "test.md", content); + + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); + assert_eq!(entry.snippet, "Hello, world! This is a snippet."); +} + +#[test] +fn test_parse_md_file_has_word_count() { + let dir = TempDir::new().unwrap(); + let content = + "---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words."; + create_test_file(dir.path(), "test.md", content); + + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); + assert_eq!(entry.word_count, 9); +} + +#[test] +fn test_parse_md_file_word_count_empty_body() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Empty Note\n"; + create_test_file(dir.path(), "test.md", content); + + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); + assert_eq!(entry.word_count, 0); +} diff --git a/src-tauri/src/vault/mod_tests/complex_frontmatter.rs b/src-tauri/src/vault/mod_tests/complex_frontmatter.rs new file mode 100644 index 0000000..35fffb6 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/complex_frontmatter.rs @@ -0,0 +1,179 @@ +use super::*; + +struct FrontmatterCase { + file_name: &'static str, + content: &'static str, + expected_type: &'static str, + context: &'static str, +} + +const COMPLEX_NOTE_CONTENT: &str = r#"--- +type: Note +_organized: true +aliases: ["My Complex Note"] +Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"] +"Created at": "2021-12-31T14:19:00.000Z" +Status: Published +Owner: "[[person-luca|Luca]]" +--- +# My Complex Note + +Content here. +"#; + +const WRITING_NOTE_CONTENT: &str = r#"--- +Is A: Evergreen +_organized: true +aliases: ["Writing for Clarity vs. Writing for Credit"] +Topics: ["[[topic-writing]]"] +Status: Published +"Last edited": "2024-06-15T10:30:00.000Z" +notion_id: "abc123def456" +--- +# Writing for Clarity vs. Writing for Credit + +Content. +"#; + +const YES_NOTE_CONTENT: &str = "---\ntype: Note\n_organized: Yes\nStatus: Draft\n---\n# Test\n"; + +const TIMESTAMP_NOTE_CONTENT: &str = + "---\ntype: Note\n_organized: true\nCreated at: 2021-12-31T14:19:00.000Z\nTopics:\n - \"[[topic-writing]]\"\n---\n# Test\n"; + +const FLOW_ARRAY_CONTENT: &str = r#"--- +type: Evergreen +_organized: true +Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"] +Has: ["[[note-one]]", "[[note-two]]", "[[note-three]]"] +--- +# Test +"#; + +const NOTION_IMPORT_CONTENT: &str = r#"--- +type: Readings +aliases: + - "1 to 1s" +"Discarded for digest?": false +"Note Status": Saved +URL: "http://theengineeringmanager.com/management-101/121s/" +Author: James Stanier +Category: Articles +"Full Title": 1 to 1s +Highlights: 21 +"Last Synced": 2025-12-10 +"Last Highlighted": 2021-04-12 +notion_id: 2c5bdf02-815c-81ce-9dce-eca60ddaeb08 +_organized: true +--- + +# 1 to 1s + +Content. +"#; + +const BITCOIN_NOTE_CONTENT: &str = r#"--- +type: Note +workspace: personal +notion_id: de48a4ad-e7ad-42aa-a5ce-1efdc259d7f9 +"Created at": "2021-12-17T10:21:00.000Z" +Reviewed: True +Topics: + - "[[web3|Web3 / Crypto]]" +URL: https://studio.glassnode.com/metrics?a=BTC&category=Market%20Indicators&m=indicators.NetUnrealizedProfitLoss&s=1320105600&u=1639267199&zoom= +aliases: + - Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio + - Note +Trashed: true +"Trashed at": 2026-03-11 +_organized: true +--- + +# Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio +"#; + +fn complex_frontmatter_cases() -> [FrontmatterCase; 7] { + [ + FrontmatterCase { + file_name: "complex-note.md", + content: COMPLEX_NOTE_CONTENT, + expected_type: "Note", + context: "type must be parsed correctly with complex frontmatter", + }, + FrontmatterCase { + file_name: "writing-note.md", + content: WRITING_NOTE_CONTENT, + expected_type: "Evergreen", + context: "Is A must be parsed correctly with quoted keys nearby", + }, + FrontmatterCase { + file_name: "yes-note.md", + content: YES_NOTE_CONTENT, + expected_type: "Note", + context: "_organized: Yes must be parsed as true", + }, + FrontmatterCase { + file_name: "timestamp-note.md", + content: TIMESTAMP_NOTE_CONTENT, + expected_type: "Note", + context: "type must survive unquoted timestamp in sibling field", + }, + FrontmatterCase { + file_name: "flow-array.md", + content: FLOW_ARRAY_CONTENT, + expected_type: "Evergreen", + context: "type must survive flow arrays with wikilinks", + }, + FrontmatterCase { + file_name: "1-to-1s.md", + content: NOTION_IMPORT_CONTENT, + expected_type: "Readings", + context: "type must be parsed with Notion-style quoted keys", + }, + FrontmatterCase { + file_name: "bitcoin-note.md", + content: BITCOIN_NOTE_CONTENT, + expected_type: "Note", + context: "type must be parsed correctly even with unquoted colon in alias", + }, + ] +} + +fn assert_complex_frontmatter_case(case: FrontmatterCase) { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, case.file_name, case.content); + assert_eq!( + entry.is_a, + Some(case.expected_type.to_string()), + "{}", + case.context + ); + assert!( + entry.organized, + "{} must keep _organized=true", + case.file_name + ); +} + +#[test] +fn test_complex_frontmatter_preserves_type_and_organized() { + for case in complex_frontmatter_cases() { + assert_complex_frontmatter_case(case); + } +} + +#[test] +fn test_fallback_parser_extracts_type_and_organized() { + use super::frontmatter::extract_fm_and_rels; + + let raw_content = + "---\ntype: Note\n_organized: true\nBroken: value: with: colons\n---\n# Test\n"; + let (frontmatter, _, _) = extract_fm_and_rels(None, raw_content); + assert_eq!( + frontmatter + .is_a + .as_ref() + .and_then(|value| value.clone().into_scalar()), + Some("Note".to_string()) + ); + assert_eq!(frontmatter.organized, Some(true)); +} diff --git a/src-tauri/src/vault/mod_tests/display_metadata.rs b/src-tauri/src/vault/mod_tests/display_metadata.rs new file mode 100644 index 0000000..fe942d1 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/display_metadata.rs @@ -0,0 +1,198 @@ +use super::*; +use std::collections::HashMap; + +fn assert_string_property(entry: &VaultEntry, key: &str, expected: &str) { + assert_eq!( + entry.properties.get(key).and_then(|value| value.as_str()), + Some(expected), + "unexpected value for {key}" + ); +} + +#[test] +fn test_parse_sidebar_label_from_type_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n"; + let entry = parse_test_entry(&dir, "news.md", content); + assert_eq!(entry.sidebar_label, Some("News".to_string())); +} + +#[test] +fn test_parse_display_from_note_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.display, Some("sheet".to_string())); +} + +#[test] +fn test_parse_display_text_from_note_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\n_display: text\n---\nMetric,January\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.display, Some("text".to_string())); +} + +#[test] +fn test_display_metadata_not_in_properties_or_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(!entry.properties.contains_key("_display")); + assert!(!entry.relationships.contains_key("_display")); +} + +#[test] +fn test_parse_sidebar_label_missing_defaults_to_none() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.sidebar_label, None); +} + +#[test] +fn test_sidebar_label_not_in_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n"; + let entry = parse_test_entry(&dir, "series.md", content); + assert!(!entry.relationships.contains_key("sidebar label")); +} + +#[test] +fn test_parse_template_from_type_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(entry.template.is_some()); +} + +#[test] +fn test_parse_template_block_scalar() { + let dir = TempDir::new().unwrap(); + let content = + "---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(entry.template.is_some()); + let template = entry.template.unwrap(); + assert!(template.contains("## Objective")); + assert!(template.contains("## Timeline")); +} + +#[test] +fn test_parse_template_from_type_body_when_it_looks_like_a_template() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n\n# Book\n\nTitle:\nAuthor:\n\n## Summary\n"; + let entry = parse_test_entry(&dir, "book.md", content); + assert_eq!( + entry.template, + Some("Title:\nAuthor:\n\n## Summary".to_string()) + ); +} + +#[test] +fn test_descriptive_type_body_is_not_a_template() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n\n# Project\n\nProjects are time-bound efforts with a clear outcome.\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.template, None); +} + +#[test] +fn test_parse_template_missing_defaults_to_none() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Note\n"; + let entry = parse_test_entry(&dir, "note.md", content); + assert_eq!(entry.template, None); +} + +#[test] +fn test_template_not_in_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(!entry.relationships.contains_key("template")); +} + +#[test] +fn test_parse_sort_from_type_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.sort, Some("modified:desc".to_string())); +} + +#[test] +fn test_parse_sort_missing_defaults_to_none() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert_eq!(entry.sort, None); +} + +#[test] +fn test_sort_not_in_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(!entry.relationships.contains_key("sort")); +} + +#[test] +fn test_sort_not_in_properties() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(!entry.properties.contains_key("sort")); +} + +#[test] +fn test_extract_properties_scalar_values() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Project +Status: Active +Priority: High +Rating: 5 +Due date: 2026-06-15 +Reviewed: true +--- +# Test +"#; + let entry = parse_test_entry(&dir, "project/test.md", content); + let expected: HashMap = [ + ("Priority".into(), serde_json::Value::String("High".into())), + ("Rating".into(), serde_json::json!(5)), + ( + "Due date".into(), + serde_json::Value::String("2026-06-15".into()), + ), + ("Reviewed".into(), serde_json::Value::Bool(true)), + ] + .into_iter() + .collect(); + assert_eq!(entry.properties, expected); +} + +#[test] +fn test_extract_properties_skips_structural_fields() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Project +Status: Active +Owner: Luca +Cadence: Weekly +Archived: false +Priority: High +--- +# Test +"#; + let entry = parse_test_entry(&dir, "project/test.md", content); + assert_eq!(entry.properties.len(), 3); + for (key, value) in [ + ("Priority", "High"), + ("Owner", "Luca"), + ("Cadence", "Weekly"), + ] { + assert_string_property(&entry, key, value); + } +} diff --git a/src-tauri/src/vault/mod_tests/folder_and_file_kind.rs b/src-tauri/src/vault/mod_tests/folder_and_file_kind.rs new file mode 100644 index 0000000..7307ec3 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/folder_and_file_kind.rs @@ -0,0 +1,122 @@ +use super::*; +use std::path::Path; + +#[test] +fn test_scan_vault_folders_returns_tree() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("projects/laputa")).unwrap(); + std::fs::create_dir_all(dir.path().join("areas")).unwrap(); + + let folders = scan_vault_folders(dir.path()).unwrap(); + let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect(); + assert!(names.contains(&"projects")); + assert!(names.contains(&"areas")); + + let projects = folders + .iter() + .find(|folder| folder.name == "projects") + .unwrap(); + assert_eq!(projects.children.len(), 1); + assert_eq!(projects.children[0].name, "laputa"); + assert_eq!(projects.children[0].path, "projects/laputa"); +} + +#[test] +fn test_scan_vault_folders_excludes_hidden() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + std::fs::create_dir_all(dir.path().join(".laputa")).unwrap(); + std::fs::create_dir_all(dir.path().join("visible")).unwrap(); + + let folders = scan_vault_folders(dir.path()).unwrap(); + assert_eq!(folders.len(), 1); + assert_eq!(folders[0].name, "visible"); +} + +#[test] +fn test_scan_vault_folders_keeps_default_vault_folders_visible() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("attachments")).unwrap(); + std::fs::create_dir_all(dir.path().join("type")).unwrap(); + std::fs::create_dir_all(dir.path().join("views")).unwrap(); + std::fs::create_dir_all(dir.path().join("projects")).unwrap(); + + let folders = scan_vault_folders(dir.path()).unwrap(); + let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect(); + + assert_eq!(names, vec!["attachments", "projects", "views"]); +} + +#[test] +fn test_scan_vault_folders_flat_vault() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "note.md", "# Note\n"); + + let folders = scan_vault_folders(dir.path()).unwrap(); + assert!(folders.is_empty(), "flat vault has no visible folders"); +} + +#[test] +fn test_list_properties_display_values_and_non_leakage() { + let dir = TempDir::new().unwrap(); + let content = + "---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n"; + let entry = parse_test_entry(&dir, "movies.md", content); + assert_eq!(entry.list_properties_display, vec!["rating", "genre"]); + assert!(!entry.properties.contains_key("_list_properties_display")); + assert!(!entry.relationships.contains_key("_list_properties_display")); + + let absent = parse_test_entry(&dir, "books.md", "---\ntype: Type\n---\n# Books\n"); + assert!(absent.list_properties_display.is_empty()); +} + +#[test] +fn test_non_markdown_files_use_expected_titles_and_kinds() { + let dir = TempDir::new().unwrap(); + + let named_yml_path = dir.path().join("active-projects.yml"); + std::fs::write( + &named_yml_path, + "name: Active Projects\nicon: rocket\ncolor: blue\n", + ) + .unwrap(); + let named_yml_entry = super::parse_non_md_file(&named_yml_path, None).unwrap(); + assert_eq!(named_yml_entry.title, "Active Projects"); + assert_eq!(named_yml_entry.filename, "active-projects.yml"); + + let unnamed_yml_path = dir.path().join("config.yml"); + std::fs::write(&unnamed_yml_path, "key: value\n").unwrap(); + let unnamed_yml_entry = super::parse_non_md_file(&unnamed_yml_path, None).unwrap(); + assert_eq!(unnamed_yml_entry.title, "config.yml"); + + let txt_path = dir.path().join("notes.txt"); + std::fs::write(&txt_path, "some content").unwrap(); + let txt_entry = super::parse_non_md_file(&txt_path, None).unwrap(); + assert_eq!(txt_entry.title, "notes.txt"); + + create_test_file( + dir.path(), + "views/my-view.yml", + "name: My View\nicon: rocket\n", + ); + let text_entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap(); + assert_eq!(text_entry.file_kind, "text"); + assert_eq!(text_entry.title, "My View"); +} + +#[test] +fn test_classify_file_kind_by_extension() { + for (path, expected_kind) in [ + ("views/active-projects.yml", "text"), + ("config.yaml", "text"), + ("data.json", "text"), + ("script.py", "text"), + ("readme.txt", "text"), + ("note.md", "markdown"), + ("README.markdown", "markdown"), + ("photo.png", "binary"), + ("archive.zip", "binary"), + ] { + assert_eq!(classify_file_kind(Path::new(path)), expected_kind, "{path}"); + } +} diff --git a/src-tauri/src/vault/mod_tests/journal_type_visibility.rs b/src-tauri/src/vault/mod_tests/journal_type_visibility.rs new file mode 100644 index 0000000..176ff2e --- /dev/null +++ b/src-tauri/src/vault/mod_tests/journal_type_visibility.rs @@ -0,0 +1,38 @@ +use super::*; +use std::collections::HashMap; + +#[test] +fn test_scan_vault_preserves_explicit_journal_type_definition() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "journal.md", + "---\ntype: Type\nvisible: true\n---\n# Journal\n", + ); + create_test_file( + dir.path(), + "2026-03-11.md", + "---\ntitle: March 11\ntype: Journal\n---\n# March 11\n", + ); + + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); + assert_eq!(entries.len(), 2); + + let journal_type = entries + .iter() + .find(|entry| entry.filename == "journal.md") + .expect("expected the explicit Journal type file to be scanned"); + assert_eq!(journal_type.title, "Journal"); + assert_eq!(journal_type.is_a.as_deref(), Some("Type")); + assert_eq!(journal_type.visible, Some(true)); + + let journal_note = entries + .iter() + .find(|entry| entry.filename == "2026-03-11.md") + .expect("expected the Journal note to be scanned"); + assert_eq!(journal_note.is_a.as_deref(), Some("Journal")); + assert_eq!( + journal_note.relationships.get("Type"), + Some(&vec!["[[journal]]".to_string()]), + ); +} diff --git a/src-tauri/src/vault/mod_tests/real_vault_consistency.rs b/src-tauri/src/vault/mod_tests/real_vault_consistency.rs new file mode 100644 index 0000000..33270da --- /dev/null +++ b/src-tauri/src/vault/mod_tests/real_vault_consistency.rs @@ -0,0 +1,118 @@ +use super::*; +use std::path::Path; + +#[derive(Clone, Copy, Debug, Default)] +struct FrontmatterExpectations { + has_type_with_value: bool, + has_organized_true: bool, +} + +fn frontmatter_block(content: &str) -> Option<&str> { + if !content.starts_with("---\n") { + return None; + } + + let end = content[4..].find("\n---")?; + Some(&content[4..end + 4]) +} + +fn frontmatter_expectations(content: &str) -> Option { + let block = frontmatter_block(content)?; + let has_type_with_value = block.lines().any(|line| { + let trimmed = line.trim(); + ["type:", "Is A:", "is_a:"] + .iter() + .find_map(|prefix| trimmed.strip_prefix(prefix)) + .is_some_and(|rest| !rest.trim().is_empty()) + }); + let has_organized_true = block.lines().any(|line| { + matches!( + line.trim(), + "_organized: true" | "_organized: True" | "_organized: yes" | "_organized: Yes" + ) + }); + + if !has_type_with_value && !has_organized_true { + return None; + } + + Some(FrontmatterExpectations { + has_type_with_value, + has_organized_true, + }) +} + +fn mismatch_messages( + path: &Path, + expectations: FrontmatterExpectations, + parsed: &VaultEntry, +) -> Vec { + let mut mismatches = Vec::new(); + if expectations.has_type_with_value && parsed.is_a.is_none() { + mismatches.push(format!( + "TYPE MISSING: {} (raw has type with value but parsed isA=None)", + path.display() + )); + } + if expectations.has_organized_true && !parsed.organized { + mismatches.push(format!( + "ORGANIZED MISSING: {} (raw has _organized: true but parsed organized=false)", + path.display() + )); + } + mismatches +} + +fn parse_real_vault_mismatches(vault_path: &Path) -> Vec { + let mut mismatches = Vec::new(); + let walker = walkdir::WalkDir::new(vault_path) + .into_iter() + .filter_entry(|entry| !entry.file_name().to_string_lossy().starts_with('.')); + + for dir_entry in walker.filter_map(|entry| entry.ok()) { + let path = dir_entry.path(); + if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") { + continue; + } + + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + let Some(expectations) = frontmatter_expectations(&content) else { + continue; + }; + + match parse_md_file(path, None) { + Ok(parsed) => mismatches.extend(mismatch_messages(path, expectations, &parsed)), + Err(error) => mismatches.push(format!("PARSE ERROR: {} -> {}", path.display(), error)), + } + } + + mismatches +} + +#[test] +fn test_real_vault_type_and_organized_consistency() { + let vault_path = Path::new("/Users/luca/Laputa"); + if !vault_path.exists() { + eprintln!("Skipping: ~/Laputa vault not found"); + return; + } + + let mismatches = parse_real_vault_mismatches(vault_path); + if mismatches.is_empty() { + return; + } + + let summary = mismatches + .iter() + .take(20) + .cloned() + .collect::>() + .join("\n"); + panic!( + "Found {} parsing mismatches in real vault:\n{}", + mismatches.len(), + summary + ); +} diff --git a/src-tauri/src/vault/mod_tests/relationships.rs b/src-tauri/src/vault/mod_tests/relationships.rs new file mode 100644 index 0000000..ed76c4c --- /dev/null +++ b/src-tauri/src/vault/mod_tests/relationships.rs @@ -0,0 +1,286 @@ +use super::*; +use std::collections::HashMap; + +fn assert_relationship_values( + relationships: &HashMap>, + expected: &[(&str, &[&str])], +) { + for (key, values) in expected { + let actual = relationships.get(*key).unwrap(); + let expected_values = values + .iter() + .map(|value| (*value).to_string()) + .collect::>(); + assert_eq!(actual, &expected_values, "relationship {key} mismatch"); + } +} + +#[test] +fn test_parse_relationships_array() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Responsibility +Has: + - "[[essay/foo|Foo Essay]]" + - "[[essay/bar|Bar Essay]]" +Topics: + - "[[topic/rust]]" + - "[[topic/wasm]]" +Status: Active +--- +# Publish Essays +"#; + create_test_file(dir.path(), "publish-essays.md", content); + + let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap(); + assert_eq!(entry.relationships.len(), 3); + assert_relationship_values( + &entry.relationships, + &[ + ( + "Has", + &["[[essay/foo|Foo Essay]]", "[[essay/bar|Bar Essay]]"], + ), + ("Topics", &["[[topic/rust]]", "[[topic/wasm]]"]), + ("Type", &["[[responsibility]]"]), + ], + ); +} + +#[test] +fn test_parse_relationships_single_string() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Project +Owner: "[[person/luca-rossi|Luca Rossi]]" +Belongs to: + - "[[responsibility/grow-newsletter]]" +--- +# Some Project +"#; + create_test_file(dir.path(), "some-project.md", content); + + let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap(); + assert!(entry.relationships.contains_key("Owner")); + assert!(!entry.properties.contains_key("Owner")); + assert_relationship_values( + &entry.relationships, + &[("Belongs to", &["[[responsibility/grow-newsletter]]"])], + ); + assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]); +} + +#[test] +fn test_parse_relationships_ignores_non_wikilinks() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Note +Status: Active +Tags: + - productivity + - writing +Custom Field: just a plain string +--- +# A Note +"#; + create_test_file(dir.path(), "plain-note.md", content); + + let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap(); + assert_eq!(entry.relationships.len(), 1); + assert_relationship_values(&entry.relationships, &[("Type", &["[[note]]"])]); +} + +const BIG_PROJECT_CONTENT: &str = "---\nIs A: Project\nHas:\n - \"[[deliverable/mvp]]\"\n - \"[[deliverable/v2]]\"\nTopics:\n - \"[[topic/ai]]\"\n - \"[[topic/compilers]]\"\nEvents:\n - \"[[event/launch-day]]\"\nNotes:\n - \"[[note/design-rationale]]\"\n - \"[[note/meeting-2024-01]]\"\n - \"[[note/meeting-2024-02]]\"\nOwner: \"[[person/alice]]\"\nRelated to:\n - \"[[project/sibling-project]]\"\nBelongs to:\n - \"[[area/engineering]]\"\nStatus: Active\n---\n# Big Project\n"; + +fn parse_big_project_relationships() -> HashMap> { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "big-project.md", BIG_PROJECT_CONTENT); + entry.relationships +} + +#[test] +fn test_parse_relationships_custom_fields() { + let relationships = parse_big_project_relationships(); + assert_eq!(relationships.get("Has").unwrap().len(), 2); + assert_eq!(relationships.get("Topics").unwrap().len(), 2); + assert_eq!(relationships.get("Events").unwrap().len(), 1); +} + +#[test] +fn test_parse_relationships_owner_and_notes() { + let relationships = parse_big_project_relationships(); + assert_eq!(relationships.get("Notes").unwrap().len(), 3); + assert!(relationships.contains_key("Owner")); +} + +#[test] +fn test_parse_relationships_builtin_wikilink_fields() { + let relationships = parse_big_project_relationships(); + assert_eq!(relationships.get("Related to").unwrap().len(), 1); + assert_eq!(relationships.get("Belongs to").unwrap().len(), 1); +} + +#[test] +fn test_parse_relationships_skip_keys_excluded_from_generic() { + let relationships = parse_big_project_relationships(); + assert!(!relationships.contains_key("Status")); + assert!(!relationships.contains_key("Is A")); +} + +#[test] +fn test_parse_relationships_single_vs_array_wikilinks() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Mentor: "[[person/bob|Bob Smith]]" +Reviewers: + - "[[person/carol]]" + - "[[person/dave]]" +Context: "[[area/research]]" +--- +# A Note +"#; + create_test_file(dir.path(), "single-vs-array.md", content); + + let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap(); + assert_relationship_values( + &entry.relationships, + &[ + ("Mentor", &["[[person/bob|Bob Smith]]"]), + ("Reviewers", &["[[person/carol]]", "[[person/dave]]"]), + ("Context", &["[[area/research]]"]), + ], + ); +} + +const SKIP_KEYS_CONTENT: &str = "---\nIs A: \"[[project]]\"\nAliases:\n - \"[[alias/foo]]\"\nStatus: \"[[status/active]]\"\nCadence: \"[[cadence/weekly]]\"\nCreated at: \"[[time/2024-01-01]]\"\nCreated time: \"[[time/noon]]\"\nReal Relation: \"[[note/important]]\"\n---\n# Skip Keys Test\n"; + +fn parse_skip_key_relationships() -> (HashMap>, usize) { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry(&dir, "skip-keys.md", SKIP_KEYS_CONTENT); + let relationship_count = entry.relationships.len(); + (entry.relationships, relationship_count) +} + +#[test] +fn test_skip_keys_identity_fields_excluded() { + let (relationships, _) = parse_skip_key_relationships(); + assert!(!relationships.contains_key("Is A")); + assert!(!relationships.contains_key("Aliases")); + assert!(!relationships.contains_key("Status")); +} + +#[test] +fn test_skip_keys_temporal_fields_excluded() { + let (relationships, _) = parse_skip_key_relationships(); + for key in ["Cadence", "Created at", "Created time"] { + assert!(relationships.contains_key(key), "missing {key}"); + } +} + +#[test] +fn test_skip_keys_real_relation_included() { + let (relationships, relationship_count) = parse_skip_key_relationships(); + assert_eq!(relationship_count, 5); + assert_relationship_values( + &relationships, + &[ + ("Real Relation", &["[[note/important]]"]), + ("Type", &["[[project]]"]), + ], + ); + for key in ["Cadence", "Created at", "Created time"] { + assert!(relationships.contains_key(key), "missing {key}"); + } +} + +#[test] +fn test_parse_relationships_mixed_wikilinks_and_plain_in_array() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +References: + - "[[source/paper-a]]" + - "just a plain string" + - "[[source/paper-b]]" + - "no links here" +--- +# Mixed Array +"#; + create_test_file(dir.path(), "mixed-array.md", content); + + let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap(); + assert_relationship_values( + &entry.relationships, + &[("References", &["[[source/paper-a]]", "[[source/paper-b]]"])], + ); +} + +#[test] +fn test_parse_large_notes_relationship_array() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +type: Topic +Referred by Data: + - "[[michele-sampieri|Michele Sampieri]]" + - "[[varun-anand|Varun Anand]]" +Belongs to: + - "[[engineering|Engineering]]" +aliases: + - No Code +Notes: + - "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]" + - "[[airdev-build-hub|Airdev Build Hub]]" + - "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]" + - "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]" + - "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]" + - "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]" + - "[[chameleon-microsurveys|Chameleon | Microsurveys]]" + - "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]" + - "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]" + - "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]" + - "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]" + - "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]" + - "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]" + - "[[michele-sampieri|Michele Sampieri]]" + - "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]" + - "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]" + - "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]" + - "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]" + - "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]" + - "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]" + - "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]" + - "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]" + - "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]" + - "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]" + - "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]" + - "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]" + - "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]" + - "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]" + - "[[varun-anand|Varun Anand]]" + - "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]" + - "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]" + - "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]" +title: No Code +--- +# No Code +"#; + create_test_file(dir.path(), "no-code.md", content); + let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap(); + + let notes = entry + .relationships + .get("Notes") + .expect("Notes relationship should exist"); + assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed"); + + let referred = entry + .relationships + .get("Referred by Data") + .expect("Referred by Data should exist"); + assert_eq!(referred.len(), 2); + + let belongs_to = entry + .relationships + .get("Belongs to") + .expect("Belongs to should exist"); + assert_eq!(belongs_to.len(), 1); +} diff --git a/src-tauri/src/vault/mod_tests/scan_and_file_access.rs b/src-tauri/src/vault/mod_tests/scan_and_file_access.rs new file mode 100644 index 0000000..3b09152 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/scan_and_file_access.rs @@ -0,0 +1,136 @@ +use super::*; +use std::collections::HashMap; +use std::path::Path; + +fn entry_filenames(entries: &[VaultEntry]) -> Vec<&str> { + entries + .iter() + .map(|entry| entry.filename.as_str()) + .collect() +} + +fn assert_filenames_include(entries: &[VaultEntry], expected: &[&str]) { + let filenames = entry_filenames(entries); + for filename in expected { + assert!(filenames.contains(filename), "missing {filename}"); + } +} + +#[test] +fn test_scan_vault_root_and_protected_folders() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "root.md", "# Root Note\n"); + create_test_file( + dir.path(), + "project.md", + "---\ntype: Type\n---\n# Project\n", + ); + create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); + create_test_file( + dir.path(), + "not-markdown.txt", + "This should be included as text", + ); + + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); + assert_eq!(entries.len(), 4); + assert_filenames_include( + &entries, + &["root.md", "project.md", "notes.md", "not-markdown.txt"], + ); + + let txt_entry = entries + .iter() + .find(|entry| entry.filename == "not-markdown.txt") + .unwrap(); + assert_eq!(txt_entry.file_kind, "text"); + assert_eq!(txt_entry.title, "not-markdown.txt"); +} + +#[test] +fn test_scan_vault_includes_subdirectory_notes() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "root.md", "# Root Note\n"); + create_test_file( + dir.path(), + "random-folder/nested.md", + "---\ntype: Note\n---\n# Nested\n", + ); + create_test_file( + dir.path(), + "project/old-project.md", + "---\ntype: Project\n---\n# Old\n", + ); + + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); + assert_eq!( + entries.len(), + 3, + "all .md files including subdirs should be scanned" + ); + assert_filenames_include(&entries, &["root.md", "nested.md", "old-project.md"]); +} + +#[test] +fn test_scan_vault_includes_all_protected_folders() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "root.md", "# Root\n"); + create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); + create_test_file(dir.path(), "assets/image.md", "# Asset\n"); + + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); + assert_eq!(entries.len(), 3); +} + +#[test] +fn test_scan_vault_skips_hidden_folders() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "root.md", "# Root\n"); + create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n"); + create_test_file(dir.path(), ".git/objects.md", "# Git\n"); + + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].filename, "root.md"); +} + +#[test] +fn test_scan_vault_nonexistent_path() { + let result = scan_vault( + Path::new("/nonexistent/path/that/does/not/exist"), + &HashMap::new(), + ); + assert!(result.is_err()); +} + +#[test] +fn test_get_note_content() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world!"; + create_test_file(dir.path(), "test.md", content); + + let path = dir.path().join("test.md"); + let result = get_note_content(&path); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), content); +} + +#[test] +fn test_get_note_content_nonexistent() { + let result = get_note_content(Path::new("/nonexistent/path/file.md")); + assert!(result.is_err()); +} + +#[test] +fn test_get_note_content_invalid_utf8() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("invalid.csv"); + std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap(); + + let result = get_note_content(&path); + + assert_eq!( + result.unwrap_err(), + format!("File is not valid UTF-8 text: {}", path.display()) + ); +} diff --git a/src-tauri/src/vault/mod_tests/type_and_links.rs b/src-tauri/src/vault/mod_tests/type_and_links.rs new file mode 100644 index 0000000..5efefd9 --- /dev/null +++ b/src-tauri/src/vault/mod_tests/type_and_links.rs @@ -0,0 +1,203 @@ +use super::*; +use std::fs; + +#[test] +fn test_type_from_frontmatter_only() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n"); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); + assert_eq!(entry.is_a, Some("Custom".to_string())); +} + +#[test] +fn test_no_type_when_frontmatter_missing() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "note/test.md", "# Test\n"); + let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap(); + assert_eq!(entry.is_a, None, "type should not be inferred from folder"); +} + +#[test] +fn test_created_at_from_filesystem() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Test\n"; + create_test_file(dir.path(), "test.md", content); + + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); + assert!( + entry.created_at.is_some(), + "created_at should come from filesystem" + ); +} + +#[test] +fn test_type_relationship_added_for_regular_entries() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Project\n---\n# My Project\n"; + let entry = parse_test_entry(&dir, "project/my-project.md", content); + assert_eq!( + entry.relationships.get("Type").unwrap(), + &vec!["[[project]]".to_string()] + ); +} + +#[test] +fn test_type_relationship_skipped_for_type_documents() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Type\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "project.md", content); + assert!(!entry.relationships.contains_key("Type")); +} + +#[test] +fn test_no_type_relationship_without_frontmatter() { + let dir = TempDir::new().unwrap(); + let content = "# A Person\n\nSome content."; + let entry = parse_test_entry(&dir, "someone.md", content); + assert_eq!(entry.is_a, None); + assert!(!entry.relationships.contains_key("Type")); +} + +#[test] +fn test_type_relationship_handles_wikilink_is_a() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: \"[[experiment]]\"\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!( + entry.relationships.get("Type").unwrap(), + &vec!["[[experiment]]".to_string()] + ); +} + +#[test] +fn test_type_from_frontmatter_not_folder() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Some Type\n"; + let entry = parse_test_entry(&dir, "some-type.md", content); + assert_eq!(entry.is_a, Some("Type".to_string())); +} + +#[test] +fn test_parse_type_key_lowercase() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\n---\n# My Project\n"; + let entry = parse_test_entry(&dir, "project/my-project.md", content); + assert_eq!(entry.is_a, Some("Project".to_string())); +} + +#[test] +fn test_parse_type_key_case_insensitive() { + for (key, expected_type) in [("Type", "Project"), ("TYPE", "Person")] { + let dir = TempDir::new().unwrap(); + let content = format!("---\n{key}: {expected_type}\n---\n# Test\n"); + let entry = parse_test_entry(&dir, "note/test.md", &content); + assert_eq!(entry.is_a, Some(expected_type.to_string())); + } +} + +#[test] +fn test_type_key_generates_type_relationship() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Person\n---\n# Alice\n"; + let entry = parse_test_entry(&dir, "person/alice.md", content); + assert_eq!( + entry.relationships.get("Type").unwrap(), + &vec!["[[person]]".to_string()] + ); +} + +#[test] +fn test_type_key_not_in_relationships_as_generic() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "note/test.md", content); + assert_eq!(entry.relationships.len(), 2); + assert!(entry.relationships.contains_key("Has")); + assert!(entry.relationships.contains_key("Type")); +} + +#[test] +fn test_outgoing_links_extracted_from_content_body() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# My Note\n\nSee [[person/alice]] and [[topic/rust]]."; + let entry = parse_test_entry(&dir, "note/my-note.md", content); + assert_eq!(entry.outgoing_links, vec!["person/alice", "topic/rust"]); +} + +#[test] +fn test_outgoing_links_excludes_frontmatter_wikilinks() { + let dir = TempDir::new().unwrap(); + let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/bob]]."; + let entry = parse_test_entry(&dir, "note/test.md", content); + assert!(!entry.outgoing_links.contains(&"task/design".to_string())); + assert!(entry.outgoing_links.contains(&"person/bob".to_string())); +} + +#[test] +fn test_outgoing_links_handles_pipe_syntax() { + let dir = TempDir::new().unwrap(); + let content = "# Note\n\nSee [[project/alpha|Alpha Project]] for details."; + let entry = parse_test_entry(&dir, "test.md", content); + assert!(entry.outgoing_links.contains(&"project/alpha".to_string())); +} + +#[test] +fn test_save_note_content_creates_parent_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("new-type/untitled-note.md"); + let content = "---\ntitle: Untitled note\n---\n# Untitled note\n\n"; + + assert!(!path.parent().unwrap().exists()); + save_note_content(path.to_str().unwrap(), content).unwrap(); + + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn test_save_note_content_existing_directory() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("note")).unwrap(); + let path = dir.path().join("note/test.md"); + let content = "# Test\n"; + + save_note_content(path.to_str().unwrap(), content).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn test_save_note_content_deeply_nested_new_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a/b/c/deep-note.md"); + let content = "---\ntitle: Deep\n---\n"; + + save_note_content(path.to_str().unwrap(), content).unwrap(); + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn test_create_note_content_creates_parent_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("new-type/briefing.md"); + let content = "---\ntitle: Briefing\ntype: Note\n---\n"; + + assert!(!path.parent().unwrap().exists()); + create_note_content(path.to_str().unwrap(), content).unwrap(); + + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn test_create_note_content_rejects_existing_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("briefing.md"); + fs::write(&path, "# Existing\n").unwrap(); + + let err = create_note_content(path.to_str().unwrap(), "# Replacement\n") + .expect_err("expected create-only write to reject collisions"); + + assert!(err.contains("already exists")); + assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n"); +} diff --git a/src-tauri/src/vault/modified_dates_tests.rs b/src-tauri/src/vault/modified_dates_tests.rs new file mode 100644 index 0000000..d2fb005 --- /dev/null +++ b/src-tauri/src/vault/modified_dates_tests.rs @@ -0,0 +1,91 @@ +use super::{parse_md_file, parse_non_md_file, resolve_entry_dates}; +use crate::git::GitDates; +use std::collections::HashMap; +use std::fs; +use std::thread; +use std::time::Duration; +use tempfile::TempDir; + +#[test] +fn resolve_entry_dates_prefers_newer_filesystem_modified_time() { + let resolved = resolve_entry_dates(Some(200), Some(50), Some((150, 25))); + + assert_eq!(resolved, (Some(200), Some(25))); +} + +#[test] +fn resolve_entry_dates_keeps_newer_git_modified_time() { + let resolved = resolve_entry_dates(Some(150), Some(50), Some((200, 25))); + + assert_eq!(resolved, (Some(200), Some(25))); +} + +#[test] +fn parse_md_file_uses_newer_filesystem_modified_time_than_git() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "# Note\n\nBody\n").unwrap(); + + let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap(); + let fs_modified = fs_modified.unwrap(); + let git_created = fs_modified.saturating_sub(600); + let git_modified = fs_modified.saturating_sub(60); + + let entry = parse_md_file(&path, Some((git_modified, git_created))).unwrap(); + + assert_eq!(entry.modified_at, Some(fs_modified)); + assert_eq!(entry.created_at, Some(git_created)); +} + +#[test] +fn parse_non_md_file_falls_back_to_git_modified_when_filesystem_missing_newer_date() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("assets/data.txt"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "hello").unwrap(); + + let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap(); + let git_modified = fs_modified.unwrap().saturating_add(60); + let git_created = git_modified.saturating_sub(600); + + let entry = parse_non_md_file(&path, Some((git_modified, git_created))).unwrap(); + + assert_eq!(entry.modified_at, Some(git_modified)); + assert_eq!(entry.created_at, Some(git_created)); +} + +#[test] +fn scan_vault_sorts_by_newer_of_git_and_filesystem_modified_time() { + let dir = TempDir::new().unwrap(); + let older_path = dir.path().join("older-git-newer-file.md"); + let newer_git_path = dir.path().join("newer-git-older-file.md"); + + fs::write(&newer_git_path, "# Newer Git\n\nBody\n").unwrap(); + thread::sleep(Duration::from_secs(1)); + fs::write(&older_path, "# Newer File\n\nBody\n").unwrap(); + + let (older_file_modified, _, _) = super::file::read_file_metadata(&older_path).unwrap(); + let older_file_modified = older_file_modified.unwrap(); + + let git_dates = HashMap::from([ + ( + "older-git-newer-file.md".to_string(), + GitDates { + created_at: older_file_modified.saturating_sub(600), + modified_at: older_file_modified.saturating_sub(120), + }, + ), + ( + "newer-git-older-file.md".to_string(), + GitDates { + created_at: older_file_modified.saturating_sub(700), + modified_at: older_file_modified.saturating_sub(30), + }, + ), + ]); + + let entries = super::scan_vault(dir.path(), &git_dates).unwrap(); + let titles: Vec<_> = entries.iter().map(|entry| entry.title.as_str()).collect(); + + assert_eq!(titles, vec!["Newer File", "Newer Git"]); +} diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs new file mode 100644 index 0000000..0d789c9 --- /dev/null +++ b/src-tauri/src/vault/parsing.rs @@ -0,0 +1,346 @@ +//! Pure text-processing helpers for markdown content parsing. +//! Snippet extraction, markdown stripping, date parsing, and string utilities. + +#[derive(Clone, Copy)] +struct TextSlice<'a>(&'a str); + +impl<'a> TextSlice<'a> { + fn as_str(self) -> &'a str { + self.0 + } +} + +/// Derive a human-readable title from a filename stem (slug). +/// Converts hyphens to spaces and title-cases each word. +/// Example: `career-tracks-depend-on-company-shape` → `Career Tracks Depend on Company Shape` +pub(super) fn slug_to_title(stem: &str) -> String { + stem.split('-') + .filter(|s| !s.is_empty()) + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => { + let upper: String = c.to_uppercase().collect(); + format!("{}{}", upper, chars.as_str()) + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +/// Extract the H1 title from the first non-empty line of the body (after frontmatter). +/// Returns `None` if no H1 is found on the first non-empty line. +pub(super) fn extract_h1_title(content: &str) -> Option { + let body = strip_frontmatter(TextSlice(content)); + let title = + first_non_empty_line(TextSlice(body)).and_then(|line| markdown_h1_text(TextSlice(line)))?; + let stripped = strip_markdown_chars(TextSlice(title)); + non_empty_trimmed(TextSlice(&stripped)).map(str::to_string) +} + +fn non_empty_trimmed(value: TextSlice<'_>) -> Option<&str> { + let trimmed = value.as_str().trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +fn first_non_empty_line(value: TextSlice<'_>) -> Option<&str> { + value + .as_str() + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) +} + +fn markdown_h1_text(line: TextSlice<'_>) -> Option<&str> { + line.as_str() + .strip_prefix("# ") + .and_then(|text| non_empty_trimmed(TextSlice(text))) +} + +/// Extract the display title for a note. +/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title. +pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String { + // 1. H1 on first non-empty line of body + if let Some(h1) = extract_h1_title(content) { + return h1; + } + // 2. frontmatter title (legacy, backward compat) + if let Some(title) = fm_title { + if !title.is_empty() { + return title.to_string(); + } + } + // 3. filename slug + let stem = filename.strip_suffix(".md").unwrap_or(filename); + slug_to_title(stem) +} + +/// Remove YAML frontmatter (triple-dash delimited) from content. +/// The closing `---` must appear at the start of a line to avoid matching +/// occurrences inside frontmatter values (e.g. `title: foo---bar`). +fn strip_frontmatter(content: TextSlice<'_>) -> &str { + let value = content.as_str(); + let Some(rest) = value.strip_prefix("---") else { + return value; + }; + // Find closing `---` at the start of a line (preceded by newline) + match rest.find("\n---") { + Some(end) => { + let after = end + 4; // skip past "\n---" + rest[after..].trim_start() + } + None => value, + } +} + +/// Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). +fn is_snippet_line(line: TextSlice<'_>) -> bool { + let t = line.as_str().trim(); + !t.is_empty() && !t.starts_with('#') && !t.starts_with("```") && !t.starts_with("---") +} + +/// Extract sub-heading text (## , ### , etc.) stripped of the `#` prefix. +fn extract_subheading_text(line: TextSlice<'_>) -> Option<&str> { + let t = line.as_str().trim(); + let stripped = t.trim_start_matches('#'); + if stripped.len() < t.len() && stripped.starts_with(' ') { + let text = stripped.trim(); + if !text.is_empty() { + return Some(text); + } + } + None +} + +/// Strip leading list markers (*, -, +, 1.) from a line. +fn strip_list_marker(line: TextSlice<'_>) -> &str { + let t = line.as_str().trim_start(); + strip_unordered_marker(TextSlice(t)) + .or_else(|| strip_ordered_marker(TextSlice(t))) + .unwrap_or(t) +} + +/// Strip unordered list markers: "* ", "- ", "+ " +fn strip_unordered_marker(s: TextSlice<'_>) -> Option<&str> { + ["* ", "- ", "+ "] + .iter() + .find_map(|prefix| s.as_str().strip_prefix(prefix)) +} + +/// Strip ordered list markers: "1. ", "2. ", etc. +fn strip_ordered_marker(s: TextSlice<'_>) -> Option<&str> { + let value = s.as_str(); + let dot_pos = value.find(". ")?; + if dot_pos <= 3 && value[..dot_pos].chars().all(|c| c.is_ascii_digit()) { + Some(&value[dot_pos + 2..]) + } else { + None + } +} + +/// Truncate a string to `max_len` bytes at a valid UTF-8 boundary, appending "...". +fn truncate_with_ellipsis(s: TextSlice<'_>, max_len: usize) -> String { + let value = s.as_str(); + if value.len() <= max_len { + return value.to_string(); + } + let mut idx = max_len; + while idx > 0 && !value.is_char_boundary(idx) { + idx -= 1; + } + format!("{}...", &value[..idx]) +} + +/// Count the number of words in the note body (excluding frontmatter and H1 title). +pub(super) fn count_body_words(content: &str) -> u32 { + let without_fm = strip_frontmatter(TextSlice(content)); + let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm); + body.split_whitespace() + .filter(|w| { + !w.chars() + .all(|c| matches!(c, '#' | '*' | '_' | '`' | '~' | '-' | '>' | '|')) + }) + .count() as u32 +} + +/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown. +pub(super) fn extract_snippet(content: &str) -> String { + let without_fm = strip_frontmatter(TextSlice(content)); + let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm); + let clean: String = body + .lines() + .filter(|line| is_snippet_line(TextSlice(line))) + .map(|line| strip_list_marker(TextSlice(line))) + .collect::>() + .join(" "); + let stripped = strip_markdown_chars(TextSlice(&clean)); + let trimmed = stripped.trim(); + if !trimmed.is_empty() { + return truncate_with_ellipsis(TextSlice(trimmed), 160); + } + // Fallback: collect sub-heading text when no paragraph content exists + let heading_text: String = body + .lines() + .filter_map(|line| extract_subheading_text(TextSlice(line))) + .collect::>() + .join(" "); + let heading_trimmed = strip_markdown_chars(TextSlice(&heading_text)); + let heading_trimmed = heading_trimmed.trim(); + if heading_trimmed.is_empty() { + return String::new(); + } + truncate_with_ellipsis(TextSlice(heading_trimmed), 160) +} + +fn without_h1_line(s: TextSlice<'_>) -> Option<&str> { + let value = s.as_str(); + let mut offset = 0; + for line in value.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\r', '\n']).trim(); + if trimmed.starts_with("# ") { + return Some(&value[offset + line.len()..]); + } + // If we hit non-empty non-heading content first, there's no H1 to skip + if !trimmed.is_empty() { + return None; + } + offset += line.len(); + } + None +} + +/// Collect chars until a delimiter, returning the collected string. +fn collect_until(chars: &mut impl Iterator, delimiter: char) -> String { + let mut buf = String::new(); + for c in chars.by_ref() { + if c == delimiter { + break; + } + buf.push(c); + } + buf +} + +/// Skip all chars until a delimiter (consuming the delimiter). +fn skip_until(chars: &mut impl Iterator, delimiter: char) { + for c in chars.by_ref() { + if c == delimiter { + break; + } + } +} + +/// Check if a char is markdown formatting that should be stripped. +fn is_markdown_formatting(ch: char) -> bool { + matches!(ch, '*' | '_' | '`' | '~') +} + +fn strip_markdown_chars(s: TextSlice<'_>) -> String { + let value = s.as_str(); + let mut result = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '[' if chars.peek() == Some(&'[') => { + process_wikilink(&mut chars, &mut result); + } + '[' => { + process_markdown_link(&mut chars, &mut result); + } + c if is_markdown_formatting(c) => {} + _ => result.push(ch), + } + } + result +} + +/// Process a wikilink `[[...]]` or `[[...|display]]`, extracting the display text. +fn process_wikilink( + chars: &mut std::iter::Peekable>, + result: &mut String, +) { + chars.next(); // consume second '[' + let inner = collect_wikilink_inner(chars); + let display_text = extract_wikilink_display(&inner); + result.push_str(display_text); +} + +/// Extract display text from wikilink inner content. +/// Returns the part after '|' if present, otherwise the whole inner text. +fn extract_wikilink_display(inner: &str) -> &str { + inner.find('|').map_or(inner, |idx| &inner[idx + 1..]) +} + +/// Process bracketed text. +/// Real markdown links `[text](url)` are unwrapped to `text`. +/// Plain bracketed text `[text]` is preserved verbatim. +fn process_markdown_link( + chars: &mut std::iter::Peekable>, + result: &mut String, +) { + let inner = collect_until(chars, ']'); + if chars.peek() == Some(&'(') { + chars.next(); + skip_until(chars, ')'); + result.push_str(&inner); + return; + } + + result.push('['); + result.push_str(&inner); + result.push(']'); +} + +/// Collect chars inside a wikilink until `]]`, consuming both closing brackets. +fn collect_wikilink_inner(chars: &mut std::iter::Peekable>) -> String { + let mut buf = String::new(); + while let Some(c) = chars.next() { + if c == ']' && chars.peek() == Some(&']') { + chars.next(); + break; + } + buf.push(c); + } + buf +} + +/// Check if a string contains a wikilink pattern `[[...]]`. +pub(super) fn contains_wikilink(s: &str) -> bool { + s.contains("[[") && s.contains("]]") +} + +/// Extract all outgoing wikilink targets from content. +/// Finds `[[target]]` and `[[target|display]]` patterns, returning just the target part. +/// Returns a sorted, deduplicated Vec of targets. +pub(super) fn extract_outgoing_links(content: &str) -> Vec { + let mut links = Vec::new(); + let mut search_from = 0; + let bytes = content.as_bytes(); + while search_from + 3 < bytes.len() { + let Some(start) = content[search_from..].find("[[") else { + break; + }; + let abs_start = search_from + start + 2; + let Some(end) = content[abs_start..].find("]]") else { + break; + }; + let inner = &content[abs_start..abs_start + end]; + let target = match inner.find('|') { + Some(idx) => &inner[..idx], + None => inner, + }; + if !target.is_empty() { + links.push(target.to_string()); + } + search_from = abs_start + end + 2; + } + links.sort(); + links.dedup(); + links +} + +#[cfg(test)] +#[path = "parsing_tests.rs"] +mod tests; diff --git a/src-tauri/src/vault/parsing_tests.rs b/src-tauri/src/vault/parsing_tests.rs new file mode 100644 index 0000000..8bbafa8 --- /dev/null +++ b/src-tauri/src/vault/parsing_tests.rs @@ -0,0 +1,559 @@ +use super::*; + +fn text(value: &str) -> TextSlice<'_> { + TextSlice(value) +} + +// --- slug_to_title tests --- + +#[test] +fn test_slug_to_title_basic() { + assert_eq!(slug_to_title("career-tracks"), "Career Tracks"); +} + +#[test] +fn test_slug_to_title_single_word() { + assert_eq!(slug_to_title("hello"), "Hello"); +} + +#[test] +fn test_slug_to_title_empty() { + assert_eq!(slug_to_title(""), ""); +} + +#[test] +fn test_slug_to_title_e2e() { + assert_eq!(slug_to_title("e2e-test"), "E2e Test"); +} + +#[test] +fn test_slug_to_title_multiple_hyphens() { + assert_eq!(slug_to_title("a--b"), "A B"); +} + +// --- extract_h1_title tests --- + +#[test] +fn test_extract_h1_title_basic() { + assert_eq!( + extract_h1_title("# Hello World\n\nBody."), + Some("Hello World".to_string()) + ); +} + +#[test] +fn test_extract_h1_title_after_frontmatter() { + let content = "---\ntype: Note\n---\n# My Note\n\nBody."; + assert_eq!(extract_h1_title(content), Some("My Note".to_string())); +} + +#[test] +fn test_extract_h1_title_with_empty_lines_before() { + let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody."; + assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string())); +} + +#[test] +fn test_extract_h1_title_preserves_plain_square_brackets() { + let content = "# [26Q2] Tolaria MVP\n\nBody."; + assert_eq!( + extract_h1_title(content), + Some("[26Q2] Tolaria MVP".to_string()) + ); +} + +#[test] +fn test_extract_h1_title_none_when_no_h1() { + assert_eq!(extract_h1_title("Just body text."), None); +} + +#[test] +fn test_extract_h1_title_none_when_h1_not_first() { + assert_eq!(extract_h1_title("Some text\n# Not first\n"), None); +} + +// --- extract_title tests --- + +#[test] +fn test_extract_title_h1_takes_priority_over_frontmatter() { + assert_eq!( + extract_title( + Some("FM Title"), + "---\ntitle: FM Title\n---\n# H1 Title\n\nBody.", + "note.md" + ), + "H1 Title" + ); +} + +#[test] +fn test_extract_title_h1_when_no_frontmatter_title() { + assert_eq!( + extract_title(None, "# Hello World\n\nBody text.", "some-file.md"), + "Hello World" + ); +} + +#[test] +fn test_extract_title_h1_after_frontmatter() { + let content = "---\nIs A: Note\n---\n# My Note\n\nBody."; + assert_eq!(extract_title(None, content, "fallback.md"), "My Note"); +} + +#[test] +fn test_extract_title_frontmatter_when_no_h1() { + assert_eq!( + extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"), + "My Great Note" + ); +} + +#[test] +fn test_extract_title_fallback_to_filename() { + assert_eq!( + extract_title(None, "", "fallback-title.md"), + "Fallback Title" + ); +} + +#[test] +fn test_extract_title_h1_wins_over_empty_frontmatter() { + assert_eq!( + extract_title(Some(""), "# From H1\n", "empty-h1.md"), + "From H1" + ); +} + +#[test] +fn test_extract_title_empty_fm_no_h1_falls_back_to_filename() { + assert_eq!( + extract_title(Some(""), "No heading here.", "empty-h1.md"), + "Empty H1" + ); +} + +// --- extract_snippet tests --- + +#[test] +fn test_extract_snippet_basic() { + let content = "---\nIs A: Note\n---\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here."; + let snippet = extract_snippet(content); + assert!(snippet.starts_with("This is the first paragraph")); + assert!(snippet.contains("More content here")); +} + +#[test] +fn test_extract_snippet_strips_markdown() { + let content = "# Title\n\nSome **bold** and *italic* and `code` text."; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Some bold and italic and code text."); +} + +#[test] +fn test_extract_snippet_strips_links() { + let content = "# Title\n\nSee [this link](https://example.com) and [[wiki link]]."; + let snippet = extract_snippet(content); + assert!(snippet.contains("this link")); + assert!(!snippet.contains("https://example.com")); + assert!(snippet.contains("wiki link")); + assert!(!snippet.contains("[[")); + assert!(!snippet.contains("]]")); +} + +#[test] +fn test_extract_snippet_wikilink_alias() { + let content = "# Title\n\nDiscussed in [[meetings/standup|standup]] today."; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Discussed in standup today."); +} + +#[test] +fn test_extract_snippet_truncates() { + let long_content = format!("# Title\n\n{}", "word ".repeat(100)); + let snippet = extract_snippet(&long_content); + assert!(snippet.len() <= 165); // 160 + "..." + assert!(snippet.ends_with("...")); +} + +#[test] +fn test_extract_snippet_no_content() { + let content = "---\nIs A: Note\n---\n# Just a Title\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, ""); +} + +#[test] +fn test_extract_snippet_code_fence_delimiters_skipped() { + let content = "# Title\n\n```rust\nfn main() {}\n```\n\nReal content here."; + let snippet = extract_snippet(content); + assert!(!snippet.contains("```")); + assert!(snippet.contains("Real content here")); +} + +#[test] +fn test_extract_snippet_only_headings_uses_fallback() { + let content = "# Title\n\n## Section One\n\n### Sub Section\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Section One Sub Section"); +} + +#[test] +fn test_extract_snippet_no_frontmatter_no_h1() { + let content = "Just plain text content without any heading."; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Just plain text content without any heading."); +} + +#[test] +fn test_extract_snippet_unclosed_frontmatter() { + let content = "---\nIs A: Note\nThis has no closing fence\n# Title\n\nBody text."; + let snippet = extract_snippet(content); + assert!(snippet.contains("Body text")); +} + +#[test] +fn test_extract_snippet_horizontal_rules_skipped() { + let content = "# Title\n\n---\n\nContent after rule."; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Content after rule."); +} + +// --- strip_list_marker tests --- + +#[test] +fn test_strip_list_marker_unordered() { + assert_eq!(strip_list_marker(text("* Item one")), "Item one"); + assert_eq!(strip_list_marker(text("- Item two")), "Item two"); + assert_eq!(strip_list_marker(text("+ Item three")), "Item three"); +} + +#[test] +fn test_strip_list_marker_ordered() { + assert_eq!(strip_list_marker(text("1. First item")), "First item"); + assert_eq!(strip_list_marker(text("10. Tenth item")), "Tenth item"); + assert_eq!(strip_list_marker(text("99. Large number")), "Large number"); +} + +#[test] +fn test_strip_list_marker_preserves_non_list() { + assert_eq!(strip_list_marker(text("Regular text")), "Regular text"); + assert_eq!(strip_list_marker(text(" Indented text")), "Indented text"); +} + +#[test] +fn test_extract_snippet_strips_list_markers() { + let content = + "---\ntype: Project\n---\n# My Project\n\n* First bullet\n* Second bullet\n- Dash item"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "First bullet Second bullet Dash item"); +} + +#[test] +fn test_extract_snippet_mixed_headings_and_bullets() { + let content = "---\ntype: Project\nstatus: Active\n---\n# Migrate newsletter to Beehiiv\n\n### 1) Newsletter is 100% on Beehiiv\n\n* Migration is successful\n\n### 2) Open rate is >27%\n\n* No regressions on open rate"; + let snippet = extract_snippet(content); + assert!( + snippet.starts_with("Migration is successful"), + "snippet should start with first bullet content, got: {}", + snippet + ); + assert!(snippet.contains("No regressions on open rate")); +} + +#[test] +fn test_extract_snippet_ordered_list() { + let content = "# Title\n\n1. First step\n2. Second step\n3. Third step"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "First step Second step Third step"); +} + +#[test] +fn test_extract_snippet_only_subheadings_fallback() { + let content = + "---\ntype: Project\n---\n# My Project\n\n## Description\n\n---\n\n## Key Results\n\n---\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Description Key Results"); +} + +#[test] +fn test_extract_snippet_subheadings_with_emoji() { + let content = "# Daily\n\n## Intentions\n\n## Reflections\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Intentions Reflections"); +} + +#[test] +fn test_extract_snippet_paragraph_takes_priority_over_headings() { + let content = "# Title\n\n## Section One\n\nActual paragraph content.\n\n## Section Two\n"; + let snippet = extract_snippet(content); + assert!( + snippet.starts_with("Actual paragraph content"), + "paragraph content should be preferred over headings, got: {}", + snippet + ); +} + +#[test] +fn test_extract_snippet_crlf_chinese_h1_table_content() { + let content = + "\r\n\r\n# 上海复盘\r\n\r\n| 指标 | 值 |\r\n| --- | --- |\r\n| 收入 | 增长 |\r\n\r\n正文包含中文字符。"; + let snippet = extract_snippet(content); + + assert!(snippet.contains("指标")); + assert!(snippet.contains("正文包含中文字符")); +} + +// --- count_body_words tests --- + +#[test] +fn test_count_body_words_basic() { + let content = "---\nIs A: Note\n---\n# My Note\n\nHello world, this is a test."; + assert_eq!(count_body_words(content), 6); +} + +#[test] +fn test_count_body_words_no_frontmatter() { + let content = "# Title\n\nOne two three four five."; + assert_eq!(count_body_words(content), 5); +} + +#[test] +fn test_count_body_words_empty_body() { + let content = "---\nIs A: Note\n---\n# Just a Title\n"; + assert_eq!(count_body_words(content), 0); +} + +#[test] +fn test_count_body_words_no_content() { + assert_eq!(count_body_words(""), 0); +} + +#[test] +fn test_count_body_words_excludes_markdown_markers() { + let content = "# Title\n\n## Section\n\nReal words here. ---\n\n> quote text"; + // "Real", "words", "here.", "quote", "text" = 5 real words + // "##", "Section", "---", ">" are markdown markers (## is a heading, --- is a rule, > is blockquote) + // "Section" passes the filter (not all markdown chars), so count includes it + assert_eq!(count_body_words(content), 6); +} + +#[test] +fn test_count_body_words_plain_text_only() { + let content = "Just plain text without any heading."; + assert_eq!(count_body_words(content), 6); +} + +// --- strip_frontmatter tests --- + +#[test] +fn test_strip_frontmatter_basic() { + let content = "---\ntitle: Test\n---\nBody content."; + assert_eq!(strip_frontmatter(text(content)), "Body content."); +} + +#[test] +fn test_strip_frontmatter_no_frontmatter() { + let content = "Just plain content."; + assert_eq!(strip_frontmatter(text(content)), "Just plain content."); +} + +#[test] +fn test_strip_frontmatter_dashes_in_value() { + // The closing --- must be at line start, not inside a value + let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here."; + assert_eq!(strip_frontmatter(text(content)), "Body here."); +} + +#[test] +fn test_strip_frontmatter_unclosed() { + let content = "---\ntitle: Test\nNo closing fence"; + assert_eq!(strip_frontmatter(text(content)), content); +} + +#[test] +fn test_strip_frontmatter_empty_body() { + let content = "---\ntitle: Test\n---\n"; + assert_eq!(strip_frontmatter(text(content)), ""); +} + +#[test] +fn test_count_body_words_with_dashes_in_frontmatter_value() { + // Regression: strip_frontmatter previously matched --- inside values + let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words."; + assert_eq!(count_body_words(content), 3); +} + +// --- strip_markdown_chars tests --- + +#[test] +fn test_strip_markdown_chars_plain_text() { + assert_eq!(strip_markdown_chars(text("hello world")), "hello world"); +} + +#[test] +fn test_strip_markdown_chars_emphasis() { + assert_eq!( + strip_markdown_chars(text("**bold** and *italic*")), + "bold and italic" + ); +} + +#[test] +fn test_strip_markdown_chars_backticks() { + assert_eq!( + strip_markdown_chars(text("use `code` here")), + "use code here" + ); +} + +#[test] +fn test_strip_markdown_chars_strikethrough() { + assert_eq!(strip_markdown_chars(text("~~deleted~~")), "deleted"); +} + +#[test] +fn test_strip_markdown_chars_link_with_url() { + assert_eq!( + strip_markdown_chars(text("[click here](https://example.com)")), + "click here" + ); +} + +#[test] +fn test_strip_markdown_chars_wikilink() { + assert_eq!(strip_markdown_chars(text("see [[my note]]")), "see my note"); +} + +#[test] +fn test_strip_markdown_chars_wikilink_alias() { + assert_eq!( + strip_markdown_chars(text("visit [[project/alpha|Alpha Project]]")), + "visit Alpha Project" + ); +} + +#[test] +fn test_strip_markdown_chars_wikilink_unclosed() { + assert_eq!( + strip_markdown_chars(text("see [[broken link")), + "see broken link" + ); +} + +#[test] +fn test_strip_markdown_chars_bracket_without_url() { + assert_eq!( + strip_markdown_chars(text("[just brackets]")), + "[just brackets]" + ); +} + +#[test] +fn test_strip_markdown_chars_empty() { + assert_eq!(strip_markdown_chars(text("")), ""); +} + +// --- without_h1_line tests --- + +#[test] +fn test_without_h1_line_starts_with_h1() { + let result = without_h1_line(text("# Title\nBody text")); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "Body text"); +} + +#[test] +fn test_without_h1_line_blank_lines_then_h1() { + let result = without_h1_line(text("\n\n# Title\nBody")); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "Body"); +} + +#[test] +fn test_without_h1_line_non_heading_first() { + let result = without_h1_line(text("Some text\n# Title\n")); + assert!(result.is_none()); +} + +#[test] +fn test_without_h1_line_empty() { + let result = without_h1_line(text("")); + assert!(result.is_none()); +} + +#[test] +fn test_without_h1_line_only_blank_lines() { + let result = without_h1_line(text("\n\n\n")); + assert!(result.is_none()); +} + +// --- contains_wikilink tests --- + +#[test] +fn test_contains_wikilink_true() { + assert!(contains_wikilink("[[some note]]")); + assert!(contains_wikilink("text before [[link]] text after")); +} + +#[test] +fn test_contains_wikilink_false_plain_text() { + assert!(!contains_wikilink("no links here")); + assert!(!contains_wikilink("[single bracket]")); +} + +#[test] +fn test_contains_wikilink_false_partial_markers() { + assert!(!contains_wikilink("only [[ opening")); + assert!(!contains_wikilink("only ]] closing")); +} + +// --- extract_outgoing_links tests --- + +#[test] +fn test_extract_outgoing_links_basic() { + let content = "# Note\n\nSee [[Alice]] and [[Bob]] for details."; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["Alice", "Bob"]); +} + +#[test] +fn test_extract_outgoing_links_pipe_syntax() { + let content = "Link to [[project/alpha|Alpha Project]] here."; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["project/alpha"]); +} + +#[test] +fn test_extract_outgoing_links_deduplicates() { + let content = "See [[Alice]] and then [[Alice]] again."; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["Alice"]); +} + +#[test] +fn test_extract_outgoing_links_sorted() { + let content = "[[Zebra]] then [[Alpha]] then [[Middle]]"; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["Alpha", "Middle", "Zebra"]); +} + +#[test] +fn test_extract_outgoing_links_with_frontmatter() { + let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/alice]]."; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["person/alice", "task/design"]); +} + +#[test] +fn test_extract_outgoing_links_empty_content() { + assert!(extract_outgoing_links("").is_empty()); + assert!(extract_outgoing_links("No links here").is_empty()); +} + +#[test] +fn test_extract_outgoing_links_unclosed_bracket() { + // First [[ matches with the only ]], yielding "unclosed and [[valid" + let content = "[[unclosed and [[valid]]"; + let links = extract_outgoing_links(content); + assert_eq!(links, vec!["unclosed and [[valid"]); +} diff --git a/src-tauri/src/vault/path_identity.rs b/src-tauri/src/vault/path_identity.rs new file mode 100644 index 0000000..ec6d30d --- /dev/null +++ b/src-tauri/src/vault/path_identity.rs @@ -0,0 +1,117 @@ +use std::path::Path; + +type PathText = str; +type RelativePathText = str; + +fn normalize_tmp_alias(path: &PathText) -> String { + if path == "/private/tmp" { + return "/tmp".to_string(); + } + if let Some(rest) = path.strip_prefix("/private/tmp/") { + return format!("/tmp/{rest}"); + } + path.to_string() +} + +fn trim_trailing_slashes(path: String) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() && path.starts_with('/') { + "/".to_string() + } else { + trimmed.to_string() + } +} + +pub(crate) fn normalize_path_for_identity(path: &PathText) -> String { + trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/"))) +} + +pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String { + path.replace('\\', "/").trim_matches('/').to_string() +} + +pub(crate) fn relative_path_key(path: &RelativePathText) -> String { + normalize_relative_path(path).to_lowercase() +} + +pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool { + normalize_relative_path(path) + .split('/') + .any(|segment| segment.starts_with('.')) +} + +pub(crate) fn push_unique_relative_path( + paths: &mut Vec, + path: impl AsRef, +) { + let normalized = normalize_relative_path(path.as_ref()); + if normalized.is_empty() || has_hidden_segment(&normalized) { + return; + } + let key = relative_path_key(&normalized); + if !paths + .iter() + .any(|existing| relative_path_key(existing) == key) + { + paths.push(normalized); + } +} + +pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result { + let vault_path = normalize_path_for_identity(&vault.to_string_lossy()); + let file_path = normalize_path_for_identity(&file.to_string_lossy()); + if file_path == vault_path { + return Ok(String::new()); + } + + let prefix = format!("{vault_path}/"); + file_path + .strip_prefix(&prefix) + .map(normalize_relative_path) + .ok_or_else(|| { + format!( + "File {} is not inside vault {}", + file.display(), + vault.display() + ) + }) +} + +pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String { + let relative = vault_relative_path_string(vault, path) + .unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy())); + relative + .strip_suffix(".md") + .unwrap_or(&relative) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() { + assert_eq!( + vault_relative_path_string( + Path::new("/private/tmp/tolaria-vault"), + Path::new("/tmp/tolaria-vault/projects\\active.md"), + ) + .unwrap(), + "projects/active.md" + ); + } + + #[test] + fn test_relative_path_key_is_case_insensitive_without_changing_output_path() { + let mut paths = vec![]; + push_unique_relative_path(&mut paths, "Projects\\Active.md"); + push_unique_relative_path(&mut paths, "projects/active.md"); + + assert_eq!(paths, vec!["Projects/Active.md"]); + assert_eq!( + relative_path_key("Projects\\Active.md"), + "projects/active.md" + ); + } +} diff --git a/src-tauri/src/vault/relationship_key_tests.rs b/src-tauri/src/vault/relationship_key_tests.rs new file mode 100644 index 0000000..366e154 --- /dev/null +++ b/src-tauri/src/vault/relationship_key_tests.rs @@ -0,0 +1,63 @@ +use super::*; +use std::fs; +use std::io::Write; +use tempfile::TempDir; + +fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); +} + +fn parse_test_entry(content: &str) -> VaultEntry { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "relationship-note.md", content); + parse_md_file(&dir.path().join("relationship-note.md"), None).unwrap() +} + +#[test] +fn prefers_snake_case_relationship_keys_for_convenience_fields() { + let entry = parse_test_entry( + "---\n\ +belongs_to:\n\ + - \"[[canonical-parent]]\"\n\ +\"Belongs to\":\n\ + - \"[[legacy-parent]]\"\n\ +related_to:\n\ + - \"[[canonical-related]]\"\n\ +\"Related to\":\n\ + - \"[[legacy-related]]\"\n\ +---\n\ +# Relationship Note\n", + ); + + assert_eq!(entry.belongs_to, vec!["[[canonical-parent]]"]); + assert_eq!(entry.related_to, vec!["[[canonical-related]]"]); + assert_eq!( + entry.relationships.get("belongs_to"), + Some(&vec!["[[canonical-parent]]".to_string()]) + ); + assert_eq!( + entry.relationships.get("Belongs to"), + Some(&vec!["[[legacy-parent]]".to_string()]) + ); +} + +#[test] +fn falls_back_to_legacy_relationship_keys_when_snake_case_is_absent() { + let entry = parse_test_entry( + "---\n\ +Belongs to:\n\ + - \"[[legacy-parent]]\"\n\ +Related to:\n\ + - \"[[legacy-related]]\"\n\ +---\n\ +# Relationship Note\n", + ); + + assert_eq!(entry.belongs_to, vec!["[[legacy-parent]]"]); + assert_eq!(entry.related_to, vec!["[[legacy-related]]"]); +} diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs new file mode 100644 index 0000000..1769467 --- /dev/null +++ b/src-tauri/src/vault/rename.rs @@ -0,0 +1,1503 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::io::Write; +use std::path::Path; +use tempfile::NamedTempFile; +use walkdir::WalkDir; + +use super::filename_rules::validate_filename_stem; +use super::path_identity::vault_relative_markdown_stem; +use super::rename_transaction::RenameWorkspace; +use crate::frontmatter::{update_frontmatter_content, FrontmatterValue}; + +/// Result of a rename operation +#[derive(Debug, Serialize, Deserialize)] +pub struct RenameResult { + /// New absolute file path after rename + pub new_path: String, + /// Number of other files updated (wiki link replacements) + pub updated_files: usize, + /// Number of linked-note rewrites that failed and need manual attention + pub failed_updates: usize, +} + +#[derive(Clone, Copy)] +pub struct RenameNoteRequest<'a> { + pub vault_path: &'a str, + pub old_path: &'a str, + pub new_title: &'a str, + pub old_title_hint: Option<&'a str>, +} + +#[derive(Clone, Copy)] +pub struct RenameNoteFilenameRequest<'a> { + pub vault_path: &'a str, + pub old_path: &'a str, + pub new_filename_stem: &'a str, +} + +#[derive(Clone, Copy)] +pub struct MoveNoteToFolderRequest<'a> { + pub vault_path: &'a str, + pub old_path: &'a str, + pub destination_folder_path: &'a str, +} + +#[derive(Clone, Copy)] +pub struct MoveNoteToWorkspaceRequest<'a> { + pub source_vault_path: &'a str, + pub destination_vault_path: &'a str, + pub old_path: &'a str, + pub destination_path: &'a str, + pub replacement_target: Option<&'a str>, +} + +#[derive(Clone, Copy)] +pub struct AutoRenameUntitledRequest<'a> { + pub vault_path: &'a str, + pub note_path: &'a str, +} + +#[derive(Debug, Default)] +struct WikilinkUpdateSummary { + updated_files: usize, + failed_updates: usize, +} + +/// Convert a title to a filename slug (lowercase, hyphens, Unicode letters/digits preserved). +pub(super) fn title_to_slug(title: &str) -> String { + let slug = title + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + if slug.is_empty() { + "untitled".to_string() + } else { + slug + } +} + +/// Build a regex that matches wiki links referencing any of the provided targets. +fn build_wikilink_pattern(targets: &[&str]) -> Option { + let escaped_targets: Vec = targets + .iter() + .filter(|target| !target.is_empty()) + .map(|target| regex::escape(target)) + .collect(); + if escaped_targets.is_empty() { + return None; + } + let pattern_str = format!(r"\[\[(?:{})(\|[^\]]*?)?\]\]", escaped_targets.join("|")); + Regex::new(&pattern_str).ok() +} + +/// Check if a path is a vault markdown file eligible for wikilink replacement. +fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool { + path.is_file() && path != exclude && path.extension().is_some_and(|ext| ext == "md") +} + +/// Replace wikilink references in a single file's content. Returns updated content if changed. +fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option { + if !re.is_match(content) { + return None; + } + let replaced = re.replace_all(content, |caps: ®ex::Captures| match caps.get(1) { + Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()), + None => format!("[[{}]]", new_target), + }); + if replaced != content { + Some(replaced.into_owned()) + } else { + None + } +} + +/// Collect all .md file paths in vault eligible for wikilink replacement. +fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec { + WalkDir::new(vault_path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.into_path()) + .filter(|p| is_replaceable_md_file(p, exclude)) + .collect() +} + +fn unique_wikilink_targets(targets: Vec<&str>) -> Vec<&str> { + let mut seen = HashSet::new(); + targets + .into_iter() + .filter(|target| !target.is_empty()) + .filter(|target| seen.insert(*target)) + .collect() +} + +fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> { + let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem); + unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem]) +} + +/// Replace wiki link references across all vault markdown files. +fn update_wikilinks_in_vault( + vault_path: &Path, + old_targets: &[&str], + new_target: &str, + exclude_path: &Path, +) -> WikilinkUpdateSummary { + let re = match build_wikilink_pattern(old_targets) { + Some(r) => r, + None => return WikilinkUpdateSummary::default(), + }; + replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target) +} + +fn replace_wikilinks_in_files( + files: Vec, + re: &Regex, + replacement: &str, +) -> WikilinkUpdateSummary { + let mut summary = WikilinkUpdateSummary::default(); + for path in files.iter() { + match rewrite_wikilinks_in_file(path, re, replacement) { + Ok(true) => summary.updated_files += 1, + Ok(false) => {} + Err(_) => summary.failed_updates += 1, + } + } + summary +} + +fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + + let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else { + return Ok(false); + }; + + fs::write(path, &new_content) + .map(|_| true) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} + +/// Extract the value of the `title:` frontmatter field from raw content. +fn extract_fm_title_value(content: &str) -> Option { + if !content.starts_with("---\n") { + return None; + } + let fm = content[4..].split("\n---").next()?; + fm.lines() + .map(str::trim_start) + .find_map(extract_title_value_from_frontmatter_line) +} + +fn extract_title_value_from_frontmatter_line(line: &str) -> Option { + ["title:", "\"title\":"] + .iter() + .find_map(|prefix| line.strip_prefix(prefix)) + .map(str::trim) + .map(|value| value.trim_matches('"').trim_matches('\'')) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + +/// Update the `title:` frontmatter field in content. +/// Always writes `title` to frontmatter (creates it if absent). +/// H1 headings are body content and are NOT modified — the title source +/// of truth is frontmatter `title:` → filename, never H1. +fn update_note_title_in_content(content: &str, new_title: &str) -> String { + let value = FrontmatterValue::String(new_title.to_string()); + match update_frontmatter_content(content, "title", Some(value)) { + Ok(c) => c, + Err(_) => content.to_string(), + } +} + +/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review"). +fn to_path_stem(path: &Path, vault_root: &Path) -> String { + vault_relative_markdown_stem(path, vault_root) +} + +pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> { + super::rename_transaction::recover_pending_rename_transactions(vault) +} + +fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(), String> { + staged + .persist(target_path) + .map(|_| ()) + .map_err(|e| format!("Failed to replace {}: {}", target_path.display(), e.error)) +} + +fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult { + let new_path = new_file.to_string_lossy().to_string(); + let new_path_stem = to_path_stem(new_file, vault); + let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file); + RenameResult { + new_path, + updated_files: summary.updated_files, + failed_updates: summary.failed_updates, + } +} + +fn create_new_note_file(path: &Path, content: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?; + } + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + "A note with that name already exists".to_string() + } else { + format!("Failed to create {}: {}", path.display(), e) + } + })?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + file.sync_all() + .map_err(|e| format!("Failed to sync {}: {}", path.display(), e)) +} + +fn remove_created_file(path: &Path) { + let _ = fs::remove_file(path); +} + +fn normalize_filename_stem(new_filename_stem: &str) -> Result { + let trimmed = new_filename_stem.trim(); + let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed).trim(); + if stem.is_empty() { + return Err("New filename cannot be empty".to_string()); + } + validate_filename_stem(stem)?; + Ok(stem.to_string()) +} + +struct LoadedNote { + content: String, + filename: String, + title: String, +} + +fn unchanged_result(path: &Path) -> RenameResult { + RenameResult { + new_path: path.to_string_lossy().to_string(), + updated_files: 0, + failed_updates: 0, + } +} + +fn validate_new_title(new_title: &str) -> Result<&str, String> { + let trimmed = new_title.trim(); + if trimmed.is_empty() { + return Err("New title cannot be empty".to_string()); + } + Ok(trimmed) +} + +fn ensure_existing_note(old_file: &Path) -> Result<(), String> { + if old_file.exists() { + return Ok(()); + } + Err(format!("File does not exist: {}", old_file.display())) +} + +fn load_note_for_title_rename( + old_file: &Path, + old_title_hint: Option<&str>, +) -> Result { + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", old_file.display(), e))?; + let filename = old_file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let fm_title = extract_fm_title_value(&content); + let extracted_title = super::extract_title(fm_title.as_deref(), &content, &filename); + + Ok(LoadedNote { + content, + filename, + title: old_title_hint.unwrap_or(&extracted_title).to_string(), + }) +} + +fn persist_title_only_update( + workspace: &RenameWorkspace, + old_file: &Path, + updated_content: &str, +) -> Result { + persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?; + Ok(unchanged_result(old_file)) +} + +/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault. +/// +/// When `old_title_hint` is provided it is used instead of extracting the title from +/// the file's frontmatter/filename. This is needed when the caller has already saved +/// updated content to disk before triggering the rename. +pub fn rename_note(request: RenameNoteRequest<'_>) -> Result { + let vault = Path::new(request.vault_path); + let old_file = Path::new(request.old_path); + + recover_pending_rename_transactions(vault)?; + ensure_existing_note(old_file)?; + let new_title = validate_new_title(request.new_title)?; + let loaded = load_note_for_title_rename(old_file, request.old_title_hint)?; + + // Check both title and filename: even if the title in content matches, + // the filename may still be stale (e.g. "untitled-note.md" after user changed H1). + let expected_filename = format!("{}.md", title_to_slug(new_title)); + let title_unchanged = loaded.title == new_title; + let filename_matches = loaded.filename == expected_filename; + + if title_unchanged && filename_matches { + return Ok(unchanged_result(old_file)); + } + + // Update content only if the title actually changed + let updated_content = if title_unchanged { + loaded.content.clone() + } else { + update_note_title_in_content(&loaded.content, new_title) + }; + let workspace = RenameWorkspace::new(vault)?; + + if filename_matches { + return persist_title_only_update(&workspace, old_file, &updated_content); + } + + let parent_dir = old_file + .parent() + .ok_or("Cannot determine parent directory")?; + let committed = workspace + .operation(request.old_path, old_file) + .rename_with_candidates( + workspace.stage_note_content(&updated_content)?, + &expected_filename, + parent_dir, + )?; + let old_path_stem = to_path_stem(old_file, vault); + let old_targets = collect_legacy_wikilink_targets(&loaded.title, &old_path_stem); + Ok(finalize_rename(vault, &old_targets, committed.new_file())) +} + +/// Rename only the file path stem while preserving title/frontmatter content. +pub fn rename_note_filename( + request: RenameNoteFilenameRequest<'_>, +) -> Result { + let vault = Path::new(request.vault_path); + let old_file = Path::new(request.old_path); + + recover_pending_rename_transactions(vault)?; + + if !old_file.exists() { + return Err(format!("File does not exist: {}", old_file.display())); + } + + let normalized_stem = normalize_filename_stem(request.new_filename_stem)?; + let old_filename = old_file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?; + let fm_title = extract_fm_title_value(&content); + let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename); + let new_filename = format!("{}.md", normalized_stem); + + if old_filename == new_filename { + return Ok(unchanged_result(old_file)); + } + + let parent_dir = old_file + .parent() + .ok_or("Cannot determine parent directory")?; + let new_file = parent_dir.join(&new_filename); + let workspace = RenameWorkspace::new(vault)?; + let committed = workspace + .operation(request.old_path, old_file) + .rename_exact(workspace.stage_note_content(&content)?, &new_file)?; + + let old_path_stem = to_path_stem(old_file, vault); + let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem); + Ok(finalize_rename(vault, &old_targets, committed.new_file())) +} + +/// Move a note into a different folder while preserving its filename and content. +pub fn move_note_to_folder(request: MoveNoteToFolderRequest<'_>) -> Result { + let vault = Path::new(request.vault_path); + let old_file = Path::new(request.old_path); + let destination_dir = Path::new(request.destination_folder_path); + + recover_pending_rename_transactions(vault)?; + ensure_existing_note(old_file)?; + + if !destination_dir.exists() { + return Err(format!( + "Folder does not exist: {}", + request.destination_folder_path + )); + } + if !destination_dir.is_dir() { + return Err(format!( + "Folder is not a directory: {}", + request.destination_folder_path + )); + } + + let old_filename = old_file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?; + let fm_title = extract_fm_title_value(&content); + let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename); + let new_file = destination_dir.join(&old_filename); + + if new_file == old_file { + return Ok(unchanged_result(old_file)); + } + + let workspace = RenameWorkspace::new(vault)?; + let committed = workspace + .operation(request.old_path, old_file) + .rename_exact(workspace.stage_note_content(&content)?, &new_file)?; + + let old_path_stem = to_path_stem(old_file, vault); + let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem); + Ok(finalize_rename(vault, &old_targets, committed.new_file())) +} + +/// Move a note into another workspace while preserving its vault-relative path. +pub fn move_note_to_workspace( + request: MoveNoteToWorkspaceRequest<'_>, +) -> Result { + let source_vault = Path::new(request.source_vault_path); + let destination_vault = Path::new(request.destination_vault_path); + let old_file = Path::new(request.old_path); + let new_file = Path::new(request.destination_path); + + recover_pending_rename_transactions(source_vault)?; + recover_pending_rename_transactions(destination_vault)?; + ensure_existing_note(old_file)?; + + if new_file == old_file { + return Ok(unchanged_result(old_file)); + } + if new_file.exists() { + return Err("A note with that name already exists".to_string()); + } + + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?; + let old_filename = old_file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let fm_title = extract_fm_title_value(&content); + let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename); + + create_new_note_file(new_file, &content)?; + if let Err(error) = fs::remove_file(old_file) { + remove_created_file(new_file); + return Err(format!( + "Failed to remove {}: {}", + old_file.display(), + error + )); + } + + let old_path_stem = to_path_stem(old_file, source_vault); + let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem); + let fallback_target = to_path_stem(new_file, destination_vault); + let replacement_target = request.replacement_target.unwrap_or(&fallback_target); + let source_summary = + update_wikilinks_in_vault(source_vault, &old_targets, replacement_target, new_file); + let destination_summary = if source_vault == destination_vault { + WikilinkUpdateSummary::default() + } else { + update_wikilinks_in_vault( + destination_vault, + &old_targets, + replacement_target, + new_file, + ) + }; + + Ok(RenameResult { + new_path: new_file.to_string_lossy().to_string(), + updated_files: source_summary.updated_files + destination_summary.updated_files, + failed_updates: source_summary.failed_updates + destination_summary.failed_updates, + }) +} + +/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md"). +fn is_untitled_filename(filename: &str) -> bool { + let stem = filename.strip_suffix(".md").unwrap_or(filename); + // Match: untitled-note-{digits} or untitled-{type}-{digits} + stem.starts_with("untitled-") + && stem + .rsplit('-') + .next() + .is_some_and(|s| s.chars().all(|c| c.is_ascii_digit())) +} + +/// Auto-rename an untitled note based on its H1 heading. +/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met. +/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1. +pub fn auto_rename_untitled( + request: AutoRenameUntitledRequest<'_>, +) -> Result, String> { + let path = Path::new(request.note_path); + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + + if !is_untitled_filename(&filename) { + return Ok(None); + } + + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", request.note_path, e))?; + + let h1_title = match super::parsing::extract_h1_title(&content) { + Some(t) => t, + None => return Ok(None), + }; + + let result = rename_note(RenameNoteRequest { + vault_path: request.vault_path, + old_path: request.note_path, + new_title: &h1_title, + old_title_hint: None, + })?; + Ok(Some(result)) +} + +/// A detected rename: old path → new path (both relative to vault root). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DetectedRename { + pub old_path: String, + pub new_path: String, +} + +/// Detect renamed files by comparing working tree against HEAD using git diff. +pub fn detect_renames(vault: &Path) -> Result, String> { + let output = crate::git::git_command_at(vault) + .and_then(|mut command| { + command + .args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"]) + .output() + }) + .map_err(|e| format!("Failed to run git diff: {e}"))?; + + if !output.status.success() { + return Ok(vec![]); // No HEAD yet or other git issue — no renames + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let renames: Vec = stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 3 && parts[0].starts_with('R') { + let old = parts[1].to_string(); + let new = parts[2].to_string(); + if old.ends_with(".md") && new.ends_with(".md") { + return Some(DetectedRename { + old_path: old, + new_path: new, + }); + } + } + None + }) + .collect(); + + Ok(renames) +} + +/// Update wikilinks across the vault for a list of detected renames. +/// Returns the total number of files updated. +pub fn update_wikilinks_for_renames( + vault: &Path, + renames: &[DetectedRename], +) -> Result { + let mut total_updated = 0; + + for rename in renames { + let old_file = vault.join(&rename.old_path); + let new_file = vault.join(&rename.new_path); + let old_stem = to_path_stem(&old_file, vault); + let new_stem = to_path_stem(&new_file, vault); + let old_filename_stem = old_stem.split('/').next_back().unwrap_or(&old_stem); + // Build title from filename stem (kebab-case → Title Case) + let old_title = super::parsing::slug_to_title(old_filename_stem); + let old_targets = collect_legacy_wikilink_targets(&old_title, &old_stem); + let summary = update_wikilinks_in_vault(vault, &old_targets, &new_stem, &new_file); + total_updated += summary.updated_files; + } + + Ok(total_updated) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn create_test_file(dir: &Path, name: impl AsRef, content: impl AsRef<[u8]>) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_ref()).unwrap(); + } + + struct RenameTestRequest<'a> { + path: &'a str, + content: &'a str, + new_title: &'a str, + old_title_hint: Option<&'a str>, + } + + fn rename_test_note_file( + vault: &Path, + request: RenameTestRequest<'_>, + ) -> (std::path::PathBuf, RenameResult) { + create_test_file(vault, request.path, request.content); + let old_path = vault.join(request.path); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: request.new_title, + old_title_hint: request.old_title_hint, + }) + .unwrap(); + (old_path, result) + } + + fn create_current_note(vault: &Path, relative_path: impl AsRef) -> std::path::PathBuf { + let relative_path = relative_path.as_ref(); + create_test_file(vault, relative_path, "# Current\n"); + vault.join(relative_path) + } + + fn run_git(vault: &Path, args: &[&str]) { + let output = crate::hidden_command("git") + .args(args) + .current_dir(vault) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_git_repo_with_quoted_paths(vault: &Path) { + run_git(vault, &["init"]); + run_git(vault, &["config", "user.email", "test@test.com"]); + run_git(vault, &["config", "user.name", "Test"]); + run_git(vault, &["config", "core.quotePath", "true"]); + } + + fn assert_rename_note_filename_error

      ( + new_filename_stem: impl AsRef, + existing_destination: Option

      , + expected_error: impl AsRef, + ) where + P: AsRef, + { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + let current_path = create_current_note(vault, "note/current.md"); + if let Some(existing_path) = existing_destination { + create_test_file(vault, existing_path.as_ref(), "# Existing\n"); + } + + let result = rename_note_filename(RenameNoteFilenameRequest { + vault_path: vault.to_str().unwrap(), + old_path: current_path.to_str().unwrap(), + new_filename_stem: new_filename_stem.as_ref(), + }); + + assert_eq!(result.unwrap_err(), expected_error.as_ref()); + } + + fn assert_move_note_to_folder_error(expected_error: impl AsRef) { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n"); + create_test_file(vault, "areas/weekly-review.md", "# Existing\n"); + + let result = move_note_to_folder(MoveNoteToFolderRequest { + vault_path: vault.to_str().unwrap(), + old_path: vault.join("projects/weekly-review.md").to_str().unwrap(), + destination_folder_path: vault.join("areas").to_str().unwrap(), + }); + + assert_eq!(result.unwrap_err(), expected_error.as_ref()); + } + + fn assert_slug_case(input: &str, expected: &str) { + assert_eq!(title_to_slug(input), expected); + } + + fn assert_unicode_rename_path(result: &RenameResult) { + assert!( + result.new_path.ends_with("你好.md"), + "got {}", + result.new_path + ); + } + + fn assert_unicode_rename_filesystem(vault: &Path, old_path: &Path, result: &RenameResult) { + assert!(Path::new(&result.new_path).exists()); + assert!(!old_path.exists()); + assert!( + !vault.join(".md").exists(), + "must not produce a stem-less .md file" + ); + } + + fn assert_unicode_rename_frontmatter(result: &RenameResult) { + let new_content = fs::read_to_string(&result.new_path).unwrap(); + assert!(new_content.contains("title: 你好")); + } + + #[test] + fn test_title_to_slug() { + assert_eq!(title_to_slug("Weekly Review"), "weekly-review"); + assert_eq!(title_to_slug("My Note! "), "my-note"); + assert_eq!(title_to_slug("Hello World"), "hello-world"); + } + + #[test] + fn test_title_to_slug_preserves_unicode_letters() { + assert_slug_case("你好", "你好"); + assert_slug_case("こんにちは", "こんにちは"); + assert_slug_case("My Note 你好", "my-note-你好"); + assert_slug_case("项目-2025 ✦ Q1", "项目-2025-q1"); + } + + #[test] + fn test_title_to_slug_falls_back_to_untitled_for_symbol_only_titles() { + assert_eq!(title_to_slug("!?"), "untitled"); + assert_eq!(title_to_slug("---"), "untitled"); + } + + #[test] + fn test_rename_note_with_cjk_title_writes_unicode_filename() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "untitled-note-1700000000.md", + "---\ntype: Note\n---\n# Untitled Note\n", + ); + + let old_path = vault.join("untitled-note-1700000000.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "你好", + old_title_hint: None, + }) + .unwrap(); + + assert_unicode_rename_path(&result); + assert_unicode_rename_filesystem(vault, &old_path, &result); + assert_unicode_rename_frontmatter(&result); + } + + #[test] + fn test_detect_renames_preserves_chinese_markdown_paths() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + + init_git_repo_with_quoted_paths(vault); + create_test_file(vault, "旧名.md", "# 旧名\n"); + run_git(vault, &["add", "旧名.md"]); + run_git(vault, &["commit", "-m", "add chinese note"]); + fs::rename(vault.join("旧名.md"), vault.join("新名.md")).unwrap(); + run_git(vault, &["add", "-A"]); + + let renames = detect_renames(vault).unwrap(); + + assert_eq!(renames.len(), 1); + assert_eq!(renames[0].old_path, "旧名.md"); + assert_eq!(renames[0].new_path, "新名.md"); + } + + #[test] + fn test_path_stem_normalizes_tmp_aliases_and_separators() { + assert_eq!( + to_path_stem( + Path::new("/tmp/tolaria-vault/projects\\weekly-review.md"), + Path::new("/private/tmp/tolaria-vault") + ), + "projects/weekly-review" + ); + } + + #[test] + fn test_rename_note_basic() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/weekly-review.md", + "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n", + ); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "Sprint Retrospective", + old_title_hint: None, + }) + .unwrap(); + + assert!(result.new_path.ends_with("sprint-retrospective.md")); + assert!(!old_path.exists()); + assert!(Path::new(&result.new_path).exists()); + + let new_content = fs::read_to_string(&result.new_path).unwrap(); + // H1 is body content — rename must NOT modify it + assert!(new_content.contains("# Weekly Review")); + assert!(new_content.contains("title: Sprint Retrospective")); + } + + #[test] + fn test_rename_note_updates_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/weekly-review.md", + "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n", + ); + create_test_file( + vault, + "note/other.md", + "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n", + ); + create_test_file( + vault, + "project/my-project.md", + "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n", + ); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "Sprint Retrospective", + old_title_hint: None, + }) + .unwrap(); + + assert_eq!(result.updated_files, 2); + + let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); + assert!(other_content.contains("[[note/sprint-retrospective]]")); + assert!(!other_content.contains("[[Weekly Review]]")); + + let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); + assert!(project_content.contains("[[note/sprint-retrospective]]")); + } + + #[test] + fn test_rename_note_empty_title_error() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/test.md", "# Test\n"); + + let old_path = vault.join("note/test.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: " ", + old_title_hint: None, + }); + assert!(result.is_err()); + } + + #[test] + fn test_rename_note_noop_variants() { + for old_title_hint in [None, Some("My Note")] { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + let (old_path, result) = rename_test_note_file( + vault, + RenameTestRequest { + path: "note/my-note.md", + content: "# My Note\n\nContent.\n", + new_title: "My Note", + old_title_hint, + }, + ); + + assert_eq!(result.new_path, old_path.to_str().unwrap()); + assert_eq!(result.updated_files, 0); + assert_eq!(result.failed_updates, 0); + } + } + + #[test] + fn test_rename_note_updates_legacy_wikilink_targets() { + struct WikilinkRewriteCase<'a> { + ref_content: &'a str, + note_content: &'a str, + new_title: &'a str, + expected_link: &'a str, + removed_link: Option<&'a str>, + } + + let cases = [ + WikilinkRewriteCase { + ref_content: "# Ref\n\nSee [[Weekly Review|my review]] for info.\n", + note_content: "# Weekly Review\n", + new_title: "Sprint Retro", + expected_link: "[[note/sprint-retro|my review]]", + removed_link: None, + }, + WikilinkRewriteCase { + ref_content: "# Ref\n\nSee [[weekly-review]] for info.\n", + note_content: "# Weekly Review\n", + new_title: "Sprint Retro", + expected_link: "[[note/sprint-retro]]", + removed_link: Some("[[weekly-review]]"), + }, + WikilinkRewriteCase { + ref_content: "See [[Weekly Review]] for details.\n", + note_content: "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n", + new_title: "Sprint Retrospective", + expected_link: "[[note/sprint-retrospective]]", + removed_link: Some("[[Weekly Review]]"), + }, + ]; + + for case in cases { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/ref.md", case.ref_content); + let (_old_path, result) = rename_test_note_file( + vault, + RenameTestRequest { + path: "note/weekly-review.md", + content: case.note_content, + new_title: case.new_title, + old_title_hint: None, + }, + ); + + assert_eq!(result.updated_files, 1); + let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); + assert!(ref_content.contains(case.expected_link)); + if let Some(removed_link) = case.removed_link { + assert!(!ref_content.contains(removed_link)); + } + } + } + + #[test] + fn test_rename_note_updates_title_frontmatter() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/old.md", + "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n", + ); + + let old_path = vault.join("note/old.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "New Name", + old_title_hint: None, + }) + .unwrap(); + + let content = fs::read_to_string(&result.new_path).unwrap(); + assert!(content.contains("title: New Name")); + // H1 is body content — rename must NOT modify it + assert!(content.contains("# Old Name")); + } + + // --- Regression: rename empty / minimal notes (nota vuota) --- + + /// Helper: create a note, rename it, assert the rename succeeded and old file is gone. + /// Returns the content of the renamed file for further assertions. + fn rename_test_note( + filename: impl AsRef, + content: impl AsRef, + new_title: impl AsRef, + ) -> String { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, filename.as_ref(), content.as_ref()); + + let old_path = vault.join(filename.as_ref()); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: new_title.as_ref(), + old_title_hint: None, + }) + .expect("rename_note should succeed"); + + let expected_slug = title_to_slug(new_title.as_ref()); + assert!( + result.new_path.ends_with(&format!("{}.md", expected_slug)), + "new path should end with slug: {}", + expected_slug + ); + assert!(!old_path.exists(), "old file should be removed"); + assert!( + Path::new(&result.new_path).exists(), + "new file should exist" + ); + + fs::read_to_string(&result.new_path).unwrap() + } + + #[test] + fn test_rename_note_empty_file() { + rename_test_note("note/empty.md", "", "Renamed Empty"); + } + + #[test] + fn test_rename_note_empty_frontmatter_no_body() { + rename_test_note("note/empty-fm.md", "---\n---\n", "Renamed Note"); + } + + #[test] + fn test_rename_note_frontmatter_title_no_body() { + let content = rename_test_note( + "note/titled.md", + "---\ntitle: Old Title\ntype: Note\n---\n", + "New Title", + ); + assert!(content.contains("title: New Title")); + } + + #[test] + fn test_rename_note_h1_only_no_body() { + let content = rename_test_note("note/heading-only.md", "# Old Heading\n", "New Heading"); + // H1 is body content — rename must NOT modify it + assert!(content.contains("# Old Heading")); + assert!(content.contains("title: New Heading")); + } + + #[test] + fn test_rename_note_frontmatter_and_h1_no_body() { + let content = rename_test_note( + "note/full-empty.md", + "---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n", + "Renamed Note", + ); + assert!(content.contains("title: Renamed Note")); + // H1 is body content — rename must NOT modify it + assert!(content.contains("# My Note")); + } + + // --- rename-on-save: filename doesn't match title slug --- + + #[test] + fn test_rename_note_filename_mismatch_same_title() { + // Simulates: user created "Untitled note", changed H1 to "My New Note", + // saved content (H1 now correct), but filename is still "untitled-note.md". + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/untitled-note.md", + "---\ntitle: My New Note\ntype: Note\n---\n\n# My New Note\n\nContent.\n", + ); + + let old_path = vault.join("note/untitled-note.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "My New Note", + old_title_hint: None, + }) + .unwrap(); + + // File should be renamed to match the title slug + assert!( + result.new_path.ends_with("my-new-note.md"), + "expected my-new-note.md, got {}", + result.new_path + ); + assert!(!old_path.exists(), "old file should be removed"); + assert!(Path::new(&result.new_path).exists()); + + // Content should be preserved (title was already correct) + let content = fs::read_to_string(&result.new_path).unwrap(); + assert!(content.contains("# My New Note")); + assert!(content.contains("title: My New Note")); + } + + #[test] + fn test_rename_note_collision_appends_suffix() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + // Existing file with the slug we want + create_test_file( + vault, + "note/my-note.md", + "---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nExisting.\n", + ); + // File with wrong name that should be renamed to my-note.md + create_test_file( + vault, + "note/untitled-note.md", + "---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nNew content.\n", + ); + + let old_path = vault.join("note/untitled-note.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "My Note", + old_title_hint: None, + }) + .unwrap(); + + // Should get a suffixed name to avoid collision + assert!( + result.new_path.ends_with("my-note-2.md"), + "expected my-note-2.md, got {}", + result.new_path + ); + assert!(!old_path.exists()); + assert!(Path::new(&result.new_path).exists()); + // Original file should be untouched + assert!(vault.join("note/my-note.md").exists()); + } + + #[test] + fn test_rename_note_filename_preserves_title_and_updates_path_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/project-kickoff.md", + "---\ntitle: Project Kickoff\ntype: Note\n---\n\n# Project Kickoff\n\nBody.\n", + ); + create_test_file( + vault, + "note/ref.md", + "# Ref\n\nSee [[note/project-kickoff]] and [[Project Kickoff]].\n", + ); + + let old_path = vault.join("note/project-kickoff.md"); + let result = rename_note_filename(RenameNoteFilenameRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_filename_stem: "manual-name", + }) + .unwrap(); + + assert!(result.new_path.ends_with("manual-name.md")); + assert!(!old_path.exists()); + + let renamed = fs::read_to_string(&result.new_path).unwrap(); + assert!(renamed.contains("title: Project Kickoff")); + assert!(renamed.contains("# Project Kickoff")); + + let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); + assert!(ref_content.contains("[[note/manual-name]]")); + assert!(!ref_content.contains("[[Project Kickoff]]")); + assert!(!ref_content.contains("[[note/project-kickoff]]")); + } + + #[test] + fn test_rename_note_filename_rejects_existing_destination() { + assert_rename_note_filename_error( + "manual-name", + Some("note/manual-name.md"), + "A note with that name already exists", + ); + } + + #[test] + fn test_rename_note_filename_rejects_windows_invalid_names() { + assert_rename_note_filename_error("quarterly:plan", None::<&str>, "Invalid filename"); + } + + #[test] + fn test_move_note_to_folder_preserves_filename_and_updates_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "projects/weekly-review.md", + "---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n", + ); + create_test_file( + vault, + "areas/linked.md", + "Reference [[projects/weekly-review]]\n", + ); + + let result = move_note_to_folder(MoveNoteToFolderRequest { + vault_path: vault.to_str().unwrap(), + old_path: vault.join("projects/weekly-review.md").to_str().unwrap(), + destination_folder_path: vault.join("areas").to_str().unwrap(), + }) + .expect("move should succeed"); + + assert!(result.new_path.ends_with("areas/weekly-review.md")); + assert!(!vault.join("projects/weekly-review.md").exists()); + assert!(vault.join("areas/weekly-review.md").exists()); + assert_eq!( + fs::read_to_string(vault.join("areas/linked.md")).unwrap(), + "Reference [[areas/weekly-review]]\n" + ); + } + + #[test] + fn test_move_note_to_folder_updates_title_and_path_links_in_body_and_frontmatter() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "projects/weekly-review.md", + "---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n", + ); + create_test_file( + vault, + "areas/linked.md", + "---\nrelated_to:\n - \"[[Weekly Review]]\"\n---\nReference [[projects/weekly-review|review notes]] and [[weekly-review]].\n", + ); + + let result = move_note_to_folder(MoveNoteToFolderRequest { + vault_path: vault.to_str().unwrap(), + old_path: vault.join("projects/weekly-review.md").to_str().unwrap(), + destination_folder_path: vault.join("areas").to_str().unwrap(), + }) + .expect("move should succeed"); + + assert_eq!(result.updated_files, 1); + assert!(result.new_path.ends_with("areas/weekly-review.md")); + assert_eq!( + fs::read_to_string(vault.join("areas/linked.md")).unwrap(), + "---\nrelated_to:\n - \"[[areas/weekly-review]]\"\n---\nReference [[areas/weekly-review|review notes]] and [[areas/weekly-review]].\n", + ); + } + + #[test] + fn test_move_note_to_folder_noop_when_destination_matches_current_parent() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n"); + + let source = vault.join("projects/weekly-review.md"); + let result = move_note_to_folder(MoveNoteToFolderRequest { + vault_path: vault.to_str().unwrap(), + old_path: source.to_str().unwrap(), + destination_folder_path: vault.join("projects").to_str().unwrap(), + }) + .expect("move should noop"); + + assert_eq!(result.new_path, source.to_string_lossy()); + assert!(source.exists()); + assert_eq!(result.updated_files, 0); + } + + #[test] + fn test_move_note_to_folder_rejects_existing_destination() { + assert_move_note_to_folder_error("A note with that name already exists"); + } + + #[test] + fn test_move_note_to_workspace_preserves_relative_path_and_updates_source_links() { + let source = TempDir::new().unwrap(); + let destination = TempDir::new().unwrap(); + create_test_file( + source.path(), + "Projects/project-kickoff.md", + "---\ntitle: Project Kickoff\ntype: Note\n---\n\nBody.\n", + ); + create_test_file( + source.path(), + "source-ref.md", + "# Ref\n\nSee [[Projects/project-kickoff]] and [[Project Kickoff]].\n", + ); + + let old_path = source.path().join("Projects/project-kickoff.md"); + let destination_path = destination.path().join("Projects/project-kickoff.md"); + let result = move_note_to_workspace(MoveNoteToWorkspaceRequest { + source_vault_path: source.path().to_str().unwrap(), + destination_vault_path: destination.path().to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + destination_path: destination_path.to_str().unwrap(), + replacement_target: Some("team/Projects/project-kickoff"), + }) + .unwrap(); + + assert_eq!(result.new_path, destination_path.to_string_lossy()); + + assert!(!old_path.exists()); + + assert!(destination_path.exists()); + + let source_reference = fs::read_to_string(source.path().join("source-ref.md")).unwrap(); + assert!(source_reference.contains("[[team/Projects/project-kickoff]]")); + } + + #[test] + fn test_rename_note_with_old_title_hint_updates_wikilinks() { + // Simulates H1 sync: content already saved with new H1, but wikilinks still use old title. + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + // Note file already has the NEW H1 (simulating savePendingForPath before rename) + create_test_file( + vault, + "note/weekly-review.md", + "---\nIs A: Note\n---\n# Sprint Retrospective\n\nContent.\n", + ); + create_test_file( + vault, + "note/other.md", + "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n", + ); + create_test_file( + vault, + "project/my-project.md", + "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n", + ); + + let old_path = vault.join("note/weekly-review.md"); + // Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop + // With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "Sprint Retrospective", + old_title_hint: Some("Weekly Review"), + }) + .unwrap(); + + assert_eq!(result.updated_files, 2); + assert!(result.new_path.ends_with("sprint-retrospective.md")); + assert!(!vault.join("note/weekly-review.md").exists()); + + let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); + assert!(other_content.contains("[[note/sprint-retrospective]]")); + assert!(!other_content.contains("[[Weekly Review]]")); + + let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); + assert!(project_content.contains("[[note/sprint-retrospective]]")); + } + + #[test] + fn test_rename_note_does_not_modify_h1() { + // H1 is body content — rename should only update frontmatter title, not H1 + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/old.md", + "---\ntitle: Old Title\ntype: Note\n---\n\n# Old Title\n\nSome body text.\n", + ); + + let old_path = vault.join("note/old.md"); + let result = rename_note(RenameNoteRequest { + vault_path: vault.to_str().unwrap(), + old_path: old_path.to_str().unwrap(), + new_title: "Brand New Title", + old_title_hint: None, + }) + .unwrap(); + + let content = fs::read_to_string(&result.new_path).unwrap(); + assert!( + content.contains("title: Brand New Title"), + "frontmatter title should be updated" + ); + assert!( + content.contains("# Old Title"), + "H1 must NOT be modified by rename" + ); + assert!( + !content.contains("# Brand New Title"), + "H1 must NOT match new title" + ); + } + + #[test] + fn test_replace_wikilinks_in_files_reports_failed_updates() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/ref.md", "See [[Old Note]] for details.\n"); + + let pattern = build_wikilink_pattern(&["Old Note"]).unwrap(); + let summary = replace_wikilinks_in_files( + vec![vault.join("note/ref.md"), vault.join("note/missing.md")], + &pattern, + "note/new-note", + ); + + assert_eq!(summary.updated_files, 1); + assert_eq!(summary.failed_updates, 1); + } + + #[test] + fn test_recover_pending_rename_transactions_restores_backup_when_new_file_is_missing() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + let old_path = vault.join("note/original.md"); + let new_path = vault.join("note/renamed.md"); + + create_test_file(vault, "note/original.md", "# Original\n"); + + let txn_dir = vault.join(".tolaria-rename-txn"); + fs::create_dir_all(&txn_dir).unwrap(); + + let backup_path = txn_dir.join("rename-backup.bak"); + let manifest_path = txn_dir.join("rename-transaction.json"); + fs::rename(&old_path, &backup_path).unwrap(); + fs::write( + &manifest_path, + serde_json::json!({ + "old_path": old_path.to_string_lossy().to_string(), + "new_path": new_path.to_string_lossy().to_string(), + "backup_path": backup_path.to_string_lossy().to_string(), + }) + .to_string(), + ) + .unwrap(); + + recover_pending_rename_transactions(vault).unwrap(); + + assert!(old_path.exists()); + assert!(!new_path.exists()); + assert!(!backup_path.exists()); + assert!(!manifest_path.exists()); + } +} diff --git a/src-tauri/src/vault/rename_transaction.rs b/src-tauri/src/vault/rename_transaction.rs new file mode 100644 index 0000000..bd187b1 --- /dev/null +++ b/src-tauri/src/vault/rename_transaction.rs @@ -0,0 +1,287 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +struct RenameTransaction { + old_path: String, + new_path: String, + backup_path: String, +} + +pub(super) struct RenameWorkspace { + dir: PathBuf, +} + +impl RenameWorkspace { + pub(super) fn new(vault: &Path) -> Result { + let dir = vault.join(".tolaria-rename-txn"); + fs::create_dir_all(&dir).map_err(|e| { + format!( + "Failed to create rename transaction dir {}: {}", + dir.display(), + e + ) + })?; + Ok(Self { dir }) + } + + pub(super) fn stage_note_content(&self, content: &str) -> Result { + let mut staged = NamedTempFile::new_in(&self.dir) + .map_err(|e| format!("Failed to create staged rename file: {}", e))?; + staged + .write_all(content.as_bytes()) + .map_err(|e| format!("Failed to write staged rename file: {}", e))?; + staged + .as_file_mut() + .sync_all() + .map_err(|e| format!("Failed to sync staged rename file: {}", e))?; + Ok(staged) + } + + pub(super) fn operation<'a>( + &self, + old_path: &'a str, + old_file: &'a Path, + ) -> RenameOperation<'a> { + RenameOperation { + old_path, + old_file, + backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())), + manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())), + } + } +} + +pub(super) struct CommittedRename { + new_file: PathBuf, + manifest_path: PathBuf, + backup_path: PathBuf, +} + +impl CommittedRename { + pub(super) fn new_file(&self) -> &Path { + &self.new_file + } +} + +impl Drop for CommittedRename { + fn drop(&mut self) { + let _ = fs::remove_file(&self.backup_path); + let _ = fs::remove_file(&self.manifest_path); + } +} + +pub(super) struct RenameOperation<'a> { + old_path: &'a str, + old_file: &'a Path, + backup_path: PathBuf, + manifest_path: PathBuf, +} + +impl<'a> RenameOperation<'a> { + pub(super) fn rename_with_candidates( + &self, + staged: NamedTempFile, + desired_filename: &str, + parent_dir: &Path, + ) -> Result { + let mut staged = staged; + for attempt in 0.. { + let candidate = parent_dir.join(candidate_filename(desired_filename, attempt)); + self.prepare(&candidate)?; + + match staged.persist_noclobber(&candidate) { + Ok(_) => return Ok(self.committed(candidate)), + Err(err) if err.error.kind() == ErrorKind::AlreadyExists => { + staged = err.file; + self.rollback()?; + } + Err(err) => { + self.rollback()?; + return Err(format!( + "Failed to create {}: {}", + candidate.display(), + err.error + )); + } + } + } + unreachable!() + } + + pub(super) fn rename_exact( + &self, + staged: NamedTempFile, + new_file: &Path, + ) -> Result { + self.prepare(new_file)?; + match staged.persist_noclobber(new_file) { + Ok(_) => Ok(self.committed(new_file.to_path_buf())), + Err(err) if err.error.kind() == ErrorKind::AlreadyExists => { + self.rollback()?; + Err("A note with that name already exists".to_string()) + } + Err(err) => { + self.rollback()?; + Err(format!( + "Failed to rename {} to {}: {}", + self.old_path, + new_file.to_string_lossy(), + err.error + )) + } + } + } + + fn prepare(&self, new_file: &Path) -> Result<(), String> { + self.write_manifest(new_file)?; + self.move_into_backup() + } + + fn write_manifest(&self, new_file: &Path) -> Result<(), String> { + let transaction = RenameTransaction { + old_path: self.old_path.to_string(), + new_path: new_file.to_string_lossy().to_string(), + backup_path: self.backup_path.to_string_lossy().to_string(), + }; + let data = serde_json::to_string(&transaction) + .map_err(|e| format!("Failed to serialize rename transaction: {}", e))?; + fs::write(&self.manifest_path, data).map_err(|e| { + format!( + "Failed to write rename transaction {}: {}", + self.manifest_path.display(), + e + ) + }) + } + + fn move_into_backup(&self) -> Result<(), String> { + fs::rename(self.old_file, &self.backup_path).map_err(|e| { + format!( + "Failed to move {} into rename backup {}: {}", + self.old_file.display(), + self.backup_path.display(), + e + ) + }) + } + + fn rollback(&self) -> Result<(), String> { + if self.backup_path.exists() { + if let Some(parent) = self.old_file.parent() { + let _ = fs::create_dir_all(parent); + } + fs::rename(&self.backup_path, self.old_file).map_err(|e| { + format!( + "Failed to restore {} from {}: {}", + self.old_file.display(), + self.backup_path.display(), + e + ) + })?; + } + let _ = fs::remove_file(&self.manifest_path); + Ok(()) + } + + fn committed(&self, new_file: PathBuf) -> CommittedRename { + CommittedRename { + new_file, + manifest_path: self.manifest_path.clone(), + backup_path: self.backup_path.clone(), + } + } +} + +fn candidate_filename(filename: &str, attempt: usize) -> String { + let stem = Path::new(filename) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let ext = Path::new(filename) + .extension() + .map(|s| format!(".{}", s.to_string_lossy())) + .unwrap_or_default(); + if attempt == 0 { + return filename.to_string(); + } + format!("{}-{}{}", stem, attempt + 1, ext) +} + +fn transaction_dir(vault: &Path) -> PathBuf { + vault.join(".tolaria-rename-txn") +} + +pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> { + let txn_dir = transaction_dir(vault); + if !txn_dir.exists() { + return Ok(()); + } + + let entries = fs::read_dir(&txn_dir).map_err(|e| { + format!( + "Failed to read rename transaction dir {}: {}", + txn_dir.display(), + e + ) + })?; + + for entry in entries { + let path = entry + .map_err(|e| format!("Failed to read rename transaction entry: {}", e))? + .path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let Ok(data) = fs::read_to_string(&path) else { + let _ = fs::remove_file(&path); + continue; + }; + let Ok(transaction) = serde_json::from_str::(&data) else { + let _ = fs::remove_file(&path); + continue; + }; + recover_rename_transaction(&path, transaction)?; + } + + Ok(()) +} + +fn recover_rename_transaction( + manifest_path: &Path, + transaction: RenameTransaction, +) -> Result<(), String> { + let old_path = Path::new(&transaction.old_path); + let new_path = Path::new(&transaction.new_path); + let backup_path = Path::new(&transaction.backup_path); + + if !backup_path.exists() { + let _ = fs::remove_file(manifest_path); + return Ok(()); + } + + if new_path.exists() || old_path.exists() { + let _ = fs::remove_file(backup_path); + let _ = fs::remove_file(manifest_path); + return Ok(()); + } + + if let Some(parent) = old_path.parent() { + let _ = fs::create_dir_all(parent); + } + fs::rename(backup_path, old_path).map_err(|e| { + format!( + "Failed to recover {} from {}: {}", + old_path.display(), + backup_path.display(), + e + ) + })?; + let _ = fs::remove_file(manifest_path); + Ok(()) +} diff --git a/src-tauri/src/vault/system_metadata_tests.rs b/src-tauri/src/vault/system_metadata_tests.rs new file mode 100644 index 0000000..6774bc8 --- /dev/null +++ b/src-tauri/src/vault/system_metadata_tests.rs @@ -0,0 +1,99 @@ +use super::*; +use std::fs; +use std::io::Write; +use std::path::Path; +use tempfile::TempDir; + +fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); +} + +fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry { + create_test_file(dir.path(), name, content); + parse_md_file(&dir.path().join(name), None).unwrap() +} + +#[test] +fn parses_canonical_system_metadata_keys() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "project.md", + "---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n", + ); + + assert_eq!(entry.icon.as_deref(), Some("rocket")); + assert_eq!(entry.order, Some(4)); + assert_eq!(entry.sidebar_label.as_deref(), Some("Projects")); + assert_eq!(entry.sort.as_deref(), Some("title:asc")); +} + +#[test] +fn parses_legacy_system_metadata_keys_without_property_leaks() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "project.md", + "---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n", + ); + + assert_eq!(entry.icon.as_deref(), Some("rocket")); + assert_eq!(entry.order, Some(4)); + assert_eq!(entry.sidebar_label.as_deref(), Some("Projects")); + assert_eq!(entry.sort.as_deref(), Some("title:asc")); + assert!(!entry.properties.contains_key("icon")); + assert!(!entry.properties.contains_key("order")); + assert!(!entry.properties.contains_key("sidebar label")); + assert!(!entry.properties.contains_key("sort")); +} + +#[test] +fn ignores_unknown_underscore_keys_in_properties_and_relationships() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "note.md", + "---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n", + ); + + assert!(!entry.properties.contains_key("_internal")); + assert!(!entry.relationships.contains_key("_hidden_link")); + assert_eq!( + entry + .properties + .get("Owner") + .and_then(|value| value.as_str()), + Some("Luca") + ); +} + +#[test] +fn parses_note_width_without_property_leaks() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "note.md", + "---\ntype: Note\n_width: wide\n---\n# Note\n", + ); + + assert_eq!(entry.note_width.as_deref(), Some("wide")); + assert!(!entry.properties.contains_key("_width")); + assert!(!entry.relationships.contains_key("_width")); +} + +#[test] +fn ignores_invalid_note_width_modes() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "note.md", + "---\ntype: Note\n_width: expanded\n---\n# Note\n", + ); + + assert_eq!(entry.note_width, None); +} diff --git a/src-tauri/src/vault/title_sync.rs b/src-tauri/src/vault/title_sync.rs new file mode 100644 index 0000000..4b7df25 --- /dev/null +++ b/src-tauri/src/vault/title_sync.rs @@ -0,0 +1,179 @@ +use std::fs; +use std::path::Path; + +use crate::frontmatter::{update_frontmatter_content, FrontmatterValue}; + +use super::parsing::slug_to_title; +use super::rename::title_to_slug; + +const TITLE_PREFIXES: [&str; 2] = ["title:", "\"title\":"]; + +/// Result of a title sync check. +#[derive(Debug, PartialEq)] +pub enum SyncAction { + /// Title and filename are already in sync. + InSync, + /// Title was absent or desynced; frontmatter was updated on disk. + Updated { title: String }, +} + +/// Extract the raw `title:` value from frontmatter in file content. +fn extract_raw_title(content: &str) -> Option { + if !content.starts_with("---\n") { + return None; + } + let fm = content[4..].split("\n---").next()?; + fm.lines().find_map(extract_title_from_line) +} + +fn extract_title_from_line(line: &str) -> Option { + TITLE_PREFIXES + .iter() + .find_map(|prefix| line.trim_start().strip_prefix(prefix)) + .map(clean_title_value) + .filter(|value| !value.is_empty()) +} + +fn clean_title_value(raw_value: &str) -> String { + raw_value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string() +} + +/// Sync the `title` frontmatter field with the filename. +/// +/// Rules (filename is source of truth): +/// - If `title` is absent → derive from filename, write to frontmatter +/// - If `title` is present but its slug doesn't match the filename stem → overwrite +/// - If both are in sync → no-op +pub fn sync_title_on_open(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let stem = filename.strip_suffix(".md").unwrap_or(&filename); + let expected_title = slug_to_title(stem); + + let fm_title = extract_raw_title(&content); + + match fm_title { + Some(ref title) if title_to_slug(title) == stem => Ok(SyncAction::InSync), + _ => { + // Title absent or desynced — filename wins + let value = FrontmatterValue::String(expected_title.clone()); + let updated = update_frontmatter_content(&content, "title", Some(value)) + .map_err(|e| format!("Failed to update frontmatter: {}", e))?; + fs::write(path, &updated) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + Ok(SyncAction::Updated { + title: expected_title, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_note(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn assert_title_update(dir: &Path, name: &str, content: &str, expected_title: &str) -> String { + let path = write_note(dir, name, content); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!( + result, + SyncAction::Updated { + title: expected_title.to_string() + } + ); + fs::read_to_string(&path).unwrap() + } + + #[test] + fn test_sync_adds_title_when_absent() { + let dir = TempDir::new().unwrap(); + let content = assert_title_update( + dir.path(), + "career-tracks.md", + "---\ntype: Note\n---\n# Career Tracks\n", + "Career Tracks", + ); + assert!(content.contains("title: Career Tracks")); + } + + #[test] + fn test_sync_noop_when_in_sync() { + let dir = TempDir::new().unwrap(); + let path = write_note( + dir.path(), + "my-note.md", + "---\ntitle: My Note\ntype: Note\n---\n# My Note\n", + ); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!(result, SyncAction::InSync); + } + + #[test] + fn test_sync_overwrites_desynced_title() { + let dir = TempDir::new().unwrap(); + // Filename says "new-name" but title says "Old Name" + let content = assert_title_update( + dir.path(), + "new-name.md", + "---\ntitle: Old Name\ntype: Note\n---\n# Old Name\n", + "New Name", + ); + assert!(content.contains("title: New Name")); + assert!(!content.contains("title: Old Name")); + } + + #[test] + fn test_sync_adds_frontmatter_when_none_exists() { + let dir = TempDir::new().unwrap(); + let content = assert_title_update( + dir.path(), + "plain-note.md", + "# Plain Note\n\nSome content.\n", + "Plain Note", + ); + assert!(content.starts_with("---\n")); + assert!(content.contains("title: Plain Note")); + } + + #[test] + fn test_sync_e2e_filename() { + let dir = TempDir::new().unwrap(); + assert_title_update( + dir.path(), + "e2e-test.md", + "---\ntype: Note\n---\n", + "E2e Test", + ); + } + + #[test] + fn test_sync_preserves_other_frontmatter() { + let dir = TempDir::new().unwrap(); + let path = write_note( + dir.path(), + "my-note.md", + "---\ntype: Project\nstatus: Active\n---\n# My Note\n", + ); + sync_title_on_open(&path).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Project")); + assert!(content.contains("status: Active")); + assert!(content.contains("title: My Note")); + } +} diff --git a/src-tauri/src/vault/trash.rs b/src-tauri/src/vault/trash.rs new file mode 100644 index 0000000..743b75c --- /dev/null +++ b/src-tauri/src/vault/trash.rs @@ -0,0 +1,111 @@ +use std::fs; +use std::path::Path; + +/// Permanently delete a single note file. +/// Returns the deleted path on success, or an error if the file doesn't exist. +pub fn delete_note(path: &str) -> Result { + let file = Path::new(path); + if !file.exists() { + return Err(format!("File does not exist: {}", path)); + } + if !file.is_file() { + return Err(format!("Path is not a file: {}", path)); + } + fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?; + log::info!("Permanently deleted note: {}", path); + Ok(path.to_string()) +} + +/// Delete multiple note files from disk. +/// Returns the list of successfully deleted paths. +/// Skips files that don't exist or fail to delete (logs warnings). +pub fn batch_delete_notes(paths: &[String]) -> Result, String> { + let mut deleted = Vec::new(); + for path in paths { + let file = Path::new(path.as_str()); + if !file.exists() { + log::warn!("File does not exist, skipping: {}", path); + continue; + } + match fs::remove_file(file) { + Ok(()) => { + log::info!("Permanently deleted note: {}", path); + deleted.push(path.clone()); + } + Err(e) => { + log::warn!("Failed to delete {}: {}", path, e); + } + } + } + Ok(deleted) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn create_test_file(dir: &Path, name: &str, content: &str) { + let file_path = dir.join(name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + } + + #[test] + fn test_delete_note_removes_file() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "doomed.md", + "---\ntitle: Doomed\n---\n# Doomed\n", + ); + let path = dir.path().join("doomed.md"); + assert!(path.exists()); + let result = delete_note(path.to_str().unwrap()); + assert!(result.is_ok()); + assert!(!path.exists()); + } + + #[test] + fn test_delete_note_nonexistent_file() { + let result = delete_note("/nonexistent/path/that/does/not/exist.md"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn test_batch_delete_notes_removes_files() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "a.md", "---\ntitle: A\n---\n# A\n"); + create_test_file(dir.path(), "b.md", "---\ntitle: B\n---\n# B\n"); + create_test_file(dir.path(), "keep.md", "---\ntitle: Keep\n---\n# Keep\n"); + + let paths = vec![ + dir.path().join("a.md").to_str().unwrap().to_string(), + dir.path().join("b.md").to_str().unwrap().to_string(), + ]; + let deleted = batch_delete_notes(&paths).unwrap(); + assert_eq!(deleted.len(), 2); + assert!(!dir.path().join("a.md").exists()); + assert!(!dir.path().join("b.md").exists()); + assert!(dir.path().join("keep.md").exists()); + } + + #[test] + fn test_batch_delete_notes_skips_nonexistent() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "exists.md", "---\ntitle: X\n---\n# X\n"); + + let paths = vec![ + dir.path().join("exists.md").to_str().unwrap().to_string(), + "/nonexistent/path.md".to_string(), + ]; + let deleted = batch_delete_notes(&paths).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(!dir.path().join("exists.md").exists()); + } +} diff --git a/src-tauri/src/vault/type_templates.rs b/src-tauri/src/vault/type_templates.rs new file mode 100644 index 0000000..b0f72b0 --- /dev/null +++ b/src-tauri/src/vault/type_templates.rs @@ -0,0 +1,57 @@ +pub(crate) struct TypeTemplateSource<'a> { + pub explicit_template: Option, + pub is_a: Option<&'a str>, + pub title: &'a str, + pub body: &'a str, +} + +struct TemplateLine<'a>(&'a str); + +impl TemplateLine<'_> { + fn has_structure(&self) -> bool { + let trimmed = self.0.trim_start(); + trimmed.starts_with("## ") + || trimmed.starts_with("- [ ] ") + || TemplateLine(trimmed).is_field() + } + + fn is_field(&self) -> bool { + let trimmed = self.0.trim(); + if !trimmed.ends_with(':') { + return false; + } + let label = trimmed.trim_end_matches(':').trim(); + !label.is_empty() && !label.starts_with('-') + } +} + +impl TypeTemplateSource<'_> { + pub fn resolve(self) -> Option { + match self.explicit_template { + Some(template) => Some(template), + None if self.is_a == Some("Type") => self.body_template(), + None => None, + } + } + + fn body_template(&self) -> Option { + let template = self.body_after_type_title()?.trim(); + if template + .lines() + .map(TemplateLine) + .any(|line| line.has_structure()) + { + Some(template.to_string()) + } else { + None + } + } + + fn body_after_type_title(&self) -> Option<&str> { + let body = self.body.trim_start(); + let (first_line, rest) = body.split_once('\n').unwrap_or((body, "")); + let first_line = first_line.trim_end_matches('\r'); + let heading = first_line.strip_prefix("# ")?.trim(); + (heading == self.title).then_some(rest) + } +} diff --git a/src-tauri/src/vault/view_date_filters.rs b/src-tauri/src/vault/view_date_filters.rs new file mode 100644 index 0000000..9c696d8 --- /dev/null +++ b/src-tauri/src/vault/view_date_filters.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc}; + +fn parse_relative_amount(token: &str) -> Option { + match token { + "a" | "an" | "one" => Some(1), + "two" => Some(2), + "three" => Some(3), + "four" => Some(4), + "five" => Some(5), + "six" => Some(6), + "seven" => Some(7), + "eight" => Some(8), + "nine" => Some(9), + "ten" => Some(10), + "eleven" => Some(11), + "twelve" => Some(12), + _ => token.parse::().ok(), + } +} + +fn parse_relative_date_filter(value: &str, reference: DateTime) -> Option> { + let normalized = value.trim().to_lowercase(); + if normalized.is_empty() { + return None; + } + + let base = reference.date_naive().and_hms_opt(0, 0, 0)?.and_utc(); + match normalized.as_str() { + "today" => return Some(base), + "yesterday" => return Some(base - Duration::days(1)), + "tomorrow" => return Some(base + Duration::days(1)), + _ => {} + } + + let tokens: Vec<&str> = normalized.split_whitespace().collect(); + let (future, amount_token, unit_token) = match tokens.as_slice() { + ["in", amount, unit] => (true, *amount, *unit), + [amount, unit, "ago"] => (false, *amount, *unit), + _ => return None, + }; + + let amount = parse_relative_amount(amount_token)?; + let unit = unit_token.strip_suffix('s').unwrap_or(unit_token); + + match (future, unit) { + (true, "day") => Some(base + Duration::days(amount as i64)), + (false, "day") => Some(base - Duration::days(amount as i64)), + (true, "week") => Some(base + Duration::weeks(amount as i64)), + (false, "week") => Some(base - Duration::weeks(amount as i64)), + (true, "month") => base.checked_add_months(Months::new(amount)), + (false, "month") => base.checked_sub_months(Months::new(amount)), + (true, "year") => base.checked_add_months(Months::new(amount * 12)), + (false, "year") => base.checked_sub_months(Months::new(amount * 12)), + _ => None, + } +} + +pub(super) fn parse_date_filter_timestamp(value: &str, reference: DateTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + return Some(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis()); + } + + if let Ok(datetime) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") { + return Some(datetime.and_utc().timestamp_millis()); + } + + if let Ok(datetime) = DateTime::parse_from_rfc3339(trimmed) { + return Some(datetime.with_timezone(&Utc).timestamp_millis()); + } + + parse_relative_date_filter(trimmed, reference).map(|datetime| datetime.timestamp_millis()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn parses_relative_days_ago() { + let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap(); + let parsed = parse_date_filter_timestamp("10 days ago", reference).unwrap(); + let expected = Utc + .with_ymd_and_hms(2026, 3, 28, 0, 0, 0) + .unwrap() + .timestamp_millis(); + + assert_eq!(parsed, expected); + } + + #[test] + fn parses_relative_one_week_ago() { + let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap(); + let parsed = parse_date_filter_timestamp("one week ago", reference).unwrap(); + let expected = Utc + .with_ymd_and_hms(2026, 3, 31, 0, 0, 0) + .unwrap() + .timestamp_millis(); + + assert_eq!(parsed, expected); + } +} diff --git a/src-tauri/src/vault/view_migration.rs b/src-tauri/src/vault/view_migration.rs new file mode 100644 index 0000000..07a4788 --- /dev/null +++ b/src-tauri/src/vault/view_migration.rs @@ -0,0 +1,69 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub(super) fn is_view_definition_file(path: &Path) -> bool { + path.extension().and_then(|ext| ext.to_str()) == Some("yml") +} + +pub(super) fn migrate_views(vault_path: &Path) { + let old_dir = legacy_views_dir(vault_path); + if !old_dir.is_dir() { + return; + } + + let Some(yml_files) = legacy_view_files(&old_dir) else { + return; + }; + let new_dir = current_views_dir(vault_path); + if fs::create_dir_all(&new_dir).is_err() { + log::warn!("Failed to create views/ directory for migration"); + return; + } + + for entry in yml_files { + migrate_view_file(&new_dir, entry); + } + + remove_empty_legacy_views_dir(&old_dir); +} + +fn legacy_views_dir(vault_path: &Path) -> PathBuf { + vault_path.join(".laputa").join("views") +} + +fn current_views_dir(vault_path: &Path) -> PathBuf { + vault_path.join("views") +} + +fn legacy_view_files(old_dir: &Path) -> Option> { + let entries = fs::read_dir(old_dir).ok()?; + let yml_files: Vec<_> = entries + .flatten() + .filter(|entry| is_view_definition_file(&entry.path())) + .collect(); + + (!yml_files.is_empty()).then_some(yml_files) +} + +fn migrate_view_file(new_dir: &Path, entry: fs::DirEntry) { + let src = entry.path(); + let dst = new_dir.join(entry.file_name()); + if dst.exists() { + return; + } + + if let Err(error) = fs::rename(&src, &dst) { + log::warn!("Failed to migrate view {:?}: {}", src, error); + } else { + log::info!("Migrated view {:?} -> {:?}", src, dst); + } +} + +fn remove_empty_legacy_views_dir(old_dir: &Path) { + if fs::read_dir(old_dir) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false) + { + let _ = fs::remove_dir(old_dir); + } +} diff --git a/src-tauri/src/vault/view_relationships.rs b/src-tauri/src/vault/view_relationships.rs new file mode 100644 index 0000000..2467165 --- /dev/null +++ b/src-tauri/src/vault/view_relationships.rs @@ -0,0 +1,156 @@ +use super::view_value_conversions::{yaml_value_to_string, yaml_value_to_string_vec}; +use super::views::FilterOp; + +pub(super) fn relationship_candidates(link: &str) -> Vec { + RelationshipLink::new(link).candidates() +} + +pub(super) fn evaluate_relationship_op( + op: &FilterOp, + rels: &[String], + value: &Option, +) -> bool { + let relationships = Relationships::new(rels); + match op { + FilterOp::Contains => { + relationship_target(value).is_some_and(|target| relationships.contains(&target)) + } + FilterOp::NotContains => { + relationship_target(value).map_or(true, |target| !relationships.contains(&target)) + } + FilterOp::AnyOf => relationships.matches_any(&relationship_values(value)), + FilterOp::NoneOf => !relationships.matches_any(&relationship_values(value)), + FilterOp::IsEmpty => relationships.is_empty(), + FilterOp::IsNotEmpty => !relationships.is_empty(), + FilterOp::Equals => relationship_target(value).map_or_else( + || relationships.is_empty(), + |target| relationships.equals(&target), + ), + FilterOp::NotEquals => relationship_target(value).map_or_else( + || !relationships.is_empty(), + |target| !relationships.equals(&target), + ), + _ => false, + } +} + +struct Relationships<'a> { + values: &'a [String], +} + +impl<'a> Relationships<'a> { + fn new(values: &'a [String]) -> Self { + Self { values } + } + + fn is_empty(&self) -> bool { + self.values.is_empty() + } + + fn contains(&self, target: &RelationshipTarget) -> bool { + self.values + .iter() + .any(|relationship| target.matches(RelationshipLink::new(relationship))) + } + + fn matches_any(&self, targets: &RelationshipTargets) -> bool { + self.values + .iter() + .any(|relationship| targets.matches(RelationshipLink::new(relationship))) + } + + fn equals(&self, target: &RelationshipTarget) -> bool { + self.values.len() == 1 && self.contains(target) + } +} + +struct RelationshipTarget { + value: String, +} + +impl RelationshipTarget { + fn new(value: String) -> Self { + Self { value } + } + + fn matches(&self, relationship: RelationshipLink<'_>) -> bool { + self.as_link().normalized_stem() == relationship.normalized_stem() + } + + fn as_link(&self) -> RelationshipLink<'_> { + RelationshipLink::new(&self.value) + } +} + +struct RelationshipTargets { + values: Vec, +} + +impl RelationshipTargets { + fn new(values: Vec) -> Self { + Self { values } + } + + fn matches(&self, relationship: RelationshipLink<'_>) -> bool { + let relationship_stem = relationship.normalized_stem(); + self.values + .iter() + .any(|value| value.as_link().normalized_stem() == relationship_stem) + } +} + +struct RelationshipLink<'a> { + value: &'a str, +} + +impl<'a> RelationshipLink<'a> { + fn new(value: &'a str) -> Self { + Self { value } + } + + fn candidates(&self) -> Vec { + let trimmed = self.value.trim(); + match self.inner().split_once('|') { + Some((stem, alias)) => vec![trimmed.to_string(), stem.to_string(), alias.to_string()], + None => vec![trimmed.to_string(), self.inner().to_string()], + } + } + + fn normalized_stem(&self) -> String { + self.stem().to_lowercase() + } + + fn stem(&self) -> &str { + match self.inner().split_once('|') { + Some((stem, _)) => stem, + None => self.inner(), + } + } + + fn inner(&self) -> &str { + let trimmed = self.value.trim(); + trimmed + .strip_prefix("[[") + .unwrap_or(trimmed) + .strip_suffix("]]") + .unwrap_or(trimmed) + } +} + +fn relationship_target(value: &Option) -> Option { + value + .as_ref() + .and_then(yaml_value_to_string) + .map(RelationshipTarget::new) +} + +fn relationship_values(value: &Option) -> RelationshipTargets { + let values = value + .as_ref() + .and_then(yaml_value_to_string_vec) + .unwrap_or_default() + .into_iter() + .map(RelationshipTarget::new) + .collect(); + RelationshipTargets::new(values) +} diff --git a/src-tauri/src/vault/view_tests.rs b/src-tauri/src/vault/view_tests.rs new file mode 100644 index 0000000..87e3d29 --- /dev/null +++ b/src-tauri/src/vault/view_tests.rs @@ -0,0 +1,541 @@ +use super::views::*; +use super::VaultEntry; +use std::fs; + +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry { + let mut entry = VaultEntry::default(); + overrides(&mut entry); + entry + } + + fn make_project_view(name: &str) -> ViewDefinition { + ViewDefinition { + name: name.to_string(), + icon: None, + color: None, + order: None, + sort: None, + list_properties_display: Vec::new(), + filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { + field: "type".to_string(), + op: FilterOp::Equals, + value: Some(serde_yaml::Value::String("Project".to_string())), + regex: false, + })]), + } + } + + #[test] + fn test_parse_simple_view() { + let yaml = r#" +name: Active Projects +icon: rocket +filters: + all: + - field: type + op: equals + value: Project +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(def.name, "Active Projects"); + assert_eq!(def.icon.as_deref(), Some("rocket")); + assert!(def.list_properties_display.is_empty()); + match &def.filters { + FilterGroup::All(nodes) => { + assert_eq!(nodes.len(), 1); + match &nodes[0] { + FilterNode::Condition(c) => { + assert_eq!(c.field, "type"); + } + _ => panic!("Expected condition"), + } + } + _ => panic!("Expected All group"), + } + } + + #[test] + fn test_evaluate_equals() { + let yaml = r#" +name: Projects +filters: + all: + - field: type + op: equals + value: Project +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let matching = make_entry(|e| e.is_a = Some("Project".to_string())); + let non_matching = make_entry(|e| e.is_a = Some("Note".to_string())); + let entries = vec![matching, non_matching]; + + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_evaluate_contains_relationship() { + let yaml = r#" +name: Related to Target +filters: + all: + - field: Related to + op: contains + value: "[[target]]" +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let mut rels = HashMap::new(); + rels.insert( + "Related to".to_string(), + vec!["[[target]]".to_string(), "[[other]]".to_string()], + ); + let matching = make_entry(|e| e.relationships = rels); + + let non_matching = make_entry(|_| {}); + let entries = vec![matching, non_matching]; + + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_evaluate_regex_on_scalar_field() { + let yaml = r#" +name: Regex Title +filters: + all: + - field: title + op: contains + value: "^alpha\\s+project$" + regex: true +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let matching = make_entry(|e| e.title = "Alpha Project".to_string()); + let case_matching = make_entry(|e| e.title = "alpha project".to_string()); + let non_matching = make_entry(|e| e.title = "Alpha Notes".to_string()); + let entries = vec![matching, case_matching, non_matching]; + + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0, 1]); + } + + #[test] + fn test_evaluate_regex_on_relationship_field() { + let yaml = r#" +name: Regex Relationship +filters: + all: + - field: Related to + op: contains + value: "monday-(112|113)|Monday #112" + regex: true +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let mut alias_rels = HashMap::new(); + alias_rels.insert( + "Related to".to_string(), + vec!["[[monday-112|Monday #112]]".to_string()], + ); + let alias_match = make_entry(|e| e.relationships = alias_rels); + + let mut stem_rels = HashMap::new(); + stem_rels.insert("Related to".to_string(), vec!["[[monday-113]]".to_string()]); + let stem_match = make_entry(|e| e.relationships = stem_rels); + + let mut other_rels = HashMap::new(); + other_rels.insert( + "Related to".to_string(), + vec!["[[tuesday-200|Tuesday]]".to_string()], + ); + let non_matching = make_entry(|e| e.relationships = other_rels); + + let entries = vec![alias_match, stem_match, non_matching]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0, 1]); + } + + #[test] + fn test_invalid_regex_matches_nothing() { + let yaml = r#" +name: Broken Regex +filters: + all: + - field: title + op: contains + value: "(" + regex: true +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let entries = vec![ + make_entry(|e| e.title = "Alpha Project".to_string()), + make_entry(|e| e.title = "Beta Project".to_string()), + ]; + + let result = evaluate_view(&def, &entries); + assert!(result.is_empty()); + } + + #[test] + fn test_evaluate_nested_and_or() { + let yaml = r#" +name: Complex +filters: + all: + - field: type + op: equals + value: Project + - any: + - field: status + op: equals + value: Active + - field: status + op: equals + value: Planning +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let active_project = make_entry(|e| { + e.is_a = Some("Project".to_string()); + e.status = Some("Active".to_string()); + }); + let planning_project = make_entry(|e| { + e.is_a = Some("Project".to_string()); + e.status = Some("Planning".to_string()); + }); + let done_project = make_entry(|e| { + e.is_a = Some("Project".to_string()); + e.status = Some("Done".to_string()); + }); + let active_note = make_entry(|e| { + e.is_a = Some("Note".to_string()); + e.status = Some("Active".to_string()); + }); + + let entries = vec![active_project, planning_project, done_project, active_note]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0, 1]); + } + + #[test] + fn test_evaluate_is_empty() { + let yaml_empty = r#" +name: No Status +filters: + all: + - field: status + op: is_empty +"#; + let yaml_not_empty = r#" +name: Has Status +filters: + all: + - field: status + op: is_not_empty +"#; + let def_empty: ViewDefinition = serde_yaml::from_str(yaml_empty).unwrap(); + let def_not_empty: ViewDefinition = serde_yaml::from_str(yaml_not_empty).unwrap(); + + let with_status = make_entry(|e| e.status = Some("Active".to_string())); + let without_status = make_entry(|_| {}); + let entries = vec![with_status, without_status]; + + assert_eq!(evaluate_view(&def_empty, &entries), vec![1]); + assert_eq!(evaluate_view(&def_not_empty, &entries), vec![0]); + } + + #[test] + fn test_scan_views_reads_yml_files() { + let dir = tempfile::TempDir::new().unwrap(); + let views_dir = dir.path().join("views"); + fs::create_dir_all(&views_dir).unwrap(); + + let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + let yaml_b = + "name: Beta\nfilters:\n any:\n - field: status\n op: equals\n value: Active\n"; + fs::write(views_dir.join("a-view.yml"), yaml_a).unwrap(); + fs::write(views_dir.join("b-view.yml"), yaml_b).unwrap(); + fs::write(views_dir.join("readme.txt"), "ignore me").unwrap(); + + let views = scan_views(dir.path()); + assert_eq!(views.len(), 2); + assert_eq!(views[0].filename, "a-view.yml"); + assert_eq!(views[0].definition.name, "Alpha"); + assert_eq!(views[1].filename, "b-view.yml"); + assert_eq!(views[1].definition.name, "Beta"); + } + + #[test] + fn test_scan_views_sorts_by_persisted_order_then_filename() { + let dir = tempfile::TempDir::new().unwrap(); + let views_dir = dir.path().join("views"); + fs::create_dir_all(&views_dir).unwrap(); + + let alpha = + "name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + let beta = + "name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + let gamma = + "name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + fs::write(views_dir.join("alpha.yml"), alpha).unwrap(); + fs::write(views_dir.join("beta.yml"), beta).unwrap(); + fs::write(views_dir.join("gamma.yml"), gamma).unwrap(); + + let views = scan_views(dir.path()); + + assert_eq!( + views + .iter() + .map(|view| (view.filename.as_str(), view.definition.order)) + .collect::>(), + vec![ + ("beta.yml", Some(10)), + ("alpha.yml", Some(20)), + ("gamma.yml", None), + ] + ); + } + + #[test] + fn test_migrate_views_from_old_location() { + let dir = tempfile::TempDir::new().unwrap(); + let old_dir = dir.path().join(".laputa").join("views"); + fs::create_dir_all(&old_dir).unwrap(); + + let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + fs::write(old_dir.join("test.yml"), yaml).unwrap(); + + let views = scan_views(dir.path()); + assert_eq!(views.len(), 1); + assert_eq!(views[0].definition.name, "Migrated"); + + assert!(dir.path().join("views").join("test.yml").exists()); + assert!(!old_dir.join("test.yml").exists()); + } + + #[test] + fn test_save_and_read_view() { + let dir = tempfile::TempDir::new().unwrap(); + + let mut def = make_project_view("Test View"); + def.icon = Some("star".to_string()); + def.sort = Some("modified:desc".to_string()); + def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()]; + + save_view(dir.path(), "test.yml", &def).unwrap(); + + let views = scan_views(dir.path()); + assert_eq!(views.len(), 1); + assert_eq!(views[0].definition.name, "Test View"); + assert_eq!(views[0].definition.icon.as_deref(), Some("star")); + assert_eq!( + views[0].definition.list_properties_display, + vec!["Priority".to_string(), "Owner".to_string()] + ); + + delete_view(dir.path(), "test.yml").unwrap(); + let views = scan_views(dir.path()); + assert_eq!(views.len(), 0); + } + + #[test] + fn test_delete_view_treats_missing_file_as_deleted() { + let dir = tempfile::TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("views")).unwrap(); + + delete_view(dir.path(), "missing.yml").unwrap(); + + assert!(scan_views(dir.path()).is_empty()); + } + + #[test] + fn test_delete_view_treats_missing_views_directory_as_deleted() { + let dir = tempfile::TempDir::new().unwrap(); + + delete_view(dir.path(), "missing.yml").unwrap(); + + assert!(scan_views(dir.path()).is_empty()); + } + + #[test] + fn test_save_and_read_view_with_emoji_icon() { + let dir = tempfile::TempDir::new().unwrap(); + + let mut def = make_project_view("Monday"); + def.icon = Some("🗂️".to_string()); + + save_view(dir.path(), "monday.yml", &def).unwrap(); + + let views = scan_views(dir.path()); + assert_eq!(views.len(), 1); + assert_eq!(views[0].definition.name, "Monday"); + assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️")); + } + + #[test] + fn test_wikilink_stem_matching() { + let yaml = r#" +name: Linked +filters: + all: + - field: Topics + op: contains + value: "[[target]]" +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let mut rels = HashMap::new(); + rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]); + let matching = make_entry(|e| e.relationships = rels); + + let mut rels2 = HashMap::new(); + rels2.insert("Topics".to_string(), vec!["[[other|Alias]]".to_string()]); + let non_matching = make_entry(|e| e.relationships = rels2); + + let entries = vec![matching, non_matching]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_contains_matches_scalar_array_property_element() { + let yaml = r#" +name: Tagged +filters: + all: + - field: tags + op: contains + value: blues +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let matching = make_entry(|e| { + e.properties + .insert("tags".to_string(), serde_json::json!(["blues", "chicago"])); + }); + let partial = make_entry(|e| { + e.properties.insert( + "tags".to_string(), + serde_json::json!(["bluegrass", "chicago"]), + ); + }); + + let entries = vec![matching, partial]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_body_contains_filters_on_snippet() { + let yaml = r#" +name: Body Search +filters: + all: + - field: body + op: contains + value: "quarterly" +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let matching = make_entry(|e| { + e.title = "Match".to_string(); + e.snippet = "This is the quarterly review summary".to_string(); + }); + let non_matching = make_entry(|e| { + e.title = "No match".to_string(); + e.snippet = "Daily standup notes".to_string(); + }); + let case_match = make_entry(|e| { + e.title = "Case match".to_string(); + e.snippet = "QUARTERLY PLANNING session".to_string(); + }); + + let entries = vec![matching, non_matching, case_match]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0, 2]); + } + + #[test] + fn test_body_not_contains() { + let yaml = r#" +name: Body Exclude +filters: + all: + - field: body + op: not_contains + value: "draft" +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let final_note = make_entry(|e| { + e.snippet = "Final version of the document".to_string(); + }); + let draft_note = make_entry(|e| { + e.snippet = "This is a draft version".to_string(); + }); + + let entries = vec![final_note, draft_note]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_body_combined_with_type_filter() { + let yaml = r#" +name: Combined +filters: + all: + - field: type + op: equals + value: Note + - field: body + op: contains + value: "important" +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let yes = make_entry(|e| { + e.is_a = Some("Note".to_string()); + e.snippet = "This is important content".to_string(); + }); + let wrong_type = make_entry(|e| { + e.is_a = Some("Project".to_string()); + e.snippet = "This is important content".to_string(); + }); + let no_match = make_entry(|e| { + e.is_a = Some("Note".to_string()); + e.snippet = "Regular content".to_string(); + }); + + let entries = vec![yes, wrong_type, no_match]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + + #[test] + fn test_body_is_empty() { + let yaml = r#" +name: Empty Body +filters: + all: + - field: body + op: is_empty +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let empty = make_entry(|e| e.snippet = String::new()); + let has_content = make_entry(|e| e.snippet = "Some text here".to_string()); + + let entries = vec![empty, has_content]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } +} diff --git a/src-tauri/src/vault/view_value_conversions.rs b/src-tauri/src/vault/view_value_conversions.rs new file mode 100644 index 0000000..422f077 --- /dev/null +++ b/src-tauri/src/vault/view_value_conversions.rs @@ -0,0 +1,29 @@ +pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + serde_json::Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option> { + value + .as_array() + .map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect()) +} + +pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(value) => Some(value.clone()), + serde_yaml::Value::Number(value) => Some(value.to_string()), + serde_yaml::Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +pub(super) fn yaml_value_to_string_vec(value: &serde_yaml::Value) -> Option> { + value + .as_sequence() + .map(|sequence| sequence.iter().filter_map(yaml_value_to_string).collect()) +} diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs new file mode 100644 index 0000000..d9a101f --- /dev/null +++ b/src-tauri/src/vault/views.rs @@ -0,0 +1,532 @@ +use chrono::Utc; +use regex::{Regex, RegexBuilder}; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::cmp::Ordering; +use std::fmt; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use super::view_date_filters::parse_date_filter_timestamp; +use super::view_migration::{is_view_definition_file, migrate_views}; +use super::view_relationships::{evaluate_relationship_op, relationship_candidates}; +use super::view_value_conversions::{ + json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string, + yaml_value_to_string_vec, +}; +use super::VaultEntry; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ViewDefinition { + pub name: String, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, + #[serde(default)] + pub sort: Option, + #[serde( + default, + rename = "listPropertiesDisplay", + skip_serializing_if = "Vec::is_empty" + )] + pub list_properties_display: Vec, + pub filters: FilterGroup, +} + +#[derive(Debug, Clone)] +pub enum FilterGroup { + All(Vec), + Any(Vec), +} + +impl Serialize for FilterGroup { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(1))?; + match self { + FilterGroup::All(nodes) => map.serialize_entry("all", nodes)?, + FilterGroup::Any(nodes) => map.serialize_entry("any", nodes)?, + } + map.end() + } +} + +impl<'de> Deserialize<'de> for FilterGroup { + fn deserialize>(deserializer: D) -> Result { + struct FilterGroupVisitor; + + impl<'de> Visitor<'de> for FilterGroupVisitor { + type Value = FilterGroup; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a map with key 'all' or 'any'") + } + + fn visit_map>(self, mut map: M) -> Result { + let key: String = map + .next_key()? + .ok_or_else(|| de::Error::custom("expected 'all' or 'any' key"))?; + match key.as_str() { + "all" => { + let nodes: Vec = map.next_value()?; + Ok(FilterGroup::All(nodes)) + } + "any" => { + let nodes: Vec = map.next_value()?; + Ok(FilterGroup::Any(nodes)) + } + other => Err(de::Error::unknown_field(other, &["all", "any"])), + } + } + } + + deserializer.deserialize_map(FilterGroupVisitor) + } +} + +#[derive(Debug, Clone)] +pub enum FilterNode { + Condition(FilterCondition), + Group(FilterGroup), +} + +impl Serialize for FilterNode { + fn serialize(&self, serializer: S) -> Result { + match self { + FilterNode::Condition(c) => c.serialize(serializer), + FilterNode::Group(g) => g.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for FilterNode { + fn deserialize>(deserializer: D) -> Result { + // Deserialize into a generic YAML value, then try group first, then condition + let value = serde_yaml::Value::deserialize(deserializer)?; + if let serde_yaml::Value::Mapping(ref m) = value { + // If the map has an "all" or "any" key, it's a group + let all_key = serde_yaml::Value::String("all".to_string()); + let any_key = serde_yaml::Value::String("any".to_string()); + if m.contains_key(&all_key) || m.contains_key(&any_key) { + let group: FilterGroup = + serde_yaml::from_value(value).map_err(de::Error::custom)?; + return Ok(FilterNode::Group(group)); + } + } + let cond: FilterCondition = serde_yaml::from_value(value).map_err(de::Error::custom)?; + Ok(FilterNode::Condition(cond)) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FilterCondition { + pub field: String, + pub op: FilterOp, + #[serde(default)] + pub value: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub regex: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum FilterOp { + #[serde(rename = "equals")] + Equals, + #[serde(rename = "not_equals")] + NotEquals, + #[serde(rename = "contains")] + Contains, + #[serde(rename = "not_contains")] + NotContains, + #[serde(rename = "any_of")] + AnyOf, + #[serde(rename = "none_of")] + NoneOf, + #[serde(rename = "is_empty")] + IsEmpty, + #[serde(rename = "is_not_empty")] + IsNotEmpty, + #[serde(rename = "before")] + Before, + #[serde(rename = "after")] + After, +} + +/// A view file on disk: filename + parsed definition. +#[derive(Debug, Serialize, Clone)] +pub struct ViewFile { + pub filename: String, + pub definition: ViewDefinition, +} + +fn read_view_file(path: &Path) -> Option { + if !is_view_definition_file(path) { + return None; + } + + let filename = path.file_name()?.to_string_lossy().to_string(); + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + log::warn!("Failed to read view file {}: {}", filename, error); + return None; + } + }; + let definition = match serde_yaml::from_str::(&content) { + Ok(definition) => definition, + Err(error) => { + log::warn!("Failed to parse view {}: {}", filename, error); + return None; + } + }; + + Some(ViewFile { + filename, + definition, + }) +} + +pub fn scan_views(vault_path: &Path) -> Vec { + migrate_views(vault_path); + let views_dir = vault_path.join("views"); + if !views_dir.is_dir() { + return Vec::new(); + } + + let mut views = Vec::new(); + let entries = match fs::read_dir(&views_dir) { + Ok(e) => e, + Err(e) => { + log::warn!("Failed to read views directory: {}", e); + return Vec::new(); + } + }; + + for entry in entries.flatten() { + if let Some(view) = read_view_file(&entry.path()) { + views.push(view); + } + } + + views.sort_by(compare_views); + views +} + +fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering { + let order = left + .definition + .order + .unwrap_or(i64::MAX) + .cmp(&right.definition.order.unwrap_or(i64::MAX)); + order.then_with(|| left.filename.cmp(&right.filename)) +} + +/// Save a view definition as YAML to `vault_path/views/{filename}`. +pub fn save_view( + vault_path: &Path, + filename: &str, + definition: &ViewDefinition, +) -> Result<(), String> { + if !filename.ends_with(".yml") { + return Err("Filename must end with .yml".to_string()); + } + let views_dir = vault_path.join("views"); + fs::create_dir_all(&views_dir) + .map_err(|e| format!("Failed to create views directory: {}", e))?; + let yaml = serde_yaml::to_string(definition) + .map_err(|e| format!("Failed to serialize view: {}", e))?; + fs::write(views_dir.join(filename), yaml) + .map_err(|e| format!("Failed to write view file: {}", e)) +} + +/// Delete a view file at `vault_path/views/{filename}`. +pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> { + let path = vault_path.join("views").join(filename); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to delete view: {}", error)), + } +} + +/// Evaluate a view definition against vault entries, returning indices of matching entries. +pub fn evaluate_view(definition: &ViewDefinition, entries: &[VaultEntry]) -> Vec { + entries + .iter() + .enumerate() + .filter(|(_, entry)| evaluate_group(&definition.filters, entry)) + .map(|(i, _)| i) + .collect() +} + +fn evaluate_group(group: &FilterGroup, entry: &VaultEntry) -> bool { + match group { + FilterGroup::All(nodes) => nodes.iter().all(|n| evaluate_node(n, entry)), + FilterGroup::Any(nodes) => nodes.iter().any(|n| evaluate_node(n, entry)), + } +} + +fn evaluate_node(node: &FilterNode, entry: &VaultEntry) -> bool { + match node { + FilterNode::Condition(cond) => evaluate_condition(cond, entry), + FilterNode::Group(group) => evaluate_group(group, entry), + } +} + +fn build_regex(pattern: &str) -> Option { + RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + .ok() +} + +fn supports_regex(op: &FilterOp) -> bool { + matches!( + op, + FilterOp::Contains | FilterOp::Equals | FilterOp::NotContains | FilterOp::NotEquals + ) +} + +enum ConditionField<'a> { + Scalar(Option), + PropertyArray(Vec), + Relationship(&'a [String]), +} + +fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool { + let field = cond.field.as_str(); + if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) { + return result; + } + + let field_value = resolve_condition_field(field, entry); + let cond_value = cond.value.as_ref().and_then(yaml_value_to_string); + let regex = condition_regex(cond, cond_value.as_deref()); + + if invalid_regex_requested(cond, regex.as_ref()) { + return false; + } + + if let Some(re) = regex.as_ref() { + return evaluate_regex_condition(&cond.op, &field_value, re); + } + + match field_value { + ConditionField::PropertyArray(values) => { + evaluate_property_array_op(&cond.op, &values, &cond.value) + } + ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value), + ConditionField::Scalar(value) => evaluate_scalar_op( + &cond.op, + value.as_deref(), + cond_value.as_deref(), + &cond.value, + ), + } +} + +fn evaluate_condition_bool_field( + field: &str, + entry: &VaultEntry, + op: &FilterOp, + value: &Option, +) -> Option { + match field { + "archived" => Some(evaluate_bool_field(entry.archived, op, value)), + "favorite" => Some(evaluate_bool_field(entry.favorite, op, value)), + _ => None, + } +} + +fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> { + match field { + "type" | "isA" => ConditionField::Scalar(entry.is_a.clone()), + "status" => ConditionField::Scalar(entry.status.clone()), + "title" => ConditionField::Scalar(Some(entry.title.clone())), + "body" => ConditionField::Scalar(Some(entry.snippet.clone())), + _ => resolve_dynamic_condition_field(field, entry), + } +} + +fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> { + if let Some(prop) = entry.properties.get(field) { + if let Some(values) = json_scalar_array_to_strings(prop) { + return ConditionField::PropertyArray(values); + } + return ConditionField::Scalar(json_scalar_to_string(prop)); + } + if let Some(relationships) = entry.relationships.get(field) { + return ConditionField::Relationship(relationships); + } + ConditionField::Scalar(None) +} + +fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option { + if !cond.regex { + return None; + } + if !supports_regex(&cond.op) { + return None; + } + cond_value.and_then(build_regex) +} + +fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> bool { + if !cond.regex { + return false; + } + if !supports_regex(&cond.op) { + return false; + } + regex.is_none() +} + +fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool { + let matched = match field { + ConditionField::Scalar(Some(value)) => regex.is_match(value), + ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)), + ConditionField::Relationship(values) => values.iter().any(|item| { + relationship_candidates(item) + .into_iter() + .any(|candidate| regex.is_match(&candidate)) + }), + ConditionField::Scalar(None) => false, + }; + + match op { + FilterOp::Contains | FilterOp::Equals => matched, + FilterOp::NotContains | FilterOp::NotEquals => !matched, + _ => false, + } +} + +fn evaluate_property_array_op( + op: &FilterOp, + values: &[String], + raw_value: &Option, +) -> bool { + match op { + FilterOp::Contains => property_array_matches_value(values, raw_value), + FilterOp::NotContains => !property_array_matches_value(values, raw_value), + FilterOp::AnyOf => property_array_matches_any(values, raw_value), + FilterOp::NoneOf => !property_array_matches_any(values, raw_value), + FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value), + FilterOp::NotEquals => { + !(values.len() == 1 && property_array_matches_value(values, raw_value)) + } + FilterOp::IsEmpty => values.is_empty(), + FilterOp::IsNotEmpty => !values.is_empty(), + _ => false, + } +} + +fn property_array_matches_value(values: &[String], raw_value: &Option) -> bool { + raw_value + .as_ref() + .and_then(yaml_value_to_string) + .is_some_and(|target| property_array_contains(values, &target)) +} + +fn property_array_matches_any(values: &[String], raw_value: &Option) -> bool { + raw_value + .as_ref() + .and_then(yaml_value_to_string_vec) + .unwrap_or_default() + .iter() + .any(|target| property_array_contains(values, target)) +} + +fn property_array_contains(values: &[String], target: &str) -> bool { + values + .iter() + .any(|value| value.eq_ignore_ascii_case(target)) +} + +fn evaluate_scalar_op( + op: &FilterOp, + field_value: Option<&str>, + cond_value: Option<&str>, + raw_value: &Option, +) -> bool { + match op { + FilterOp::Equals => scalar_equals(field_value, cond_value), + FilterOp::NotEquals => !scalar_equals(field_value, cond_value), + FilterOp::Contains => scalar_contains(field_value, cond_value), + FilterOp::NotContains => !scalar_contains(field_value, cond_value), + FilterOp::AnyOf => scalar_matches_any(field_value, raw_value), + FilterOp::NoneOf => !scalar_matches_any(field_value, raw_value), + FilterOp::IsEmpty => field_value.map_or(true, str::is_empty), + FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()), + FilterOp::Before => { + scalar_date_compare(field_value, cond_value, |field, target| field < target) + } + FilterOp::After => { + scalar_date_compare(field_value, cond_value, |field, target| field > target) + } + } +} + +fn scalar_equals(field_value: Option<&str>, cond_value: Option<&str>) -> bool { + match (field_value, cond_value) { + (Some(field), Some(value)) => field.eq_ignore_ascii_case(value), + (None, None) => true, + _ => false, + } +} + +fn scalar_contains(field_value: Option<&str>, cond_value: Option<&str>) -> bool { + match (field_value, cond_value) { + (Some(field), Some(value)) => field.to_lowercase().contains(&value.to_lowercase()), + _ => false, + } +} + +fn scalar_matches_any(field_value: Option<&str>, raw_value: &Option) -> bool { + let Some(field) = field_value else { + return false; + }; + raw_value + .as_ref() + .and_then(yaml_value_to_string_vec) + .unwrap_or_default() + .iter() + .any(|value| field.eq_ignore_ascii_case(value)) +} + +fn scalar_date_compare( + field_value: Option<&str>, + cond_value: Option<&str>, + predicate: impl FnOnce(i64, i64) -> bool, +) -> bool { + let (Some(field), Some(value)) = (field_value, cond_value) else { + return false; + }; + let reference = Utc::now(); + match ( + parse_date_filter_timestamp(field, reference), + parse_date_filter_timestamp(value, reference), + ) { + (Some(field_ts), Some(target_ts)) => predicate(field_ts, target_ts), + _ => false, + } +} + +fn evaluate_bool_field(field_val: bool, op: &FilterOp, value: &Option) -> bool { + match op { + FilterOp::Equals => { + let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true); + field_val == expected + } + FilterOp::NotEquals => { + let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true); + field_val != expected + } + FilterOp::IsEmpty => !field_val, + FilterOp::IsNotEmpty => field_val, + _ => false, + } +} diff --git a/src-tauri/src/vault_list.rs b/src-tauri/src/vault_list.rs new file mode 100644 index 0000000..0e5480f --- /dev/null +++ b/src-tauri/src/vault_list.rs @@ -0,0 +1,275 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::app_config::{preferred_app_config_path, resolve_existing_or_preferred_app_config_path}; +use crate::commands::expand_tilde; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct VaultEntry { + pub label: String, + pub path: String, + #[serde(default)] + pub alias: Option, + #[serde(default)] + #[serde(rename = "shortLabel")] + pub short_label: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub mounted: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VaultList { + pub vaults: Vec, + pub active_vault: Option, + #[serde(default)] + pub default_workspace_path: Option, + #[serde(default)] + pub hidden_defaults: Vec, +} + +fn vault_list_path() -> Result { + resolve_existing_or_preferred_app_config_path("vaults.json") +} + +fn load_at(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(VaultList::default()); + } + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?; + serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e)) +} + +fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + let json = serde_json::to_string_pretty(list) + .map_err(|e| format!("Failed to serialize vault list: {}", e))?; + fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e)) +} + +fn expand_optional_tilde_path(path: Option) -> Option { + path.map(|value| expand_tilde(&value).into_owned()) +} + +fn expand_vault_list_paths(mut list: VaultList) -> VaultList { + for vault in &mut list.vaults { + vault.path = expand_tilde(&vault.path).into_owned(); + } + list.active_vault = expand_optional_tilde_path(list.active_vault); + list.default_workspace_path = expand_optional_tilde_path(list.default_workspace_path); + list.hidden_defaults = list + .hidden_defaults + .into_iter() + .map(|path| expand_tilde(&path).into_owned()) + .collect(); + list +} + +pub fn load_vault_list() -> Result { + load_at(&vault_list_path()?).map(expand_vault_list_paths) +} + +pub fn save_vault_list(list: &VaultList) -> Result<(), String> { + save_at(&preferred_app_config_path("vaults.json")?, list) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn save_and_reload(list: &VaultList) -> VaultList { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("vaults.json"); + save_at(&path, list).unwrap(); + load_at(&path).unwrap() + } + + #[test] + fn default_vault_list_is_empty() { + let vl = VaultList::default(); + assert!(vl.vaults.is_empty()); + assert!(vl.active_vault.is_none()); + } + + #[test] + fn roundtrip_preserves_data() { + let list = VaultList { + vaults: vec![ + VaultEntry { + label: "My Vault".to_string(), + path: "/Users/luca/Laputa".to_string(), + ..Default::default() + }, + VaultEntry { + label: "Work".to_string(), + path: "/Users/luca/Work".to_string(), + ..Default::default() + }, + ], + active_vault: Some("/Users/luca/Laputa".to_string()), + default_workspace_path: None, + hidden_defaults: vec![], + }; + let loaded = save_and_reload(&list); + assert_eq!(loaded.vaults.len(), 2); + assert_eq!(loaded.vaults[0].label, "My Vault"); + assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa"); + assert_eq!(loaded.vaults[1].label, "Work"); + assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa")); + } + + #[test] + fn load_returns_default_for_missing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + let result = load_at(&path).unwrap(); + assert!(result.vaults.is_empty()); + assert!(result.active_vault.is_none()); + } + + #[test] + fn load_returns_error_for_malformed_json() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("bad.json"); + fs::write(&path, "not valid json{{{").unwrap(); + let err = load_at(&path).unwrap_err(); + assert!(err.contains("Failed to parse vault list")); + } + + #[test] + fn save_creates_parent_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("dir").join("vaults.json"); + let list = VaultList { + vaults: vec![VaultEntry { + label: "Test".to_string(), + path: "/tmp/test".to_string(), + ..Default::default() + }], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec![], + }; + save_at(&path, &list).unwrap(); + assert!(path.exists()); + let loaded = load_at(&path).unwrap(); + assert_eq!(loaded.vaults.len(), 1); + } + + #[test] + fn vault_list_path_returns_ok() { + let result = vault_list_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + let path = path.to_str().unwrap(); + assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app")); + } + + #[test] + fn preferred_vault_list_path_uses_tolaria_namespace() { + let result = preferred_app_config_path("vaults.json"); + assert!(result.is_ok()); + assert!(result + .unwrap() + .to_str() + .unwrap() + .contains("com.tolaria.app")); + } + + #[test] + fn empty_vault_list_roundtrip() { + let list = VaultList::default(); + let loaded = save_and_reload(&list); + assert!(loaded.vaults.is_empty()); + assert!(loaded.active_vault.is_none()); + assert!(loaded.hidden_defaults.is_empty()); + } + + #[test] + fn hidden_defaults_roundtrip() { + let list = VaultList { + vaults: vec![], + active_vault: None, + default_workspace_path: None, + hidden_defaults: vec!["/Users/luca/Documents/Getting Started".to_string()], + }; + let loaded = save_and_reload(&list); + assert_eq!(loaded.hidden_defaults.len(), 1); + assert_eq!( + loaded.hidden_defaults[0], + "/Users/luca/Documents/Getting Started" + ); + } + + #[test] + fn workspace_metadata_roundtrip() { + let list = VaultList { + vaults: vec![VaultEntry { + label: "Team Notes".to_string(), + path: "/tmp/team".to_string(), + alias: Some("team".to_string()), + short_label: Some("TN".to_string()), + color: Some("green".to_string()), + icon: Some("briefcase".to_string()), + mounted: Some(false), + }], + active_vault: Some("/tmp/personal".to_string()), + default_workspace_path: Some("/tmp/team".to_string()), + hidden_defaults: vec![], + }; + + let loaded = save_and_reload(&list); + + assert_eq!(loaded.default_workspace_path.as_deref(), Some("/tmp/team")); + assert_eq!(loaded.vaults[0].alias.as_deref(), Some("team")); + assert_eq!(loaded.vaults[0].short_label.as_deref(), Some("TN")); + assert_eq!(loaded.vaults[0].color.as_deref(), Some("green")); + assert_eq!(loaded.vaults[0].icon.as_deref(), Some("briefcase")); + assert_eq!(loaded.vaults[0].mounted, Some(false)); + } + + #[test] + fn loaded_vault_list_expands_tilde_paths() { + let home = dirs::home_dir().unwrap(); + let expected_vault = home.join("Workspace/refactoring-vault"); + let expected_hidden = home.join("Workspace/tolaria/demo-vault-v2"); + let list = VaultList { + vaults: vec![VaultEntry { + label: "Refactoring".to_string(), + path: "~/Workspace/refactoring-vault".to_string(), + ..Default::default() + }], + active_vault: Some("~/Workspace/refactoring-vault".to_string()), + default_workspace_path: Some("~/Workspace/refactoring-vault".to_string()), + hidden_defaults: vec!["~/Workspace/tolaria/demo-vault-v2".to_string()], + }; + + let loaded = expand_vault_list_paths(list); + + assert_eq!(loaded.vaults[0].path, expected_vault.to_string_lossy()); + assert_eq!(loaded.active_vault.as_deref(), expected_vault.to_str()); + assert_eq!( + loaded.default_workspace_path.as_deref(), + expected_vault.to_str() + ); + assert_eq!(loaded.hidden_defaults[0], expected_hidden.to_string_lossy()); + } + + #[test] + fn load_legacy_format_without_hidden_defaults() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("legacy.json"); + // Simulate old format without hidden_defaults field + fs::write(&path, r#"{"vaults":[],"active_vault":null}"#).unwrap(); + let loaded = load_at(&path).unwrap(); + assert!(loaded.hidden_defaults.is_empty()); + } +} diff --git a/src-tauri/src/vault_watcher.rs b/src-tauri/src/vault_watcher.rs new file mode 100644 index 0000000..156e7a8 --- /dev/null +++ b/src-tauri/src/vault_watcher.rs @@ -0,0 +1,445 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +pub const VAULT_CHANGED_EVENT: &str = "vault-changed"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct VaultChangedPayload { + vault_path: String, + paths: Vec, +} + +fn has_ignored_component(path: &Path) -> bool { + path.components().any(|part| { + let component = part.as_os_str(); + component == OsStr::new(".git") || component == OsStr::new("node_modules") + }) +} + +fn is_temp_file_name(name: &OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + is_exact_temp_file_name(name) || has_temp_file_prefix(name) || has_temp_file_suffix(name) +} + +fn is_exact_temp_file_name(name: &str) -> bool { + [".DS_Store", ".tolaria-rename-txn"].contains(&name) +} + +fn has_temp_file_prefix(name: &str) -> bool { + [".#", ".gitstatus."] + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +fn has_temp_file_suffix(name: &str) -> bool { + ["~", ".tmp", ".swp", ".swx", ".icloud"] + .iter() + .any(|suffix| name.ends_with(suffix)) +} + +/// Resolve the real git directory for `vault_path`. Handles three cases: +/// - regular `.git/` directory +/// - `.git` symlink (e.g. the iCloud `.git -> .git.nosync` workaround) +/// - `.git` file containing `gitdir: ` (worktrees, submodules) +fn resolve_git_dir(vault_path: &Path) -> Option { + let git_path = vault_path.join(".git"); + if let Ok(target) = std::fs::read_link(&git_path) { + let resolved = if target.is_absolute() { + target + } else { + vault_path.join(target) + }; + return Some(resolved); + } + if git_path.is_dir() { + return Some(git_path); + } + let content = std::fs::read_to_string(&git_path).ok()?; + let rest = content.lines().next()?.strip_prefix("gitdir:")?.trim(); + let target = Path::new(rest); + Some(if target.is_absolute() { + target.to_path_buf() + } else { + vault_path.join(target) + }) +} + +fn is_watchable_path(path: &Path, git_dir: Option<&Path>) -> bool { + if has_ignored_component(path) { + return false; + } + if let Some(git_dir) = git_dir { + if path.starts_with(git_dir) { + return false; + } + } + match path.file_name() { + Some(name) => !is_temp_file_name(name), + None => true, + } +} + +#[cfg(desktop)] +mod desktop { + use std::sync::Mutex; + + use notify::{ + recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, + }; + use tauri::Emitter; + + use super::{ + is_watchable_path, resolve_git_dir, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT, + }; + + struct ActiveVaultWatcher { + path: PathBuf, + _watcher: RecommendedWatcher, + } + + pub struct VaultWatcherState { + active: Mutex>, + } + + impl Default for VaultWatcherState { + fn default() -> Self { + Self::new() + } + } + + impl VaultWatcherState { + pub fn new() -> Self { + Self { + active: Mutex::new(Vec::new()), + } + } + } + + fn validate_vault_path(vault_path: PathBuf) -> Result { + if vault_path.as_os_str().is_empty() { + return Err("Vault path is required".to_string()); + } + if !vault_path.is_dir() { + return Err(format!( + "Vault path is not a directory: {}", + vault_path.display() + )); + } + Ok(vault_path) + } + + fn should_emit_event(event: &Event) -> bool { + !matches!(event.kind, EventKind::Access(_)) + } + + fn changed_paths(event: Event, git_dir: Option<&Path>) -> Vec { + if !should_emit_event(&event) { + return Vec::new(); + } + event + .paths + .into_iter() + .filter(|path| is_watchable_path(path, git_dir)) + .map(|path| path.to_string_lossy().to_string()) + .collect() + } + + fn emit_vault_change( + app: &tauri::AppHandle, + vault_path: &Path, + git_dir: Option<&Path>, + event: Event, + ) { + let paths = changed_paths(event, git_dir); + if paths.is_empty() { + return; + } + let payload = VaultChangedPayload { + vault_path: vault_path.to_string_lossy().to_string(), + paths, + }; + if let Err(err) = app.emit(VAULT_CHANGED_EVENT, payload) { + log::warn!("Failed to emit vault watcher event: {}", err); + } + } + + pub fn start( + app: tauri::AppHandle, + state: tauri::State<'_, VaultWatcherState>, + path: PathBuf, + ) -> Result<(), String> { + let vault_path = validate_vault_path(path)?; + let mut active = state + .active + .lock() + .map_err(|_| "Failed to lock vault watcher state".to_string())?; + if active.iter().any(|watcher| watcher.path == vault_path) { + return Ok(()); + } + + let event_vault_path = vault_path.clone(); + let event_git_dir = resolve_git_dir(&vault_path); + let event_app = app.clone(); + let mut watcher = recommended_watcher(move |event| match event { + Ok(event) => emit_vault_change( + &event_app, + &event_vault_path, + event_git_dir.as_deref(), + event, + ), + Err(err) => log::warn!("Vault watcher event failed: {}", err), + }) + .map_err(|err| format!("Failed to create vault watcher: {err}"))?; + watcher + .watch(&vault_path, RecursiveMode::Recursive) + .map_err(|err| format!("Failed to watch {}: {err}", vault_path.display()))?; + + active.push(ActiveVaultWatcher { + path: vault_path, + _watcher: watcher, + }); + Ok(()) + } + + pub fn stop(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> { + let mut active = state + .active + .lock() + .map_err(|_| "Failed to lock vault watcher state".to_string())?; + active.clear(); + Ok(()) + } + + #[cfg(test)] + mod tests { + use notify::event::{AccessKind, CreateKind, EventAttributes}; + use notify::{Event, EventKind}; + + use super::*; + + fn event(kind: EventKind, paths: &[&str]) -> Event { + Event { + kind, + paths: paths.iter().map(PathBuf::from).collect(), + attrs: EventAttributes::default(), + } + } + + #[test] + fn validate_vault_path_accepts_existing_directories_only() { + let dir = tempfile::TempDir::new().unwrap(); + + assert_eq!( + validate_vault_path(dir.path().to_path_buf()).unwrap(), + dir.path() + ); + assert_eq!( + validate_vault_path(PathBuf::new()).unwrap_err(), + "Vault path is required" + ); + assert!(validate_vault_path(dir.path().join("missing")) + .unwrap_err() + .contains("Vault path is not a directory")); + } + + #[test] + fn changed_paths_ignores_access_events() { + let paths = changed_paths( + event(EventKind::Access(AccessKind::Read), &["notes/today.md"]), + None, + ); + + assert!(paths.is_empty()); + } + + #[test] + fn changed_paths_filters_unwatchable_paths() { + let paths = changed_paths( + event( + EventKind::Create(CreateKind::File), + &[ + ".git/index.lock", + "node_modules/pkg/index.js", + "notes/today.md", + ], + ), + None, + ); + + assert_eq!(paths, vec!["notes/today.md"]); + } + + #[test] + fn changed_paths_filters_editor_temporary_files() { + let paths = changed_paths( + event( + EventKind::Create(CreateKind::File), + &[ + ".DS_Store", + ".tolaria-rename-txn", + ".#draft.md", + "draft.md~", + "draft.tmp", + "draft.swp", + "draft.swx", + "notes/keep.md", + ], + ), + None, + ); + + assert_eq!(paths, vec!["notes/keep.md"]); + } + } +} + +#[cfg(not(desktop))] +mod mobile { + use super::PathBuf; + + pub struct VaultWatcherState; + + impl Default for VaultWatcherState { + fn default() -> Self { + Self::new() + } + } + + impl VaultWatcherState { + pub fn new() -> Self { + Self + } + } + + pub fn start(_path: PathBuf) -> Result<(), String> { + Ok(()) + } + + pub fn stop() -> Result<(), String> { + Ok(()) + } +} + +#[cfg(desktop)] +pub use desktop::VaultWatcherState; +#[cfg(not(desktop))] +pub use mobile::VaultWatcherState; + +#[cfg(desktop)] +#[tauri::command] +pub fn start_vault_watcher( + app: tauri::AppHandle, + state: tauri::State<'_, VaultWatcherState>, + path: PathBuf, +) -> Result<(), String> { + desktop::start(app, state, path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn stop_vault_watcher(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> { + desktop::stop(state) +} + +#[cfg(not(desktop))] +#[tauri::command] +pub fn start_vault_watcher(path: PathBuf) -> Result<(), String> { + mobile::start(path) +} + +#[cfg(not(desktop))] +#[tauri::command] +pub fn stop_vault_watcher() -> Result<(), String> { + mobile::stop() +} + +#[cfg(test)] +mod tests { + use super::{is_watchable_path, resolve_git_dir}; + use std::path::{Path, PathBuf}; + + #[test] + fn ignores_git_and_dependency_directory_changes() { + assert!(!is_watchable_path(Path::new(".git/index.lock"), None)); + assert!(!is_watchable_path( + Path::new("node_modules/package/index.js"), + None + )); + } + + #[test] + fn ignores_common_temporary_files() { + assert!(!is_watchable_path(Path::new("note.md.tmp"), None)); + assert!(!is_watchable_path(Path::new("note.md.swp"), None)); + assert!(!is_watchable_path(Path::new("draft.md~"), None)); + assert!(!is_watchable_path(Path::new(".DS_Store"), None)); + assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"), None)); + assert!(!is_watchable_path(Path::new(".gitstatus.KASSUJ"), None)); + assert!(!is_watchable_path(Path::new("notes/draft.md.icloud"), None)); + } + + #[test] + fn keeps_notes_assets_and_saved_views_watchable() { + assert!(is_watchable_path(Path::new("notes/day.md"), None)); + assert!(is_watchable_path(Path::new("attachments/image.png"), None)); + assert!(is_watchable_path(Path::new(".laputa/views/work.yml"), None)); + } + + #[test] + fn ignores_paths_inside_resolved_git_dir() { + // .git -> .git.nosync symlink trick used for iCloud/Dropbox vaults + let git_dir = PathBuf::from("/vault/.git.nosync"); + assert!(!is_watchable_path( + Path::new("/vault/.git.nosync/index.lock"), + Some(&git_dir) + )); + assert!(!is_watchable_path( + Path::new("/vault/.git.nosync/refs/remotes/origin/HEAD"), + Some(&git_dir) + )); + assert!(is_watchable_path( + Path::new("/vault/notes/day.md"), + Some(&git_dir) + )); + } + + #[test] + fn resolves_real_git_dir_through_symlink() { + let dir = tempfile::tempdir().unwrap(); + let real_git = dir.path().join(".git.nosync"); + std::fs::create_dir(&real_git).unwrap(); + std::os::unix::fs::symlink(".git.nosync", dir.path().join(".git")).unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join(".git.nosync")); + } + + #[test] + fn resolves_real_git_dir_for_regular_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join(".git")); + } + + #[test] + fn resolves_real_git_dir_for_worktree_pointer_file() { + let dir = tempfile::tempdir().unwrap(); + let worktree_target = dir.path().join("main/.git/worktrees/foo"); + std::fs::create_dir_all(&worktree_target).unwrap(); + std::fs::write( + dir.path().join(".git"), + format!("gitdir: {}\n", worktree_target.display()), + ) + .unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, worktree_target); + } +} diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs new file mode 100644 index 0000000..74548a5 --- /dev/null +++ b/src-tauri/src/window_state.rs @@ -0,0 +1,587 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{ + App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow, + WindowEvent, +}; + +pub(crate) const MAIN_WINDOW_LABEL: &str = "main"; +const WINDOW_STATE_FILE: &str = "window-state.json"; +const MIN_WINDOW_WIDTH: u32 = 480; +const MIN_WINDOW_HEIGHT: u32 = 400; + +#[derive(Debug, Default)] +pub(crate) struct MainWindowFrameState(Mutex>); + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +struct WindowFrame { + x: i32, + y: i32, + width: u32, + height: u32, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +struct ScreenArea { + x: i32, + y: i32, + width: u32, + height: u32, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct PersistedWindowState { + main: Option, + #[serde(default)] + coordinate_space: WindowFrameCoordinateSpace, +} + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WindowFrameCoordinateSpace { + #[default] + Physical, + Logical, +} + +pub(crate) fn restore_main_window_state(app: &mut App) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + restore_main_window_frame(app.handle(), &window, "during setup"); +} + +pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) { + match event { + event if restores_window_frame_after_runtime_ready(event) => { + restore_main_window_state_from_handle(app_handle) + } + RunEvent::WindowEvent { + label, + event: + WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. }, + .. + } if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle), + RunEvent::WindowEvent { label, event, .. } + if label == MAIN_WINDOW_LABEL && saves_live_main_window_frame_on_event(event) => + { + save_main_window_frame(app_handle) + } + RunEvent::WindowEvent { + label, + event: WindowEvent::Destroyed, + .. + } if label == MAIN_WINDOW_LABEL => save_cached_main_window_frame(app_handle), + RunEvent::Exit => save_cached_main_window_frame(app_handle), + _ => {} + } +} + +fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool { + matches!(event, RunEvent::Ready) +} + +fn saves_live_main_window_frame_on_event(event: &WindowEvent) -> bool { + matches!(event, WindowEvent::CloseRequested { .. }) +} + +fn restore_main_window_state_from_handle(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + restore_main_window_frame(app_handle, &window, "after runtime ready"); +} + +fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) { + let Some(frame) = read_main_window_frame(window_scale_factor(window)) else { + return; + }; + let areas = current_screen_areas(window); + let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else { + return; + }; + + if let Err(err) = apply_window_frame(window, restored_frame) { + log::warn!("Failed to restore main window state {phase}: {err}"); + return; + } + + cache_frame(app_handle, restored_frame); +} + +fn cache_current_normal_frame(app_handle: &AppHandle) { + if let Some(frame) = current_normal_main_window_frame(app_handle) { + cache_frame(app_handle, frame); + } +} + +fn save_main_window_frame(app_handle: &AppHandle) { + let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle)); + write_main_window_frame_if_available(frame); +} + +fn save_cached_main_window_frame(app_handle: &AppHandle) { + write_main_window_frame_if_available(cached_frame(app_handle)); +} + +fn write_main_window_frame_if_available(frame: Option) { + if let Some(frame) = frame { + if let Err(err) = write_main_window_frame(frame) { + log::warn!("Failed to save main window state: {err}"); + } + } +} + +fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option { + let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?; + if !is_normal_window(&window) { + return None; + } + read_window_frame(&window).filter(is_valid_saved_frame) +} + +fn is_normal_window(window: &WebviewWindow) -> bool { + let is_fullscreen = window.is_fullscreen().unwrap_or(false); + let is_maximized = window.is_maximized().unwrap_or(false); + let is_minimized = window.is_minimized().unwrap_or(false); + !is_fullscreen && !is_maximized && !is_minimized +} + +fn read_window_frame(window: &WebviewWindow) -> Option { + let scale_factor = window_scale_factor(window); + let position = window.outer_position().ok()?; + let size = window.inner_size().ok()?; + Some(WindowFrame::from_logical_geometry( + position.to_logical::(scale_factor), + size.to_logical::(scale_factor), + )) +} + +fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> { + window.set_size(Size::Logical(LogicalSize::new( + frame.width as f64, + frame.height as f64, + )))?; + window.set_position(Position::Logical(LogicalPosition::new( + frame.x as f64, + frame.y as f64, + ))) +} + +fn current_screen_areas(window: &WebviewWindow) -> Vec { + let scale_factor = window_scale_factor(window); + window + .available_monitors() + .unwrap_or_default() + .into_iter() + .map(|monitor| { + let area = monitor.work_area(); + let position = area.position.to_logical::(scale_factor); + let size = area.size.to_logical::(scale_factor); + ScreenArea { + x: rounded_i32(position.x), + y: rounded_i32(position.y), + width: rounded_u32(size.width), + height: rounded_u32(size.height), + } + }) + .filter(ScreenArea::has_area) + .collect() +} + +fn window_scale_factor(window: &WebviewWindow) -> f64 { + window.scale_factor().unwrap_or(1.0).max(1.0) +} + +fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option { + if frame_is_visible(frame, screens) { + return Some(frame); + } + + let screen = best_screen_for_frame(frame, screens)?; + let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width); + let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height); + Some(WindowFrame { + x: clamp_axis(frame.x, width, screen.x, screen.width), + y: clamp_axis(frame.y, height, screen.y, screen.height), + width, + height, + }) +} + +fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool { + frame_corners(frame) + .into_iter() + .all(|point| screens.iter().any(|screen| screen.contains(point))) +} + +fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] { + let right = frame.right() - 1; + let bottom = frame.bottom() - 1; + [ + (frame.x, frame.y), + (right, frame.y), + (frame.x, bottom), + (right, bottom), + ] +} + +fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option { + screens + .iter() + .copied() + .filter(ScreenArea::has_area) + .max_by_key(|screen| intersection_area(frame, *screen)) +} + +fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 { + let left = frame.x.max(screen.x); + let top = frame.y.max(screen.y); + let right = frame.right().min(screen.right()); + let bottom = frame.bottom().min(screen.bottom()); + if right <= left || bottom <= top { + return 0; + } + (right - left) as u64 * (bottom - top) as u64 +} + +fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 { + if max < min { + max + } else { + value.clamp(min, max) + } +} + +fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 { + let max_start = area_start + area_size as i32 - size as i32; + if max_start < area_start { + return area_start; + } + value.clamp(area_start, max_start) +} + +fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) { + let state: tauri::State<'_, MainWindowFrameState> = app_handle.state(); + if let Ok(mut cached_frame) = state.0.lock() { + *cached_frame = Some(frame); + }; +} + +fn cached_frame(app_handle: &AppHandle) -> Option { + let state: tauri::State<'_, MainWindowFrameState> = app_handle.state(); + state.0.lock().ok().and_then(|cached_frame| *cached_frame) +} + +fn window_state_path() -> Result { + crate::settings::preferred_app_config_path(WINDOW_STATE_FILE) +} + +fn read_main_window_frame(scale_factor: f64) -> Option { + read_main_window_frame_at(&window_state_path().ok()?, scale_factor) +} + +fn read_main_window_frame_at(path: &Path, scale_factor: f64) -> Option { + let content = fs::read_to_string(path).ok()?; + let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?; + persisted + .main + .map(|frame| { + persisted + .coordinate_space + .to_logical_frame(frame, scale_factor) + }) + .filter(is_valid_saved_frame) +} + +fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> { + write_main_window_frame_at(&window_state_path()?, frame) +} + +fn write_main_window_frame_at(path: &Path, frame: WindowFrame) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create window state directory: {e}"))?; + } + + let persisted = PersistedWindowState { + main: Some(frame), + coordinate_space: WindowFrameCoordinateSpace::Logical, + }; + let json = serde_json::to_string_pretty(&persisted) + .map_err(|e| format!("Failed to serialize window state: {e}"))?; + fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}")) +} + +fn is_valid_saved_frame(frame: &WindowFrame) -> bool { + frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT +} + +fn rounded_i32(value: f64) -> i32 { + value.round() as i32 +} + +fn rounded_u32(value: f64) -> u32 { + value.round().max(0.0) as u32 +} + +impl WindowFrame { + fn from_logical_geometry(position: LogicalPosition, size: LogicalSize) -> Self { + Self { + x: rounded_i32(position.x), + y: rounded_i32(position.y), + width: rounded_u32(size.width), + height: rounded_u32(size.height), + } + } + + fn to_logical(self, scale_factor: f64) -> Self { + let scale_factor = scale_factor.max(1.0); + Self { + x: rounded_i32(self.x as f64 / scale_factor), + y: rounded_i32(self.y as f64 / scale_factor), + width: rounded_u32(self.width as f64 / scale_factor), + height: rounded_u32(self.height as f64 / scale_factor), + } + } + + fn right(self) -> i32 { + self.x + self.width as i32 + } + + fn bottom(self) -> i32 { + self.y + self.height as i32 + } +} + +impl WindowFrameCoordinateSpace { + fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame { + match self { + Self::Logical => frame, + Self::Physical => frame.to_logical(scale_factor), + } + } +} + +impl ScreenArea { + fn right(self) -> i32 { + self.x + self.width as i32 + } + + fn bottom(self) -> i32 { + self.y + self.height as i32 + } + + fn has_area(&self) -> bool { + self.width > 0 && self.height > 0 + } + + fn contains(&self, point: (i32, i32)) -> bool { + let (x, y) = point; + x >= self.x && x < self.right() && y >= self.y && y < self.bottom() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame { + WindowFrame { + x, + y, + width, + height, + } + } + + fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea { + ScreenArea { + x, + y, + width, + height, + } + } + + #[test] + fn records_logical_window_geometry_for_persistence() { + let saved = WindowFrame::from_logical_geometry( + LogicalPosition::new(80.0, 120.0), + LogicalSize::new(1100.0, 700.0), + ); + + assert_eq!(saved, frame(80, 120, 1100, 700)); + } + + #[test] + fn migrates_legacy_physical_frames_to_logical_points() { + let saved = frame(160, 240, 2200, 1400); + + assert_eq!( + WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0), + frame(80, 120, 1100, 700) + ); + } + + #[test] + fn keeps_explicit_logical_frames_unscaled() { + let saved = frame(80, 120, 1100, 700); + + assert_eq!( + WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0), + saved + ); + } + + #[test] + fn keeps_valid_frame_unchanged() { + let saved = frame(120, 80, 1400, 900); + let screens = [screen(0, 0, 1920, 1080)]; + + assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved)); + } + + #[test] + fn clamps_oversized_frame_to_current_work_area() { + let saved = frame(-100, -80, 2600, 1800); + let screens = [screen(0, 0, 1440, 900)]; + + assert_eq!( + fit_frame_to_screens(saved, &screens), + Some(frame(0, 0, 1440, 900)) + ); + } + + #[test] + fn moves_offscreen_frame_back_to_a_visible_screen() { + let saved = frame(3200, 1800, 900, 700); + let screens = [screen(0, 0, 1440, 900)]; + + assert_eq!( + fit_frame_to_screens(saved, &screens), + Some(frame(540, 200, 900, 700)) + ); + } + + #[test] + fn picks_the_screen_with_the_largest_visible_overlap() { + let saved = frame(1700, 100, 900, 700); + let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)]; + + assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved)); + } + + #[test] + fn ignores_empty_screen_areas_when_restoring() { + let saved = frame(100, 100, 800, 600); + let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)]; + + assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved)); + } + + #[test] + fn returns_none_when_no_usable_screens_exist() { + let saved = frame(100, 100, 800, 600); + + assert_eq!(fit_frame_to_screens(saved, &[]), None); + assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None); + } + + #[test] + fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() { + let saved = frame(100, 100, 800, 600); + let screens = [screen(0, 0, 320, 240)]; + + assert_eq!( + fit_frame_to_screens(saved, &screens), + Some(frame(0, 0, 320, 240)) + ); + } + + #[test] + fn reports_visibility_across_adjacent_screens() { + let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)]; + + assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens)); + assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens)); + } + + #[test] + fn computes_frame_and_screen_edges_for_overlap_checks() { + let saved = frame(10, 20, 800, 600); + let area = screen(0, 0, 500, 400); + + assert_eq!(saved.right(), 810); + assert_eq!(saved.bottom(), 620); + assert_eq!(area.right(), 500); + assert_eq!(area.bottom(), 400); + assert_eq!(intersection_area(saved, area), 490 * 380); + assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0); + } + + #[test] + fn rejects_corrupted_tiny_saved_frames() { + assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900))); + assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1))); + } + + #[test] + fn restores_again_after_runtime_ready() { + assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready)); + assert!(!restores_window_frame_after_runtime_ready( + &RunEvent::Resumed + )); + assert!(!saves_live_main_window_frame_on_event( + &WindowEvent::Destroyed + )); + } + + #[test] + fn persists_and_reads_logical_frame_from_disk() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested/window-state.json"); + let saved = frame(80, 120, 1100, 700); + + write_main_window_frame_at(&path, saved).unwrap(); + + let json = std::fs::read_to_string(&path).unwrap(); + assert!(json.contains("\"coordinate_space\": \"logical\"")); + assert_eq!(read_main_window_frame_at(&path, 2.0), Some(saved)); + } + + #[test] + fn reads_legacy_physical_frame_from_disk_as_logical_points() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("window-state.json"); + std::fs::write( + &path, + r#"{ + "main": { "x": 160, "y": 240, "width": 2200, "height": 1400 }, + "coordinate_space": "physical" + }"#, + ) + .unwrap(); + + assert_eq!( + read_main_window_frame_at(&path, 2.0), + Some(frame(80, 120, 1100, 700)) + ); + } + + #[test] + fn ignores_missing_corrupt_or_tiny_window_state_files() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("window-state.json"); + + assert_eq!(read_main_window_frame_at(&path, 1.0), None); + + std::fs::write(&path, "not json").unwrap(); + assert_eq!(read_main_window_frame_at(&path, 1.0), None); + + std::fs::write(&path, r#"{"main":{"x":0,"y":0,"width":100,"height":100}}"#).unwrap(); + assert_eq!(read_main_window_frame_at(&path, 1.0), None); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..ef80241 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,84 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "光湖", + "version": "0.1.0", + "identifier": "com.guanghu.desktop", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5202", + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build && pnpm bundle-mcp && pnpm agent-docs" + }, + "app": { + "withGlobalTauri": true, + "macOSPrivateApi": true, + "windows": [ + { + "title": "光湖", + "width": 1400, + "height": 900, + "minWidth": 480, + "minHeight": 400, + "resizable": true, + "fullscreen": false, + "titleBarStyle": "Overlay", + "trafficLightPosition": { + "x": 18, + "y": 24 + }, + "hiddenTitle": true, + "backgroundColor": "#F7F6F3", + "dragDropEnabled": false + } + ], + "security": { + "csp": { + "default-src": "'self' ipc: http://ipc.localhost", + "script-src": "'self' 'wasm-unsafe-eval' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com", + "connect-src": "'self' ipc: http://ipc.localhost data: ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:", + "img-src": "'self' asset: http://asset.localhost data: blob: https:", + "style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com", + "style-src-elem": "'self' 'unsafe-inline' https://fonts.googleapis.com", + "style-src-attr": "'unsafe-inline'", + "font-src": "'self' data: https://fonts.gstatic.com", + "media-src": "'self' asset: http://asset.localhost data: blob: https:", + "object-src": "'self' asset: http://asset.localhost" + }, + "devCsp": "default-src 'self' ipc: http://ipc.localhost http://localhost:5202 http://127.0.0.1:5202 asset: http://asset.localhost data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5202 http://127.0.0.1:5202 https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com; connect-src 'self' ipc: http://ipc.localhost http://localhost:5202 http://127.0.0.1:5202 data: ws://localhost:5202 ws://127.0.0.1:5202 ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:; img-src 'self' asset: http://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' data: https://fonts.gstatic.com; media-src 'self' asset: http://asset.localhost data: blob: https:; object-src 'self' asset: http://asset.localhost", + "dangerousDisableAssetCspModification": ["style-src"], + "assetProtocol": { + "enable": true, + "scope": [] + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": false, + "category": "Productivity", + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + } + }, + "resources": { + "resources/mcp-server/**/*": "mcp-server/", + "resources/agent-docs/**/*": "agent-docs/" + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["tolaria", "guanghu"] + } + } + } +} diff --git a/src-tauri/tests/git_connect_no_remote.rs b/src-tauri/tests/git_connect_no_remote.rs new file mode 100644 index 0000000..3a67b6e --- /dev/null +++ b/src-tauri/tests/git_connect_no_remote.rs @@ -0,0 +1,61 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; +use tolaria_lib::git::{git_add_remote, git_commit, git_remote_status}; + +fn run_git(path: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} + +fn setup_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + run_git(dir.path(), &["init", "-b", "main"]); + dir +} + +fn setup_bare_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + run_git(dir.path(), &["init", "--bare"]); + dir +} + +#[test] +fn git_add_remote_ignores_name_only_origin_config() { + let local = setup_repo(); + fs::write(local.path().join("note.md"), "# Note\n").unwrap(); + git_commit(local.path().to_str().unwrap(), "initial").unwrap(); + + run_git(local.path(), &["config", "remote.origin.prune", "true"]); + let remote_names = Command::new("git") + .args(["remote"]) + .current_dir(local.path()) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin")); + + let bare = setup_bare_repo(); + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + assert!( + git_remote_status(local.path().to_str().unwrap()) + .unwrap() + .has_remote + ); +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..9567c88 --- /dev/null +++ b/src/App.css @@ -0,0 +1,86 @@ +/* App layout - minimal CSS, most styling via Tailwind */ +.app-shell { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--border-primary); +} + +.app { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.app__sidebar { + flex: 0 0 auto; + min-width: 220px; + display: flex; + flex-direction: column; +} + +.app__sidebar > * { + flex: 1; +} + +.app:has(.app__note-list) .app__sidebar > * { + border-right-color: transparent; +} + +.app__note-list { + flex: 0 0 auto; + min-width: 220px; + display: flex; + flex-direction: column; + position: relative; + z-index: 20; + background: var(--surface-sidebar); +} + +.app__note-list > * { + flex: 1; + border-left: 1px solid var(--sidebar-border); +} + +.app__editor { + flex: 1; + min-width: 480px; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + container-type: inline-size; + container-name: editor; +} + +.app__editor > * { + flex: 1; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes tab-status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.tab-status-pulse { + animation: tab-status-pulse 1.2s ease-in-out infinite; +} + +/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */ +@keyframes ai-highlight-glow { + 0% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 80%, transparent); } + 50% { box-shadow: inset 0 0 8px 2px color-mix(in srgb, var(--accent-blue) 40%, transparent); } + 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 0%, transparent); } +} + +.ai-highlight { + animation: ai-highlight-glow 0.8s ease-out; +} diff --git a/src/App.note-window-properties.test.tsx b/src/App.note-window-properties.test.tsx new file mode 100644 index 0000000..ef08653 --- /dev/null +++ b/src/App.note-window-properties.test.tsx @@ -0,0 +1,293 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import type { Settings, VaultEntry } from './types' +import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher' +import { TooltipProvider } from './components/ui/tooltip' + +const localStorageMock = (() => { + let store: Record = {} + return { + clear: () => { store = {} }, + getItem: (key: string) => store[key] ?? null, + removeItem: (key: string) => { delete store[key] }, + setItem: (key: string, value: string) => { store[key] = value }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: query, + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })), +}) + +const editorSnapshots = vi.hoisted(() => [] as Array<{ + activeTabPath: string | null + entryTitles: string[] +}>) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + innerSize: vi.fn(async () => ({ toLogical: () => ({ width: 1400, height: 900 }) })), + scaleFactor: vi.fn(async () => 1), + setMinSize: vi.fn(async () => {}), + setSize: vi.fn(async () => {}), + }), +})) + +vi.mock('./components/Editor', () => ({ + Editor: (props: { activeTabPath: string | null; entries: VaultEntry[] }) => { + const entryTitles = props.entries.map((entry) => entry.title) + editorSnapshots.push({ activeTabPath: props.activeTabPath, entryTitles }) + return

      {entryTitles.join('|')}
      + }, +})) + +vi.mock('./hooks/useUpdater', () => ({ + restartApp: vi.fn(), + useUpdater: () => ({ + status: { state: 'idle' }, + actions: { + checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })), + dismiss: vi.fn(), + openReleaseNotes: vi.fn(), + startDownload: vi.fn(), + }, + }), +})) + +vi.mock('./utils/ai-chat', async () => { + const actual = await vi.importActual('./utils/ai-chat') + return { + ...actual, + buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })), + checkClaudeCli: vi.fn(async () => ({ installed: false })), + streamClaudeChat: vi.fn(async () => 'mock-session'), + } +}) + +vi.mock('./utils/streamAiAgent', () => ({ + streamAiAgent: vi.fn(async () => {}), +})) + +function makeEntry(overrides: Partial): VaultEntry { + return { + path: '/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1_700_000_000, + createdAt: null, + fileSize: 256, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + ...overrides, + } +} + +const activeEntry = makeEntry({ + path: '/vault/project/test.md', + filename: 'test.md', + title: 'Test Project', + isA: 'Project', +}) +const relatedEntry = makeEntry({ + path: '/vault/topic/dev.md', + filename: 'dev.md', + title: 'Software Development', + isA: 'Topic', +}) +const secondEntry = makeEntry({ + path: '/vault/project/second.md', + filename: 'second.md', + title: 'Second Project', + isA: 'Project', +}) +const entries = [activeEntry, relatedEntry, secondEntry] +const noteContent = '---\ntitle: Test Project\ntype: Project\n---\n\n# Test Project\n' +const defaultVaultPath = DEFAULT_VAULTS[0].path || '/Users/mock/Documents/Getting Started' + +function createSettings(): Settings { + return { + anonymous_id: null, + analytics_enabled: null, + auto_pull_interval_minutes: null, + autogit_enabled: false, + autogit_idle_threshold_seconds: 90, + autogit_inactive_threshold_seconds: 30, + crash_reporting_enabled: null, + release_channel: null, + telemetry_consent: true, + } +} + +const commandResults: Record = {} + +function resetCommandResults() { + Object.assign(commandResults, { + check_vault_exists: true, + get_all_content: { [activeEntry.path]: noteContent }, + get_default_vault_path: defaultVaultPath, + get_file_history: [], + get_modified_files: [], + get_note_content: vi.fn(() => noteContent), + get_settings: createSettings(), + get_vault_settings: { theme: null }, + is_git_repo: true, + list_themes: [], + list_vault: vi.fn(() => entries), + list_vault_folders: [], + list_views: [], + load_vault_list: { + active_vault: '/vault', + hidden_defaults: [], + vaults: [{ label: 'Test Vault', path: '/vault' }], + }, + reload_vault_entry: vi.fn(({ path }: { path: string }) => + entries.find((entry) => entry.path === path) ?? null, + ), + save_settings: null, + sync_vault_asset_scope_for_window: null, + }) +} + +function resolveCommandResult(command: string, args?: unknown) { + const result = Reflect.get(commandResults, command) as unknown + return typeof result === 'function' + ? (result as (input?: unknown) => unknown)(args) + : result ?? null +} + +vi.mock('./mock-tauri', () => ({ + addMockEntry: vi.fn(), + isTauri: vi.fn(() => false), + mockInvoke: vi.fn(async (command: string, args?: unknown) => resolveCommandResult(command, args)), + trackMockChange: vi.fn(), + updateMockContent: vi.fn(), +})) + +import App from './App' + +function renderApp(children: ReactNode) { + return render({children}) +} + +describe('App note windows', () => { + beforeEach(() => { + vi.clearAllMocks() + editorSnapshots.length = 0 + resetCommandResults() + localStorage.clear() + localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1') + window.history.replaceState( + {}, + '', + '/?window=note&path=%2Fvault%2Fproject%2Ftest.md&vault=%2Fvault&title=Test+Project', + ) + }) + + it('loads the active vault graph in note windows while opening the requested note', async () => { + renderApp() + + await waitFor(() => { + expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({ + path: activeEntry.path, + vaultPath: '/vault', + }) + }) + expect(commandResults.list_vault).toHaveBeenCalled() + + await waitFor(() => { + expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent( + 'Test Project|Software Development|Second Project', + ) + }) + expect(editorSnapshots.at(-1)).toEqual({ + activeTabPath: activeEntry.path, + entryTitles: ['Test Project', 'Software Development', 'Second Project'], + }) + }) + + it('opens repeated note windows through the full app vault loader', async () => { + const firstWindow = renderApp() + + await waitFor(() => { + expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({ + path: activeEntry.path, + vaultPath: '/vault', + }) + }) + firstWindow.unmount() + + window.history.replaceState( + {}, + '', + '/?window=note&path=%2Fvault%2Fproject%2Fsecond.md&vault=%2Fvault&title=Second+Project', + ) + renderApp() + + await waitFor(() => { + expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({ + path: secondEntry.path, + vaultPath: '/vault', + }) + }) + expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2) + expect(vi.mocked(commandResults.list_vault).mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it('probes installed AI agents in note windows for target-picker parity', async () => { + const getAiAgentsStatus = vi.fn(() => ({ + claude_code: { installed: true, version: '2.1.90' }, + codex: { installed: true, version: '0.122.0-alpha.1' }, + copilot: { installed: true, version: '1.0.58' }, + opencode: { installed: true, version: '0.7.4' }, + pi: { installed: false, version: null }, + antigravity: { installed: true, version: '0.3.2' }, + kiro: { installed: false, version: null }, + hermes: { installed: false, version: null }, + })) + commandResults.get_ai_agents_status = getAiAgentsStatus + + renderApp() + + await waitFor(() => { + expect(getAiAgentsStatus).toHaveBeenCalled() + }) + }) +}) diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..cd1cce4 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,1438 @@ +import { act, render as testingLibraryRender, screen, fireEvent, waitFor, within } from '@testing-library/react' +import type { ReactElement, ReactNode } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher' +import { formatShortcutDisplay } from './hooks/appCommandCatalog' +import { invoke } from '@tauri-apps/api/core' +import type { Settings, ViewDefinition, ViewFile } from './types' + +// Provide a localStorage mock that supports all methods (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Mock @tauri-apps/api/core before importing App +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('@tauri-apps/api/window', async () => { + const actual = await vi.importActual('@tauri-apps/api/window') + + return { + ...actual, + getCurrentWindow: () => ({ + innerSize: vi.fn(async () => ({ toLogical: () => ({ width: 1400, height: 900 }) })), + scaleFactor: vi.fn(async () => 1), + setMinSize: vi.fn(async () => {}), + setSize: vi.fn(async () => {}), + }), + } +}) + +// Mock mock-tauri module +const mockEntries = [ + { + path: '/vault/project/test.md', + filename: 'test.md', + title: 'Test Project', + isA: 'Project', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + archived: false, + owner: 'Luca', + cadence: null, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 1024, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + }, + { + path: '/vault/topic/dev.md', + filename: 'dev.md', + title: 'Software Development', + isA: 'Topic', + aliases: ['Dev'], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + owner: null, + cadence: null, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 256, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + }, +] + +const mockAllContent: Record = { + '/vault/project/test.md': '---\ntitle: Test Project\nis_a: Project\n---\n\n# Test Project\n\nSome content.', + '/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n', +} + +const mockVaultList = { + vaults: [{ label: 'Test Vault', path: '/vault' }], + active_vault: '/vault', + hidden_defaults: [], +} + +const mockDefaultVaultPath = '/Users/mock/Documents/Getting Started' +const expectedDefaultVaultPath = DEFAULT_VAULTS[0].path || mockDefaultVaultPath + +function createSettings(overrides: Partial = {}): Settings { + return { + auto_pull_interval_minutes: null, + telemetry_consent: true, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + release_channel: null, + ...overrides, + } +} + +const mockCommandResults: Record = { + load_vault_list: mockVaultList, + list_vault: mockEntries, + list_vault_folders: [], + list_views: [], + get_all_content: mockAllContent, + get_modified_files: [], + get_note_content: mockAllContent['/vault/project/test.md'] || '', + save_note_content: null, + reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null, + sync_vault_asset_scope_for_window: null, + get_file_history: [], + get_settings: createSettings(), + is_git_repo: true, + init_git_repo: null, + git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, + save_settings: null, + check_vault_exists: true, + get_default_vault_path: expectedDefaultVaultPath, + list_themes: [], + get_vault_settings: { theme: null }, +} + +function buildNeighborhoodEntry({ + path, + title, + relatedRefs, + outgoingLinks, + modifiedAt, +}: { + path: string + title: string + relatedRefs: string[] + outgoingLinks: string[] + modifiedAt: number +}) { + return { + path, + filename: path.split('/').pop() ?? `${title.toLowerCase()}.md`, + title, + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: relatedRefs, + status: null, + modifiedAt, + createdAt: null, + fileSize: 128, + archived: false, + snippet: '', + wordCount: 12, + relationships: relatedRefs.length > 0 ? { 'Related to': relatedRefs } : {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks, + properties: {}, + hasH1: true, + fileKind: 'markdown', + } +} + +const neighborhoodEntries = [ + buildNeighborhoodEntry({ + path: '/vault/alpha.md', + title: 'Alpha', + relatedRefs: ['[[Beta]]'], + outgoingLinks: ['Beta'], + modifiedAt: 1700000003, + }), + buildNeighborhoodEntry({ + path: '/vault/beta.md', + title: 'Beta', + relatedRefs: ['[[Gamma]]'], + outgoingLinks: ['Gamma'], + modifiedAt: 1700000002, + }), + buildNeighborhoodEntry({ + path: '/vault/gamma.md', + title: 'Gamma', + relatedRefs: [], + outgoingLinks: [], + modifiedAt: 1700000001, + }), +] + +const neighborhoodContent: Record = { + '/vault/alpha.md': '# Alpha\n\n[[Beta]]', + '/vault/beta.md': '# Beta\n\n[[Gamma]]', + '/vault/gamma.md': '# Gamma', +} + +function configureNeighborhoodVault() { + mockCommandResults.list_vault = neighborhoodEntries + mockCommandResults.get_all_content = neighborhoodContent + mockCommandResults.get_note_content = ({ path }: { path: string }) => neighborhoodContent[path] ?? '' +} + +function configureNeighborhoodFavoritesVault() { + mockCommandResults.list_vault = neighborhoodEntries.map((entry) => + entry.path === '/vault/alpha.md' + ? { ...entry, favorite: true, favoriteIndex: 0 } + : entry, + ) + mockCommandResults.get_all_content = neighborhoodContent + mockCommandResults.get_note_content = ({ path }: { path: string }) => neighborhoodContent[path] ?? '' +} + +function getHeaderForNoteList(noteListContainer: HTMLElement) { + return within(noteListContainer.parentElement as HTMLElement).getByRole('heading', { level: 3 }) +} + +async function clickNoteListItem(noteListContainer: HTMLElement, title: string, options?: MouseEventInit) { + await waitFor(() => { + expect(within(noteListContainer).getByText(title)).toBeInTheDocument() + }) + await act(async () => { + fireEvent.click(within(noteListContainer).getByText(title), options) + await Promise.resolve() + }) +} + +async function enterNeighborhood(noteListContainer: HTMLElement, title: string) { + await clickNoteListItem(noteListContainer, title, { metaKey: true }) +} + +async function pressEscape() { + await act(async () => { + fireEvent.keyDown(window, { key: 'Escape' }) + await Promise.resolve() + }) +} + +function resetMockCommandResults() { + Object.assign(mockCommandResults, { + load_vault_list: mockVaultList, + list_vault: mockEntries, + list_vault_folders: [], + list_views: [], + get_all_content: mockAllContent, + get_modified_files: [], + get_note_content: mockAllContent['/vault/project/test.md'] || '', + save_note_content: null, + reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null, + sync_vault_asset_scope_for_window: null, + get_file_history: [], + get_settings: createSettings({ auto_advance_inbox_after_organize: null }), + is_git_repo: true, + init_git_repo: null, + save_settings: null, + check_vault_exists: true, + get_default_vault_path: expectedDefaultVaultPath, + list_themes: [], + get_vault_settings: { theme: null }, + }) +} + +function resolveMockCommandResult(cmd: string, args?: unknown) { + const result = Reflect.get(mockCommandResults, cmd) as unknown + return typeof result === 'function' + ? (result as (input?: unknown) => unknown)(args) + : result ?? null +} + +vi.mock('./mock-tauri', () => ({ + isTauri: vi.fn(() => false), + mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)), + addMockEntry: vi.fn(), + updateMockContent: vi.fn(), + trackMockChange: vi.fn(), +})) + +// Mock ai-chat utilities +vi.mock('./utils/ai-chat', async () => { + const actual = await vi.importActual('./utils/ai-chat') + + return { + ...actual, + buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })), + checkClaudeCli: vi.fn(async () => ({ installed: false })), + streamClaudeChat: vi.fn(async () => 'mock-session'), + } +}) + +vi.mock('./utils/streamAiAgent', () => ({ + streamAiAgent: vi.fn(async () => {}), +})) + +vi.mock('./hooks/useUpdater', async () => { + const actual = await vi.importActual('./hooks/useUpdater') + + return { + ...actual, + useUpdater: vi.fn(() => ({ + status: { state: 'idle' }, + actions: { + checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })), + startDownload: vi.fn(), + openReleaseNotes: vi.fn(), + dismiss: vi.fn(), + }, + })), + restartApp: vi.fn(), + } +}) + +// Mock BlockNote components (they need DOM APIs not available in jsdom) +vi.mock('@blocknote/core', () => ({ + audioParse: vi.fn(() => undefined), createAudioBlockConfig: vi.fn(() => ({})), + BlockNoteSchema: { create: () => ({ extend: () => ({}) }) }, + createCodeBlockSpec: vi.fn(() => ({})), + createExtension: (factory: unknown) => () => factory, + createStyleSpec: vi.fn(() => ({})), + createVideoBlockConfig: vi.fn(() => ({})), defaultInlineContentSpecs: {}, + filterSuggestionItems: vi.fn(() => []), videoParse: vi.fn(() => undefined), +})) + +vi.mock('@blocknote/code-block', () => ({ codeBlockOptions: {} })) + +vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: vi.fn(() => []) })) + +vi.mock('@blocknote/react', () => { + const blockNoteEditor = { + tryParseMarkdownToBlocks: async () => [], + replaceBlocks: () => {}, + document: [], + insertInlineContent: () => {}, + setTextCursorPosition: () => {}, + focus: () => {}, + domElement: null, + onChange: () => () => {}, + onMount: (cb: () => void) => { cb(); return () => {} }, + } + + return { + AudioBlock: () => null, AudioToExternalHTML: () => null, + createReactBlockSpec: () => () => ({}), + createReactInlineContentSpec: () => ({ render: () => null }), + VideoBlock: () => null, VideoToExternalHTML: () => null, + BlockNoteViewRaw: ({ children, editable }: { children?: ReactNode; editable?: boolean }) => ( +
      +
      + mock editor +
      + {children} +
      + ), + LinkToolbar: ({ children }: { children?: ReactNode }) => <>{children}, + ComponentsContext: { + Provider: ({ children }: { children?: ReactNode }) => <>{children}, + }, + useCreateBlockNote: () => blockNoteEditor, + useBlockNoteEditor: () => blockNoteEditor, + LinkToolbarController: () => null, + EditLinkButton: () => null, + DeleteLinkButton: () => null, + SideMenuController: () => null, + SuggestionMenuController: () => null, + GridSuggestionMenuController: () => null, + useComponentsContext: () => ({ + LinkToolbar: { + Button: ({ + children, + label, + onClick, + }: { children?: ReactNode; label?: string; onClick?: () => void }) => ( + + ), + }, + }), + useDictionary: () => ({ + link_toolbar: { + open: { tooltip: 'Open in a new tab' }, + }, + }), + } +}) + +vi.mock('@blocknote/mantine', () => ({ + components: {}, + BlockNoteView: ({ children }: { children?: React.ReactNode }) =>
      {children}
      , +})) + +vi.mock('@blocknote/mantine/style.css', () => ({})) + +vi.mock('./components/tolariaEditorFormatting', () => ({ + TolariaFormattingToolbar: () => null, + TolariaFormattingToolbarController: () => null, +})) + +import App from './App' +import { TooltipProvider } from './components/ui/tooltip' +import { useUpdater } from './hooks/useUpdater' +import { isTauri } from './mock-tauri' +import { streamAiAgent } from './utils/streamAiAgent' + +const AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:ai-agents-onboarding-dismissed' +const CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME = 'tolaria:claude-code-onboarding-dismissed' +const SLOW_APP_READY_TIMEOUT_MS = 10_000 + +function render(ui: ReactElement, options?: Parameters[1]) { + return testingLibraryRender(ui, { + wrapper: ({ children }) => {children}, + ...options, + }) +} + +function createMockUpdaterResult( + checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }), +) { + return { + status: { state: 'idle' as const }, + actions: { + checkForUpdates, + startDownload: vi.fn(), + openReleaseNotes: vi.fn(), + dismiss: vi.fn(), + }, + } +} + +describe('App', () => { + beforeEach(() => { + vi.clearAllMocks() + resetMockCommandResults() + vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)) + vi.mocked(isTauri).mockReturnValue(false) + vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult()) + localStorage.clear() + window.history.replaceState({}, '', '/') + localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME, '1') + }) + + it('renders the four-panel layout', async () => { + render() + expect(await screen.findByText('All Notes', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('creates custom views with a portable fallback filename for symbol-only names', async () => { + const savedViews: ViewFile[] = [] + const saveView = vi.fn(({ filename, definition }: { filename: string; definition: ViewDefinition }) => { + if (filename === '.yml') throw new Error('Invalid view filename') + savedViews.push({ filename, definition }) + return null + }) + mockCommandResults.save_view_cmd = saveView + mockCommandResults.list_views = () => savedViews + mockCommandResults.reload_vault = mockEntries + + render() + + await screen.findByText('All Notes') + fireEvent.click(screen.getByRole('button', { name: 'Create view' })) + const dialog = await screen.findByRole('dialog') + fireEvent.change(within(dialog).getByPlaceholderText(/Active Projects|Reading List/i), { + target: { value: '🚀' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(saveView).toHaveBeenCalledWith(expect.objectContaining({ + filename: 'view.yml', + definition: expect.objectContaining({ name: '🚀' }), + })) + }) + }, 10000) + + it('loads and displays vault entries in sidebar', async () => { + render() + await waitFor(() => { + // Entries appear in both Sidebar and NoteList + expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0) + expect(screen.getAllByText('Software Development').length).toBeGreaterThan(0) + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + }) + + it('keeps the app shell usable while the vault note scan is pending', async () => { + let resolveListVault: ((value: typeof mockEntries) => void) | null = null + const listVaultPromise = new Promise((resolve) => { + resolveListVault = resolve + }) + mockCommandResults.list_vault = () => listVaultPromise + + render() + + expect(await screen.findByTestId('sidebar-loading-favorites', {}, { timeout: 5000 })).toBeInTheDocument() + expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument() + expect(screen.getByTestId('sidebar-top-nav')).toHaveTextContent('Inbox') + expect(screen.getByTestId('sidebar-loading-views')).toBeInTheDocument() + expect(screen.getByTestId('sidebar-loading-types')).toBeInTheDocument() + expect(screen.getByTestId('sidebar-loading-folders')).toBeInTheDocument() + expect(screen.getByTestId('note-list-loading-skeleton')).toBeInTheDocument() + expect(screen.getByTestId('breadcrumb-title-skeleton')).toBeInTheDocument() + expect(screen.queryByTestId('editor-content-skeleton')).not.toBeInTheDocument() + expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument() + expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk') + await act(async () => { + fireEvent.keyDown(window, { key: 'p', code: 'KeyP', metaKey: true }) + await Promise.resolve() + }) + expect(within(screen.getByTestId('quick-open-palette')).getByText('Reloading vault...')).toBeInTheDocument() + + await act(async () => { + resolveListVault?.(mockEntries) + await Promise.resolve() + }) + + await waitFor(() => { + expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument() + expect(screen.queryByTestId('note-list-loading-skeleton')).not.toBeInTheDocument() + expect(screen.queryByTestId('breadcrumb-title-skeleton')).not.toBeInTheDocument() + expect(screen.queryByTestId('editor-content-skeleton')).not.toBeInTheDocument() + expect(screen.queryByTestId('status-vault-reloading')).not.toBeInTheDocument() + expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0) + }) + }) + + it('shows empty state in editor when no note is selected', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Select a note to start editing')).toBeInTheDocument() + }) + }) + + it('opens a note window after loading the active vault graph', async () => { + const listVault = vi.fn(() => mockEntries) + const reloadVaultEntry = vi.fn(({ path }: { path: string }) => + mockEntries.find((entry) => entry.path === path) ?? null, + ) + const getNoteContent = vi.fn(({ path }: { path: string }) => mockAllContent[path] ?? '') + mockCommandResults.list_vault = listVault + mockCommandResults.reload_vault_entry = reloadVaultEntry + mockCommandResults.get_note_content = getNoteContent + window.history.replaceState( + {}, + '', + '/?window=note&path=%2Fvault%2Fproject%2Ftest.md&vault=%2Fvault&title=Test+Project', + ) + + render() + + await waitFor(() => expect(reloadVaultEntry).toHaveBeenCalled()) + expect(reloadVaultEntry).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' }) + await waitFor(() => expect(getNoteContent).toHaveBeenCalled()) + expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' }) + await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md')) + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') + expect(listVault).toHaveBeenCalled() + }) + + it('shows keyboard shortcut hints', async () => { + const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' }) + const newNoteHint = formatShortcutDisplay({ display: '⌘N' }) + const { container } = render() + await screen.findByText('Select a note to start editing') + + await waitFor(() => { + const visibleText = container.textContent ?? '' + expect(visibleText).toContain(`${quickOpenHint} to search`) + expect(visibleText).toContain(`${newNoteHint} to create`) + }) + }) + + it('registers keyboard shortcuts without error', async () => { + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + // Cmd+S with no pending changes shows "Nothing to save" + fireEvent.keyDown(window, { key: 's', metaKey: true }) + await waitFor(() => { + expect(screen.getByText('Nothing to save')).toBeInTheDocument() + }) + }) + + it('persists a Cmd+N note before opening it in the editor', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(1700000000000) + let resolveSave!: () => void + const saveNoteContent = vi.fn(() => new Promise((resolve) => { resolveSave = resolve })) + mockCommandResults.save_note_content = saveNoteContent + + try { + render() + await screen.findByText('All Notes') + + fireEvent.keyDown(window, { key: 'n', code: 'KeyN', metaKey: true }) + + await waitFor(() => { + expect(saveNoteContent).toHaveBeenCalledWith({ + path: '/vault/untitled-note-1700000000.md', + content: '---\ntype: Note\n---\n\n# \n\n', + vaultPath: '/vault', + }) + }) + expect(window.__laputaTest?.activeTabPath).not.toBe('/vault/untitled-note-1700000000.md') + + await act(async () => { + resolveSave() + await Promise.resolve() + }) + + await waitFor(() => { + expect(window.__laputaTest?.activeTabPath).toBe('/vault/untitled-note-1700000000.md') + }) + expect(screen.getAllByText('Untitled Note 1700000000').length).toBeGreaterThan(0) + } finally { + dateNow.mockRestore() + } + }) + + it('shows visible feedback when a manual update check finds an update', async () => { + vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ + kind: 'available', + version: '2026.4.25', + displayVersion: '2026.4.25', + }))) + + render() + + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('status-build-number')) + + await waitFor(() => { + expect(screen.getByText('Tolaria 2026.4.25 is available')).toBeInTheDocument() + }) + }) + + it('shows visible feedback when a menu-driven update check finds no eligible update', async () => { + vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ kind: 'up-to-date' }))) + + render() + + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function') + }) + + act(() => { + window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates') + }) + + await waitFor(() => { + expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument() + }) + }) + + it('shows immediate feedback while a menu-driven update check is pending', async () => { + let resolveUpdate: ((result: { kind: 'up-to-date' }) => void) | null = null + const checkForUpdates = vi.fn(() => new Promise<{ kind: 'up-to-date' }>((resolve) => { + resolveUpdate = resolve + })) + vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(checkForUpdates)) + + render() + + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function') + }) + + act(() => { + window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates') + }) + + await waitFor(() => { + expect(screen.getByText('Checking for updates...')).toBeInTheDocument() + }) + expect(checkForUpdates).toHaveBeenCalledOnce() + + await act(async () => { + resolveUpdate?.({ kind: 'up-to-date' }) + await Promise.resolve() + }) + + await waitFor(() => { + expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument() + }) + }) + + it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => { + localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_STORAGE_NAME) + localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME) + mockCommandResults.get_ai_agents_status = { + claude_code: { installed: true, version: '2.1.90' }, + codex: { installed: true, version: '0.122.0-alpha.1' }, + opencode: { installed: false, version: null }, + pi: { installed: false, version: null }, + antigravity: { installed: false, version: null }, + } + mockCommandResults.check_mcp_status = 'installed' + + render() + + await waitFor(() => { + expect(screen.getByText('AI is ready')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + + await waitFor(() => { + expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function') + }) + + act(() => { + window.__laputaTest?.dispatchBrowserMenuCommand?.('vault-install-mcp') + }) + + await waitFor(() => { + expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument() + }) + expect(screen.getByTestId('mcp-setup-dialog')).toBeInTheDocument() + expect(screen.queryByText('AI is ready')).not.toBeInTheDocument() + }) + + it('routes right-panel AI chat messages to the selected default agent', async () => { + mockCommandResults.get_settings = createSettings({ + auto_advance_inbox_after_organize: null, + default_ai_agent: 'codex', + }) + mockCommandResults.get_ai_agents_status = { + claude_code: { installed: true, version: '2.1.90' }, + codex: { installed: true, version: '0.122.0-alpha.1' }, + opencode: { installed: false, version: null }, + pi: { installed: false, version: null }, + antigravity: { installed: false, version: null }, + } + + render() + + await screen.findByText('All Notes') + fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true }) + + const input = await screen.findByTestId('agent-input') + await waitFor(() => { + expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex') + }) + + input.textContent = 'Summarize the active vault' + fireEvent.input(input) + fireEvent.click(screen.getByTestId('agent-send')) + + await waitFor(() => { + expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'codex', + })) + }) + }) + + it('waits for saved AI agent settings before sending right-panel messages', async () => { + let resolveSettings: ((settings: Settings) => void) | null = null + mockCommandResults.get_settings = () => new Promise((resolve) => { + resolveSettings = resolve + }) + mockCommandResults.get_ai_agents_status = { + claude_code: { installed: true, version: '2.1.90' }, + codex: { installed: true, version: '0.122.0-alpha.1' }, + opencode: { installed: false, version: null }, + pi: { installed: false, version: null }, + antigravity: { installed: false, version: null }, + } + + render() + + await screen.findByText('All Notes') + fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true }) + + const input = await screen.findByTestId('agent-input') + fireEvent.click(screen.getByTestId('agent-send')) + + await act(async () => { + await Promise.resolve() + }) + expect(streamAiAgent).not.toHaveBeenCalled() + + await act(async () => { + resolveSettings?.(createSettings({ + auto_advance_inbox_after_organize: null, + default_ai_agent: 'codex', + })) + }) + + await waitFor(() => { + expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex') + }) + + input.textContent = 'Summarize the active vault' + fireEvent.input(input) + fireEvent.click(screen.getByTestId('agent-send')) + + await waitFor(() => { + expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'codex', + })) + }) + }) + + it('shows onboarding after telemetry consent when no active vault is configured', async () => { + mockCommandResults.get_settings = createSettings({ telemetry_consent: null }) + mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath + + render() + + await waitFor(() => { + expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + + fireEvent.click(screen.getByTestId('telemetry-accept')) + + await waitFor(() => { + expect(screen.getByTestId('welcome-screen')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault') + }) + + it.each([ + ['telemetry-accept', 'Allow anonymous reporting'], + ['telemetry-decline', 'No thanks'], + ])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => { + const rememberedDefaultVaultPath = expectedDefaultVaultPath + localStorage.setItem('tolaria_welcome_dismissed', '1') + mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath + mockCommandResults.get_settings = createSettings({ telemetry_consent: null }) + mockCommandResults.load_vault_list = { + vaults: [], + active_vault: rememberedDefaultVaultPath, + hidden_defaults: [], + } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath + + render() + + await waitFor(() => { + expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + + fireEvent.click(screen.getByTestId(buttonTestId)) + + await waitFor(() => { + expect(screen.getByTestId('welcome-screen')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault') + }) + + it('uses the app shell loading state while the last vault is still resolving', async () => { + localStorage.setItem('tolaria_welcome_dismissed', '1') + + let resolveVaultList: ((value: typeof mockVaultList) => void) | null = null + + mockCommandResults.load_vault_list = () => + new Promise((resolve) => { + resolveVaultList = resolve + }) + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/work' + + render() + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument() + expect(screen.getByTestId('sidebar-loading-favorites')).toBeInTheDocument() + expect(screen.getByTestId('note-list-loading-skeleton')).toBeInTheDocument() + expect(screen.getByTestId('breadcrumb-title-skeleton')).toBeInTheDocument() + expect(screen.queryByTestId('editor-content-skeleton')).not.toBeInTheDocument() + expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk') + + await act(async () => { + resolveVaultList?.({ + vaults: [{ label: 'Work Vault', path: '/work' }], + active_vault: '/work', + hidden_defaults: [], + }) + await Promise.resolve() + }) + + await waitFor(() => { + expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault') + }) + }) + + it('shows the missing-vault screen once the resolved active vault is confirmed missing', async () => { + localStorage.setItem('tolaria_welcome_dismissed', '1') + mockCommandResults.load_vault_list = { + vaults: [{ label: 'Old Vault', path: '/missing-vault' }], + active_vault: '/missing-vault', + hidden_defaults: [], + } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath + + render() + + await waitFor(() => { + expect(screen.getByText(/folder may have moved or been deleted/)).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault') + }) + + it('shows welcome instead of vault-missing when the missing path was not a persisted active vault', async () => { + localStorage.setItem('tolaria_welcome_dismissed', '1') + mockCommandResults.load_vault_list = { + vaults: [], + active_vault: null, + hidden_defaults: [], + } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath + + render() + + await waitFor(() => { + expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument() + }) + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault') + }) + + it('persists an existing vault and shows AI onboarding after first-run open', async () => { + const selectedVaultPath = '/Users/mock/Documents/Work Vault' + const saveVaultList = vi.fn() + const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('file:///Users/mock/Documents/Work%20Vault') + + localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_STORAGE_NAME) + mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === selectedVaultPath + mockCommandResults.get_ai_agents_status = {} + mockCommandResults.save_vault_list = (args?: { + list?: { vaults?: Array<{ label: string; path: string }>; active_vault?: string | null } + }) => { + saveVaultList(args) + return null + } + + render() + + await waitFor(() => { + expect(screen.getByTestId('welcome-screen')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + + fireEvent.click(screen.getByTestId('welcome-open-folder')) + + await waitFor(() => { + expect(saveVaultList).toHaveBeenCalledWith({ + list: { + vaults: [{ + label: 'Work Vault', + path: selectedVaultPath, + alias: null, + color: null, + icon: null, + mounted: true, + }], + active_vault: selectedVaultPath, + default_workspace_path: selectedVaultPath, + hidden_defaults: [], + }, + }) + }) + expect(saveVaultList).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(screen.getByTestId('ai-agents-onboarding-screen')).toBeInTheDocument() + }, { timeout: SLOW_APP_READY_TIMEOUT_MS }) + expect(screen.getByText('AI setup is optional')).toBeInTheDocument() + + promptSpy.mockRestore() + }) + + it('persists and opens the onboarding template vault after cloning', async () => { + let templateExists = false + const saveVaultList = vi.fn() + const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('file:///Users/mock/Documents') + const expectedLabel = 'Getting Started' + + mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] } + mockCommandResults.check_vault_exists = (args?: { path?: string }) => { + if (args?.path === expectedDefaultVaultPath) { + return templateExists + } + return false + } + mockCommandResults.create_getting_started_vault = () => { + templateExists = true + return expectedDefaultVaultPath + } + mockCommandResults.save_vault_list = (args?: { + list?: { vaults?: Array<{ label: string; path: string }>; active_vault?: string | null } + }) => { + saveVaultList(args) + return null + } + + render() + + await waitFor(() => { + expect(screen.getByTestId('welcome-screen')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('welcome-create-vault')) + + await waitFor(() => { + expect(saveVaultList).toHaveBeenCalledWith({ + list: { + vaults: [], + active_vault: expectedDefaultVaultPath, + default_workspace_path: expectedDefaultVaultPath, + hidden_defaults: [], + }, + }) + }) + expect(saveVaultList).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent(expectedLabel) + }) + + promptSpy.mockRestore() + }) + + it('renders sidebar with correct default selection (All Notes)', async () => { + render() + await waitFor(() => { + // "All Notes" should be rendered as the selected nav item + expect(screen.getByText('All Notes')).toBeInTheDocument() + expect(screen.getByText('Archive')).toBeInTheDocument() + }) + }) + + it('pressing Escape in Neighborhood mode blurs the editor before unwinding note-list history', async () => { + configureNeighborhoodVault() + + render() + + const noteListContainer = await screen.findByTestId('note-list-container', {}, { timeout: 5000 }) + const getHeader = () => getHeaderForNoteList(noteListContainer) + + await waitFor(() => { + expect(getHeader()).toHaveTextContent('Inbox') + }) + + await enterNeighborhood(noteListContainer, 'Alpha') + + await waitFor(() => { + expect(getHeader()).toHaveTextContent('Alpha') + }) + + const editor = screen.getByTestId('mock-editor') + editor.focus() + expect(editor).toHaveFocus() + + await pressEscape() + + await waitFor(() => { + expect(noteListContainer).toHaveFocus() + expect(getHeader()).toHaveTextContent('Alpha') + }) + + await enterNeighborhood(noteListContainer, 'Beta') + + await waitFor(() => { + expect(getHeader()).toHaveTextContent('Beta') + }) + + await pressEscape() + + await waitFor(() => { + expect(getHeader()).toHaveTextContent('Alpha') + }) + + await pressEscape() + + await waitFor(() => { + expect(getHeader()).toHaveTextContent('Inbox') + }) + }, 10_000) + + it('opens favorites directly into Neighborhood mode', async () => { + configureNeighborhoodFavoritesVault() + + render() + + let favoritesSection: HTMLElement | undefined + await waitFor(() => { + const sidebar = screen.getByText('FAVORITES') + const currentFavoritesSection = sidebar.closest('div')?.parentElement as HTMLElement + expect(within(currentFavoritesSection).getByText('Alpha')).toBeInTheDocument() + favoritesSection = currentFavoritesSection + }) + fireEvent.click(within(favoritesSection!).getByText('Alpha')) + + const noteListContainer = await screen.findByTestId('note-list-container') + await waitFor(() => { + expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Alpha') + }) + + expect(screen.getByText('Related to')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + }) + + it('defaults to All Notes when explicit organization is disabled in vault config', async () => { + const workVaultPath = '/Users/mock/Documents/Work' + mockCommandResults.load_vault_list = { + vaults: [{ label: 'Work Vault', path: workVaultPath }], + active_vault: workVaultPath, + hidden_defaults: [], + } + const disabledWorkflowConfig = JSON.stringify({ + zoom: null, + view_mode: null, + editor_mode: null, + tag_colors: null, + status_colors: null, + property_display_modes: null, + inbox: { noteListProperties: null, explicitOrganization: false }, + }) + localStorage.setItem(`laputa:vault-config:${workVaultPath}`, disabledWorkflowConfig) + + render() + + await waitFor(() => { + expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument() + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + }) + + it('auto-advances to the next inbox item after organizing when the setting is enabled', async () => { + configureNeighborhoodVault() + mockCommandResults.get_settings = createSettings({ auto_advance_inbox_after_organize: true }) + + render() + + const noteListContainer = await screen.findByTestId('note-list-container') + await waitFor(() => { + expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox') + }) + + await clickNoteListItem(noteListContainer, 'Alpha') + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' })) + await Promise.resolve() + }) + + await waitFor(() => { + expect(window.__laputaTest?.activeTabPath).toBe('/vault/beta.md') + }) + }, 10_000) + + it('keeps the manually selected note after organizing finishes later', async () => { + configureNeighborhoodVault() + mockCommandResults.get_settings = createSettings({ auto_advance_inbox_after_organize: true }) + + let resolveOrganizeSave!: () => void + const organizeSave = new Promise((resolve) => { + resolveOrganizeSave = resolve + }) + mockCommandResults.save_note_content = vi.fn(() => organizeSave) + + render() + + const noteListContainer = await screen.findByTestId('note-list-container') + await waitFor(() => { + expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox') + }) + + await clickNoteListItem(noteListContainer, 'Alpha') + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' })) + await Promise.resolve() + }) + + await act(async () => { + fireEvent.click(within(noteListContainer).getByText('Gamma')) + await Promise.resolve() + }) + await waitFor(() => { + expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md') + }) + + await act(async () => { + resolveOrganizeSave() + await organizeSave + await Promise.resolve() + await Promise.resolve() + }) + + expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md') + }, 10_000) + + it('renders status bar', async () => { + render() + // StatusBar should be present + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + // The status bar element should exist in the DOM + const appShell = document.querySelector('.app-shell') + expect(appShell).toBeInTheDocument() + }) + + it('switches vaults from the bottom bar after onboarding is ready', async () => { + mockCommandResults.load_vault_list = { + vaults: [ + { label: 'Test Vault', path: '/work' }, + { label: 'Work Vault', path: '/vault-2' }, + ], + active_vault: '/work', + hidden_defaults: [], + } + + render() + + await waitFor(() => { + expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Switch vault' })) + fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault')) + + await waitFor(() => { + expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault') + }) + }) + + it('clears the Git setup dialog when switching to a Git-enabled vault', async () => { + mockCommandResults.load_vault_list = { + vaults: [ + { label: 'Missing Git', path: '/work' }, + { label: 'Git Vault', path: '/vault-2' }, + ], + active_vault: '/work', + hidden_defaults: [], + } + mockCommandResults.is_git_repo = ({ vaultPath }: { vaultPath?: string } = {}) => vaultPath === '/vault-2' + + render() + + expect(await screen.findByTestId('status-missing-git', {}, { timeout: SLOW_APP_READY_TIMEOUT_MS })).toBeInTheDocument() + fireEvent.click(screen.getByTestId('status-missing-git')) + expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('status-vault-trigger')) + fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault')) + + await waitFor(() => { + expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Git Vault') + }) + await waitFor(() => { + expect(screen.queryByText('Enable Git for this vault?')).not.toBeInTheDocument() + }) + }) + + it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => { + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + // All panels visible by default + expect(document.querySelector('.app__sidebar')).toBeInTheDocument() + expect(document.querySelector('.app__note-list')).toBeInTheDocument() + + // Cmd+1 → editor-only + fireEvent.keyDown(window, { key: '1', metaKey: true }) + await waitFor(() => { + expect(document.querySelector('.app__sidebar')).not.toBeInTheDocument() + expect(document.querySelector('.app__note-list')).not.toBeInTheDocument() + }) + }) + + it('Cmd+2 shows editor + note list (sidebar hidden)', async () => { + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + fireEvent.keyDown(window, { key: '2', metaKey: true }) + await waitFor(() => { + expect(document.querySelector('.app__sidebar')).not.toBeInTheDocument() + expect(document.querySelector('.app__note-list')).toBeInTheDocument() + }) + }) + + it('Cmd+3 restores all panels after Cmd+1', async () => { + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + // Switch to editor-only first + fireEvent.keyDown(window, { key: '1', metaKey: true }) + await waitFor(() => { + expect(document.querySelector('.app__sidebar')).not.toBeInTheDocument() + }) + + // Cmd+3 → all panels + fireEvent.keyDown(window, { key: '3', metaKey: true }) + await waitFor(() => { + expect(document.querySelector('.app__sidebar')).toBeInTheDocument() + expect(document.querySelector('.app__note-list')).toBeInTheDocument() + }) + }) + + it('updates the main-window size constraints when the view mode changes', async () => { + const { invoke } = await import('@tauri-apps/api/core') as { invoke: ReturnType } + + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + invoke.mockClear() + + fireEvent.keyDown(window, { key: '1', metaKey: true }) + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', { + minWidth: 480, + minHeight: 400, + growToFit: true, + }) + }) + + invoke.mockClear() + + fireEvent.keyDown(window, { key: '3', metaKey: true }) + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', { + minWidth: 1030, + minHeight: 400, + growToFit: true, + }) + }) + }) + + it('does not ask Windows to grow the native window when toggling Properties', async () => { + const { invoke } = await import('@tauri-apps/api/core') as { invoke: ReturnType } + const originalUserAgent = navigator.userAgent + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + + try { + render() + await waitFor(() => { + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + + invoke.mockClear() + + fireEvent.keyDown(window, { key: 'I', metaKey: true, shiftKey: true }) + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', expect.objectContaining({ + growToFit: false, + })) + }) + } finally { + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: originalUserAgent, + }) + } + }) +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2fb775c --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,1823 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Sidebar } from './components/Sidebar' +import { NoteList } from './components/NoteList' +import { Editor } from './components/Editor' +import { ResizeHandle } from './components/ResizeHandle' +import { CreateTypeDialog } from './components/CreateTypeDialog' +import { CreateViewDialog } from './components/CreateViewDialog' +import { QuickOpenPalette } from './components/QuickOpenPalette' +import { CommandPalette } from './components/CommandPalette' +import { SearchPanel } from './components/SearchPanel' +import { Toast } from './components/Toast' +import { CommitDialog } from './components/CommitDialog' +import { PulseView } from './components/PulseView' +import { StatusBar } from './components/StatusBar' +import { AppAiWorkspaceSurface } from './components/AppAiWorkspaceSurface' +import { AiWorkspaceFloatingButton } from './components/AiWorkspaceFloatingButton' +import { AiWorkspaceWindowApp } from './components/AiWorkspaceWindowApp' +import { SettingsPanel } from './components/SettingsPanel' +import { CloneVaultModal } from './components/CloneVaultModal' +import { FeedbackDialog } from './components/FeedbackDialog' +import { McpSetupDialog } from './components/McpSetupDialog' +import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs' +import { StartupScreen } from './components/StartupScreen' +import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' +import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' +import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus' +import { useAutoGit } from './hooks/useAutoGit' +import { useVaultLoader } from './hooks/useVaultLoader' +import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher' +import { useSettings } from './hooks/useSettings' +import { useNoteWidthMode } from './hooks/useNoteWidthMode' +import { useNoteActions } from './hooks/useNoteActions' +import { useCommitFlow } from './hooks/useCommitFlow' +import { useGitRepositories } from './hooks/useGitRepositories' +import { useEntryActions } from './hooks/useEntryActions' +import { useAppCommands } from './hooks/useAppCommands' +import { triggerCommitEntryAction } from './utils/commitEntryAction' +import { generateCommitMessage } from './utils/commitMessage' +import { useDialogs } from './hooks/useDialogs' +import { useVaultSwitcher } from './hooks/useVaultSwitcher' +import { useGitHistory } from './hooks/useGitHistory' +import { useUpdater, restartApp } from './hooks/useUpdater' +import { useAutoSync } from './hooks/useAutoSync' +import { useConflictResolver } from './hooks/useConflictResolver' +import { useVaultConfig } from './hooks/useVaultConfig' +import { useOnboarding } from './hooks/useOnboarding' +import { useGettingStartedClone } from './hooks/useGettingStartedClone' +import { useNetworkStatus } from './hooks/useNetworkStatus' +import { useAppNavigation } from './hooks/useAppNavigation' +import { useAiActivity } from './hooks/useAiActivity' +import { useBulkActions } from './hooks/useBulkActions' +import { useDeleteActions } from './hooks/useDeleteActions' +import { useFolderActions } from './hooks/useFolderActions' +import { useFileActions } from './hooks/useFileActions' +import { useDeepLinks } from './hooks/useDeepLinks' +import { useNoteGitUrls } from './hooks/useNoteGitUrls' +import { useLayoutPanels } from './hooks/useLayoutPanels' +import { useConflictFlow } from './hooks/useConflictFlow' +import { useAppSave } from './hooks/useAppSave' +import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi' +import { useVaultBridge } from './hooks/useVaultBridge' +import { useSavedViewOrdering } from './hooks/useSavedViewOrdering' +import { useAppViewActions } from './hooks/useAppViewActions' +import { useAppWindowControls } from './hooks/useAppWindowControls' +import { useAiWorkspacePublishedContext } from './hooks/useAiWorkspacePublishedContext' +import { + useNeighborhoodEntry, + useNeighborhoodEscape, + useNeighborhoodHistoryBack, + useSelectionSanitizer, +} from './hooks/useNeighborhoodSelection' +import { ConflictResolverModal } from './components/ConflictResolverModal' +import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' +import { DeleteProgressNotice } from './components/DeleteProgressNotice' +import { UpdateBanner } from './components/UpdateBanner' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from './mock-tauri' +import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelection, InboxPeriod, VaultEntry, WorkspaceIdentity } from './types' +import { initializeNoteProperties } from './utils/initializeNoteProperties' +import { type NoteListFilter } from './utils/noteListHelpers' +import { openNoteInNewWindow } from './utils/openNoteWindow' +import { refreshPulledVaultState } from './utils/pulledVaultRefresh' +import { viewMatchesSelection } from './utils/viewIdentity' +import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode' +import { GitSetupDialog } from './components/GitRequiredModal' +import { RenameDetectedBanner } from './components/RenameDetectedBanner' +import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' +import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands' +import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' +import { trackEvent } from './lib/telemetry' +import { areAutomaticUpdateChecksEnabled } from './lib/automaticUpdateChecks' +import { areAiFeaturesEnabled } from './lib/aiFeatures' +import { aiTargetReady, type AiTarget } from './lib/aiTargets' +import { areGitFeaturesEnabled } from './lib/gitSettings' +import { useAppCommandAiActions } from './hooks/useAppCommandAiActions' +import { TOLARIA_DOCS_URL } from './constants/feedback' +import { openExternalUrl } from './utils/url' +import { + translate, +} from './lib/i18n' +import { normalizeReleaseChannel } from './lib/releaseChannel' +import { + buildVaultAiGuidanceRefreshKey, +} from './lib/vaultAiGuidance' +import { hasNoteIconValue } from './utils/noteIcon' +import { + INBOX_SELECTION, + isExplicitOrganizationEnabled, + sanitizeSelectionForOrganization, +} from './utils/organizationWorkflow' +import { requestPlainTextPaste } from './utils/plainTextPaste' +import { SETTINGS_SECTION_IDS } from './components/settingsSectionIds' +import { + vaultPathForEntry, +} from './utils/workspaces' +import { activeGitRepositories } from './utils/gitRepositories' +import { isMarkdownEntry } from './utils/typeDefinitions' +import type { RichEditorBlockTypeDefinition } from './utils/richEditorBlockTypes' +import { resolveTypeDeleteRequest, typeDeleteBlockedMessageKey } from './utils/typeDeletion' +import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState' +import { useGitSetupState } from './hooks/useGitSetupState' +import { AppPreferencesProvider, useAppPreferences } from './hooks/useAppPreferences' +import { useInboxOrganizeAdvance } from './hooks/useInboxOrganizeAdvance' +import { syncVaultAssetScope, useNoteWindowLifecycle } from './hooks/useNoteWindowLifecycle' +import { useVaultRenameDetection } from './hooks/useVaultRenameDetection' +import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry' +import { useStartupScreenState } from './hooks/useStartupScreenState' +import { useGitFileWorkflows } from './hooks/useGitFileWorkflows' +import { useAutoGitWork } from './hooks/useAutoGitWork' +import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge' +import { useAiWorkspaceWindowBridgeEvents } from './hooks/useAiWorkspaceWindowBridgeEvents' +import { useMcpSetupDialogController } from './hooks/useMcpSetupDialogController' +import { shouldReplaceSyncedTabEntry } from './utils/tabEntrySync' +import { + activeVaultModifiedFiles, + aiWorkspaceWindowContextForPath, + canCustomizeColumnsForSelection, + isActiveElementInsideEditorSurface, + mergeModifiedFiles, + runNativeTextHistoryCommand, + shouldPreferOnboardingVaultPath, +} from './utils/appOrchestration' +import './App.css' + +// Type declarations for mock content storage and test overrides +declare global { + interface Window { + __mockContent?: Record + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides + __mockHandlers?: Record any> + } +} + +const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION + +/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ +function App() { + const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, []) + const aiWorkspaceWindow = useMemo(() => isAiWorkspaceWindow(), []) + + if (aiWorkspaceWindow) return + + return +} + +function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | null }) { + const aiWorkspaceWindow = false + const [selection, setSelection] = useState(DEFAULT_SELECTION) + const [noteListFilter, setNoteListFilter] = useState('open') + const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState(null) + const selectionRef = useRef(DEFAULT_SELECTION) + const neighborhoodHistoryRef = useRef([]) + const inboxPeriod: InboxPeriod = 'all' + const handleSetSelection = useCallback((sel: SidebarSelection, options?: { preserveNeighborhoodHistory?: boolean }) => { + if (!options?.preserveNeighborhoodHistory && sel.kind !== 'entity') { + neighborhoodHistoryRef.current = [] + } + selectionRef.current = sel + setSelection(sel) + setNoteListFilter('open') + }, []) + const handleEnterNeighborhood = useNeighborhoodEntry({ + neighborhoodHistoryRef, + selectionRef, + setSelection: handleSetSelection, + }) + const layout = useLayoutPanels(noteWindowParams || aiWorkspaceWindow ? { initialInspectorCollapsed: true } : undefined) + const { setInspectorCollapsed } = layout + const visibleNotesRef = useRef([]) + const multiSelectionCommandRef = useRef(null) + const [toastMessage, setToastMessage] = useState(null) + const [gitHistoryRefreshKey, setGitHistoryRefreshKey] = useState(0) + const dialogs = useDialogs() + const { closeAIChat, openAIChat, showAIChat } = dialogs + const [showFeedback, setShowFeedback] = useState(false) + const openFeedback = useCallback(() => setShowFeedback(true), []) + const closeFeedback = useCallback(() => setShowFeedback(false), []) + const openDocs = useCallback(() => { + void openExternalUrl(TOLARIA_DOCS_URL) + }, []) + const networkStatus = useNetworkStatus() + const { settings, loaded: settingsLoaded, saveSettings } = useSettings() + const aiFeaturesEnabled = areAiFeaturesEnabled(settings) + + // onSwitch closure captures `notes` declared below — safe because it's only + // called on user interaction, never during render (refs inside the hook + // guarantee the latest closure is always used). + const vaultSwitcher = useVaultSwitcher({ + onSwitch: () => { + if (noteWindowParams || aiWorkspaceWindow) return + handleSetSelection(DEFAULT_SELECTION) + notes.closeAllTabs() + }, + onToast: (msg) => setToastMessage(msg), + }) + const { + allVaults, + defaultWorkspacePath, + registerVaultSelection, + selectedVaultPath, + syncVaultSelection, + switchVault, + } = vaultSwitcher + + const rememberVaultChoice = useCallback((vaultPath: string) => { + if (!vaultPath) return + + if (allVaults.some((vault) => vault.path === vaultPath)) { + switchVault(vaultPath) + return + } + + const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault' + syncVaultSelection(vaultPath, label) + }, [allVaults, switchVault, syncVaultSelection]) + + const handleGettingStartedVaultReady = useCallback((vaultPath: string) => { + rememberVaultChoice(vaultPath) + setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`) + }, [rememberVaultChoice]) + + const handleOnboardingVaultReady = useCallback((vaultPath: string, source: 'template' | 'empty' | 'existing') => { + rememberVaultChoice(vaultPath) + if (source === 'template') { + setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`) + } + }, [rememberVaultChoice]) + const cloneGettingStartedVault = useGettingStartedClone({ + onError: (message) => setToastMessage(message), + onSuccess: handleGettingStartedVaultReady, + }) + const onboarding = useOnboarding(vaultSwitcher.vaultPath, { + onVaultReady: handleOnboardingVaultReady, + registerVault: registerVaultSelection, + }, vaultSwitcher.loaded) + const aiAgentsStatus = useAiAgentsStatus({ + enabled: aiFeaturesEnabled && !aiWorkspaceWindow, + }) + const aiAgentsOnboarding = useAiAgentsOnboarding( + aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams && !aiWorkspaceWindow, + ) + + // Onboarding can briefly own the vault path for a newly created/opened vault + // before the persisted switcher catches up, but once the path is already in + // the switcher list we should trust the explicit switcher state. + const resolvedPath = noteWindowParams?.vaultPath ?? ( + shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults) + ? onboarding.state.vaultPath + : vaultSwitcher.vaultPath + ) + const aiWorkspaceWindowContext = useMemo(() => aiWorkspaceWindowContextForPath(resolvedPath), [resolvedPath]) + const [settingsInitialSectionId, setSettingsInitialSectionId] = useState(null) + const { + effectiveShowAIChat, + handleOpenAiSettings, + handleOpenDockedAiWorkspace, + } = useAppAiWorkspaceBridge({ + aiFeaturesEnabled, + aiWorkspaceWindow, + closeAIChat, + openAIChat, + openSettings: dialogs.openSettings, + setSettingsInitialSectionId, + showAIChat, + }) + const handleToggleAiWorkspace = useCallback(() => { + if (effectiveShowAIChat) { + closeAIChat() + return + } + handleOpenDockedAiWorkspace() + }, [closeAIChat, effectiveShowAIChat, handleOpenDockedAiWorkspace]) + const [lastAiWorkspaceConversationId, setLastAiWorkspaceConversationId] = useState(null) + const handleActiveAiWorkspaceConversationChange = useCallback((id: string) => { + setLastAiWorkspaceConversationId(id) + }, []) + const [lastAiWorkspaceTarget, setLastAiWorkspaceTarget] = useState(null) + const handleActiveAiWorkspaceTargetChange = useCallback((target: AiTarget) => { + setLastAiWorkspaceTarget((current) => (current?.id === target.id ? current : target)) + }, []) + const { + folderVaults, + graphDefaultWorkspacePath, + graphVaults, + inspectorWorkspaces, + multiWorkspaceEnabled, + visibleWorkspacePathList, + writableVaultPaths, + } = useWorkspaceGraphState({ + allVaults, + defaultWorkspacePath, + resolvedPath, + settings, + vaultSwitcherLoaded: vaultSwitcher.loaded, + windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, + }) + const vaultWorkspaceOrder = useMemo( + () => vaultSwitcher.allVaults.map((vault) => vault.path), + [vaultSwitcher.allVaults], + ) + const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) + const gitFeaturesEnabled = areGitFeaturesEnabled(settings) + const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => { + updateConfig('git_setup_preference', preference) + }, [updateConfig]) + const { + dismissGitSetupDialog, + gitRepoState, + handleInitGitRepo, + neverForVaultGitSetupDialog, + openGitSetupDialog, + shouldShowGitSetupDialog, + } = useGitSetupState({ + gitSetupPreference: vaultConfig.git_setup_preference, + onGitSetupPreferenceChange: handleGitSetupPreferenceChange, + onToast: setToastMessage, + resolvedPath, + windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, + }) + + const vault = useVaultLoader(resolvedPath, graphVaults, multiWorkspaceEnabled ? defaultWorkspacePath : null, folderVaults) + const gitRepositories = useMemo(() => activeGitRepositories({ + defaultVaultPath: graphDefaultWorkspacePath, + multiWorkspaceEnabled, + vaults: allVaults, + }), [allVaults, graphDefaultWorkspacePath, multiWorkspaceEnabled]) + const activeGitRepositoryPaths = useMemo( + () => gitRepositories.map((repository) => repository.path), + [gitRepositories], + ) + const gitSurfaces = useGitRepositories({ + defaultVaultPath: graphDefaultWorkspacePath, + repositories: gitRepositories, + }) + const watchedVaultPaths = useMemo(() => { + if (visibleWorkspacePathList && visibleWorkspacePathList.length > 0) return visibleWorkspacePathList + return resolvedPath.trim() ? [resolvedPath] : [] + }, [resolvedPath, visibleWorkspacePathList]) + const visibleEntries = useVisibleWorkspaceEntries({ + entries: vault.entries, + multiWorkspaceEnabled, + visibleWorkspacePathList, + }) + const runtimeMissingVaultPath = vault.unavailableVaultPath + const { + markInternalWrite: markRecentVaultWrite, + filterExternalPaths: filterExternalVaultPaths, + } = useRecentVaultWrites({ + vaultPath: resolvedPath, + vaultPaths: watchedVaultPaths, + }) + const { + status: vaultAiGuidanceStatus, + refresh: refreshVaultAiGuidance, + } = useVaultAiGuidanceStatus( + aiFeaturesEnabled ? resolvedPath : null, + buildVaultAiGuidanceRefreshKey(vault.entries), + ) + const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) + const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) + const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes' + + useSelectionSanitizer({ + effectiveSelection, + neighborhoodHistoryRef, + selection, + selectionRef, + setNoteListFilter, + setSelection, + }) + + const handleNeighborhoodHistoryBack = useNeighborhoodHistoryBack({ + neighborhoodHistoryRef, + setSelection: handleSetSelection, + }) + + const handleSaveExplicitOrganization = useCallback((enabled: boolean) => { + updateConfig('inbox', { + noteListProperties: vaultConfig.inbox?.noteListProperties ?? null, + explicitOrganization: enabled, + }) + }, [updateConfig, vaultConfig.inbox?.noteListProperties]) + const { + aiAgentPreferences, + allNotesFileVisibility, + appLocale, + dateDisplayFormat, + documentThemeMode, + handleSetThemeMode, + handleSetUiLanguage, + handleToggleThemeMode, + selectedUiLanguage, + systemLocale, + } = useAppPreferences({ + aiAgentsStatus, + onToast: setToastMessage, + saveSettings, + settings, + settingsLoaded, + }) + const quickPromptTarget = lastAiWorkspaceTarget ?? aiAgentPreferences.defaultAiTarget + const quickPromptTargetReady = aiTargetReady(quickPromptTarget, aiAgentsStatus) + + useVaultOpenedTelemetry({ + entryCount: vault.entries.length, + gitRepoState, + resolvedPath, + }) + const mcpSetupDialog = useMcpSetupDialogController(resolvedPath, setToastMessage, appLocale) + const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles + const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles + const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository + const refreshAllGitRemoteStatuses = gitSurfaces.refreshAllRemoteStatuses + const refreshRemoteStatusForRepository = gitSurfaces.refreshRemoteStatusForRepository + const refreshGitRemoteStatus = useCallback( + () => refreshRemoteStatusForRepository(resolvedPath), + [refreshRemoteStatusForRepository, resolvedPath], + ) + const refreshGitModifiedFiles = useCallback(async () => { + if (!gitFeaturesEnabled) return + await Promise.all([ + loadDefaultVaultModifiedFiles(), + loadAllGitModifiedFiles({ includeStats: isChangesSelection }), + ]) + }, [gitFeaturesEnabled, isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) + const loadVaultModifiedFiles = refreshGitModifiedFiles + + useEffect(() => { + if (!gitFeaturesEnabled) return + if (gitRepoState !== 'ready') return + void loadVaultModifiedFiles() + void refreshGitRemoteStatus() + void refreshAllGitRemoteStatuses() + }, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus]) + + const handleOpenSettings = useCallback(() => { + setSettingsInitialSectionId(null) + dialogs.openSettings() + }, [dialogs]) + + const handleOpenVaultSettings = useCallback(() => { + setSettingsInitialSectionId(SETTINGS_SECTION_IDS.workspaces) + dialogs.openSettings() + }, [dialogs]) + + const { + detectedRenames, + handleUpdateWikilinks, + handleDismissRenames, + } = useVaultRenameDetection({ + reloadVault: vault.reloadVault, + setToastMessage, + vaultPath: resolvedPath, + }) + + const conflictResolver = useConflictResolver({ + vaultPath: resolvedPath, + onResolved: () => { + dialogs.closeConflictResolver() + autoSync.resumePull() + vault.reloadVault() + autoSync.triggerSync() + }, + onToast: (msg) => setToastMessage(msg), + onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath), + }) + const flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null) + const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null) + const appSaveFlushBeforeActionRef = useRef<((path: string) => Promise) | null>(null) + const flushEditorStateBeforeAction = useCallback(async (path: string) => { + flushPendingEditorContentRef.current?.(path) + flushPendingRawContentRef.current?.(path) + await appSaveFlushBeforeActionRef.current?.(path) + }, []) + const handleCreatedVaultEntryPersisting = useCallback((path: string) => { + markRecentVaultWrite(path) + vault.addPendingSave(path) + }, [markRecentVaultWrite, vault]) + const handleCreatedVaultEntryPersisted = useCallback((path: string) => { + markRecentVaultWrite(path) + void refreshGitModifiedFiles() + }, [markRecentVaultWrite, refreshGitModifiedFiles]) + const handleMissingActiveVault = useCallback(() => { + if (!noteWindowParams && !aiWorkspaceWindow && resolvedPath) vault.markVaultUnavailable(resolvedPath) + }, [aiWorkspaceWindow, noteWindowParams, resolvedPath, vault]) + + const notes = useNoteActions({ + addEntry: vault.addEntry, + removeEntry: vault.removeEntry, + entries: visibleEntries, + flushBeforeNoteSwitch: flushEditorStateBeforeAction, + flushBeforeNoteMutation: flushEditorStateBeforeAction, + reloadVault: vault.reloadVault, + setToastMessage, + updateEntry: vault.updateEntry, + vaultPath: resolvedPath, + defaultWorkspacePath: multiWorkspaceEnabled ? defaultWorkspacePath : null, + vaults: graphVaults ?? [], + addPendingSave: handleCreatedVaultEntryPersisting, + removePendingSave: vault.removePendingSave, + trackUnsaved: vault.trackUnsaved, + clearUnsaved: vault.clearUnsaved, + unsavedPaths: vault.unsavedPaths, + markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), + onNewNotePersisted: handleCreatedVaultEntryPersisted, + onMissingActiveVault: handleMissingActiveVault, + onTypeStateChanged: async () => { await vault.reloadVault() }, + replaceEntry: vault.replaceEntry, + onInternalVaultWrite: markRecentVaultWrite, + onFrontmatterPersisted: refreshGitModifiedFiles, + onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), + }) + const { + handleSelectNote, + handleReplaceActiveTab, + closeAllTabs, + openTabWithContent, + } = notes + const noteActiveTabPath = notes.activeTabPath + const noteActiveTabPathRef = notes.activeTabPathRef + const refocusActiveEditor = useCallback((path: string) => { + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path } })) + }, []) + useNoteWindowLifecycle({ + activeTabPath: notes.activeTabPath, + handleSelectNote, + noteWindowParams, + openTabWithContent, + setToastMessage, + tabs: notes.tabs, + }) + const handleVaultUpdate = useCallback(async ( + updatedFiles: string[], + options: { vaultPath?: string } = {}, + ) => { + const updateVaultPath = options.vaultPath ?? resolvedPath + await refreshPulledVaultState({ + activeTabPath: noteActiveTabPath, + closeAllTabs, + getActiveTabPath: () => noteActiveTabPathRef.current, + hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), + reloadFolders: vault.reloadFolders, + reloadVault: vault.reloadVault, + reloadViews: vault.reloadViews, + replaceActiveTab: handleReplaceActiveTab, + refocusActiveEditor, + shouldRefocusActiveEditor: isActiveElementInsideEditorSurface, + updatedFiles, + vaultPath: updateVaultPath, + }) + await refreshGitModifiedFiles() + }, [ + closeAllTabs, + handleReplaceActiveTab, + noteActiveTabPath, + noteActiveTabPathRef, + refocusActiveEditor, + refreshGitModifiedFiles, + resolvedPath, + vault.reloadFolders, + vault.reloadVault, + vault.reloadViews, + vault.unsavedPaths, + ]) + const handlePulledVaultUpdate = useCallback( + (updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, { vaultPath }), + [handleVaultUpdate], + ) + const refreshGitHistorySurfaces = useCallback(() => { + setGitHistoryRefreshKey((key) => key + 1) + }, []) + const handleFocusedVaultUpdate = useCallback( + (updatedFiles: string[]) => handleVaultUpdate(updatedFiles), + [handleVaultUpdate], + ) + useEffect(() => { + if (watchedVaultPaths.length === 0) return + let cancelled = false + for (const vaultPath of watchedVaultPaths) { + void syncVaultAssetScope(vaultPath).catch((err) => { + if (!cancelled) console.warn('[vault] Failed to sync asset scope:', err) + }) + } + return () => { + cancelled = true + } + }, [watchedVaultPaths]) + useVaultWatcher({ + vaultPath: resolvedPath, + vaultPaths: watchedVaultPaths, + onVaultChanged: handleFocusedVaultUpdate, + filterChangedPaths: filterExternalVaultPaths, + }) + const autoSync = useAutoSync({ + enabled: gitFeaturesEnabled && gitRepoState === 'ready', + vaultPath: gitSurfaces.syncRepositoryPath, + vaultPaths: activeGitRepositoryPaths, + intervalMinutes: settings.auto_pull_interval_minutes, + onVaultUpdated: handlePulledVaultUpdate, + onSyncUpdated: refreshGitHistorySurfaces, + onConflict: (files) => { + const names = files.map((f) => f.split('/').pop()).join(', ') + setToastMessage(`Conflict in ${names} — click to resolve`) + }, + onToast: (msg) => setToastMessage(msg), + }) + // Keep note entry in sync with vault entries so banners (trash/archive) + // and read-only state react immediately without reopening the note. + useEffect(() => { + notes.setTabs(prev => { + let changed = false + const next = prev.map(tab => { + const fresh = visibleEntries.find(e => e.path === tab.entry.path) + if (fresh && shouldReplaceSyncedTabEntry(tab.entry, fresh)) { + changed = true + return { ...tab, entry: fresh } + } + return tab + }) + return changed ? next : prev + }) + }, [visibleEntries, notes.setTabs]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter) + + const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({ + entries: visibleEntries, + activeTabPath: notes.activeTabPath, + onSelectNote: notes.handleSelectNote, + }) + + const handleOpenFavorite = useCallback(async (entry: VaultEntry) => { + await handleReplaceActiveTab(entry) + handleEnterNeighborhood(entry) + }, [handleEnterNeighborhood, handleReplaceActiveTab]) + + const vaultBridge = useVaultBridge({ + entriesByPath, + resolvedPath, + reloadVault: vault.reloadVault, + reloadFolders: vault.reloadFolders, + reloadViews: vault.reloadViews, + closeAllTabs, + replaceActiveTab: handleReplaceActiveTab, + refocusActiveEditor, + hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), + shouldRefocusActiveEditor: isActiveElementInsideEditorSurface, + onSelectNote: notes.handleSelectNote, + activeTabPath: notes.activeTabPath, + getActiveTabPath: () => notes.activeTabPathRef.current, + }) + const handleAiWorkspaceWindowOpenNote = notes.handleNavigateWikilink + const { + handleAgentFileCreated: handleAiWorkspaceWindowFileCreated, + handleAgentFileModified: handleAiWorkspaceWindowFileModified, + handleAgentVaultChanged: handleAiWorkspaceWindowVaultChanged, + } = vaultBridge + useAiWorkspaceWindowBridgeEvents({ + onFileCreated: handleAiWorkspaceWindowFileCreated, + onFileModified: handleAiWorkspaceWindowFileModified, + onOpenNote: handleAiWorkspaceWindowOpenNote, + onVaultChanged: handleAiWorkspaceWindowVaultChanged, + }) + + const conflictFlow = useConflictFlow({ + resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath, + entries: visibleEntries, + conflictFiles: autoSync.conflictFiles, + pausePull: autoSync.pausePull, resumePull: autoSync.resumePull, + triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault, + initConflictFiles: conflictResolver.initFiles, + openConflictResolver: dialogs.openConflictResolver, + closeConflictResolver: dialogs.closeConflictResolver, + onSelectNote: notes.handleSelectNote, + activeTabPath: notes.activeTabPath, + setToastMessage, + }) + + const appSave = useAppSave({ + updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage, + loadModifiedFiles: refreshGitModifiedFiles, reloadViews: async () => { await vault.reloadViews() }, + trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, + tabs: notes.tabs, activeTabPath: notes.activeTabPath, + handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, + replaceEntry: vault.replaceEntry, resolvedPath, + writableVaultPaths, + initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, + onInternalVaultWrite: markRecentVaultWrite, + locale: appLocale, + }) + useEffect(() => { + appSaveFlushBeforeActionRef.current = appSave.flushBeforeAction + }, [appSave.flushBeforeAction]) + + const handleChangeWorkspace = useCallback(async (entry: VaultEntry, workspace: WorkspaceIdentity) => { + const sourceVaultPath = vaultPathForEntry(entry, resolvedPath) + if (sourceVaultPath === workspace.path) return + + try { + await flushEditorStateBeforeAction(entry.path) + const result = await notes.handleMoveNoteToWorkspace( + entry.path, + workspace, + sourceVaultPath, + (oldPath, newEntry) => { + appSave.trackRenamedPath(oldPath, newEntry.path) + vault.replaceEntry(oldPath, newEntry) + if (effectiveSelection.kind === 'entity' && effectiveSelection.entry.path === oldPath) { + handleSetSelection({ + kind: 'entity', + entry: { + ...effectiveSelection.entry, + ...newEntry, + }, + }) + } + }, + ) + if (!result) return + + markRecentVaultWrite(entry.path) + markRecentVaultWrite(result.new_path) + await refreshGitModifiedFiles() + } catch (err) { + console.error('Failed to change note workspace:', err) + setToastMessage(`Failed to move note: ${String(err)}`) + } + }, [ + appSave, + effectiveSelection, + flushEditorStateBeforeAction, + handleSetSelection, + markRecentVaultWrite, + notes, + refreshGitModifiedFiles, + resolvedPath, + vault + ]) + + const aiActivity = useAiActivity({ + onOpenNote: vaultBridge.openNoteByPath, + onOpenTab: vaultBridge.openNoteByPath, + onSetFilter: (filterType) => { + handleSetSelection({ kind: 'sectionGroup', type: filterType }) + }, + onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) }, + }) + + const handleInitializeProperties = useCallback((path: string) => { + void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => { + console.warn('Failed to initialize note properties:', err) + }) + }, [notes]) + + const handleRemoveNoteIcon = useCallback(async (path: string) => { + await notes.handleDeleteProperty(path, 'icon') + }, [notes]) + + const handleSetNoteIconCommand = useCallback(() => { + setInspectorCollapsed(false) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + focusNoteIconPropertyEditor() + }) + }) + }, [setInspectorCollapsed]) + + const handleCustomizeNoteListColumns = useCallback(() => { + if (effectiveSelection.kind === 'view') { + openNoteListPropertiesPicker('view') + return + } + + if (effectiveSelection.kind !== 'filter') return + if (effectiveSelection.filter === 'all') { + openNoteListPropertiesPicker('all') + return + } + if (effectiveSelection.filter === 'inbox') { + openNoteListPropertiesPicker('inbox') + } + }, [effectiveSelection]) + + const handleUpdateAllNotesNoteListProperties = useCallback((value: string[] | null) => { + updateConfig('allNotes', { + ...(vaultConfig.allNotes ?? { noteListProperties: null }), + noteListProperties: value && value.length > 0 ? value : null, + }) + }, [updateConfig, vaultConfig.allNotes]) + + const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => { + updateConfig('inbox', { + ...(vaultConfig.inbox ?? { noteListProperties: null }), + noteListProperties: value && value.length > 0 ? value : null, + }) + }, [updateConfig, vaultConfig.inbox]) + + const handleCreateFolder = useCallback(async ( + name: string, + parent?: { path: string; rootPath?: string }, + ) => { + try { + const vaultPath = parent?.rootPath?.trim() ? parent.rootPath : resolvedPath + const parentPath = parent?.path && parent.path.length > 0 ? parent.path : null + const args = { vaultPath, folderName: name, parentPath } + if (isTauri()) { + await invoke('create_vault_folder', args) + } else { + await mockInvoke('create_vault_folder', args) + } + await vault.reloadFolders() + setToastMessage(`Created folder "${name}"`) + return true + } catch (e) { + setToastMessage(`Failed to create folder: ${e}`) + return false + } + }, [resolvedPath, vault]) + + const folderActions = useFolderActions({ + vaultPath: resolvedPath, + selection: effectiveSelection, + setSelection: handleSetSelection, + setTabs: notes.setTabs, + activeTabPathRef: notes.activeTabPathRef, + handleSwitchTab: notes.handleSwitchTab, + closeAllTabs: notes.closeAllTabs, + reloadVault: vault.reloadVault, + reloadFolders: vault.reloadFolders, + setToastMessage, + }) + const fileActions = useFileActions({ + selection: effectiveSelection, + setToastMessage, + vaultPath: resolvedPath, + }) + + const handleRemoveNoteIconCommand = useCallback(() => { + if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) + }, [notes.activeTabPath, handleRemoveNoteIcon]) + + const handleOpenInNewWindow = useCallback(() => { + const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath) + if (activeTab) { + openNoteInNewWindow( + activeTab.entry.path, + vaultPathForEntry(activeTab.entry, resolvedPath), + activeTab.entry.title, + ) + } + }, [notes.tabs, notes.activeTabPath, resolvedPath]) + + const handleOpenEntryInNewWindow = useCallback((entry: Pick) => { + openNoteInNewWindow(entry.path, vaultPathForEntry(entry, resolvedPath), entry.title) + }, [resolvedPath]) + + const allGitModifiedFiles = useMemo( + () => mergeModifiedFiles( + gitSurfaces.allModifiedFiles, + activeVaultModifiedFiles(vault.modifiedFiles, resolvedPath), + ), + [gitSurfaces.allModifiedFiles, resolvedPath, vault.modifiedFiles], + ) + const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles + const commitModifiedFiles = gitSurfaces.commitModifiedFiles + const changesRepositoryPath = gitSurfaces.changesRepositoryPath + const gitModifiedCount = gitFeaturesEnabled ? allGitModifiedFiles.length : 0 + + const { + activeDeletedFile, + activeNoteModified, + handleDiscardFile, + handleOpenDeletedNote, + handlePendingDiffHandled, + handlePulseOpenNote, + handleReplaceActiveTabWithQueuedDiff, + loadDiffAtCommitForPath, + loadDiffForPath, + loadGitHistoryForPath, + pendingDiffRequest, + } = useGitFileWorkflows({ + activeTabPath: notes.activeTabPath, + allGitModifiedFiles, + changesRepositoryPath, + effectiveSelection, + entriesByPath, + historyRepositoryPath: gitSurfaces.historyRepositoryPath, + loadModifiedFilesForRepository, + onCloseAllTabs: notes.closeAllTabs, + onOpenTabWithContent: notes.openTabWithContent, + onReplaceActiveTab: notes.handleReplaceActiveTab, + onSelectNote: notes.handleSelectNote, + reloadVault: vault.reloadVault, + resolvedPath, + selectedChangesModifiedFiles, + setToastMessage, + tabs: notes.tabs, + vaultEntries: vault.entries, + visibleEntries, + }) + + const commitFlow = useCommitFlow({ + aiFeaturesEnabled, + commitMessageTarget: quickPromptTarget, + commitMessageTargetReady: quickPromptTargetReady, + savePending: appSave.savePending, + loadModifiedFiles: refreshGitModifiedFiles, + loadModifiedFilesForVaultPath: loadModifiedFilesForRepository, + resolveRemoteStatusForVaultPath: refreshRemoteStatusForRepository, + setToastMessage, + onPushRejected: autoSync.handlePushRejected, + automaticVaultPaths: gitFeaturesEnabled ? activeGitRepositoryPaths : [], + locale: appLocale, + manualVaultPath: gitSurfaces.commitRepositoryPath, + vaultPath: resolvedPath, + }) + const suggestedCommitMessage = useMemo(() => generateCommitMessage(commitModifiedFiles), [commitModifiedFiles]) + const isGitVault = gitFeaturesEnabled && gitRepoState !== 'missing' + const { + activitySignature: autoGitActivitySignature, + hasPendingWork: autoGitHasPendingWork, + } = useAutoGitWork({ + activeRemoteStatus: autoSync.remoteStatus, + activeVaultPath: resolvedPath, + modifiedFiles: allGitModifiedFiles, + repositoryPaths: activeGitRepositoryPaths, + remoteStatusForRepository: gitSurfaces.remoteStatusForRepository, + }) + const autoGit = useAutoGit({ + enabled: settings.autogit_enabled === true, + idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90, + inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30, + isGitVault, + hasPendingChanges: autoGitHasPendingWork, + hasUnsavedChanges: vault.unsavedPaths.size > 0, + onCheckpoint: () => commitFlow.runAutomaticCheckpoint(), + }) + const recordAutoGitActivity = autoGit.recordActivity + const openCommitDialog = commitFlow.openCommitDialog + const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint + const handleAppContentChange = appSave.handleContentChange + const handleAppSave = appSave.handleSave + const loadModifiedFiles = refreshGitModifiedFiles + const triggerSync = autoSync.triggerSync + const pullAndPush = autoSync.pullAndPush + + useEffect(() => { + if (!gitFeaturesEnabled) return + if (!isChangesSelection) return + void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true }) + }, [changesRepositoryPath, gitFeaturesEnabled, isChangesSelection, loadModifiedFilesForRepository]) + + useEffect(() => { + if (autoGitActivitySignature.length === 0) return + recordAutoGitActivity() + }, [autoGitActivitySignature, recordAutoGitActivity]) + + const handleCommitPush = useCallback(() => { + if (!gitFeaturesEnabled) return + triggerCommitEntryAction({ + autoGitEnabled: settings.autogit_enabled === true, + openCommitDialog, + runAutomaticCheckpoint, + }) + }, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) + const handlePullRepository = useCallback((targetVaultPath: string) => { + if (!gitFeaturesEnabled) return + triggerSync(targetVaultPath) + }, [gitFeaturesEnabled, triggerSync]) + const handlePullSelectedRepository = useCallback(() => { + if (!gitFeaturesEnabled) return + triggerSync() + }, [gitFeaturesEnabled, triggerSync]) + const handlePullAndPushSelectedRepository = useCallback(() => { + pullAndPush() + }, [pullAndPush]) + + const handleTrackedContentChange = useCallback((path: string, content: string) => { + recordAutoGitActivity() + handleAppContentChange(path, content) + }, [handleAppContentChange, recordAutoGitActivity]) + + const handleTrackedSave = useCallback(async (...args: Parameters) => { + if (notes.activeTabPath) { + flushPendingEditorContentRef.current?.(notes.activeTabPath) + flushPendingRawContentRef.current?.(notes.activeTabPath) + } + const result = await handleAppSave(...args) + const activeTab = notes.activeTabPath + ? notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath) + : null + if (activeTab) { + await loadModifiedFilesForRepository(vaultPathForEntry(activeTab.entry, resolvedPath), { + includeStats: isChangesSelection, + }) + } + recordAutoGitActivity() + return result + }, [ + handleAppSave, + isChangesSelection, + loadModifiedFilesForRepository, + notes.activeTabPath, + notes.tabs, + recordAutoGitActivity, + resolvedPath, + ]) + + const seedAutoGitSavedChange = useCallback(async () => { + if (isTauri()) { + throw new Error('seedAutoGitSavedChange is only available in browser smoke tests') + } + + const activePath = notes.activeTabPath + const activeTab = activePath + ? notes.tabs.find((tab) => tab.entry.path === activePath) + : null + + if (!activePath || !activeTab) { + throw new Error('No active note is available for the AutoGit test bridge') + } + + const saveNoteContent = window.__mockHandlers?.save_note_content + const activeVaultPath = vaultPathForEntry(activeTab.entry, resolvedPath) + if (typeof saveNoteContent === 'function') { + await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content, vaultPath: activeVaultPath })) + } else { + await mockInvoke('save_note_content', { path: activePath, content: activeTab.content, vaultPath: activeVaultPath }) + } + + await loadModifiedFiles() + recordAutoGitActivity() + }, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity, resolvedPath]) + + useEffect(() => { + window.__laputaTest = { + ...window.__laputaTest, + activeTabPath: notes.activeTabPath, + seedAutoGitSavedChange, + } + + return () => { + if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) { + delete window.__laputaTest.seedAutoGitSavedChange + } + } + }, [notes.activeTabPath, seedAutoGitSavedChange]) + + const entryActions = useEntryActions({ + entries: visibleEntries, updateEntry: vault.updateEntry, + handleUpdateFrontmatter: notes.handleUpdateFrontmatter, + handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, + createTypeEntry: notes.createTypeEntrySilent, + onBeforeAction: flushEditorStateBeforeAction, + actionHistory: notes.actionHistory, + }) + + const resolveVaultPathForNotePath = useCallback((path: string) => { + const entry = vault.entries.find((candidate) => candidate.path === path) + return entry ? vaultPathForEntry(entry, resolvedPath) : resolvedPath + }, [resolvedPath, vault.entries]) + + const deleteActions = useDeleteActions({ + onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() }, + removeEntry: vault.removeEntry, + removeEntries: vault.removeEntries, + resolveVaultPathForPath: resolveVaultPathForNotePath, + refreshModifiedFiles: refreshGitModifiedFiles, + reloadVault: vault.reloadVault, + setToastMessage, + }) + + const handleDeleteType = useCallback((typeName: string) => { + const request = resolveTypeDeleteRequest(vault.entries, typeName) + if (request.kind === 'blocked') { + trackEvent('sidebar_type_delete_blocked', { + reason: request.reason, + instance_count: request.instanceCount, + }) + setToastMessage(translate(appLocale, typeDeleteBlockedMessageKey(request), { + count: request.instanceCount, + type: typeName, + })) + return + } + + trackEvent('sidebar_type_delete_requested') + deleteActions.handleDeleteNote(request.typeEntry.path) + }, [appLocale, deleteActions, vault.entries]) + + const shouldLoadGitHistory = !layout.inspectorCollapsed && !effectiveShowAIChat + const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory, gitHistoryRefreshKey) + + const { + availableFields, + handleCreateMissingType, + handleCreateOrUpdateView, + handleCreateType, + handleDeleteView, + handleEditView, + handleSidebarUpdateViewDefinition, + handleUpdateViewDefinition, + } = useAppViewActions({ + editingView: dialogs.editingView, + graphDefaultWorkspacePath, + handleSetSelection, + multiWorkspaceEnabled, + notes, + onOpenEditView: dialogs.openEditView, + resolvedPath, + selection, + setToastMessage, + vault, + visibleEntries, + }) + + const bulkActions = useBulkActions(entryActions, visibleEntries, setToastMessage) + + const { + buildNumber, + diffToggleRef, + findInNoteRef, + handleCollapseSidebar, + handleSetViewMode, + handleToggleInspector, + noteListVisible, + pdfExportRef, + rawToggleRef, + sidebarVisible, + tableOfContentsToggleRef, + zoom, + } = useAppWindowControls({ + layout, + windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, + }) + const turnCurrentBlockIntoRef = useRef<((target: RichEditorBlockTypeDefinition) => void) | null>(null) + + const { status: updateStatus, actions: updateActions } = useUpdater( + settings.release_channel, + areAutomaticUpdateChecksEnabled(settings), + ) + + const handleCheckForUpdates = useCallback(async () => { + if (updateStatus.state === 'downloading') { + setToastMessage('Update is downloading…') + return + } + if (updateStatus.state === 'ready') { + await restartApp() + return + } + setToastMessage(translate(appLocale, 'update.checking')) + const result = await updateActions.checkForUpdates() + if (result.kind === 'up-to-date') { + const checkedChannel = normalizeReleaseChannel(settings.release_channel) + setToastMessage(`No newer ${checkedChannel} update is available right now`) + } else if (result.kind === 'available') { + setToastMessage(`Tolaria ${result.displayVersion} is available`) + } else { + setToastMessage(result.message) + } + }, [appLocale, settings.release_channel, updateActions, updateStatus.state]) + + const handleRepairVault = useCallback(async () => { + if (!resolvedPath) return + try { + const tauriInvoke = isTauri() ? invoke : mockInvoke + const msg = await tauriInvoke('repair_vault', { vaultPath: resolvedPath }) + await vault.reloadVault() + await refreshVaultAiGuidance() + setToastMessage(msg) + } catch (err) { + setToastMessage(`Failed to repair vault: ${err}`) + } + }, [refreshVaultAiGuidance, resolvedPath, vault]) + + const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => { + if (!resolvedPath) return + try { + const tauriInvoke = isTauri() ? invoke : mockInvoke + await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath }) + await vault.reloadVault() + await refreshVaultAiGuidance() + if (successToast) setToastMessage(successToast) + } catch (err) { + setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`) + } + }, [refreshVaultAiGuidance, resolvedPath, vault]) + + const activeCommandEntry = useMemo(() => { + if (!notes.activeTabPath) return null + return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry + ?? vault.entries.find((entry) => entry.path === notes.activeTabPath) + ?? null + }, [notes.activeTabPath, notes.tabs, vault.entries]) + const noteRetargetingUi = useNoteRetargetingUi({ + activeEntry: activeCommandEntry, + activeNoteBlocked: !!activeDeletedFile, + entries: visibleEntries, + folders: vault.folders, + selection: effectiveSelection, + setSelection: handleSetSelection, + setToastMessage, + vaultPath: resolvedPath, + updateFrontmatter: notes.handleUpdateFrontmatter, + moveNoteToFolder: notes.handleMoveNoteToFolder, + }) + + const canToggleRichEditor = !!activeCommandEntry + && activeCommandEntry.filename.toLowerCase().endsWith('.md') + && !activeDeletedFile + const shouldBlockNeighborhoodEscape = ( + dialogs.showCreateTypeDialog + || dialogs.showQuickOpen + || dialogs.showCommandPalette + || effectiveShowAIChat + || dialogs.showSettings + || dialogs.showCloneVault + || dialogs.showSearch + || dialogs.showConflictResolver + || dialogs.showCreateViewDialog + || noteRetargetingUi.isDialogOpen + || showFeedback + ) + + useNeighborhoodEscape({ + onBack: handleNeighborhoodHistoryBack, + selectionRef, + shouldBlockNeighborhoodEscape, + }) + + const noteListColumnsLabel = useMemo(() => { + if (effectiveSelection.kind === 'view') { + const selectedView = vault.views.find((view) => viewMatchesSelection(view, effectiveSelection)) + return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns' + } + + return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all' + ? 'Customize All Notes columns' + : 'Customize Inbox columns' + }, [effectiveSelection, vault.views]) + const viewOrdering = useSavedViewOrdering({ + views: vault.views, + selection: effectiveSelection, + vaultPath: resolvedPath, + reloadViews: vault.reloadViews, + loadModifiedFiles: refreshGitModifiedFiles, + onToast: setToastMessage, + locale: appLocale, + }) + const canReorderSavedViews = useMemo(() => ( + vault.views.every((view) => !view.rootPath) + ), [vault.views]) + const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [diffToggleRef]) + const toggleRawEditorCommand = useMemo( + () => canToggleRichEditor ? () => rawToggleRef.current() : undefined, + [canToggleRichEditor, rawToggleRef], + ) + const toggleTableOfContentsCommand = useCallback(() => { + if (notes.activeTabPath) tableOfContentsToggleRef.current() + }, [notes.activeTabPath, tableOfContentsToggleRef]) + const exportNotePdfCommand = useCallback(() => { + pdfExportRef.current?.('app_command') + }, [pdfExportRef]) + const findInNoteCommand = useCallback(() => { + findInNoteRef.current?.({ replace: false }) + }, [findInNoteRef]) + const replaceInNoteCommand = useCallback(() => { + findInNoteRef.current?.({ replace: true }) + }, [findInNoteRef]) + const turnCurrentBlockIntoCommand = useCallback((target: RichEditorBlockTypeDefinition) => { + turnCurrentBlockIntoRef.current?.(target) + }, []) + const pastePlainTextCommand = useCallback(() => { + void requestPlainTextPaste().catch((error) => { + console.warn('[paste] Failed to paste plain text:', error) + }) + }, []) + const removeActiveVaultCommand = useCallback(() => { + vaultSwitcher.removeVault(vaultSwitcher.vaultPath) + }, [vaultSwitcher]) + const restoreVaultAiGuidanceCommand = useCallback(() => { + void restoreVaultAiGuidance() + }, [restoreVaultAiGuidance]) + const changeNoteTypeCommand = useMemo( + () => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined, + [noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog], + ) + const moveNoteToFolderCommand = useMemo( + () => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined, + [noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog], + ) + const activeNoteHasIcon = useMemo(() => { + const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath) + return hasNoteIconValue(entry?.icon) + }, [notes.activeTabPath, vault.entries]) + const handleToggleOrganizedWithInboxAdvance = useInboxOrganizeAdvance({ + activeTabPath: notes.activeTabPath, + activeTabPathRef: notes.activeTabPathRef, + autoAdvanceEnabled: settings.auto_advance_inbox_after_organize === true, + entries: visibleEntries, + onSelectNote: notes.handleSelectNote, + onToggleOrganized: entryActions.handleToggleOrganized, + requestedActiveTabPathRef: notes.requestedActiveTabPathRef, + selection: effectiveSelection, + visibleNotesRef, + }) + const toggleOrganizedCommand = explicitOrganizationEnabled ? handleToggleOrganizedWithInboxAdvance : undefined + const canCustomizeNoteListColumns = useMemo(() => ( + canCustomizeColumnsForSelection(effectiveSelection, explicitOrganizationEnabled) + ), [effectiveSelection, explicitOrganizationEnabled]) + const restoreDeletedNoteCommand = useMemo( + () => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, + [activeDeletedFile, handleDiscardFile], + ) + const reloadVaultForCommand = vault.reloadVault + const handleManualVaultReload = useCallback(async () => { + const entries = await reloadVaultForCommand() + setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`) + return entries + }, [reloadVaultForCommand]) + + const { + activeTab, + defaultNoteWidth, + noteWidth: activeNoteWidth, + setDefaultNoteWidth: handleSetDefaultNoteWidth, + setNoteWidth: handleSetActiveNoteWidth, + toggleNoteWidth: handleToggleNoteWidth, + } = useNoteWidthMode({ + tabs: notes.tabs, + activeTabPath: notes.activeTabPath, + settings, + saveSettings, + updateFrontmatter: notes.handleUpdateFrontmatter, + setToastMessage, + }) + const activeTabEntry = activeTab?.entry ?? null + const activeTabPath = activeTabEntry?.path + const handleSelectNoteForPdfExport = notes.handleSelectNote + const handleExportNotePdfFromList = useCallback((entry: VaultEntry) => { + if (!isMarkdownEntry(entry)) return + + if (activeTabPath === entry.path) { + pdfExportRef.current?.('note_list_context_menu') + return + } + + setPendingNoteListPdfExportPath(entry.path) + handleSelectNoteForPdfExport(entry) + }, [activeTabPath, handleSelectNoteForPdfExport, pdfExportRef]) + useEffect(() => { + if (!pendingNoteListPdfExportPath) return + if (!activeTabEntry || activeTabPath !== pendingNoteListPdfExportPath) return + + const frameId = requestAnimationFrame(() => { + if (isMarkdownEntry(activeTabEntry)) pdfExportRef.current?.('note_list_context_menu') + setPendingNoteListPdfExportPath(null) + }) + + return () => cancelAnimationFrame(frameId) + }, [activeTabEntry, activeTabPath, pendingNoteListPdfExportPath, pdfExportRef]) + + const { + isStartupLoading, + isVaultContentLoading, + shouldResumeFreshStartOnboarding, + shouldShowStartupScreen, + } = useStartupScreenState({ + aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt, + isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow, + onboardingState: onboarding.state, + runtimeMissingVaultPath, + selectedVaultPath, + settingsLoaded, + showMcpSetupDialog: mcpSetupDialog.open, + telemetryConsent: settings.telemetry_consent, + vaultIsLoading: vault.isLoading, + vaultSwitcher, + }) + const deepLinks = useDeepLinks({ + activeEntry: activeTab?.entry ?? null, + currentVaultPath: resolvedPath, + enabled: !noteWindowParams && !aiWorkspaceWindow, + entries: visibleEntries, + isVaultContentLoading, + locale: appLocale, + onSelectNote: notes.handleSelectNote, + onSwitchVault: vaultSwitcher.switchVault, + reloadVault: vault.reloadVault, + setToastMessage, + vaultListLoaded: vaultSwitcher.loaded, + vaults: vaultSwitcher.allVaults, + }) + const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath + const noteGitUrls = useNoteGitUrls({ + currentVaultPath: resolvedPath, + locale: appLocale, + remoteStatusForRepository: gitSurfaces.remoteStatusForRepository, + setToastMessage, + }) + const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences) + const undoCommand = useCallback(() => { + if (runNativeTextHistoryCommand('undo')) return + void notes.handleUndo() + }, [notes]) + const redoCommand = useCallback(() => { + if (runNativeTextHistoryCommand('redo')) return + void notes.handleRedo() + }, [notes]) + + const commands = useAppCommands({ + activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, + entries: visibleEntries, + visibleNotesRef, + multiSelectionCommandRef, + modifiedCount: gitModifiedCount, + activeNoteModified, + selection: effectiveSelection, + onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette, + onSearch: dialogs.openSearch, + onFindInNote: findInNoteCommand, + onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand, + onTurnCurrentBlockInto: activeDeletedFile ? undefined : turnCurrentBlockIntoCommand, + onPastePlainText: pastePlainTextCommand, + onCreateNote: notes.handleCreateNoteImmediate, + onCreateNoteOfType: notes.handleCreateNoteImmediate, + onSave: appSave.handleSave, + onUndo: undoCommand, + onRedo: redoCommand, + canUndo: notes.canUndo, + canRedo: notes.canRedo, + undoLabel: notes.undoLabel, + redoLabel: notes.redoLabel, + onOpenSettings: handleOpenSettings, + onOpenFeedback: openFeedback, + onDeleteNote: deleteActions.handleDeleteNote, + onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, + onCommitPush: handleCommitPush, + onGenerateCommitMessage: commitFlow.openCommitDialogWithGeneratedMessage, + gitRepositories, + gitFeaturesEnabled, + isGitVault, + onInitializeGit: openGitSetupDialog, + onPull: handlePullSelectedRepository, + onPullRepository: handlePullRepository, + onResolveConflicts: conflictFlow.handleOpenConflictResolver, + onSetViewMode: handleSetViewMode, + onToggleInspector: handleToggleInspector, + onToggleDiff: toggleDiffCommand, + onToggleRawEditor: toggleRawEditorCommand, + onToggleTableOfContents: toggleTableOfContentsCommand, + onExportNoteAsPdf: activeDeletedFile ? undefined : exportNotePdfCommand, + noteWidth: activeNoteWidth, + defaultNoteWidth, + onSetNoteWidth: handleSetActiveNoteWidth, + onSetDefaultNoteWidth: handleSetDefaultNoteWidth, + selectedViewName: viewOrdering.selectedViewName, + onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp, + onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown, + canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp, + canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown, + onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, + zoomLevel: zoom.zoomLevel, + onSelect: handleSetSelection, + onRenameFolder: folderActions.renameSelectedFolder, + onDeleteFolder: folderActions.deleteSelectedFolder, + onRevealSelectedFolder: fileActions.revealSelectedFolder, + onCopySelectedFolderPath: fileActions.copySelectedFolderPath, + showInbox: explicitOrganizationEnabled, + onReplaceActiveTab: notes.handleReplaceActiveTab, + onSelectNote: notes.handleSelectNote, + onGoBack: handleGoBack, onGoForward: handleGoForward, + canGoBack: canGoBack, canGoForward: canGoForward, + onOpenVault: vaultSwitcher.handleOpenLocalFolder, + onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault, + onCreateType: dialogs.openCreateType, + ...commandAiActions, + onCheckForUpdates: handleCheckForUpdates, + onRemoveActiveVault: removeActiveVaultCommand, + onRestoreGettingStarted: cloneGettingStartedVault, + isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, + vaultCount: vaultSwitcher.allVaults.length, + locale: appLocale, + systemLocale, + selectedUiLanguage, + onSetUiLanguage: handleSetUiLanguage, + onSetThemeMode: handleSetThemeMode, + mcpStatus: mcpSetupDialog.status, + onInstallMcp: mcpSetupDialog.openDialog, + onReloadVault: handleManualVaultReload, + onRepairVault: handleRepairVault, + onSetNoteIcon: handleSetNoteIconCommand, + onRemoveNoteIcon: handleRemoveNoteIconCommand, + onChangeNoteType: changeNoteTypeCommand, + onMoveNoteToFolder: moveNoteToFolderCommand, + canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder, + activeNoteHasIcon, + noteListFilter, + onSetNoteListFilter: setNoteListFilter, + onOpenInNewWindow: handleOpenInNewWindow, + onRevealActiveFile: fileActions.revealFile, + onCopyActiveFilePath: fileActions.copyFilePath, + onCopyActiveDeepLink: deepLinks.copyPathDeepLink, + onOpenActiveFileExternal: fileActions.openExternalFile, + onToggleFavorite: entryActions.handleToggleFavorite, + onToggleOrganized: toggleOrganizedCommand, + onCustomizeNoteListColumns: handleCustomizeNoteListColumns, + canCustomizeNoteListColumns, + noteListColumnsLabel, + onRestoreDeletedNote: restoreDeletedNoteCommand, + canRestoreDeletedNote: !!activeDeletedFile, + }) + + const { + inboxCount, + noteList: aiNoteList, + noteListFilter: aiNoteListFilter, + } = useAiWorkspacePublishedContext({ + activeTab, + allNotesFileVisibility, + context: aiWorkspaceWindowContext, + effectiveSelection, + entries: visibleEntries, + inboxPeriod, + tabs: notes.tabs, + views: vault.views, + }) + + const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => { + void saveSettings({ ...settings, ai_workspace_conversations: conversations }) + }, [saveSettings, settings]) + const aiWorkspaceSurface = ( + tab.entry)} + noteList={aiNoteList} + noteListFilter={aiNoteListFilter} + onActiveConversationChange={handleActiveAiWorkspaceConversationChange} + onActiveTargetChange={handleActiveAiWorkspaceTargetChange} + onClose={closeAIChat} + onConversationSettingsChange={handleAiWorkspaceConversationsChange} + onOpenAiSettings={handleOpenAiSettings} + onOpenNote={notes.handleNavigateWikilink} + onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} + onUnsupportedAiPaste={setToastMessage} + onFileCreated={vaultBridge.handleAgentFileCreated} + onFileModified={vaultBridge.handleAgentFileModified} + onVaultChanged={vaultBridge.handleAgentVaultChanged} + vaultAiGuidanceStatus={vaultAiGuidanceStatus} + vaultPath={activeEditorVaultPath} + vaultPaths={writableVaultPaths} + locale={appLocale} + /> + ) + if (shouldShowStartupScreen) { + return ( + + ) + } + + if (aiWorkspaceWindow) { + return ( + + {aiWorkspaceSurface} + + ) + } + + const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : undefined + const noteListModifiedFilesError = isChangesSelection ? gitSurfaces.changesModifiedFilesError : null + + return ( + +
      +
      + {sidebarVisible && ( + <> +
      + +
      + + + )} + {noteListVisible && ( + <> +
      + {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( + handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} /> + ) : ( + + )} +
      + + + )} +
      + +
      +
      + + + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpSetupDialog.status} onInstallMcp={mcpSetupDialog.openDialog} locale={appLocale} /> + {aiFeaturesEnabled && !effectiveShowAIChat ? ( + + ) : null} + + + setToastMessage(null)} /> + notes.handleCreateNote(title, 'Note', 'quick_open')} onClose={dialogs.closeQuickOpen} locale={appLocale} /> + + + + + + + + + + + + {deleteActions.confirmDelete && ( + deleteActions.setConfirmDelete(null)} + /> + )} + {folderActions.confirmDeleteFolder && ( + + )} +
      +
      + ) +} + +export default App diff --git a/src/assets/sponsors/circleci-dark.svg b/src/assets/sponsors/circleci-dark.svg new file mode 100644 index 0000000..cceda54 --- /dev/null +++ b/src/assets/sponsors/circleci-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/sponsors/circleci-light.svg b/src/assets/sponsors/circleci-light.svg new file mode 100644 index 0000000..f9607e3 --- /dev/null +++ b/src/assets/sponsors/circleci-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/sponsors/codacy-dark.svg b/src/assets/sponsors/codacy-dark.svg new file mode 100644 index 0000000..882c2c5 --- /dev/null +++ b/src/assets/sponsors/codacy-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/sponsors/codacy-light.svg b/src/assets/sponsors/codacy-light.svg new file mode 100644 index 0000000..6b74afc --- /dev/null +++ b/src/assets/sponsors/codacy-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/sponsors/codescene-dark.svg b/src/assets/sponsors/codescene-dark.svg new file mode 100644 index 0000000..2cc8968 --- /dev/null +++ b/src/assets/sponsors/codescene-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/sponsors/codescene-light.svg b/src/assets/sponsors/codescene-light.svg new file mode 100644 index 0000000..56dcb59 --- /dev/null +++ b/src/assets/sponsors/codescene-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/sponsors/unblocked-dark.svg b/src/assets/sponsors/unblocked-dark.svg new file mode 100644 index 0000000..049daef --- /dev/null +++ b/src/assets/sponsors/unblocked-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/sponsors/unblocked-light.svg b/src/assets/sponsors/unblocked-light.svg new file mode 100644 index 0000000..50bd778 --- /dev/null +++ b/src/assets/sponsors/unblocked-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/tolaria-icon.svg b/src/assets/tolaria-icon.svg new file mode 100644 index 0000000..8d3de9a --- /dev/null +++ b/src/assets/tolaria-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/collections/collectionFromSelection.test.ts b/src/collections/collectionFromSelection.test.ts new file mode 100644 index 0000000..3a9f279 --- /dev/null +++ b/src/collections/collectionFromSelection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import type { ViewDefinition, ViewFile } from '../types' +import { makeEntry } from '../test-utils/noteListTestUtils' +import { collectionFromSelection } from './collectionFromSelection' + +function emptyFilters(): ViewDefinition['filters'] { + return { all: [] } +} + +function makeView(definition: Partial = {}): ViewFile { + return { + filename: 'active-projects.yml', + definition: { + name: 'Active Projects', + icon: null, + color: null, + sort: 'modified:desc', + listPropertiesDisplay: ['status'], + filters: emptyFilters(), + ...definition, + }, + } +} + +describe('collectionFromSelection', () => { + it('maps built-in filters to list-presented collections', () => { + const collection = collectionFromSelection({ kind: 'filter', filter: 'all' }) + + expect(collection).toMatchObject({ + id: 'builtin:all', + label: 'All Notes', + origin: 'builtin', + presentation: { type: 'list', sort: null, properties: [] }, + }) + }) + + it('maps type and folder selections without requiring saved YAML', () => { + expect(collectionFromSelection({ kind: 'sectionGroup', type: 'Project' })).toMatchObject({ + id: 'type:Project', + label: 'Project', + origin: 'type', + }) + + expect(collectionFromSelection({ kind: 'folder', path: 'clients', rootPath: '/vault' })).toMatchObject({ + id: 'folder:/vault:clients', + label: 'clients', + origin: 'folder', + }) + }) + + it('maps neighborhood selections to a collection around the source note', () => { + const entry = makeEntry({ path: '/vault/alpha.md', title: 'Alpha' }) + const collection = collectionFromSelection({ kind: 'entity', entry }) + + expect(collection).toMatchObject({ + id: 'neighborhood:/vault/alpha.md', + label: 'Alpha', + origin: 'neighborhood', + entry, + }) + }) + + it('normalizes legacy saved-view list settings into presentation config', () => { + const view = makeView() + const collection = collectionFromSelection( + { kind: 'view', filename: view.filename }, + { views: [view] }, + ) + + expect(collection).toMatchObject({ + id: 'saved-view::active-projects.yml', + label: 'Active Projects', + origin: 'saved-view', + filter: view.definition.filters, + presentation: { type: 'list', sort: 'modified:desc', properties: ['status'] }, + view, + }) + }) + + it('lets nested list presentation config override legacy saved-view fields in memory', () => { + const view = makeView({ + presentation: { + type: 'list', + sort: 'title:asc', + properties: ['Owner'], + }, + } as Partial) + + const collection = collectionFromSelection( + { kind: 'view', filename: view.filename }, + { views: [view] }, + ) + + expect(collection.presentation).toEqual({ + type: 'list', + sort: 'title:asc', + properties: ['Owner'], + }) + }) +}) diff --git a/src/collections/collectionFromSelection.ts b/src/collections/collectionFromSelection.ts new file mode 100644 index 0000000..bbdb89a --- /dev/null +++ b/src/collections/collectionFromSelection.ts @@ -0,0 +1,88 @@ +import type { SidebarFilter, SidebarSelection, ViewFile } from '../types' +import { viewMatchesSelection } from '../utils/viewIdentity' +import { defaultListPresentation, presentationFromViewDefinition } from './presentationConfig' +import type { CollectionDefinition } from './collectionTypes' + +interface CollectionContext { + views?: ViewFile[] +} + +const BUILTIN_LABELS: Record = { + all: 'All Notes', + archived: 'Archived', + changes: 'Changes', + pulse: 'Pulse', + inbox: 'Inbox', + favorites: 'Favorites', +} + +function folderLabel(selection: Extract): string { + return selection.path || 'Vault' +} + +function identityPart(value: string | undefined): string { + return value ?? '' +} + +function selectedView(selection: SidebarSelection, views?: ViewFile[]): ViewFile | undefined { + return selection.kind === 'view' + ? views?.find((view) => viewMatchesSelection(view, selection)) + : undefined +} + +export function collectionFromSelection( + selection: SidebarSelection, + context: CollectionContext = {}, +): CollectionDefinition { + if (selection.kind === 'filter') { + return { + id: `builtin:${selection.filter}`, + label: BUILTIN_LABELS[selection.filter], + origin: 'builtin', + selection, + presentation: defaultListPresentation(), + } + } + + if (selection.kind === 'sectionGroup') { + return { + id: `type:${selection.type}`, + label: selection.type, + origin: 'type', + selection, + presentation: defaultListPresentation(), + } + } + + if (selection.kind === 'folder') { + return { + id: `folder:${identityPart(selection.rootPath)}:${selection.path}`, + label: folderLabel(selection), + origin: 'folder', + selection, + presentation: defaultListPresentation(), + } + } + + if (selection.kind === 'entity') { + return { + id: `neighborhood:${selection.entry.path}`, + label: selection.entry.title, + origin: 'neighborhood', + selection, + presentation: defaultListPresentation(), + entry: selection.entry, + } + } + + const view = selectedView(selection, context.views) + return { + id: `saved-view:${identityPart(selection.rootPath)}:${selection.filename}`, + label: view?.definition.name ?? selection.filename, + origin: 'saved-view', + selection, + presentation: view ? presentationFromViewDefinition(view.definition) : defaultListPresentation(), + filter: view?.definition.filters, + view, + } +} diff --git a/src/collections/collectionTypes.ts b/src/collections/collectionTypes.ts new file mode 100644 index 0000000..0b8b706 --- /dev/null +++ b/src/collections/collectionTypes.ts @@ -0,0 +1,26 @@ +import type { FilterGroup, SidebarSelection, VaultEntry, ViewFile } from '../types' + +export const COLLECTION_PRESENTATION_LIST = 'list' + +export type CollectionPresentationType = typeof COLLECTION_PRESENTATION_LIST + +export interface ListCollectionPresentationConfig { + type: typeof COLLECTION_PRESENTATION_LIST + sort: string | null + properties: string[] +} + +export type CollectionPresentationConfig = ListCollectionPresentationConfig + +export type CollectionOrigin = 'builtin' | 'type' | 'folder' | 'saved-view' | 'neighborhood' + +export interface CollectionDefinition { + id: string + label: string + origin: CollectionOrigin + selection: SidebarSelection + presentation: CollectionPresentationConfig + filter?: FilterGroup + entry?: VaultEntry + view?: ViewFile +} diff --git a/src/collections/presentationConfig.ts b/src/collections/presentationConfig.ts new file mode 100644 index 0000000..a270cf5 --- /dev/null +++ b/src/collections/presentationConfig.ts @@ -0,0 +1,44 @@ +import type { ViewDefinition } from '../types' +import { + COLLECTION_PRESENTATION_LIST, + type CollectionPresentationConfig, + type ListCollectionPresentationConfig, +} from './collectionTypes' + +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function nullableString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +function listPresentation(source: UnknownRecord, fallback: ListCollectionPresentationConfig): ListCollectionPresentationConfig { + return { + type: COLLECTION_PRESENTATION_LIST, + sort: nullableString(source.sort) ?? fallback.sort, + properties: stringArray(source.properties).length > 0 ? stringArray(source.properties) : fallback.properties, + } +} + +export function defaultListPresentation(): ListCollectionPresentationConfig { + return { type: COLLECTION_PRESENTATION_LIST, sort: null, properties: [] } +} + +export function presentationFromViewDefinition(definition: ViewDefinition): CollectionPresentationConfig { + const fallback: ListCollectionPresentationConfig = { + type: COLLECTION_PRESENTATION_LIST, + sort: nullableString(definition.sort), + properties: stringArray(definition.listPropertiesDisplay), + } + const rawPresentation = Reflect.get(definition as unknown as UnknownRecord, 'presentation') + if (!isRecord(rawPresentation)) return fallback + if (rawPresentation.type !== COLLECTION_PRESENTATION_LIST) return fallback + return listPresentation(rawPresentation, fallback) +} diff --git a/src/collections/resolveCollectionEntries.test.ts b/src/collections/resolveCollectionEntries.test.ts new file mode 100644 index 0000000..ddddd2f --- /dev/null +++ b/src/collections/resolveCollectionEntries.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { ViewFile } from '../types' +import { makeEntry } from '../test-utils/noteListTestUtils' +import { collectionFromSelection } from './collectionFromSelection' +import { resolveCollectionEntries } from './resolveCollectionEntries' + +describe('resolveCollectionEntries', () => { + it('resolves ordinary collections through current note-list filtering', () => { + const entries = [ + makeEntry({ path: '/vault/alpha.md', title: 'Alpha', isA: 'Project' }), + makeEntry({ path: '/vault/beta.md', title: 'Beta', isA: 'Note' }), + ] + const collection = collectionFromSelection({ kind: 'sectionGroup', type: 'Project' }) + + const resolved = resolveCollectionEntries(collection, entries) + + expect(resolved.entries.map((entry) => entry.title)).toEqual(['Alpha']) + expect(resolved.entityEntry).toBeNull() + expect(resolved.relationshipGroups).toEqual([]) + }) + + it('keeps Changes and Inbox entries caller-supplied for existing transient flows', () => { + const changes = [makeEntry({ path: '/vault/changed.md', title: 'Changed' })] + const inbox = [makeEntry({ path: '/vault/inbox.md', title: 'Inbox' })] + + expect( + resolveCollectionEntries( + collectionFromSelection({ kind: 'filter', filter: 'changes' }), + [], + { changesEntries: changes }, + ).entries, + ).toBe(changes) + + expect( + resolveCollectionEntries( + collectionFromSelection({ kind: 'filter', filter: 'inbox' }), + [], + { inboxEntries: inbox }, + ).entries, + ).toBe(inbox) + }) + + it('resolves saved views using their existing YAML filters', () => { + const entries = [ + makeEntry({ path: '/vault/alpha.md', title: 'Alpha', isA: 'Project' }), + makeEntry({ path: '/vault/beta.md', title: 'Beta', isA: 'Note' }), + ] + const view: ViewFile = { + filename: 'projects.yml', + definition: { + name: 'Projects', + icon: null, + color: null, + sort: null, + filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] }, + }, + } + const collection = collectionFromSelection({ kind: 'view', filename: view.filename }, { views: [view] }) + + const resolved = resolveCollectionEntries(collection, entries, { views: [view] }) + + expect(resolved.entries.map((entry) => entry.title)).toEqual(['Alpha']) + }) + + it('resolves neighborhood collections into grouped relationship data', () => { + const alpha = makeEntry({ + path: '/vault/alpha.md', + title: 'Alpha', + relationships: { related_to: ['[[beta]]'] }, + }) + const beta = makeEntry({ path: '/vault/beta.md', filename: 'beta.md', title: 'Beta' }) + const collection = collectionFromSelection({ kind: 'entity', entry: alpha }) + + const resolved = resolveCollectionEntries(collection, [alpha, beta]) + + expect(resolved.entries).toEqual([]) + expect(resolved.entityEntry).toBe(alpha) + expect(resolved.relationshipGroups.some((group) => group.entries.includes(beta))).toBe(true) + }) +}) diff --git a/src/collections/resolveCollectionEntries.ts b/src/collections/resolveCollectionEntries.ts new file mode 100644 index 0000000..171c40a --- /dev/null +++ b/src/collections/resolveCollectionEntries.ts @@ -0,0 +1,57 @@ +import type { VaultEntry } from '../types' +import { + type FilterEntriesOptions, + type RelationshipGroup, + buildRelationshipGroups, + filterEntries, +} from '../utils/noteListHelpers' +import type { CollectionDefinition } from './collectionTypes' + +interface ResolveCollectionEntriesOptions extends FilterEntriesOptions { + changesEntries?: VaultEntry[] + inboxEntries?: VaultEntry[] +} + +export interface ResolvedCollectionEntries { + entries: VaultEntry[] + entityEntry: VaultEntry | null + relationshipGroups: RelationshipGroup[] +} + +function specialFilterEntries( + collection: CollectionDefinition, + options: ResolveCollectionEntriesOptions, +): VaultEntry[] | null { + const selection = collection.selection + if (selection.kind !== 'filter') return null + if (selection.filter === 'changes') return options.changesEntries ?? [] + if (selection.filter === 'inbox') return options.inboxEntries ?? [] + return null +} + +function currentEntityEntry(collection: CollectionDefinition, entries: VaultEntry[]): VaultEntry | null { + if (collection.selection.kind !== 'entity') return null + const selectedEntry = collection.selection.entry + return entries.find((entry) => entry.path === selectedEntry.path) ?? selectedEntry +} + +export function resolveCollectionEntries( + collection: CollectionDefinition, + entries: VaultEntry[], + options: ResolveCollectionEntriesOptions = {}, +): ResolvedCollectionEntries { + const entityEntry = currentEntityEntry(collection, entries) + if (entityEntry) { + return { + entries: [], + entityEntry, + relationshipGroups: buildRelationshipGroups(entityEntry, entries), + } + } + + return { + entries: specialFilterEntries(collection, options) ?? filterEntries(entries, collection.selection, options), + entityEntry: null, + relationshipGroups: [], + } +} diff --git a/src/components/AccentColorPicker.tsx b/src/components/AccentColorPicker.tsx new file mode 100644 index 0000000..ab35bee --- /dev/null +++ b/src/components/AccentColorPicker.tsx @@ -0,0 +1,76 @@ +import type { MouseEvent } from 'react' +import { Check } from '@phosphor-icons/react' +import { Button } from './ui/button' +import { cn } from '@/lib/utils' +import { ACCENT_COLOR_PICKER_COLORS } from '../utils/typeColors' + +type AccentColorIndicator = 'border' | 'check' + +interface AccentColorPickerProps { + className?: string + disabled?: boolean + getOptionTestId?: (key: string) => string + indicator?: AccentColorIndicator + onSelectColor: (key: string) => void + selectedColor: string | null + size?: number + stopPropagation?: boolean +} + +const DEFAULT_SWATCH_SIZE = 24 + +function selectedBorder(indicator: AccentColorIndicator, selected: boolean): string { + if (indicator === 'check') return '0 solid transparent' + return selected ? '2px solid var(--foreground)' : '2px solid transparent' +} + +export function AccentColorPicker({ + className, + disabled = false, + getOptionTestId, + indicator = 'border', + onSelectColor, + selectedColor, + size = DEFAULT_SWATCH_SIZE, + stopPropagation = false, +}: AccentColorPickerProps) { + const handleSelect = (event: MouseEvent, key: string) => { + if (stopPropagation) event.stopPropagation() + if (!disabled) onSelectColor(key) + } + + return ( +
      + {ACCENT_COLOR_PICKER_COLORS.map((color) => { + const selected = selectedColor === color.key + return ( + + ) + })} +
      + ) +} diff --git a/src/components/AddPropertyForm.tsx b/src/components/AddPropertyForm.tsx new file mode 100644 index 0000000..1bb8ce1 --- /dev/null +++ b/src/components/AddPropertyForm.tsx @@ -0,0 +1,222 @@ +import { CalendarBlank as CalendarIcon, Check, X } from '@phosphor-icons/react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + type PropertyDisplayMode, + formatDateValue, + toISODate, +} from '../utils/propertyTypes' +import { StatusPill, StatusDropdown } from './StatusDropdown' +import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes' +import { translate, type AppLocale } from '../lib/i18n' +import { useDateDisplayFormat } from '../hooks/useAppPreferences' + +function parseDateValue(value: string): Date | undefined { + const iso = toISODate(value) + const d = new Date(iso + 'T00:00:00') + return isNaN(d.getTime()) ? undefined : d +} + +function dateToISO(day: Date): string { + const yyyy = day.getFullYear() + const mm = String(day.getMonth() + 1).padStart(2, '0') + const dd = String(day.getDate()).padStart(2, '0') + return `${yyyy}-${mm}-${dd}` +} + +const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary" + +function isValidNumberValue(value: string): boolean { + const trimmed = value.trim() + if (trimmed === '') return false + return Number.isFinite(Number(trimmed)) +} + +function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean { + if (!key.trim()) return false + return displayMode !== 'number' || isValidNumberValue(value) +} + +function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) { + const boolVal = value.toLowerCase() === 'true' + return ( + + ) +} + +function AddDateInput({ + value, + locale, + onChange, +}: { + value: string + locale: AppLocale + onChange: (v: string) => void +}) { + const dateDisplayFormat = useDateDisplayFormat() + const selectedDate = value ? parseDateValue(value) : undefined + const formatted = value ? formatDateValue(value, dateDisplayFormat) : '' + return ( + + + + + + { if (day) onChange(dateToISO(day)) }} + defaultMonth={selectedDate} + /> + + + ) +} + +function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) { + const [showDropdown, setShowDropdown] = useState(false) + return ( + + + {showDropdown && ( + { onChange(v); setShowDropdown(false) }} + onCancel={() => setShowDropdown(false)} + /> + )} + + ) +} + +function AddNumberInput({ value, onChange, onKeyDown }: { + value: string + onChange: (v: string) => void + onKeyDown: (e: React.KeyboardEvent) => void +}) { + return ( + onChange(event.target.value)} + onKeyDown={onKeyDown} + data-testid="add-property-number-input" + /> + ) +} + +function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses, locale }: { + displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void + onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[] + locale: AppLocale +}) { + switch (displayMode) { + case 'number': + return + case 'boolean': return + case 'date': return + case 'status': return + case 'tags': return ( + onChange(e.target.value)} onKeyDown={onKeyDown} + /> + ) + default: return ( + onChange(e.target.value)} onKeyDown={onKeyDown} + /> + ) + } +} + +export function AddPropertyForm({ onAdd, onCancel, vaultStatuses, locale = 'en' }: { + onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void + vaultStatuses: string[] + locale?: AppLocale +}) { + const [newKey, setNewKey] = useState('') + const [newValue, setNewValue] = useState('') + const [displayMode, setDisplayMode] = useState('text') + const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode }) + + const handleModeChange = (mode: PropertyDisplayMode) => { + setDisplayMode(mode) + if (mode === 'boolean') setNewValue('false') + else if (mode !== displayMode) setNewValue('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode) + else if (e.key === 'Escape') onCancel() + } + + return ( +
      + setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus + /> + + + + +
      + ) +} diff --git a/src/components/AddRemoteModal.test.tsx b/src/components/AddRemoteModal.test.tsx new file mode 100644 index 0000000..146e1cd --- /dev/null +++ b/src/components/AddRemoteModal.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AddRemoteModal } from './AddRemoteModal' + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: vi.fn(), +})) + +import { mockInvoke } from '../mock-tauri' + +const mockInvokeFn = vi.mocked(mockInvoke) + +describe('AddRemoteModal', () => { + const onClose = vi.fn() + const onRemoteConnected = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockInvokeFn.mockResolvedValue({ + status: 'connected', + message: 'Remote connected. This vault now tracks origin/main.', + }) + }) + + it('renders the add-remote form when open', async () => { + render( + + ) + + expect(screen.getByText('Add Remote')).toBeInTheDocument() + const input = screen.getByTestId('add-remote-url') + await waitFor(() => expect(input).toHaveFocus()) + }) + + it('submits the repository URL to git_add_remote', async () => { + render( + + ) + + fireEvent.change(screen.getByTestId('add-remote-url'), { + target: { value: 'git@github.com:user/my-vault.git' }, + }) + fireEvent.click(screen.getByTestId('add-remote-submit')) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('git_add_remote', { + request: { + vaultPath: '/vault', + remoteUrl: 'git@github.com:user/my-vault.git', + }, + }) + }) + + expect(onRemoteConnected).toHaveBeenCalledWith('Remote connected. This vault now tracks origin/main.') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows backend validation errors without closing the modal', async () => { + mockInvokeFn.mockResolvedValueOnce({ + status: 'incompatible_history', + message: 'This repository has unrelated history.', + }) + + render( + + ) + + fireEvent.change(screen.getByTestId('add-remote-url'), { + target: { value: 'https://example.com/repo.git' }, + }) + fireEvent.click(screen.getByTestId('add-remote-submit')) + + await waitFor(() => { + expect(screen.getByTestId('add-remote-error')).toHaveTextContent('This repository has unrelated history.') + }) + + expect(onRemoteConnected).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AddRemoteModal.tsx b/src/components/AddRemoteModal.tsx new file mode 100644 index 0000000..f852bb7 --- /dev/null +++ b/src/components/AddRemoteModal.tsx @@ -0,0 +1,171 @@ +import { useCallback, useRef, useState, type ChangeEvent, type FormEvent } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { GitAddRemoteResult } from '../types' +import { isTauri, mockInvoke } from '../mock-tauri' + +type ConnectState = 'idle' | 'connecting' + +interface AddRemoteModalProps { + open: boolean + vaultPath: string + onClose: () => void + onRemoteConnected: (message: string) => void | Promise +} + +function tauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +function shouldCloseAfterResult(result: GitAddRemoteResult): boolean { + return result.status === 'connected' || result.status === 'already_configured' +} + +async function submitRemoteConnection( + vaultPath: string, + remoteUrl: string, +): Promise { + return tauriCall('git_add_remote', { + request: { + vaultPath, + remoteUrl, + }, + }) +} + +async function getConnectErrorMessage({ + vaultPath, + remoteUrl, + onRemoteConnected, + onClose, +}: { + vaultPath: string + remoteUrl: string + onRemoteConnected: (message: string) => void | Promise + onClose: () => void +}): Promise { + try { + const result = await submitRemoteConnection(vaultPath, remoteUrl) + + if (shouldCloseAfterResult(result)) { + await onRemoteConnected(result.message) + onClose() + return null + } + + return result.message + } catch (error) { + return `Could not connect that remote: ${String(error)}` + } +} + +export function AddRemoteModal({ + open, + vaultPath, + onClose, + onRemoteConnected, +}: AddRemoteModalProps) { + const [remoteUrl, setRemoteUrl] = useState('') + const [connectState, setConnectState] = useState('idle') + const [connectError, setConnectError] = useState(null) + const inputRef = useRef(null) + + const resetState = useCallback(() => { + setRemoteUrl('') + setConnectState('idle') + setConnectError(null) + }, []) + + const handleClose = useCallback(() => { + resetState() + onClose() + }, [onClose, resetState]) + + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) { + handleClose() + } + }, [handleClose]) + const handleRemoteUrlChange = useCallback((event: ChangeEvent) => { + setRemoteUrl(event.target.value) + setConnectError(null) + }, []) + + const handleSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const trimmedUrl = remoteUrl.trim() + if (!trimmedUrl) return + + setConnectState('connecting') + setConnectError(null) + + const errorMessage = await getConnectErrorMessage({ + vaultPath, + remoteUrl: trimmedUrl, + onRemoteConnected, + onClose: handleClose, + }) + + if (errorMessage) { + setConnectError(errorMessage) + } + + setConnectState('idle') + }, [handleClose, onRemoteConnected, remoteUrl, vaultPath]) + + const connectDisabled = connectState === 'connecting' || !remoteUrl.trim() + + return ( + + + + Add Remote + + Connect this local vault to a git remote. Your existing local commits stay intact; Tolaria + will only connect the vault when the remote history is safe to use. + + + +
      +
      + + +
      + +

      + Use an empty repository or one created from this vault. SSH keys, Git Credential Manager, + and other system git auth methods all work. +

      + + {connectError && ( +

      {connectError}

      + )} + + + + +
      +
      +
      + ) +} diff --git a/src/components/AiActionCard.test.tsx b/src/components/AiActionCard.test.tsx new file mode 100644 index 0000000..a6d06e3 --- /dev/null +++ b/src/components/AiActionCard.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiActionCard } from './AiActionCard' + +const defaults = { + tool: 'create_note', + label: 'Creating note... (abc123)', + status: 'done' as const, + expanded: false, + onToggle: vi.fn(), +} + +describe('AiActionCard', () => { + it('renders label text', () => { + render() + expect(screen.getByText('Created test.md')).toBeTruthy() + }) + + it('shows pending spinner', () => { + render() + expect(screen.getByTestId('status-pending')).toBeTruthy() + }) + + it('shows done check', () => { + render() + expect(screen.getByTestId('status-done')).toBeTruthy() + }) + + it('shows error icon', () => { + render() + expect(screen.getByTestId('status-error')).toBeTruthy() + }) + + it('navigates to note when no details and path+onOpenNote provided', () => { + const onOpenNote = vi.fn() + const toggle = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('action-card-header')) + expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md') + expect(toggle).not.toHaveBeenCalled() + }) + + it('toggles expand instead of navigating when details exist', () => { + const onOpenNote = vi.fn() + const toggle = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('action-card-header')) + expect(toggle).toHaveBeenCalled() + expect(onOpenNote).not.toHaveBeenCalled() + }) + + it('header has button role and is focusable', () => { + render() + const header = screen.getByTestId('action-card-header') + expect(header.getAttribute('role')).toBe('button') + expect(header.getAttribute('tabindex')).toBe('0') + }) + + it('uses lighter background for open_note tool', () => { + render() + const card = screen.getByTestId('ai-action-card') + expect(card.style.background).toBe('var(--accent-blue-light)') + }) + + it('uses standard background for vault tools', () => { + render() + const card = screen.getByTestId('ai-action-card') + expect(card.style.background).toBe('var(--accent-blue-bg)') + }) + + // --- Expand / collapse --- + + it('does not show details when collapsed', () => { + render() + expect(screen.queryByTestId('action-card-details')).toBeNull() + }) + + it('shows details when expanded with input and output', () => { + render() + expect(screen.getByTestId('action-card-details')).toBeTruthy() + expect(screen.getByTestId('detail-input')).toBeTruthy() + expect(screen.getByTestId('detail-output')).toBeTruthy() + }) + + it('shows only input when no output', () => { + render() + expect(screen.getByTestId('detail-input')).toBeTruthy() + expect(screen.queryByTestId('detail-output')).toBeNull() + }) + + it('shows only output when no input', () => { + render() + expect(screen.queryByTestId('detail-input')).toBeNull() + expect(screen.getByTestId('detail-output')).toBeTruthy() + }) + + it('does not show details when expanded but no input or output', () => { + render() + expect(screen.queryByTestId('action-card-details')).toBeNull() + }) + + it('formats JSON input prettily', () => { + render() + const inputBlock = screen.getByTestId('detail-input') + expect(inputBlock.textContent).toContain('"title": "Hello"') + }) + + it('truncates very long output', () => { + const longOutput = 'x'.repeat(1000) + render() + const outputBlock = screen.getByTestId('detail-output') + expect(outputBlock.textContent!.length).toBeLessThan(1000) + }) + + // --- Keyboard accessibility --- + + it('expands on Enter key', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' }) + expect(toggle).toHaveBeenCalled() + }) + + it('expands on Space key', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' }) + expect(toggle).toHaveBeenCalled() + }) + + it('collapses on Escape key when expanded', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' }) + expect(toggle).toHaveBeenCalled() + }) + + it('does not collapse on Escape when already collapsed', () => { + const toggle = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' }) + expect(toggle).not.toHaveBeenCalled() + }) + + it('sets aria-expanded attribute', () => { + const { rerender } = render() + expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false') + rerender() + expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true') + }) + + it('shows error output in red for error status', () => { + render() + const outputBlock = screen.getByTestId('detail-output') + expect(outputBlock.style.color).toContain('destructive') + }) +}) diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx new file mode 100644 index 0000000..3add405 --- /dev/null +++ b/src/components/AiActionCard.tsx @@ -0,0 +1,262 @@ +import { type KeyboardEvent, type ReactNode, useCallback } from 'react' +import { + PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye, + CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown, + Terminal, File, FolderOpen, NotePencil, +} from '@phosphor-icons/react' + +export type AiActionStatus = 'pending' | 'done' | 'error' + +export interface AiActionCardProps { + tool: string + label: string + path?: string + status: AiActionStatus + input?: string + output?: string + expanded: boolean + onToggle: () => void + onOpenNote?: (path: string) => void +} + +const MAX_DETAIL_LENGTH = 800 +const DEFAULT_ACTION_CARD_BACKGROUND = 'var(--accent-blue-bg)' +const TOOL_BACKGROUND_MAP: Record = { + open_note: 'var(--accent-blue-light)', +} +const TOOL_BACKGROUND_BY_NAME = new Map(Object.entries(TOOL_BACKGROUND_MAP)) + +type IconRenderer = (size: number) => ReactNode + +const TOOL_ICON_MAP: Record = { + // Native Claude Code tools + Bash: (s) => , + Write: (s) => , + Edit: (s) => , + Read: (s) => , + Glob: (s) => , + Grep: (s) => , + // Tolaria MCP tools + search_notes: (s) => , + get_vault_context: (s) => , + get_note: (s) => , + open_note: (s) => , + // Legacy tools (for backward compatibility with existing messages) + create_note: (s) => , + delete_note: (s) => , +} +const TOOL_ICON_BY_NAME = new Map(Object.entries(TOOL_ICON_MAP)) + +const DEFAULT_ICON: IconRenderer = (s) => + +function StatusIndicator({ status }: { status: AiActionStatus }) { + if (status === 'pending') { + return + } + if (status === 'done') { + return + } + return +} + +function truncateText(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false } + return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true } +} + +function formatInputForDisplay(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + +function hasActionDetails(input?: string, output?: string): boolean { + return Boolean(input || output) +} + +function resolveDirectOpenPath({ + hasDetails, + onOpenNote, + path, +}: Pick & { + hasDetails: boolean +}): string | null { + if (hasDetails || !path || !onOpenNote) return null + return path +} + +function ActionCardHeader({ + expanded, + hasDetails, + label, + onClick, + onKeyDown, + renderIcon, + status, +}: { + expanded: boolean + hasDetails: boolean + label: string + onClick: () => void + onKeyDown: (event: KeyboardEvent) => void + renderIcon: IconRenderer + status: AiActionStatus +}) { + const setHeaderRef = useCallback((node: HTMLButtonElement | null) => { + if (!node) return + node.setAttribute('role', 'button') + node.setAttribute('tabindex', '0') + }, []) + + return ( + + ) +} + +function ActionIcon({ + expanded, + hasDetails, + renderIcon, +}: { + expanded: boolean + hasDetails: boolean + renderIcon: IconRenderer +}) { + if (!hasDetails) return <>{renderIcon(14)} + return expanded ? : +} + +function DetailBlock({ label, content, isError }: { + label: string; content: string; isError?: boolean +}) { + const { text, truncated } = truncateText(content) + return ( +
      +
      + {label} +
      +
      +        {text}{truncated && {'…'}}
      +      
      +
      + ) +} + +function ActionCardDetails({ + expanded, + hasDetails, + input, + output, + status, +}: { + expanded: boolean + hasDetails: boolean + input?: string + output?: string + status: AiActionStatus +}) { + if (!expanded || !hasDetails) return null + + const formattedInput = input ? formatInputForDisplay(input) : undefined + return ( +
      + {formattedInput && } + {output && ( + + )} +
      + ) +} + +export function AiActionCard({ + tool, label, path, status, input, output, expanded, onToggle, onOpenNote, +}: AiActionCardProps) { + const renderIcon = TOOL_ICON_BY_NAME.get(tool) ?? DEFAULT_ICON + const hasDetails = hasActionDetails(input, output) + const directOpenPath = resolveDirectOpenPath({ path, onOpenNote, hasDetails }) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } else if (e.key === 'Escape' && expanded) { + e.preventDefault() + onToggle() + } + }, [onToggle, expanded]) + + const handleClick = useCallback(() => { + if (directOpenPath && onOpenNote) { + onOpenNote(directOpenPath) + return + } + + onToggle() + }, [directOpenPath, onOpenNote, onToggle]) + + return ( +
      + + +
      + ) +} diff --git a/src/components/AiAgentIcon.tsx b/src/components/AiAgentIcon.tsx new file mode 100644 index 0000000..847ca2d --- /dev/null +++ b/src/components/AiAgentIcon.tsx @@ -0,0 +1,57 @@ +import type { CSSProperties } from 'react' +import { cn } from '@/lib/utils' +import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents' + +interface AiAgentIconProps { + agent: AiAgentId + className?: string + size?: number + title?: string +} + +const ICON_STYLE: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flex: '0 0 auto', + overflow: 'hidden', +} + +const AI_AGENT_ICON_SOURCES: Record = { + claude_code: '/ai-agent-icons/claude-code.svg', + codex: '/ai-agent-icons/codex.svg', + copilot: '/ai-agent-icons/copilot.svg', + opencode: '/ai-agent-icons/opencode.svg', + pi: '/ai-agent-icons/pi.svg', + antigravity: '/ai-agent-icons/gemini.svg', + kiro: '/ai-agent-icons/kiro.svg', + hermes: '/ai-agent-icons/hermes.svg', +} + +export function AiAgentIcon({ + agent, + className, + size = 16, + title, +}: AiAgentIconProps) { + const label = title ?? getAiAgentDefinition(agent).label + + return ( + + + + ) +} diff --git a/src/components/AiAgentsOnboardingPrompt.test.tsx b/src/components/AiAgentsOnboardingPrompt.test.tsx new file mode 100644 index 0000000..a54e3ee --- /dev/null +++ b/src/components/AiAgentsOnboardingPrompt.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiAgentsStatus } from '../lib/aiAgents' +import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt' + +const openExternalUrl = vi.fn() +const dragRegionMouseDown = vi.fn() +const missingStatuses: AiAgentsStatus = { + claude_code: { status: 'missing', version: null }, + codex: { status: 'missing', version: null }, + copilot: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, + pi: { status: 'missing', version: null }, + antigravity: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, + hermes: { status: 'missing', version: null }, +} +const installLinkTargets = [ + ['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'], + ['ai-agents-onboarding-install-codex', 'https://developers.openai.com/codex/cli'], + ['ai-agents-onboarding-install-copilot', 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli'], + ['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'], + ['ai-agents-onboarding-install-pi', 'https://pi.dev'], + ['ai-agents-onboarding-install-antigravity', 'https://antigravity.google/docs/cli/install'], + ['ai-agents-onboarding-install-kiro', 'https://kiro.dev/docs/cli'], + ['ai-agents-onboarding-install-hermes', 'https://hermes-agent.nousresearch.com/docs/getting-started/quickstart'], +] as const + +vi.mock('../utils/url', () => ({ + openExternalUrl: (...args: unknown[]) => openExternalUrl(...args), +})) +vi.mock('../hooks/useDragRegion', () => ({ + useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }), +})) + +function renderPrompt(statuses: Partial = {}) { + return render( + , + ) +} + +function openSupportedAgentsMenu() { + fireEvent.pointerDown(screen.getByTestId('ai-agents-onboarding-supported-menu')) +} + +describe('AiAgentsOnboardingPrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the ready state when at least one agent is installed', () => { + renderPrompt({ + claude_code: { status: 'installed', version: '1.0.20' }, + }) + + expect(screen.getByText('AI is ready')).toBeInTheDocument() + expect(screen.getByText('Detected on this machine')).toBeInTheDocument() + expect(screen.getByText('Claude Code')).toBeInTheDocument() + expect(screen.queryByTestId('ai-agents-onboarding-install-codex')).not.toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue') + }) + + it('shows the missing state when no agents are installed', () => { + renderPrompt() + + expect(screen.getByText('AI setup is optional')).toBeInTheDocument() + expect(screen.queryByTestId('ai-agents-onboarding-empty')).not.toBeInTheDocument() + expect(screen.queryByTestId('ai-agents-onboarding-detected-list')).not.toBeInTheDocument() + expect(screen.getByText('More AI options')).toBeInTheDocument() + expect(screen.queryByTestId('ai-agents-onboarding-install-claude_code')).not.toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Set up later') + }) + + it('opens the supported agent install links from the menu', () => { + renderPrompt() + + installLinkTargets.forEach(([testId]) => { + openSupportedAgentsMenu() + fireEvent.click(screen.getByTestId(testId)) + }) + + installLinkTargets.forEach(([, url]) => { + expect(openExternalUrl).toHaveBeenCalledWith(url) + }) + }) + + it('keeps the long setup card bounded with a scrollable content area', () => { + renderPrompt() + + expect(screen.getByTestId('ai-agents-onboarding-card')).toHaveClass( + 'max-h-[calc(100dvh-2rem)]', + 'overflow-hidden', + ) + expect(screen.getByTestId('ai-agents-onboarding-scroll')).toHaveClass( + 'min-h-0', + 'overflow-y-auto', + 'overscroll-contain', + ) + expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Set up later') + }) + + it('uses the surrounding surface as a drag region and excludes the card', () => { + renderPrompt({ + claude_code: { status: 'installed', version: '1.0.20' }, + }) + + const screenContainer = screen.getByTestId('ai-agents-onboarding-screen') + fireEvent.mouseDown(screenContainer) + + expect(dragRegionMouseDown).toHaveBeenCalledOnce() + expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull() + }) +}) diff --git a/src/components/AiAgentsOnboardingPrompt.tsx b/src/components/AiAgentsOnboardingPrompt.tsx new file mode 100644 index 0000000..8eb616c --- /dev/null +++ b/src/components/AiAgentsOnboardingPrompt.tsx @@ -0,0 +1,240 @@ +import { ArrowUpRight, CaretDown, CheckCircle as CheckCircle2, CircleNotch as Loader2, Robot as Bot } from '@phosphor-icons/react' +import { + AI_AGENT_DEFINITIONS, + getAiAgentAvailability, + getAiAgentDefinition, + hasAnyInstalledAiAgent, + isAiAgentsStatusChecking, + type AiAgentDefinition, + type AiAgentsStatus, +} from '../lib/aiAgents' +import { translate, type AppLocale } from '../lib/i18n' +import { openExternalUrl } from '../utils/url' +import { AiAgentIcon } from './AiAgentIcon' +import { OnboardingShell } from './OnboardingShell' +import { Button } from './ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu' + +interface AiAgentsOnboardingPromptProps { + statuses: AiAgentsStatus + onContinue: () => void + locale?: AppLocale +} + +interface InfoPanelProps { + description: string + testId?: string + textAlign?: 'left' | 'center' + title: string +} + +function getPromptCopy(statuses: AiAgentsStatus, locale: AppLocale) { + if (isAiAgentsStatusChecking(statuses)) { + return { + accentClassName: 'bg-muted text-muted-foreground', + description: translate(locale, 'onboarding.ai.checkingDescription'), + icon: , + title: translate(locale, 'onboarding.ai.checkingTitle'), + } + } + + if (!hasAnyInstalledAiAgent(statuses)) { + return { + accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]', + description: translate(locale, 'onboarding.ai.missingDescription'), + icon: , + title: translate(locale, 'onboarding.ai.missingTitle'), + } + } + + return { + accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]', + description: translate(locale, 'onboarding.ai.readyDescription'), + icon: , + title: translate(locale, 'onboarding.ai.readyTitle'), + } +} + +function installedAgentDefinitions(statuses: AiAgentsStatus): AiAgentDefinition[] { + return AI_AGENT_DEFINITIONS.filter((definition) => { + return getAiAgentAvailability(statuses, definition.id).status === 'installed' + }) +} + +function InfoPanel({ + description, + testId, + textAlign = 'left', + title, +}: InfoPanelProps) { + const textAlignClass = textAlign === 'center' ? 'text-center' : 'text-left' + return ( +
      +
      + {title} +
      +

      + {description} +

      +
      + ) +} + +function DetectedAgentsList({ + agents, + locale, + statuses, +}: { + agents: AiAgentDefinition[] + locale: AppLocale + statuses: AiAgentsStatus +}) { + return ( +
      +
      + {translate(locale, 'onboarding.ai.detectedHeader')} +
      + {agents.map((definition) => { + const status = getAiAgentAvailability(statuses, definition.id) + return ( +
      +
      + +
      +
      {definition.label}
      +
      + {status.version + ? translate(locale, 'onboarding.ai.installedVersion', { version: status.version }) + : translate(locale, 'onboarding.ai.installed')} +
      +
      +
      + + {translate(locale, 'onboarding.ai.installedBadge')} + +
      + ) + })} +
      + ) +} + +function SupportedAgentsMenu({ locale }: { locale: AppLocale }) { + return ( + + + + + + {AI_AGENT_DEFINITIONS.map((definition) => ( + void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)} + data-testid={`ai-agents-onboarding-install-${definition.id}`} + > + + {definition.label} + + + ))} + + + ) +} + +function AiSetupSummary({ locale }: { locale: AppLocale }) { + return ( + + ) +} + +function CheckingAgents({ locale }: { locale: AppLocale }) { + return ( + + ) +} + +export function AiAgentsOnboardingPrompt({ + statuses, + onContinue, + locale = 'en', +}: AiAgentsOnboardingPromptProps) { + const copy = getPromptCopy(statuses, locale) + const checking = isAiAgentsStatusChecking(statuses) + const installedAgents = installedAgentDefinitions(statuses) + const showDetectedAgents = !checking && installedAgents.length > 0 + + return ( + + + +
      + {copy.icon} +
      +
      + + {copy.title} + +

      + {copy.description} +

      +
      +
      + + + {checking ? : null} + {showDetectedAgents ? ( + + ) : null} + + + + + +
      + +
      +
      +
      +
      + ) +} diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx new file mode 100644 index 0000000..e7c053a --- /dev/null +++ b/src/components/AiMessage.test.tsx @@ -0,0 +1,246 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiMessage } from './AiMessage' + +vi.mock('./MarkdownContent', () => ({ + MarkdownContent: ({ content }: { content: string }) =>
      {content}
      , +})) + +const writeClipboardText = vi.fn() + +vi.mock('../utils/clipboardText', () => ({ + writeClipboardText: (text: string) => writeClipboardText(text), +})) + +describe('AiMessage', () => { + beforeEach(() => { + writeClipboardText.mockReset() + writeClipboardText.mockResolvedValue(undefined) + }) + + it('renders user message', () => { + const { container } = render() + expect(screen.getByText('Hello AI')).toBeTruthy() + expect(container.querySelector('[style*="background: var(--state-hover)"]')).toBeTruthy() + }) + + it('keeps long user messages inside the chat column', () => { + const longToken = 'https://example.com/'.padEnd(180, 'a') + render() + + const bubble = screen.getByText(longToken) + expect(bubble).toHaveClass('min-w-0', 'max-w-[85%]', 'overflow-hidden') + expect(bubble).toHaveStyle({ overflowWrap: 'anywhere' }) + }) + + it('renders response as markdown', () => { + render() + expect(screen.getByTestId('markdown-content')).toBeTruthy() + expect(screen.getByText('Here is the **answer**')).toBeTruthy() + }) + + it('constrains assistant responses to the available chat width', () => { + render() + expect(screen.getByTestId('ai-response-block')).toHaveClass('min-w-0', 'max-w-full', 'overflow-hidden') + }) + + it('shows assistant message actions with response', () => { + render() + expect(screen.getByTestId('ai-message-actions')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Regenerate response' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Copy response' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Fork chat from here' })).toBeTruthy() + }) + + it('localizes reasoning and tool use chrome', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /ragionamento/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /uso degli strumenti/i })).toBeTruthy() + }) + + it('shows reasoning expanded while streaming (reasoningDone=false)', () => { + render() + expect(screen.getByTestId('reasoning-toggle')).toBeTruthy() + expect(screen.getByTestId('reasoning-content')).toBeTruthy() + expect(screen.getByText('Thinking about it...')).toBeTruthy() + }) + + it('auto-collapses reasoning when reasoningDone=true', () => { + render() + expect(screen.getByTestId('reasoning-toggle')).toBeTruthy() + expect(screen.queryByTestId('reasoning-content')).toBeNull() + }) + + it('expands collapsed reasoning on toggle click', () => { + render() + // Starts collapsed (reasoningDone=true) + expect(screen.queryByTestId('reasoning-content')).toBeNull() + fireEvent.click(screen.getByTestId('reasoning-toggle')) + expect(screen.getByTestId('reasoning-content')).toBeTruthy() + }) + + it('collapses expanded reasoning on toggle click', () => { + render() + // Starts expanded (reasoningDone=false) + expect(screen.getByTestId('reasoning-content')).toBeTruthy() + fireEvent.click(screen.getByTestId('reasoning-toggle')) + expect(screen.queryByTestId('reasoning-content')).toBeNull() + }) + + it('collapses tool use by default and shows the live call count', () => { + render( + , + ) + expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByTestId('tool-use-count').textContent).toBe('2') + expect(screen.getByTestId('tool-use-count')).toHaveAttribute('data-pending', 'true') + expect(screen.queryByTestId('ai-action-card')).toBeNull() + + fireEvent.click(screen.getByTestId('tool-use-toggle')) + + expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2) + }) + + it('passes onOpenNote to action cards', () => { + const onOpenNote = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('tool-use-toggle')) + fireEvent.click(screen.getByTestId('action-card-header')) + expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md') + }) + + it('shows streaming indicator when streaming without response', () => { + const { container } = render( + , + ) + expect(container.querySelector('.typing-dot')).toBeTruthy() + }) + + it('does not show streaming indicator when response is present', () => { + const { container } = render( + , + ) + expect(container.querySelector('.typing-dot')).toBeNull() + }) + + it('runs assistant message actions', () => { + const onRegenerate = vi.fn() + const onFork = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Regenerate response' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy response' })) + fireEvent.click(screen.getByRole('button', { name: 'Fork chat from here' })) + + expect(onRegenerate).toHaveBeenCalledWith('message-1') + expect(writeClipboardText).toHaveBeenCalledWith('Done') + expect(onFork).toHaveBeenCalledWith('message-1') + }) + + it('does not render reasoning block when no reasoning', () => { + render() + expect(screen.queryByTestId('reasoning-toggle')).toBeNull() + }) + + it('does not render actions when empty array', () => { + render() + expect(screen.queryByTestId('ai-action-card')).toBeNull() + }) + + it('renders reference pills in user bubble', () => { + render( + , + ) + const pills = screen.getAllByTestId('message-reference-pill') + expect(pills).toHaveLength(2) + expect(pills[0].textContent).toBe('Marco') + expect(pills[1].textContent).toBe('Project X') + }) + + it('does not render pills when no references', () => { + render() + expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0) + }) + + it('does not render pills when references array is empty', () => { + render() + expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0) + }) + + it('calls onOpenNote when a reference pill is clicked', () => { + const onOpenNote = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('message-reference-pill')) + expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md') + }) + + it('expands and collapses action cards independently', () => { + render( + , + ) + fireEvent.click(screen.getByTestId('tool-use-toggle')) + const headers = screen.getAllByTestId('action-card-header') + // Both collapsed initially + expect(screen.queryByTestId('action-card-details')).toBeNull() + // Expand first card + fireEvent.click(headers[0]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(1) + // Expand second card too + fireEvent.click(headers[1]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(2) + // Collapse first card + fireEvent.click(headers[0]) + expect(screen.getAllByTestId('action-card-details')).toHaveLength(1) + }) +}) diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx new file mode 100644 index 0000000..2897941 --- /dev/null +++ b/src/components/AiMessage.tsx @@ -0,0 +1,413 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { CaretRight, CaretDown, Brain, ArrowsClockwise, Copy, GitBranch, Terminal } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { AiActionCard, type AiActionStatus } from './AiActionCard' +import { MarkdownContent } from './MarkdownContent' +import { translate, type AppLocale } from '../lib/i18n' +import type { NoteReference } from '../utils/ai-context' +import { writeClipboardText } from '../utils/clipboardText' +import { getTypeColor, getTypeLightColor } from '../utils/typeColors' + +export interface AiAction { + tool: string + toolId: string + label: string + path?: string + status: AiActionStatus + input?: string + output?: string +} + +export interface AiMessageProps { + userMessage: string + references?: NoteReference[] + localMarker?: string + locale?: AppLocale + messageId?: string + reasoning?: string + reasoningDone?: boolean + actions: AiAction[] + response?: string + isStreaming?: boolean + onFork?: (messageId: string) => void + onOpenNote?: (path: string) => void + onNavigateWikilink?: (target: string) => void + onRegenerate?: (messageId: string) => void +} + +function LocalMarker({ text }: { text: string }) { + return ( +
      + {text} +
      + ) +} + +function ReferencePill({ reference, onClick }: { + reference: NoteReference + onClick?: (path: string) => void +}) { + const type = reference.type ?? null + const color = getTypeColor(type) + const lightColor = getTypeLightColor(type) + return ( + + ) +} + +function UserBubble({ content, references, onOpenNote }: { + content: string + references?: NoteReference[] + onOpenNote?: (path: string) => void +}) { + return ( +
      +
      + {references && references.length > 0 && ( +
      + {references.map(ref => ( + + ))} +
      + )} + {content} +
      +
      + ) +} + +function ReasoningBlock({ locale, text, expanded, onToggle }: { + locale: AppLocale; text: string; expanded: boolean; onToggle: () => void +}) { + const contentRef = useRef(null) + + useEffect(() => { + void text + if (expanded && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight + } + }, [expanded, text]) + + return ( +
      + + {expanded && ( +
      + {text} +
      + )} +
      + ) +} + +function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: { + actions: AiAction[] + onOpenNote?: (path: string) => void + expandedIds: Set + onToggleExpand: (toolId: string) => void +}) { + return ( +
      + {actions.map((action) => ( + onToggleExpand(action.toolId)} + onOpenNote={onOpenNote} + /> + ))} +
      + ) +} + +function ToolUseBlock({ + actions, + expanded, + expandedActionIds, + locale, + onOpenNote, + onToggle, + onToggleAction, +}: { + actions: AiAction[] + expanded: boolean + expandedActionIds: Set + locale: AppLocale + onOpenNote?: (path: string) => void + onToggle: () => void + onToggleAction: (toolId: string) => void +}) { + const pending = actions.some((action) => action.status === 'pending') + + return ( +
      + + {expanded && ( +
      + +
      + )} +
      + ) +} + +function ResponseActions({ + locale, + messageId, + onCopy, + onFork, + onRegenerate, +}: { + locale: AppLocale + messageId?: string + onCopy: () => void + onFork?: (messageId: string) => void + onRegenerate?: (messageId: string) => void +}) { + const regenerateDisabled = !messageId || !onRegenerate + const forkDisabled = !messageId || !onFork + + return ( +
      + + + +
      + ) +} + +function ResponseBlock({ + locale, + messageId, + onFork, + onNavigateWikilink, + onRegenerate, + text, +}: { + locale: AppLocale + messageId?: string + onFork?: (messageId: string) => void + onNavigateWikilink?: (target: string) => void + onRegenerate?: (messageId: string) => void + text: string +}) { + const handleCopy = useCallback(() => { + void writeClipboardText(text).catch((error) => { + console.warn('[ai] Failed to copy assistant message:', error) + }) + }, [text]) + + return ( +
      + + +
      + ) +} + +function StreamingIndicator() { + return ( +
      +
      + + + +
      +
      + ) +} + +export function AiMessage(props: AiMessageProps) { + if (props.localMarker) { + return + } + + return +} + +function ConversationMessage({ userMessage, references, locale = 'en', messageId, reasoning, reasoningDone, actions, response, isStreaming, onFork, onOpenNote, onNavigateWikilink, onRegenerate }: AiMessageProps) { + // Manual override: null = follow auto behavior, true/false = user forced + const [userOverride, setUserOverride] = useState(false) + const [expandedActions, setExpandedActions] = useState>(new Set()) + const [toolUseExpanded, setToolUseExpanded] = useState(false) + + // Auto: expanded while reasoning streams, collapsed once done + // User can manually toggle to override the auto state + const autoExpanded = !reasoningDone + const reasoningExpanded = userOverride ? !autoExpanded : autoExpanded + + const toggleAction = useCallback((toolId: string) => { + setExpandedActions(prev => { + const next = new Set(prev) + if (next.has(toolId)) next.delete(toolId) + else next.add(toolId) + return next + }) + }, []) + + return ( +
      + + {reasoning && ( + setUserOverride(prev => !prev)} + /> + )} + {actions.length > 0 && ( + setToolUseExpanded((current) => !current)} + onToggleAction={toggleAction} + /> + )} + {response && ( + + )} + {isStreaming && !response && } +
      + ) +} diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx new file mode 100644 index 0000000..b679d9d --- /dev/null +++ b/src/components/AiPanel.test.tsx @@ -0,0 +1,556 @@ +import { useState } from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render as rtlRender, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { AiPanel, AiPanelView } from './AiPanel' +import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AiPanelController } from './useAiPanelController' +import type { VaultEntry } from '../types' +import { queueAiPrompt } from '../utils/aiPromptBridge' +import type { NoteReference } from '../utils/ai-context' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' + +const { trackEventMock } = vi.hoisted(() => ({ + trackEventMock: vi.fn(), +})) + +vi.mock('../lib/telemetry', () => ({ + trackEvent: trackEventMock, +})) + +// Mock the hooks and utils to isolate component tests +let mockMessages: ReturnType['messages'] = [] +let mockStatus: ReturnType['status'] = 'idle' +const mockSendMessage = vi.fn() +const mockStopMessage = vi.fn() +const mockClearConversation = vi.fn() +const mockAddLocalMarker = vi.fn() +const mockUseCliAiAgent = vi.fn() + +vi.mock('../hooks/useCliAiAgent', () => ({ + useCliAiAgent: (...args: unknown[]) => { + mockUseCliAiAgent(...args) + return { + messages: mockMessages, + status: mockStatus, + sendMessage: mockSendMessage, + stopMessage: mockStopMessage, + clearConversation: mockClearConversation, + addLocalMarker: mockAddLocalMarker, + } + }, +})) + +vi.mock('../utils/ai-chat', () => ({ + nextMessageId: () => `msg-${Date.now()}`, +})) + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/vault/note/test.md', + filename: 'test.md', + title: 'Test Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, + ...overrides, +}) + +function render(ui: Parameters[0]) { + return rtlRender(ui, { wrapper: TooltipProvider }) +} + +function QueuedPromptTargetHarness({ onTargetChange }: { onTargetChange: (targetId: string) => void }) { + const [input, setInput] = useState('') + const [targetId, setTargetId] = useState('agent:claude_code') + const handleTargetChange = (nextTargetId: string) => { + onTargetChange(nextTargetId) + setTargetId(nextTargetId) + } + const controller: AiPanelController = { + agent: { + messages: [], + status: 'idle', + sendMessage: (text: string, references?: NoteReference[]) => { + mockSendMessage(text, references) + return Promise.resolve() + }, + stopMessage: mockStopMessage, + regenerateMessage: () => Promise.resolve(), + clearConversation: () => { + mockClearConversation() + }, + addLocalMarker: (text: string) => { + mockAddLocalMarker(text) + }, + }, + input, + setInput, + linkedEntries: [], + hasContext: false, + isActive: false, + permissionMode: 'safe', + handleSend: (text: string, references: NoteReference[]) => { + mockSendMessage(text, references) + setInput('') + }, + handleStop: mockStopMessage, + handleNavigateWikilink: vi.fn(), + handlePermissionModeChange: vi.fn(), + handleNewChat: vi.fn(), + } + + return ( + + ) +} + +describe('AiPanel', () => { + beforeEach(() => { + mockMessages = [] + mockStatus = 'idle' + mockSendMessage.mockReset() + mockStopMessage.mockReset() + mockClearConversation.mockReset() + mockAddLocalMarker.mockReset() + mockUseCliAiAgent.mockReset() + trackEventMock.mockClear() + resetVaultConfigStore() + bindVaultConfigStore({ + zoom: null, + view_mode: null, + editor_mode: null, + note_layout: null, + tag_colors: null, + status_colors: null, + property_display_modes: null, + inbox: null, + allNotes: null, + ai_agent_permission_mode: 'safe', + }, vi.fn()) + }) + + it('renders panel with the default CLI agent header', () => { + render() + expect(screen.getByText('AI Agent')).toBeTruthy() + expect(screen.getByText('Claude Code · Safe')).toBeTruthy() + }) + + it('passes the vault permission mode to the AI agent session', () => { + bindVaultConfigStore({ + ...getVaultConfig(), + ai_agent_permission_mode: 'power_user', + }, vi.fn()) + + render() + + expect(screen.getByText('Claude Code · Power User')).toBeTruthy() + expect(mockUseCliAiAgent).toHaveBeenCalledWith( + '/tmp/vault', + undefined, + undefined, + expect.any(Object), + expect.objectContaining({ permissionMode: 'power_user' }), + ) + }) + + it('persists permission mode changes and records a local transcript marker', () => { + const save = vi.fn() + bindVaultConfigStore({ + ...getVaultConfig(), + ai_agent_permission_mode: 'safe', + }, save) + mockMessages = [{ + userMessage: 'Existing question', + actions: [], + response: 'Existing answer.', + id: 'msg-existing', + }] + + render() + fireEvent.click(screen.getByRole('radio', { name: 'Power User' })) + + expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user') + expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ + ai_agent_permission_mode: 'power_user', + })) + expect(mockAddLocalMarker).toHaveBeenCalledWith( + 'AI permission mode changed to Power User. It will apply to the next message.', + ) + expect(trackEventMock).toHaveBeenCalledWith('ai_agent_permission_mode_changed', { + agent: 'claude_code', + permission_mode: 'power_user', + }) + }) + + it('disables permission mode changes while the AI agent is running', () => { + mockStatus = 'thinking' + + render() + + expect(screen.getByRole('radio', { name: 'Vault Safe' })).toBeDisabled() + expect(screen.getByRole('radio', { name: 'Power User' })).toBeDisabled() + }) + + it('replaces the composer send button with a stop button while the AI agent is running', () => { + mockStatus = 'thinking' + + render() + + expect(screen.queryByTestId('agent-send')).toBeNull() + const stopButton = screen.getByRole('button', { name: 'Stop response' }) + expect(stopButton).toHaveAttribute('data-testid', 'agent-stop') + expect(stopButton).not.toBeDisabled() + + fireEvent.click(stopButton) + + expect(mockStopMessage).toHaveBeenCalledOnce() + }) + + it('renders the permission mode toggle with high contrast selected state and per-mode tooltips', async () => { + render() + + expect(screen.getByTestId('ai-permission-mode-toggle')).toHaveClass('border', 'bg-muted') + + const safeMode = screen.getByRole('radio', { name: 'Vault Safe' }) + const powerUserMode = screen.getByRole('radio', { name: 'Power User' }) + expect(safeMode).toHaveAttribute('aria-checked', 'true') + expect(safeMode).toHaveClass('bg-background', 'text-foreground', 'shadow-xs') + expect(powerUserMode).toHaveClass('text-muted-foreground') + + fireEvent.focus(safeMode) + + expect(await screen.findByTestId('ai-permission-mode-tooltip')).toHaveTextContent( + 'Vault Safe keeps agents limited to file, search, and edit tools.', + ) + + fireEvent.blur(safeMode) + fireEvent.focus(powerUserMode) + + await waitFor(() => { + expect(screen.getByTestId('ai-permission-mode-tooltip')).toHaveTextContent( + 'Power User also allows local shell commands for this vault.', + ) + }) + }) + + it('renders data-testid ai-panel', () => { + render() + expect(screen.getByTestId('ai-panel')).toBeTruthy() + }) + + it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => { + render() + + const editor = screen.getByTestId('agent-input') + editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n') + fireEvent.input(editor) + + expect(editor).toHaveClass('max-h-[120px]', 'overflow-y-auto', 'overscroll-contain') + expect(editor).toHaveStyle({ maxHeight: '120px', overflowY: 'auto' }) + expect(screen.getByTestId('agent-send')).toBeVisible() + }) + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn() + render() + const panel = screen.getByTestId('ai-panel') + const buttons = panel.querySelectorAll('button') + const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close')) + expect(closeBtn).toBeTruthy() + fireEvent.click(closeBtn!) + expect(onClose).toHaveBeenCalled() + }) + + it('starts a new AI chat when the header action is clicked', () => { + render() + fireEvent.click(screen.getByTitle('New AI chat')) + expect(mockClearConversation).toHaveBeenCalledOnce() + }) + + it('keeps the MCP config action out of the AI panel header', () => { + render() + + expect(screen.queryByRole('button', { name: 'Copy MCP config' })).toBeNull() + }) + + it('renders empty state without context', () => { + render() + expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy() + }) + + it('renders contextual empty state when active entry is provided', () => { + const entry = makeEntry({ title: 'My Note' }) + render( + + ) + expect(screen.getByText('Ask anything to Claude Code')).toBeTruthy() + }) + + it('does not render a context bar for the active entry', () => { + const entry = makeEntry({ title: 'My Note' }) + render( + + ) + expect(screen.queryByTestId('context-bar')).toBeNull() + expect(screen.queryByText('My Note')).toBeNull() + }) + + it('does not show linked count in a sub-header', () => { + const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' }) + const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] }) + render( + + ) + expect(screen.queryByText('+ 1 linked')).toBeNull() + expect(screen.queryByTestId('context-bar')).toBeNull() + }) + + it('renders input field enabled', () => { + render() + const input = screen.getByTestId('agent-input') + expect(input).toBeTruthy() + expect(input).toHaveAttribute('contenteditable', 'true') + }) + + it('has send button disabled when input is empty', () => { + render() + const sendBtn = screen.getByTestId('agent-send') + expect((sendBtn as HTMLButtonElement).disabled).toBe(true) + }) + + it('shows active agent placeholder when active entry exists', () => { + const entry = makeEntry({ title: 'My Note' }) + render( + + ) + const input = screen.getByTestId('agent-input') + expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code') + }) + + it('shows active agent placeholder when no active entry', () => { + render() + const input = screen.getByTestId('agent-input') + expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code') + }) + + it('uses the selected AI agent in the placeholder', () => { + render( + , + ) + expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex') + }) + + it('disables sending while the selected AI agent is still loading', () => { + render( + , + ) + + expect(screen.getByText('Checking availability')).toBeTruthy() + expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...') + expect(screen.getByTestId('agent-send')).toBeDisabled() + }) + + it('auto-focuses input on mount', async () => { + vi.useFakeTimers() + render() + await act(() => { vi.advanceTimersByTime(1) }) + const input = screen.getByTestId('agent-input') + expect(document.activeElement).toBe(input) + vi.useRealTimers() + }) + + it('focuses the panel shell when reopening with existing messages', async () => { + vi.useFakeTimers() + mockMessages = [{ + userMessage: 'Remember this', + actions: [], + response: 'Still here.', + id: 'msg-3', + }] + render() + await act(() => { vi.advanceTimersByTime(1) }) + expect(document.activeElement).toBe(screen.getByTestId('ai-panel')) + vi.useRealTimers() + }) + + it('does not steal composer focus after a response when the send button becomes enabled', async () => { + vi.useFakeTimers() + mockMessages = [{ + userMessage: 'First question', + actions: [], + response: 'First answer.', + id: 'msg-3', + }] + render() + + const input = screen.getByTestId('agent-input') + input.focus() + input.textContent = 'f' + fireEvent.input(input) + + await act(() => { vi.advanceTimersByTime(1) }) + + expect(screen.getByTestId('agent-send')).toBeEnabled() + expect(document.activeElement).toBe(screen.getByTestId('agent-input')) + vi.useRealTimers() + }) + + it('calls onClose when Escape is pressed while panel has focus', async () => { + vi.useFakeTimers() + const onClose = vi.fn() + render() + await act(() => { vi.advanceTimersByTime(1) }) + // Input is focused inside the panel, so Escape should trigger onClose + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('calls onClose when Escape is pressed on panel element', () => { + const onClose = vi.fn() + render() + const panel = screen.getByTestId('ai-panel') + panel.focus() + fireEvent.keyDown(panel, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('clicking a wikilink in AI response calls onOpenNote with the target', () => { + mockMessages = [{ + userMessage: 'Tell me about notes', + actions: [], + response: 'Check out [[Build Laputa App]] for details.', + id: 'msg-1', + }] + const onOpenNote = vi.fn() + const { container } = render( + , + ) + const wikilink = container.querySelector('.chat-wikilink') + expect(wikilink).toBeTruthy() + expect(wikilink!.textContent).toBe('Build Laputa App') + fireEvent.click(wikilink!) + expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App') + }) + + it('renders wikilinks with special characters and clicking works', () => { + mockMessages = [{ + userMessage: 'Tell me about meetings', + actions: [], + response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].', + id: 'msg-2', + }] + const onOpenNote = vi.fn() + const { container } = render( + , + ) + const wikilinks = container.querySelectorAll('.chat-wikilink') + expect(wikilinks).toHaveLength(2) + fireEvent.click(wikilinks[0]) + expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15') + fireEvent.click(wikilinks[1]) + expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara') + }) + + it('auto-sends a queued prompt from the command palette bridge', async () => { + render() + + await act(async () => { + queueAiPrompt('summarize [[alpha]]', [ + { title: 'Alpha', path: '/vault/alpha.md', type: 'Project' }, + ]) + }) + + expect(mockClearConversation).toHaveBeenCalledOnce() + expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [ + { title: 'Alpha', path: '/vault/alpha.md', type: 'Project' }, + ]) + expect(screen.getByTestId('agent-send')).toBeDisabled() + }) + + it('retargets a queued prompt before sending when the active target differs', async () => { + const onTargetChange = vi.fn() + render() + + await act(async () => { + queueAiPrompt('ask codex', [], 'agent:codex') + }) + + await waitFor(() => { + expect(onTargetChange).toHaveBeenCalledWith('agent:codex') + expect(mockClearConversation).toHaveBeenCalledOnce() + expect(mockSendMessage).toHaveBeenCalledWith('ask codex', []) + }) + }) + + it('surfaces an unsupported image paste notice without locking the composer', () => { + const onUnsupportedAiPaste = vi.fn() + const entry = makeEntry({ title: 'My Note' }) + + render( + , + ) + + fireEvent.paste(screen.getByTestId('agent-input'), { + clipboardData: { + getData: vi.fn(() => ''), + files: [new File(['image'], 'paste.png', { type: 'image/png' })], + items: [{ kind: 'file', type: 'image/png' }], + }, + }) + + expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) + expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png') + }) +}) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx new file mode 100644 index 0000000..552dad3 --- /dev/null +++ b/src/components/AiPanel.tsx @@ -0,0 +1,315 @@ +import { useCallback, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react' +import { + AiPanelComposer, + AiPanelHeader, + AiPanelMessageHistory, +} from './AiPanelChrome' +import { + DEFAULT_AI_AGENT, + getAiAgentDefinition, + type AiAgentId, + type AiAgentReadiness, +} from '../lib/aiAgents' +import type { AiTarget } from '../lib/aiTargets' +import type { AppLocale } from '../lib/i18n' +import { type NoteListItem } from '../utils/ai-context' +import type { VaultEntry } from '../types' +import { useAiPanelController, type AiPanelController } from './useAiPanelController' +import { useAiPanelPromptQueue } from './useAiPanelPromptQueue' +import { useAiPanelFocus } from './useAiPanelFocus' + +export type { AiAgentMessage } from '../hooks/useCliAiAgent' + +interface AiPanelProps { + onClose: () => void + onOpenNote?: (path: string) => void + onUnsupportedAiPaste?: (message: string) => void + defaultAiAgent?: AiAgentId + defaultAiTarget?: AiTarget + defaultAiAgentReadiness?: AiAgentReadiness + defaultAiAgentReady?: boolean + locale?: AppLocale + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void + onVaultChanged?: () => void + vaultPath: string + vaultPaths?: string[] + activeEntry?: VaultEntry | null + /** Direct content of the active note from the editor tab. */ + activeNoteContent?: string | null + entries?: VaultEntry[] + openTabs?: VaultEntry[] + noteList?: NoteListItem[] + noteListFilter?: { type: string | null; query: string } +} + +interface AiPanelViewProps { + controller: AiPanelController + onClose: () => void + onOpenNote?: (path: string) => void + onUnsupportedAiPaste?: (message: string) => void + defaultAiAgent?: AiAgentId + defaultAiTarget?: AiTarget + defaultAiAgentReadiness?: AiAgentReadiness + defaultAiAgentReady?: boolean + locale?: AppLocale + activeEntry?: VaultEntry | null + entries?: VaultEntry[] + interactive?: boolean + showHeader?: boolean + showLeftBorder?: boolean + surface?: 'default' | 'sidebar' + composerControls?: ReactNode + onForkMessage?: (messageId: string) => void + onQueuedPromptTarget?: (targetId: string) => void + onSendPrompt?: (text: string) => void + onMessageHistoryScrollStateChange?: (scrolled: boolean) => void + targetId?: string +} + +function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness { + return (ready ?? true) ? 'ready' : 'missing' +} + +interface AiPanelViewModel { + agentLabel: string + defaultAiAgent: AiAgentId + defaultAiAgentReadiness: AiAgentReadiness + targetKind: AiTarget['kind'] +} + +function resolveAiPanelViewModel({ + defaultAiAgent, + defaultAiAgentReadiness, + defaultAiAgentReady, + defaultAiTarget, +}: { + defaultAiAgent?: AiAgentId + defaultAiAgentReadiness?: AiAgentReadiness + defaultAiAgentReady?: boolean + defaultAiTarget?: AiTarget +}): AiPanelViewModel { + const resolvedAgent = defaultAiAgent ?? DEFAULT_AI_AGENT + const resolvedReadiness = defaultAiAgentReadiness ?? readinessFromReadyFlag(defaultAiAgentReady) + + return { + agentLabel: defaultAiTarget?.label ?? getAiAgentDefinition(resolvedAgent).label, + defaultAiAgent: resolvedAgent, + defaultAiAgentReadiness: resolvedReadiness, + targetKind: defaultAiTarget?.kind ?? 'agent', + } +} + +function aiPanelFrameStyle(isActive: boolean, showLeftBorder: boolean): CSSProperties { + return { + outline: 'none', + borderLeft: showLeftBorder + ? isActive + ? '2px solid var(--accent-blue)' + : '1px solid var(--border)' + : undefined, + animation: showLeftBorder && isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined, + transition: showLeftBorder ? 'border-color 0.3s ease' : undefined, + } +} + +function AiPanelFrame({ + children, + isActive, + panelRef, + showLeftBorder, + surface, +}: { + children: ReactNode + isActive: boolean + panelRef: RefObject + showLeftBorder: boolean + surface: 'default' | 'sidebar' +}) { + return ( + + ) +} + +export function AiPanelView({ + controller, + onClose, + onOpenNote, + onUnsupportedAiPaste, + defaultAiAgent: providedDefaultAiAgent, + defaultAiTarget, + defaultAiAgentReadiness: providedDefaultAiAgentReadiness, + defaultAiAgentReady: providedDefaultAiAgentReady, + locale = 'en', + entries, + interactive = true, + showHeader = true, + showLeftBorder = true, + surface = 'default', + composerControls, + onForkMessage, + onQueuedPromptTarget, + onSendPrompt, + onMessageHistoryScrollStateChange, + targetId, +}: AiPanelViewProps) { + const view = resolveAiPanelViewModel({ + defaultAiAgent: providedDefaultAiAgent, + defaultAiAgentReadiness: providedDefaultAiAgentReadiness, + defaultAiAgentReady: providedDefaultAiAgentReady, + defaultAiTarget, + }) + const inputRef = useRef(null) + const panelRef = useRef(null) + const { + agent, + input, + setInput, + hasContext, + isActive, + permissionMode, + handleSend, + handleStop, + handleNavigateWikilink, + handlePermissionModeChange, + handleNewChat, + } = controller + + useAiPanelPromptQueue({ + agent, + currentTargetId: targetId, + input, + isActive, + onTargetChange: onQueuedPromptTarget, + setInput, + enabled: interactive, + }) + useAiPanelFocus({ + inputRef, + panelRef, + hasMessages: agent.messages.length > 0, + isActive, + onClose, + enabled: interactive, + }) + const handleComposerSend = useCallback((text: string, references: Parameters[1]) => { + if (!text.trim() || isActive) return + onSendPrompt?.(text) + handleSend(text, references) + }, [handleSend, isActive, onSendPrompt]) + + return ( + + {showHeader && ( + + )} + + + + ) +} + +export function AiPanel({ + onClose, + onOpenNote, + onUnsupportedAiPaste, + defaultAiAgent: providedDefaultAiAgent, + defaultAiTarget, + defaultAiAgentReadiness: providedDefaultAiAgentReadiness, + defaultAiAgentReady: providedDefaultAiAgentReady, + locale = 'en', + onFileCreated, + onFileModified, + onVaultChanged, + vaultPath, + vaultPaths, + activeEntry, + activeNoteContent, + entries, + openTabs, + noteList, + noteListFilter, +}: AiPanelProps) { + const defaultAiAgentReadiness = providedDefaultAiAgentReadiness + ?? readinessFromReadyFlag(providedDefaultAiAgentReady) + const controller = useAiPanelController({ + vaultPath, + vaultPaths, + defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT, + defaultAiTarget, + defaultAiAgentReady: providedDefaultAiAgentReady ?? true, + defaultAiAgentReadiness, + activeEntry, + activeNoteContent, + entries, + openTabs, + noteList, + noteListFilter, + locale, + onOpenNote, + onFileCreated, + onFileModified, + onVaultChanged, + }) + + return ( + + ) +} diff --git a/src/components/AiPanelChrome.performance.test.tsx b/src/components/AiPanelChrome.performance.test.tsx new file mode 100644 index 0000000..e83b1ac --- /dev/null +++ b/src/components/AiPanelChrome.performance.test.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { AiPanelMessageHistory } from './AiPanelChrome' + +const aiMessageRender = vi.hoisted(() => vi.fn()) + +vi.mock('./AiMessage', () => ({ + AiMessage: (props: { id?: string; response: string }) => { + aiMessageRender(props.id) + return
      {props.response}
      + }, +})) + +const noop = () => {} + +function HistoryRerenderHarness() { + const [draft, setDraft] = useState('') + const [messages] = useState([{ + userMessage: 'Explain the note', + actions: [], + response: 'Here is a long answer with [[Test Note]].', + id: 'msg-stable', + }]) + + return ( + <> + + {draft} + + + ) +} + +describe('AiPanelChrome performance', () => { + it('keeps stable message history from re-rendering while composer state changes', () => { + render() + expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer') + expect(aiMessageRender).toHaveBeenCalledTimes(1) + aiMessageRender.mockClear() + + fireEvent.click(screen.getByRole('button', { name: 'type' })) + + expect(screen.getByTestId('draft')).toHaveTextContent('typing') + expect(aiMessageRender).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx new file mode 100644 index 0000000..d53b520 --- /dev/null +++ b/src/components/AiPanelChrome.tsx @@ -0,0 +1,573 @@ +import { memo, useCallback, useEffect, useRef, type CSSProperties, type ReactNode } from 'react' +import { Sparkle, X, PaperPlaneRight, Plus, Link, Stop } from '@phosphor-icons/react' +import { AiMessage } from './AiMessage' +import { Button } from '@/components/ui/button' +import { ActionTooltip } from '@/components/ui/action-tooltip' +import { TooltipProvider } from '@/components/ui/tooltip' +import { WikilinkChatInput } from './WikilinkChatInput' +import { extractInlineWikilinkReferences } from './inlineWikilinkText' +import { + aiAgentPermissionModeLabels, + type AiAgentPermissionMode, +} from '../lib/aiAgentPermissionMode' +import { createTranslator, type AppLocale } from '../lib/i18n' +import type { AiAgentMessage } from '../hooks/useCliAiAgent' +import type { AiAgentReadiness } from '../lib/aiAgents' +import type { NoteReference } from '../utils/ai-context' +import type { VaultEntry } from '../types' +import { cn } from '@/lib/utils' + +interface AiPanelHeaderProps { + agentLabel: string + agentReadiness: AiAgentReadiness + targetKind?: 'agent' | 'api_model' + locale?: AppLocale + permissionMode: AiAgentPermissionMode + permissionModeDisabled: boolean + onPermissionModeChange: (mode: AiAgentPermissionMode) => void + onClose: () => void + onNewChat: () => void +} + +interface AiPanelContextBarProps { + activeEntry: VaultEntry + locale?: AppLocale + linkedCount: number +} + +interface AiPanelMessageHistoryProps { + agentLabel: string + agentReadiness: AiAgentReadiness + locale?: AppLocale + messages: AiAgentMessage[] + isActive: boolean + onForkMessage?: (messageId: string) => void + onOpenNote?: (path: string) => void + onNavigateWikilink?: (target: string) => void + onRegenerateMessage?: (messageId: string) => void + onScrollStateChange?: (scrolled: boolean) => void + hasContext: boolean +} + +interface AiPanelComposerProps { + entries: VaultEntry[] + agentLabel: string + agentReadiness: AiAgentReadiness + locale?: AppLocale + input: string + inputRef: React.RefObject + isActive: boolean + controls?: ReactNode + onChange: (value: string) => void + onSend: (text: string, references: NoteReference[]) => void + onStop: () => void + onUnsupportedAiPaste?: (message: string) => void +} + +function getComposerPlaceholder( + agentLabel: string, + agentReadiness: AiAgentReadiness, + t: ReturnType, +): string { + if (agentReadiness === 'checking') { + return t('ai.panel.placeholder.checking') + } + + if (agentReadiness === 'missing') { + return t('ai.panel.placeholder.missing', { agent: agentLabel }) + } + + return t('ai.panel.placeholder.ready', { agent: agentLabel }) +} + +function composerSendButtonStyle(canSend: boolean): CSSProperties { + return { + background: canSend ? 'var(--primary)' : 'var(--muted)', + color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)', + borderRadius: 8, + width: 30, + height: 30, + cursor: canSend ? 'pointer' : 'not-allowed', + } +} + +function composerStopButtonStyle(): CSSProperties { + return { + background: 'var(--destructive)', + color: 'var(--destructive-foreground)', + borderRadius: 8, + width: 30, + height: 30, + cursor: 'pointer', + } +} + +function ComposerInput({ + disabled, + entries, + hasControls, + input, + inputRef, + onChange, + onSend, + onUnsupportedAiPaste, + placeholder, +}: { + disabled: boolean + entries: VaultEntry[] + hasControls: boolean + input: string + inputRef: React.RefObject + onChange: (value: string) => void + onSend: (text: string, references: NoteReference[]) => void + onUnsupportedAiPaste?: (message: string) => void + placeholder: string +}) { + return ( + + ) +} + +function ComposerSendButton({ + canSend, + entries, + input, + label, + onSend, +}: { + canSend: boolean + entries: VaultEntry[] + input: string + label: string + onSend: (text: string, references: NoteReference[]) => void +}) { + return ( + + ) +} + +function ComposerStopButton({ + label, + onStop, +}: { + label: string + onStop: () => void +}) { + return ( + + ) +} + +function ComposerControlsRow({ + children, + hasControls, + sendButton, +}: { + children?: ReactNode + hasControls: boolean + sendButton: ReactNode +}) { + if (!hasControls) return <>{sendButton} + + return ( +
      +
      + {children} +
      + {sendButton} +
      + ) +} + +function permissionModeTooltip( + mode: AiAgentPermissionMode, + t: ReturnType, +): { label: string } { + return { + label: t(mode === 'power_user' + ? 'ai.permission.powerUser.tooltip' + : 'ai.permission.safe.tooltip'), + } +} + +function headerStatusText({ + agentLabel, + agentReadiness, + modeLabel, + t, +}: { + agentLabel: string + agentReadiness: AiAgentReadiness + modeLabel: string + t: ReturnType +}): string { + if (agentReadiness === 'checking') return t('ai.panel.status.checking') + if (agentReadiness === 'missing') return t('ai.panel.status.missing', { agent: agentLabel }) + return t('ai.panel.status.ready', { agent: agentLabel, mode: modeLabel }) +} + +function AiPanelEmptyState({ + agentLabel, + agentReadiness, + hasContext, + locale = 'en', +}: Pick) { + const t = createTranslator(locale) + + if (agentReadiness === 'checking') { + return ( +
      + +

      + {t('ai.panel.empty.checkingTitle')} +

      +

      + {t('ai.panel.empty.checkingDescription')} +

      +
      + ) + } + + if (agentReadiness === 'missing') { + return ( +
      + +

      + {t('ai.panel.empty.missingTitle', { agent: agentLabel })} +

      +

      + {t('ai.panel.empty.missingDescription')} +

      +
      + ) + } + + return ( +
      + +

      + {hasContext + ? t('ai.panel.empty.withContextTitle', { agent: agentLabel }) + : t('ai.panel.empty.noContextTitle', { agent: agentLabel }) + } +

      +

      + {hasContext + ? t('ai.panel.empty.withContextDescription') + : t('ai.panel.empty.noContextDescription') + } +

      +
      + ) +} + +export const AiPanelHeader = memo(function AiPanelHeader({ + agentLabel, + agentReadiness, + targetKind = 'agent', + locale = 'en', + permissionMode, + permissionModeDisabled, + onPermissionModeChange, + onClose, + onNewChat, +}: AiPanelHeaderProps) { + const t = createTranslator(locale) + const modeLabel = targetKind === 'api_model' + ? t('ai.panel.mode.chat') + : aiAgentPermissionModeLabels(permissionMode, locale).short + + return ( +
      +
      + +
      + + {t('ai.panel.title')} + + + {headerStatusText({ agentLabel, agentReadiness, modeLabel, t })} + +
      + + +
      + {targetKind === 'agent' ? ( + + ) : ( +
      + {t('ai.panel.mode.chatDescription')} +
      + )} +
      + ) +}) + +function AiPermissionModeToggle({ + value, + locale = 'en', + disabled, + onChange, +}: { + value: AiAgentPermissionMode + locale?: AppLocale + disabled: boolean + onChange: (mode: AiAgentPermissionMode) => void +}) { + const t = createTranslator(locale) + + return ( + +
      + {(['safe', 'power_user'] as const).map((mode) => { + const selected = value === mode + return ( + + + + ) + })} +
      +
      + ) +} + +export const AiPanelContextBar = memo(function AiPanelContextBar({ + activeEntry, + linkedCount, + locale = 'en', +}: AiPanelContextBarProps) { + const t = createTranslator(locale) + + return ( +
      + + {activeEntry.title} + {linkedCount > 0 && ( + {t('ai.panel.linkedCount', { count: linkedCount })} + )} +
      + ) +}) + +export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({ + agentLabel, + agentReadiness, + locale = 'en', + messages, + isActive, + onForkMessage, + onOpenNote, + onNavigateWikilink, + onRegenerateMessage, + onScrollStateChange, + hasContext, +}: AiPanelMessageHistoryProps) { + const containerRef = useRef(null) + const endRef = useRef(null) + + const updateScrollState = useCallback(() => { + const element = containerRef.current + onScrollStateChange?.((element?.scrollTop ?? 0) > 1) + }, [onScrollStateChange]) + + useEffect(() => { + void isActive + void messages + endRef.current?.scrollIntoView({ behavior: 'smooth' }) + if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(updateScrollState) + else updateScrollState() + }, [messages, isActive, updateScrollState]) + + return ( +
      + {messages.length === 0 && !isActive && ( + + )} + {messages.map((message, index) => ( + + ))} +
      +
      + ) +}) + +export function AiPanelComposer({ + entries, + agentLabel, + agentReadiness, + locale = 'en', + input, + inputRef, + isActive, + controls, + onChange, + onSend, + onStop, + onUnsupportedAiPaste, +}: AiPanelComposerProps) { + const t = createTranslator(locale) + const composerDisabled = isActive || agentReadiness !== 'ready' + const canSend = !composerDisabled && input.trim().length > 0 + const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t) + const hasControls = controls !== undefined && controls !== null + const sendButton = isActive + ? + : ( + + ) + + return ( +
      +
      +
      + +
      + + {controls} + +
      +
      + ) +} diff --git a/src/components/AiProviderSettings.tsx b/src/components/AiProviderSettings.tsx new file mode 100644 index 0000000..24641d3 --- /dev/null +++ b/src/components/AiProviderSettings.tsx @@ -0,0 +1,357 @@ +import { useId, useState } from 'react' +import { + DEFAULT_MODEL_CAPABILITIES, + aiModelProviderCatalog, + aiModelProviderCatalogEntry, + configuredModelTargets, + isLocalAiProvider, + normalizeAiModelProviders, + type AiModelApiKeyStorage, + type AiModelProvider, + type AiModelProviderKind, +} from '../lib/aiTargets' +import type { createTranslator } from '../lib/i18n' +import { deleteAiModelProviderApiKey, saveAiModelProviderApiKey, testAiModelProvider } from '../utils/aiProviderSecrets' +import { Button } from './ui/button' +import { Input } from './ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select' + +type Translate = ReturnType +type ProviderMode = 'local' | 'api' +type TestState = 'idle' | 'testing' | 'success' + +interface AiProviderSettingsProps { + t: Translate + mode: ProviderMode + providers: AiModelProvider[] + onChange: (providers: AiModelProvider[]) => void +} + +interface ProviderDraft { + kind: AiModelProviderKind + name: string + baseUrl: string + modelId: string + apiKeyStorage: AiModelApiKeyStorage + apiKey: string + apiKeyEnvVar: string +} + +function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] { + return aiModelProviderCatalog() + .filter((entry) => entry.local === (mode === 'local')) + .map((entry) => entry.kind) +} + +function initialDraft(mode: ProviderMode): ProviderDraft { + const [kind] = providerKindsForMode(mode) + if (!kind) throw new Error(`No AI model providers are configured for ${mode} mode`) + return draftFromProviderKind(kind) +} + +function draftFromProviderKind(kind: AiModelProviderKind): ProviderDraft { + const defaults = aiModelProviderCatalogEntry(kind) + return { + kind, + name: defaults.name, + baseUrl: defaults.base_url, + modelId: '', + apiKeyStorage: defaults.api_key_storage, + apiKey: '', + apiKeyEnvVar: defaults.api_key_env_var ?? '', + } +} + +function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> { + return providerKindsForMode(mode).map((kind) => { + const defaults = aiModelProviderCatalogEntry(kind) + return { value: kind, label: t(defaults.label_key) } + }) +} + +function providerPresetPatch(kind: AiModelProviderKind): Pick { + const defaults = draftFromProviderKind(kind) + return { + kind, + name: defaults.name, + baseUrl: defaults.baseUrl, + apiKeyStorage: defaults.apiKeyStorage, + apiKeyEnvVar: defaults.apiKeyEnvVar, + } +} + +function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider { + return { + id: providerId, + name: draft.name, + kind: draft.kind, + base_url: draft.baseUrl || null, + api_key_storage: draft.apiKeyStorage, + api_key_env_var: draft.apiKeyStorage === 'env' ? draft.apiKeyEnvVar || null : null, + headers: null, + models: [{ + id: draft.modelId, + display_name: null, + context_window: null, + max_output_tokens: null, + capabilities: DEFAULT_MODEL_CAPABILITIES, + }], + } +} + +function providerModeTitle(mode: ProviderMode, t: Translate): string { + return mode === 'local' ? t('settings.aiProviders.localTitle') : t('settings.aiProviders.apiTitle') +} + +function providerModeDescription(mode: ProviderMode, t: Translate): string { + return mode === 'local' ? t('settings.aiProviders.localDescription') : t('settings.aiProviders.apiDescription') +} + +function providerStorageLabel(provider: AiModelProvider, t: Translate): string { + if (provider.api_key_storage === 'local_file') return t('settings.aiProviders.keyLocalSaved') + if (provider.api_key_storage === 'env' && provider.api_key_env_var) { + return t('settings.aiProviders.keyEnvSaved', { env: provider.api_key_env_var }) + } + return t('settings.aiProviders.noKey') +} + +function visibleProviders(providers: AiModelProvider[], mode: ProviderMode): AiModelProvider[] { + return providers.filter((provider) => mode === 'local' ? isLocalAiProvider(provider) : !isLocalAiProvider(provider)) +} + +function editableInputClassName(): string { + return 'border-border bg-background text-foreground placeholder:text-muted-foreground/65 shadow-xs' +} + +function LabeledInput({ + label, + value, + onChange, + placeholder, + type = 'text', +}: { + label: string + value: string + onChange: (value: string) => void + placeholder?: string + type?: 'text' | 'password' +}) { + const inputId = useId() + + return ( + + ) +} + +function ProviderKindSelect({ + mode, + t, + value, + onChange, +}: { + mode: ProviderMode + t: Translate + value: AiModelProviderKind + onChange: (value: AiModelProviderKind) => void +}) { + const triggerId = useId() + + return ( + + ) +} + +function ApiKeyStorageFields({ + t, + draft, + updateDraft, +}: { + t: Translate + draft: ProviderDraft + updateDraft: (patch: Partial) => void +}) { + const triggerId = useId() + + return ( + <> + + {draft.apiKeyStorage === 'local_file' ? ( + updateDraft({ apiKey })} + placeholder={t('settings.aiProviders.keyPlaceholder')} + type="password" + /> + ) : null} + {draft.apiKeyStorage === 'env' ? ( + updateDraft({ apiKeyEnvVar })} + placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''} + /> + ) : null} + + ) +} + +function ProviderList({ + t, + mode, + providers, + onRemove, +}: { + t: Translate + mode: ProviderMode + providers: AiModelProvider[] + onRemove: (providerId: string) => void +}) { + const visible = visibleProviders(providers, mode) + if (visible.length === 0) { + return
      {t('settings.aiProviders.empty')}
      + } + + return ( +
      + {configuredModelTargets(visible).map((target) => ( +
      +
      +
      {target.label}
      +
      + {target.provider.base_url || t('settings.aiProviders.defaultEndpoint')} · {providerStorageLabel(target.provider, t)} +
      +
      + +
      + ))} +
      + ) +} + +export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderSettingsProps) { + const [draft, setDraft] = useState(() => initialDraft(mode)) + const [error, setError] = useState(null) + const [testState, setTestState] = useState('idle') + const updateDraft = (patch: Partial) => setDraft((current) => ({ ...current, ...patch })) + const resetTest = () => { + setTestState('idle') + setError(null) + } + const updateForm = (patch: Partial) => { + resetTest() + updateDraft(patch) + } + const updateKind = (kind: AiModelProviderKind) => updateForm(providerPresetPatch(kind)) + const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim()) + const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null + + const addProvider = async () => { + const providerId = `${draft.kind}-${Date.now().toString(36)}` + setError(null) + try { + if (draft.apiKeyStorage === 'local_file') { + await saveAiModelProviderApiKey(providerId, draft.apiKey) + } + onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)])) + setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl })) + setTestState('idle') + } catch (error) { + setError(error instanceof Error ? error.message : String(error)) + } + } + const testProvider = async () => { + setError(null) + setTestState('testing') + try { + await testAiModelProvider(buildProvider(draft, 'draft-provider-test'), draft.modelId, apiKeyOverride) + setTestState('success') + } catch (error) { + setTestState('idle') + setError(error instanceof Error ? error.message : String(error)) + } + } + const removeProvider = (providerId: string) => { + void deleteAiModelProviderApiKey(providerId) + onChange(providers.filter((provider) => provider.id !== providerId)) + } + + return ( +
      +
      +
      {providerModeTitle(mode, t)}
      +
      {providerModeDescription(mode, t)}
      +
      + +
      + + updateForm({ name })} /> + updateForm({ baseUrl })} /> + updateForm({ modelId })} placeholder={aiModelProviderCatalogEntry(draft.kind).default_model_id} /> + {mode === 'api' ? : null} +
      +
      + {mode === 'api' ? t('settings.aiProviders.keySafetyLocal') : t('settings.aiProviders.localSafety')} +
      + {testState === 'success' ?
      {t('settings.aiProviders.testSuccess')}
      : null} + {error ?
      {error}
      : null} +
      + + +
      +
      + ) +} diff --git a/src/components/AiWorkspace.test.tsx b/src/components/AiWorkspace.test.tsx new file mode 100644 index 0000000..6d8ef99 --- /dev/null +++ b/src/components/AiWorkspace.test.tsx @@ -0,0 +1,681 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { ReactNode } from 'react' +import { AiWorkspace } from './AiWorkspace' +import { buildAiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups' +import { + createAiAgentAvailability, + createMissingAiAgentsStatus, + type AiAgentsStatus, +} from '../lib/aiAgents' +import type { AiModelProvider } from '../lib/aiTargets' +import type { AgentStatus } from '../hooks/useCliAiAgent' +import { resetVaultConfigStore } from '../utils/vaultConfigStore' +import type { VaultEntry } from '../types' +import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' + +let mockedAgentStatus: AgentStatus = 'idle' +let mockMessages: ReturnType['messages'] = [] +let controllerCalls: unknown[] = [] +const { generateTitleMock } = vi.hoisted(() => ({ + generateTitleMock: vi.fn(), +})) + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true }) + +vi.mock('./useAiPanelController', () => ({ + useAiPanelController: (args: unknown) => { + controllerCalls.push(args) + return { + agent: { + messages: mockMessages, + status: mockedAgentStatus, + sendMessage: vi.fn(), + clearConversation: vi.fn(), + addLocalMarker: vi.fn(), + }, + input: '', + setInput: vi.fn(), + linkedEntries: [], + hasContext: false, + isActive: false, + permissionMode: 'safe', + handleSend: vi.fn(), + handleNavigateWikilink: vi.fn(), + handlePermissionModeChange: vi.fn(), + handleNewChat: vi.fn(), + } + }, +})) + +vi.mock('./AiPanel', () => ({ + AiPanelView: ({ + composerControls, + onMessageHistoryScrollStateChange, + onSendPrompt, + showHeader, + }: { + composerControls?: ReactNode + onMessageHistoryScrollStateChange?: (scrolled: boolean) => void + onSendPrompt?: (prompt: string) => void + showHeader?: boolean + }) => ( +
      + + + {composerControls} +
      + ), +})) + +vi.mock('../utils/aiConversationTitle', () => ({ + generateAiConversationTitleForTarget: generateTitleMock, +})) + +function installedStatuses(): AiAgentsStatus { + return { + ...createMissingAiAgentsStatus(), + claude_code: createAiAgentAvailability('installed', '1.0.0'), + codex: createAiAgentAvailability('installed', '0.9.0'), + antigravity: createAiAgentAvailability('missing', null), + } +} + +const providers: AiModelProvider[] = [ + { + id: 'ollama-local', + name: 'Ollama', + kind: 'ollama', + api_key_storage: 'none', + models: [{ + id: 'llama3.2', + display_name: 'Llama 3.2', + capabilities: { streaming: true, tools: false, vision: false, json_mode: false, reasoning: false }, + }], + }, + { + id: 'openai', + name: 'OpenAI', + kind: 'open_ai', + api_key_storage: 'env', + api_key_env_var: 'OPENAI_API_KEY', + models: [{ + id: 'gpt-4.1', + display_name: 'GPT-4.1', + capabilities: { streaming: true, tools: true, vision: true, json_mode: true, reasoning: true }, + }], + }, +] + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { + aliases: [], + archived: false, + belongsTo: [], + color: null, + createdAt: 1700000000, + favorite: false, + favoriteIndex: null, + fileSize: 100, + filename: 'active.md', + hasH1: false, + icon: null, + isA: 'Note', + listPropertiesDisplay: [], + modifiedAt: 1700000000, + order: null, + organized: false, + outgoingLinks: [], + path: '/tmp/vault/active.md', + properties: {}, + relatedTo: [], + relationships: {}, + sidebarLabel: null, + snippet: '', + sort: null, + status: null, + template: null, + title: 'Active', + view: null, + visible: null, + wordCount: 0, + ...overrides, + } +} + +function contextReadyControllerCalls(): unknown[] { + return controllerCalls.filter((args) => { + const call = args as { activeEntry?: unknown; entries?: unknown[] } + return call.activeEntry !== null && call.activeEntry !== undefined && Array.isArray(call.entries) + }) +} + +describe('AiWorkspace', () => { + beforeEach(() => { + mockedAgentStatus = 'idle' + mockMessages = [] + controllerCalls = [] + generateTitleMock.mockReset() + generateTitleMock.mockResolvedValue('Quarterly sponsor outreach') + localStorage.clear() + resetVaultConfigStore() + }) + + it('groups installed agents and configured local/API models', () => { + const groups = buildAiWorkspaceTargetGroups(installedStatuses(), providers) + + expect(groups.localAgents.map((target) => target.agent)).toEqual(['claude_code', 'codex']) + expect(groups.localAgents.some((target) => target.agent === 'antigravity')).toBe(false) + expect(groups.localModels.map((target) => target.shortLabel)).toEqual(['Llama 3.2']) + expect(groups.apiModels.map((target) => target.shortLabel)).toEqual(['GPT-4.1']) + }) + + it('creates chats from the sidebar and hides the legacy AI panel header', () => { + render() + + const workspace = screen.getByTestId('ai-workspace') + expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked') + expect(workspace).toHaveStyle({ width: '560px' }) + expect(workspace.className).toContain('bottom-[30px]') + expect(workspace.className).not.toContain('shadow') + expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false') + expect(screen.queryByText('Agents')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) + expect(screen.getByText('Agents')).toBeTruthy() + expect(screen.queryByText('AI Agent')).toBeNull() + expect(screen.queryByText('Idle')).toBeNull() + + fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat')) + + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0) + expect(screen.getAllByText('AI Chat 2').length).toBeGreaterThan(0) + }) + + it('renders side mode as an in-editor tabbed panel and expands in place', () => { + const onClose = vi.fn() + render() + + const workspace = screen.getByTestId('ai-workspace') + expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'side') + expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'false') + expect(workspace).not.toHaveClass('fixed') + expect(workspace).toHaveClass('bg-sidebar') + expect(workspace).toHaveStyle({ width: '320px', minWidth: '320px' }) + const header = screen.getByTestId('ai-workspace-side-header') + const tabStrip = screen.getByTestId('ai-workspace-side-tabs') + expect(header).not.toHaveClass('border-b') + fireEvent.click(screen.getByRole('button', { name: 'Mock history scroll' })) + expect(header).toHaveClass('border-b') + expect(tabStrip).toHaveClass('overflow-x-auto') + expect(screen.getByRole('button', { name: 'AI Chat' })).toBeTruthy() + expect( + within(header).getByRole('button', { name: 'Expand AI workspace' }).compareDocumentPosition( + within(header).getByRole('button', { name: 'Close AI workspace' }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'New chat' })) + expect(screen.getByRole('button', { name: 'AI Chat 2' })).toBeTruthy() + expect( + within(tabStrip).getByRole('button', { name: 'AI Chat 2' }).compareDocumentPosition( + within(tabStrip).getByRole('button', { name: 'New chat' }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Close AI Chat 2' })) + expect(screen.queryByRole('button', { name: 'AI Chat 2' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Expand AI workspace' })) + expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'true') + expect(workspace).toHaveClass('absolute') + expect(screen.getByRole('button', { name: 'Restore AI workspace panel' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Close AI workspace' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('opens AI settings from the composer controls', () => { + const onOpenAiSettings = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('ai-workspace-composer-settings')) + + expect(onOpenAiSettings).toHaveBeenCalledOnce() + }) + + it('reports the active side chat so reopening can restore it', () => { + const onActiveConversationChange = vi.fn() + + const { unmount } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Follow-up draft' })) + expect(onActiveConversationChange).toHaveBeenLastCalledWith('chat-b') + unmount() + + render( + , + ) + + expect(screen.getByRole('button', { name: 'Follow-up draft' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('renames a side chat from the tab on double-click', () => { + const onConversationSettingsChange = vi.fn() + render( + , + ) + + fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat' })) + const input = screen.getByLabelText('Rename chat') + fireEvent.change(input, { target: { value: 'Research thread' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(screen.getByRole('button', { name: 'Research thread' })).toBeTruthy() + expect(onConversationSettingsChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'chat-a', title: 'Research thread' }), + ]) + }) + + it('resizes the docked workspace from the left edge and the sidebar split', () => { + render() + + const workspace = screen.getByTestId('ai-workspace') + fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) + fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 }) + fireEvent.mouseMove(window, { clientX: 60, clientY: 20 }) + fireEvent.mouseUp(window) + expect(workspace).toHaveStyle({ width: '600px' }) + + const sidebar = screen.getByTestId('ai-workspace-sidebar-header').parentElement + const sidebarHandle = workspace.querySelector('.cursor-col-resize:not([data-testid])') + expect(sidebar).toHaveStyle({ width: '168px' }) + fireEvent.mouseDown(sidebarHandle as Element, { clientX: 100, clientY: 20 }) + fireEvent.mouseMove(document, { clientX: 120, clientY: 20 }) + fireEvent.mouseUp(document) + expect(sidebar).toHaveStyle({ width: '188px' }) + }) + + it('restores the last resized side panel width when reopened', () => { + const { unmount } = render() + + const workspace = screen.getByTestId('ai-workspace') + fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 }) + fireEvent.mouseMove(window, { clientX: 40, clientY: 20 }) + fireEvent.mouseUp(window) + expect(workspace).toHaveStyle({ width: '380px' }) + + unmount() + render() + + expect(screen.getByTestId('ai-workspace')).toHaveStyle({ width: '380px' }) + }) + + it('separates the guidance warning from the header and uses a short restore action', () => { + const status: VaultAiGuidanceStatus = { + agentsState: 'missing', + claudeState: 'managed', + geminiState: 'managed', + canRestore: true, + } + + render( + , + ) + + expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken')).toHaveClass('min-w-0') + expect(screen.getByRole('button', { name: 'Restore' })).toBeTruthy() + expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken').parentElement).toHaveClass('border-y') + }) + + it('does not archive an empty chat', () => { + render() + + const archiveButtons = screen.getAllByRole('button', { name: 'Archive chat' }) + expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true) + fireEvent.click(archiveButtons[0]) + + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0) + expect(screen.queryByText('AI Chat 2')).toBeNull() + }) + + it('activates a visible chat when persisted settings start with an archived chat', () => { + render( + , + ) + + expect(screen.getByTestId('ai-workspace-session-visible-chat')).toHaveClass('flex') + expect(screen.getByTestId('ai-workspace-session-archived-chat')).toHaveClass('hidden') + }) + + it('passes vault context only to the active conversation controller', () => { + const entries = [ + makeEntry({ path: '/tmp/vault/active.md', filename: 'active.md', title: 'Active' }), + makeEntry({ path: '/tmp/vault/related.md', filename: 'related.md', title: 'Related' }), + ] + const singleConversation = render( + , + ) + const activeContextCallCount = contextReadyControllerCalls().length + singleConversation.unmount() + controllerCalls = [] + + render( + , + ) + + expect(screen.getByTestId('ai-workspace-session-active-chat')).toHaveClass('flex') + expect(screen.getByTestId('ai-workspace-session-inactive-chat')).toHaveClass('hidden') + expect(contextReadyControllerCalls()).toHaveLength(activeContextCallCount) + }) + + it('shows grouped target choices without missing agents', async () => { + render() + + const trigger = screen.getByTestId('ai-workspace-target-trigger') + act(() => { + trigger.focus() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + }) + const menu = await screen.findByRole('menu') + + expect(within(menu).getByText('Local agents')).toBeTruthy() + expect(within(menu).getByText('Local models')).toBeTruthy() + expect(within(menu).getByText('API models')).toBeTruthy() + expect(within(menu).getByText('Claude Code')).toBeTruthy() + expect(within(menu).getByText('Codex')).toBeTruthy() + expect(within(menu).queryByText('Antigravity CLI')).toBeNull() + expect(within(menu).getByText('Ollama · Llama 3.2')).toBeTruthy() + expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy() + }) + + it('reports the selected workspace target to parent surfaces', async () => { + const onActiveTargetChange = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(onActiveTargetChange).toHaveBeenCalledWith(expect.objectContaining({ + agent: 'claude_code', + id: 'agent:claude_code', + kind: 'agent', + })) + }) + + const trigger = screen.getByTestId('ai-workspace-target-trigger') + act(() => { + trigger.focus() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + }) + const menu = await screen.findByRole('menu') + fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Codex/i })) + + await waitFor(() => { + expect(onActiveTargetChange).toHaveBeenLastCalledWith(expect.objectContaining({ + agent: 'codex', + id: 'agent:codex', + kind: 'agent', + })) + }) + }) + + it('does not repeat active target notifications when equivalent target props are recreated', async () => { + const onActiveTargetChange = vi.fn() + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(onActiveTargetChange).toHaveBeenCalledTimes(1) + }) + expect(onActiveTargetChange).toHaveBeenLastCalledWith(expect.objectContaining({ + agent: 'codex', + id: 'agent:codex', + kind: 'agent', + })) + + await act(async () => { + rerender( + , + ) + await Promise.resolve() + }) + + expect(onActiveTargetChange).toHaveBeenCalledTimes(1) + }) + + it('marks the first chat active when a prompt is submitted', () => { + const onConversationSettingsChange = vi.fn() + render() + + fireEvent.click(screen.getByText('Send mocked prompt')) + + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true) + expect(generateTitleMock).not.toHaveBeenCalled() + expect(onConversationSettingsChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ title: 'AI Chat' }), + ]) + }) + + it('renames the first chat after the first assistant reply and stores conversation settings', async () => { + mockMessages = [{ + userMessage: 'summarize quarterly sponsor outreach', + actions: [], + response: 'The next step is to follow up with the sponsor pipeline.', + id: 'msg-title', + }] + const onConversationSettingsChange = vi.fn() + render() + + await waitFor(() => { + expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0) + }) + expect(generateTitleMock).toHaveBeenCalledWith(expect.objectContaining({ + assistantResponse: 'The next step is to follow up with the sponsor pipeline.', + permissionMode: 'safe', + prompt: 'summarize quarterly sponsor outreach', + target: expect.objectContaining({ kind: 'agent', agent: 'claude_code' }), + targetReady: true, + vaultPath: '/tmp/vault', + })) + expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true) + await waitFor(() => { + expect(onConversationSettingsChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ title: 'Quarterly sponsor outreach' }), + ]) + }) + }) + + it('renames a persisted default chat title after the first assistant reply', async () => { + mockMessages = [{ + userMessage: 'summarize quarterly sponsor outreach', + actions: [], + response: 'The next step is to follow up with the sponsor pipeline.', + id: 'msg-title', + }] + const onConversationSettingsChange = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0) + }) + await waitFor(() => { + expect(onConversationSettingsChange.mock.calls.some(([settings]) => ( + settings.some((setting) => ( + setting.id === 'stored-chat' + && setting.title === 'Quarterly sponsor outreach' + )) + ))).toBe(true) + }) + }) + + it('allows a chat title to be renamed from the sidebar', () => { + const onConversationSettingsChange = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) + fireEvent.doubleClick(screen.getByRole('button', { name: /^AI Chat$/i })) + const input = screen.getByLabelText('Rename chat') + fireEvent.change(input, { target: { value: 'Sponsor Plan' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(screen.getAllByText('Sponsor Plan').length).toBeGreaterThan(0) + expect(onConversationSettingsChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ title: 'Sponsor Plan' }), + ]) + }) + + it('opens with the workspace sidebar collapsed and expands from the sidebar header', () => { + render() + + expect(screen.queryByText('Agents')).toBeNull() + expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) + + expect(screen.getByText('Agents')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Collapse AI chat list' })).toBeTruthy() + }) +}) diff --git a/src/components/AiWorkspace.tsx b/src/components/AiWorkspace.tsx new file mode 100644 index 0000000..9f21e77 --- /dev/null +++ b/src/components/AiWorkspace.tsx @@ -0,0 +1,1071 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CaretDown, GearSix } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { + DEFAULT_AI_AGENT, + getAiAgentAvailability, + type AiAgentId, + type AiAgentReadiness, + type AiAgentsStatus, +} from '../lib/aiAgents' +import { + aiTargetReady, + targetAgent, + type AiModelProvider, + type AiTarget, +} from '../lib/aiTargets' +import { + aiAgentPermissionModeLabels, + type AiAgentPermissionMode, +} from '../lib/aiAgentPermissionMode' +import { + type VaultAiGuidanceStatus, +} from '../lib/vaultAiGuidance' +import { translate, type AppLocale } from '../lib/i18n' +import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics' +import type { AgentStatus, AiAgentMessage } from '../hooks/useCliAiAgent' +import type { AiWorkspaceConversationSetting } from '../types' +import type { NoteListItem } from '../utils/ai-context' +import type { VaultEntry } from '../types' +import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge' +import { type GenerateAiConversationTitleRequest } from '../utils/aiConversationTitle' +import { cloneAiWorkspaceSessionUntilMessage } from '../lib/aiWorkspaceSessionStore' +import { AiPanelView } from './AiPanel' +import { GuidanceWarning, WorkspaceHeader } from './AiWorkspaceChrome' +import { WorkspaceResizeHandles } from './AiWorkspaceResizeHandles' +import { AiAgentIcon } from './AiAgentIcon' +import { ConversationSidebar } from './AiWorkspaceSidebar' +import { ResizeHandle } from './ResizeHandle' +import { SideWorkspaceHeader } from './AiWorkspaceSideHeader' +import { useAiPanelController } from './useAiPanelController' +import { buildAiWorkspaceTargetGroups, type AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups' +import { + activeConversationForState, + canArchiveConversation, + firstTarget, + flatTargets, + resolveTarget, + useConversations, + type AiConversation, +} from './aiWorkspaceConversations' +import { + useAiWorkspaceSizing, + workspaceClassName, + workspaceStyle, + type AiWorkspaceMode, + type AiWorkspaceSizing, +} from './aiWorkspaceSizing' + +export type { AiConversation } from './aiWorkspaceConversations' + +interface AiWorkspaceProps { + activeEntry?: VaultEntry | null + activeNoteContent?: string | null + aiAgentsStatus: AiAgentsStatus + aiModelProviders?: AiModelProvider[] + conversationSettings?: AiWorkspaceConversationSetting[] | null + conversationSettingsReady?: boolean + defaultAiAgent?: AiAgentId + defaultAiAgentReadiness?: AiAgentReadiness + defaultAiAgentReady?: boolean + defaultAiTarget?: AiTarget + entries?: VaultEntry[] + initialActiveConversationId?: string + locale?: AppLocale + mode?: 'docked' | 'side' | 'window' + noteList?: NoteListItem[] + noteListFilter?: { type: string | null; query: string } + onActiveConversationChange?: (id: string) => void + onActiveTargetChange?: (target: AiTarget) => void + onClose: () => void + onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void + onDock?: () => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void + onOpenAiSettings?: () => void + onOpenNote?: (path: string) => void + onPopOut?: (context?: { activeConversationId?: string }) => void + onRestoreVaultAiGuidance?: () => void + onUnsupportedAiPaste?: (message: string) => void + onVaultChanged?: () => void + open: boolean + openTabs?: VaultEntry[] + vaultAiGuidanceStatus?: VaultAiGuidanceStatus + vaultPath: string + vaultPaths?: string[] +} + +function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): AiAgentReadiness { + if (target.kind === 'api_model') return 'ready' + const status = getAiAgentAvailability(statuses, target.agent).status + if (status === 'checking') return 'checking' + return status === 'installed' ? 'ready' : 'missing' +} + +function TargetGroup({ label, targets }: { label: string; targets: AiTarget[] }) { + if (targets.length === 0) return null + + return ( + <> + {label} + {targets.map((target) => ( + + {target.kind === 'agent' ? : null} + {target.label} + + ))} + + ) +} + +function TargetPickerTrigger({ + compact, + disabled, + hasTargets, + locale, + selectedTarget, +}: { + compact: boolean + disabled: boolean + hasTargets: boolean + locale: AppLocale + selectedTarget: AiTarget +}) { + return ( + + + + ) +} + +function TargetPickerContent({ + groups, + hasTargets, + locale, + onSelectTarget, + selectedTarget, + side, +}: { + groups: AiWorkspaceTargetGroups + hasTargets: boolean + locale: AppLocale + selectedTarget: AiTarget + onSelectTarget: (targetId: string) => void + side: 'bottom' | 'top' +}) { + const hasLocalAgentsSeparator = groups.localAgents.length > 0 + && (groups.localModels.length > 0 || groups.apiModels.length > 0) + const hasLocalModelsSeparator = groups.localModels.length > 0 && groups.apiModels.length > 0 + + return ( + + {hasTargets ? ( + + + {hasLocalAgentsSeparator && } + + {hasLocalModelsSeparator && } + + + ) : ( + {translate(locale, 'ai.workspace.noTargets')} + )} + + ) +} + +function TargetPicker({ + compact = false, + disabled, + groups, + locale, + selectedTarget, + side = 'bottom', + onSelectTarget, +}: { + compact?: boolean + disabled: boolean + groups: AiWorkspaceTargetGroups + locale: AppLocale + selectedTarget: AiTarget + side?: 'bottom' | 'top' + onSelectTarget: (targetId: string) => void +}) { + const hasTargets = flatTargets(groups).length > 0 + + return ( + + + + + ) +} + +function PermissionPicker({ + compact = false, + disabled, + locale, + permissionMode, + side = 'bottom', + targetKind, + onChange, +}: { + compact?: boolean + disabled: boolean + locale: AppLocale + permissionMode: AiAgentPermissionMode + side?: 'bottom' | 'top' + targetKind: AiTarget['kind'] + onChange: (mode: AiAgentPermissionMode) => void +}) { + if (targetKind === 'api_model') { + return ( + + ) + } + + return ( + + + + + + {(['safe', 'power_user'] as const).map((mode) => ( + onChange(mode)}> + {aiAgentPermissionModeLabels(mode, locale).control} + + ))} + + + ) +} + +type ConversationSessionProps = { + active: boolean + activeEntry?: VaultEntry | null + activeNoteContent?: string | null + aiAgentsStatus: AiAgentsStatus + conversation: AiConversation + defaultAiAgentReady: boolean + entries?: VaultEntry[] + groups: AiWorkspaceTargetGroups + locale: AppLocale + mode: AiWorkspaceMode + noteList?: NoteListItem[] + noteListFilter?: { type: string | null; query: string } + onArchive: () => void + onClose: () => void + onDock?: () => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void + onForkMessage?: (messageId: string) => void + onMessageHistoryScrollStateChange?: (scrolled: boolean) => void + onOpenAiSettings?: () => void + onOpenNote?: (path: string) => void + onPopOut?: () => void + onRestoreVaultAiGuidance?: () => void + onSelectTarget: (targetId: string) => void + onStatusChange: (id: string, status: AgentStatus) => void + onPromptSubmitted: (id: string) => void + onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void + onUnsupportedAiPaste?: (message: string) => void + onVaultChanged?: () => void + openTabs?: VaultEntry[] + target: AiTarget + vaultAiGuidanceStatus?: VaultAiGuidanceStatus + vaultPath: string + vaultPaths?: string[] +} + +function firstCompletedAssistantMessage(messages: AiAgentMessage[]): AiAgentMessage | undefined { + return messages.find((message) => ( + !message.localMarker + && !message.isStreaming + && !!message.userMessage.trim() + && !!message.response?.trim() + )) +} + +function useGeneratedConversationTitle({ + aiAgentsStatus, + conversation, + messages, + onTitleFromAnswer, + permissionMode, + target, + vaultPath, + vaultPaths, +}: { + aiAgentsStatus: AiAgentsStatus + conversation: AiConversation + messages: AiAgentMessage[] + onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void + permissionMode: AiAgentPermissionMode + target: AiTarget + vaultPath: string + vaultPaths?: string[] +}) { + const requestedTitleKeysRef = useRef(new Set()) + + useEffect(() => { + if (!conversation.usesDefaultTitle) return + + const firstMessage = firstCompletedAssistantMessage(messages) + const prompt = firstMessage?.userMessage.trim() + const assistantResponse = firstMessage?.response?.trim() + if (!firstMessage || !prompt || !assistantResponse) return + + const titleKey = `${conversation.id}:${firstMessage.id ?? prompt}` + if (requestedTitleKeysRef.current.has(titleKey)) return + requestedTitleKeysRef.current.add(titleKey) + + onTitleFromAnswer({ + assistantResponse, + id: conversation.id, + permissionMode, + prompt, + target, + targetReady: aiTargetReady(target, aiAgentsStatus), + vaultPath, + vaultPaths, + }) + }, [ + aiAgentsStatus, + conversation.id, + conversation.usesDefaultTitle, + messages, + onTitleFromAnswer, + permissionMode, + target, + vaultPath, + vaultPaths, + ]) +} + +interface ConversationSessionContext { + activeEntry: VaultEntry | null + activeNoteContent: string | null + entries?: VaultEntry[] + noteList?: NoteListItem[] + noteListFilter?: { type: string | null; query: string } + openTabs?: VaultEntry[] +} + +function activeContextForSession({ + active, + activeEntry, + activeNoteContent, + entries, + noteList, + noteListFilter, + openTabs, +}: Pick): ConversationSessionContext { + if (!active) { + return { + activeEntry: null, + activeNoteContent: null, + } + } + + return { + activeEntry: activeEntry ?? null, + activeNoteContent: activeNoteContent ?? null, + entries, + noteList, + noteListFilter, + openTabs, + } +} + +function ConversationComposerControls({ + disabled, + groups, + locale, + onOpenAiSettings, + onPermissionModeChange, + onSelectTarget, + permissionMode, + side, + target, +}: { + disabled: boolean + groups: AiWorkspaceTargetGroups + locale: AppLocale + onOpenAiSettings?: () => void + onPermissionModeChange: (mode: AiAgentPermissionMode) => void + onSelectTarget: (targetId: string) => void + permissionMode: AiAgentPermissionMode + side: 'bottom' | 'top' + target: AiTarget +}) { + return ( + <> + + + {onOpenAiSettings && ( + + )} + + ) +} + +function ConversationWorkspaceHeader({ + conversation, + locale, + mode, + onArchive, + onClose, + onDock, + onOpenAiSettings, + onPopOut, +}: Pick< + ConversationSessionProps, + 'conversation' | 'locale' | 'mode' | 'onArchive' | 'onClose' | 'onDock' | 'onOpenAiSettings' | 'onPopOut' +>) { + if (mode === 'side') return null + + return ( + + ) +} + +function ConversationSession({ + active, + activeEntry, + activeNoteContent, + aiAgentsStatus, + conversation, + defaultAiAgentReady, + entries, + groups, + locale, + mode, + noteList, + noteListFilter, + onArchive, + onClose, + onDock, + onFileCreated, + onFileModified, + onForkMessage, + onMessageHistoryScrollStateChange, + onOpenAiSettings, + onOpenNote, + onPopOut, + onRestoreVaultAiGuidance, + onSelectTarget, + onStatusChange, + onPromptSubmitted, + onTitleFromAnswer, + onUnsupportedAiPaste, + onVaultChanged, + openTabs, + target, + vaultAiGuidanceStatus, + vaultPath, + vaultPaths, +}: ConversationSessionProps) { + const context = activeContextForSession({ + active, + activeEntry, + activeNoteContent, + entries, + noteList, + noteListFilter, + openTabs, + }) + const readiness = agentReadinessForTarget(target, aiAgentsStatus) + const controller = useAiPanelController({ + vaultPath, + vaultPaths, + defaultAiAgent: targetAgent(target), + defaultAiTarget: target, + defaultAiAgentReady: target.kind === 'api_model' || defaultAiAgentReady, + defaultAiAgentReadiness: readiness, + activeEntry: context.activeEntry, + activeNoteContent: context.activeNoteContent, + entries: context.entries, + openTabs: context.openTabs, + noteList: context.noteList, + noteListFilter: context.noteListFilter, + locale, + onOpenNote, + onFileCreated, + onFileModified, + onVaultChanged, + sessionId: conversation.id, + }) + const running = controller.agent.status === 'thinking' || controller.agent.status === 'tool-executing' + const composerMenuSide = mode === 'window' ? 'bottom' : 'top' + const composerControls = ( + + ) + + useEffect(() => { + onStatusChange(conversation.id, controller.agent.status) + }, [conversation.id, controller.agent.status, onStatusChange]) + useGeneratedConversationTitle({ + aiAgentsStatus, + conversation, + messages: controller.agent.messages, + onTitleFromAnswer, + permissionMode: controller.permissionMode, + target, + vaultPath, + vaultPaths, + }) + + return ( +
      + + +
      + onPromptSubmitted(conversation.id)} + onQueuedPromptTarget={onSelectTarget} + onUnsupportedAiPaste={onUnsupportedAiPaste} + showHeader={false} + showLeftBorder={false} + surface={mode === 'side' ? 'sidebar' : 'default'} + targetId={target.id} + /> +
      +
      + ) +} + +type ResolvedAiWorkspaceProps = AiWorkspaceProps & { + defaultAiAgent: AiAgentId + defaultAiAgentReady: boolean + entries: VaultEntry[] + locale: AppLocale + mode: AiWorkspaceMode +} + +interface AiWorkspaceModel { + activeConversation: AiConversation | undefined + activeId: string + addDefaultConversation: () => void + archiveConversationSafely: (id: string) => void + canArchiveConversation: (conversation: AiConversation) => boolean + closeConversationSafely: (id: string) => void + conversations: AiConversation[] + fallbackTarget: AiTarget + forkConversationUntilMessage: (sourceId: string, messageId: string) => void + groups: AiWorkspaceTargetGroups + handleStatusChange: (id: string, status: AgentStatus) => void + renameConversation: (id: string, title: string) => void + reorderConversation: (activeId: string, overId: string) => void + restoreConversation: (id: string) => void + sidebarCollapsed: boolean + setActiveId: (id: string) => void + setConversationTarget: (id: string, targetId: string) => void + setShowArchived: (show: boolean) => void + showArchived: boolean + statuses: Record + markConversationActivity: (id: string) => void + titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void + toggleSidebarCollapsed: () => void + updateDefaultConversationTargets: (targetId: string) => void +} + +function resolveAiWorkspaceProps(props: AiWorkspaceProps): ResolvedAiWorkspaceProps { + return { + ...props, + defaultAiAgent: props.defaultAiAgent ?? DEFAULT_AI_AGENT, + defaultAiAgentReady: props.defaultAiAgentReady ?? true, + entries: props.entries ?? [], + locale: props.locale ?? 'en', + mode: props.mode ?? 'docked', + } +} + +function SideAiWorkspaceLayout({ + model, + sizing, + workspace, +}: { + model: AiWorkspaceModel + sizing: AiWorkspaceSizing + workspace: ResolvedAiWorkspaceProps +}) { + const [expanded, setExpanded] = useState(false) + const [headerSeparated, setHeaderSeparated] = useState(false) + + return ( +
      + {!expanded && } +
      + setExpanded((current) => !current)} + separated={headerSeparated} + statuses={model.statuses} + /> + +
      +
      + ) +} + +function useActiveConversationSync( + activeConversation: AiConversation | undefined, + activeId: string, + setActiveId: (id: string) => void, +) { + useEffect(() => { + if (activeConversation && activeConversation.id !== activeId) setActiveId(activeConversation.id) + }, [activeConversation, activeId, setActiveId]) +} + +function useArchiveConversationSafely({ + addConversation, + archiveConversation, + conversations, + fallbackTarget, +}: { + addConversation: (target: AiTarget) => void + archiveConversation: (id: string) => void + conversations: AiConversation[] + fallbackTarget: AiTarget +}) { + return useCallback((id: string) => { + const conversation = conversations.find((candidate) => candidate.id === id) + if (!conversation || !canArchiveConversation(conversation)) return + + const activeCount = conversations.filter((conversation) => !conversation.archived).length + archiveConversation(id) + if (activeCount <= 1) addConversation(fallbackTarget) + }, [addConversation, archiveConversation, conversations, fallbackTarget]) +} + +function useTrackedConversationActions({ + conversations, + renameConversation, + titleConversationFromAnswer, +}: { + conversations: AiConversation[] + renameConversation: (id: string, title: string) => void + titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void +}) { + const trackedRenameConversation = useCallback((id: string, title: string) => { + if (!title.trim()) return + renameConversation(id, title) + trackAiWorkspaceChatTitled('manual') + }, [renameConversation]) + const trackedTitleConversationFromAnswer = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => { + const conversation = conversations.find((candidate) => candidate.id === request.id) + titleConversationFromAnswer(request) + if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated') + }, [conversations, titleConversationFromAnswer]) + + return { trackedRenameConversation, trackedTitleConversationFromAnswer } +} + +function useAiWorkspaceNewChatEvent(open: boolean, addDefaultConversation: () => void) { + useEffect(() => { + if (!open) return + const handleNewChat = () => addDefaultConversation() + window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat) + return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat) + }, [addDefaultConversation, open]) +} + +function useActiveTargetChange({ + fallbackTarget, + groups, + onActiveTargetChange, + open, + conversation, +}: { + fallbackTarget: AiTarget + groups: AiWorkspaceTargetGroups + onActiveTargetChange?: (target: AiTarget) => void + open: boolean + conversation: AiConversation | undefined +}) { + const activeTarget = useMemo( + () => (conversation ? resolveTarget(conversation, groups, fallbackTarget) : undefined), + [conversation, fallbackTarget, groups], + ) + const notifiedTargetIdRef = useRef(null) + + useEffect(() => { + if (!open || !activeTarget) { + notifiedTargetIdRef.current = null + return + } + if (notifiedTargetIdRef.current === activeTarget.id) return + + notifiedTargetIdRef.current = activeTarget.id + onActiveTargetChange?.(activeTarget) + }, [activeTarget, onActiveTargetChange, open]) +} + +function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceModel { + const groups = useMemo( + () => buildAiWorkspaceTargetGroups(workspace.aiAgentsStatus, workspace.aiModelProviders), + [workspace.aiAgentsStatus, workspace.aiModelProviders], + ) + const fallbackTarget = useMemo( + () => firstTarget(groups, workspace.defaultAiTarget, workspace.defaultAiAgent), + [groups, workspace.defaultAiAgent, workspace.defaultAiTarget], + ) + const { + activeId, + addConversation, + archiveConversation, + closeConversation, + conversations, + forkConversation, + renameConversation, + reorderConversation, + restoreConversation, + setActiveId, + setConversationTarget, + setShowArchived, + showArchived, + markConversationActivity, + titleConversationFromAnswer, + updateDefaultConversationTargets, + } = useConversations({ + fallbackTarget, + initialActiveConversationId: workspace.initialActiveConversationId, + locale: workspace.locale, + onSettingsChange: workspace.onConversationSettingsChange, + settings: workspace.conversationSettings, + settingsReady: workspace.conversationSettingsReady ?? true, + }) + const [statuses, setStatuses] = useState>({}) + const [sidebarCollapsed, setSidebarCollapsed] = useState(true) + const activeConversation = activeConversationForState(conversations, activeId, showArchived) + + const addDefaultConversation = useCallback(() => { + addConversation(fallbackTarget) + }, [addConversation, fallbackTarget]) + const archiveConversationSafely = useArchiveConversationSafely({ + addConversation, + archiveConversation, + conversations, + fallbackTarget, + }) + useActiveConversationSync(activeConversation, activeId, setActiveId) + const handleStatusChange = useCallback((id: string, status: AgentStatus) => { + setStatuses((current) => current[id] === status ? current : { ...current, [id]: status }) + }, []) + const forkConversationUntilMessage = useCallback((sourceId: string, messageId: string) => { + const targetId = forkConversation(sourceId) + if (!targetId) return + + cloneAiWorkspaceSessionUntilMessage(sourceId, targetId, messageId) + }, [forkConversation]) + const { trackedRenameConversation, trackedTitleConversationFromAnswer } = useTrackedConversationActions({ + conversations, + renameConversation, + titleConversationFromAnswer, + }) + const toggleSidebarCollapsed = useCallback(() => { + setSidebarCollapsed((current) => { + const next = !current + trackAiWorkspaceSidebarToggled(next, workspace.mode) + return next + }) + }, [workspace.mode]) + + useAiWorkspaceNewChatEvent(workspace.open, addDefaultConversation) + useEffect(() => { + updateDefaultConversationTargets(fallbackTarget.id) + }, [fallbackTarget.id, updateDefaultConversationTargets]) + + return { + activeConversation, + activeId, + addDefaultConversation, + archiveConversationSafely, + canArchiveConversation, + closeConversationSafely: closeConversation, + conversations, + fallbackTarget, + forkConversationUntilMessage, + groups, + handleStatusChange, + renameConversation: trackedRenameConversation, + reorderConversation, + restoreConversation, + sidebarCollapsed, + setActiveId, + setConversationTarget, + setShowArchived, + showArchived, + statuses, + markConversationActivity, + titleConversationFromAnswer: trackedTitleConversationFromAnswer, + toggleSidebarCollapsed, + updateDefaultConversationTargets, + } +} + +function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; workspace: ResolvedAiWorkspaceProps }) { + const sizing = useAiWorkspaceSizing(workspace.mode) + if (workspace.mode === 'side') { + return + } + + return ( +
      + + + {!model.sidebarCollapsed && ( + + )} +
      + +
      +
      + ) +} + +function ConversationSessions({ + model, + onMessageHistoryScrollStateChange, + workspace, +}: { + model: AiWorkspaceModel + onMessageHistoryScrollStateChange?: (scrolled: boolean) => void + workspace: ResolvedAiWorkspaceProps +}) { + return ( +
      + {model.conversations.map((conversation) => { + const target = resolveTarget(conversation, model.groups, model.fallbackTarget) + + return ( + model.archiveConversationSafely(conversation.id)} + onClose={workspace.onClose} + onDock={workspace.onDock} + onFileCreated={workspace.onFileCreated} + onFileModified={workspace.onFileModified} + onForkMessage={(messageId) => model.forkConversationUntilMessage(conversation.id, messageId)} + onMessageHistoryScrollStateChange={onMessageHistoryScrollStateChange} + onOpenAiSettings={workspace.onOpenAiSettings} + onOpenNote={workspace.onOpenNote} + onPopOut={workspace.onPopOut} + onRestoreVaultAiGuidance={workspace.onRestoreVaultAiGuidance} + onSelectTarget={(targetId) => model.setConversationTarget(conversation.id, targetId)} + onStatusChange={model.handleStatusChange} + onPromptSubmitted={model.markConversationActivity} + onTitleFromAnswer={model.titleConversationFromAnswer} + onUnsupportedAiPaste={workspace.onUnsupportedAiPaste} + onVaultChanged={workspace.onVaultChanged} + openTabs={workspace.openTabs} + target={target} + vaultAiGuidanceStatus={workspace.vaultAiGuidanceStatus} + vaultPath={workspace.vaultPath} + vaultPaths={workspace.vaultPaths} + /> + ) + })} +
      + ) +} + +export function AiWorkspace(props: AiWorkspaceProps) { + const workspace = resolveAiWorkspaceProps(props) + const model = useAiWorkspaceModel(workspace) + const { onActiveConversationChange } = workspace + + useEffect(() => { + if (!workspace.open || !model.activeId) return + onActiveConversationChange?.(model.activeId) + }, [model.activeId, onActiveConversationChange, workspace.open]) + useActiveTargetChange({ + fallbackTarget: model.fallbackTarget, + groups: model.groups, + onActiveTargetChange: workspace.onActiveTargetChange, + open: workspace.open, + conversation: model.activeConversation, + }) + + if (!workspace.open || !model.activeConversation) return null + + return +} diff --git a/src/components/AiWorkspaceChrome.tsx b/src/components/AiWorkspaceChrome.tsx new file mode 100644 index 0000000..670b598 --- /dev/null +++ b/src/components/AiWorkspaceChrome.tsx @@ -0,0 +1,93 @@ +import { Archive, ArrowSquareIn, ArrowSquareOut, GearSix, WarningCircle, X } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { useDragRegion } from '../hooks/useDragRegion' +import { getVaultAiGuidanceSummary, vaultAiGuidanceNeedsRestore, type VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' +import { translate, type AppLocale } from '../lib/i18n' +import type { AiConversation } from './aiWorkspaceConversations' +import type { AiWorkspaceMode } from './aiWorkspaceSizing' + +export function GuidanceWarning({ + locale, + onRestore, + status, +}: { + locale: AppLocale + onRestore?: () => void + status?: VaultAiGuidanceStatus +}) { + if (!status || !vaultAiGuidanceNeedsRestore(status)) return null + + return ( +
      + + + {translate(locale, 'ai.workspace.guidanceWarning', { summary: getVaultAiGuidanceSummary(status) })} + + {status.canRestore && onRestore && ( + + )} +
      + ) +} + +export function WorkspaceHeader({ + conversation, + archiveDisabled, + locale, + mode, + onArchive, + onClose, + onDock, + onOpenAiSettings, + onPopOut, +}: { + conversation: AiConversation + archiveDisabled: boolean + locale: AppLocale + mode: AiWorkspaceMode + onArchive: () => void + onClose: () => void + onDock?: () => void + onOpenAiSettings?: () => void + onPopOut?: (context?: { activeConversationId?: string }) => void +}) { + const { dragRegionRef } = useDragRegion() + + return ( +
      +
      +
      +
      {conversation.title}
      +
      +
      +
      + {onOpenAiSettings && ( + + )} + + {mode === 'docked' ? ( + + ) : ( + + )} + +
      +
      + ) +} diff --git a/src/components/AiWorkspaceFloatingButton.test.tsx b/src/components/AiWorkspaceFloatingButton.test.tsx new file mode 100644 index 0000000..dce31cc --- /dev/null +++ b/src/components/AiWorkspaceFloatingButton.test.tsx @@ -0,0 +1,75 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog' +import { + createAiAgentAvailability, + createMissingAiAgentsStatus, + type AiAgentsStatus, +} from '../lib/aiAgents' +import { AiWorkspaceFloatingButton } from './AiWorkspaceFloatingButton' + +function createCodexReadyStatus(): AiAgentsStatus { + return { + ...createMissingAiAgentsStatus(), + codex: createAiAgentAvailability('installed', '0.12.0'), + } +} + +function renderButton({ + statuses = createCodexReadyStatus(), + updateBannerVisible = false, +}: { + statuses?: AiAgentsStatus + updateBannerVisible?: boolean +} = {}) { + return render( + + + , + ) +} + +describe('AiWorkspaceFloatingButton', () => { + it('uses the normal bottom offset when no update banner is visible', () => { + renderButton() + + expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-11') + }) + + it('moves above the update banner when one is visible', () => { + renderButton({ updateBannerVisible: true }) + + expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-[80px]') + }) + + it('uses the selected agent icon when that agent is installed', () => { + renderButton() + + const iconImage = screen.getByTestId('ai-workspace-floating-button').querySelector('img') + expect(iconImage).toHaveAttribute('src', '/ai-agent-icons/codex.svg') + }) + + it('falls back to sparkles when no selected agent is available', () => { + renderButton({ statuses: createMissingAiAgentsStatus() }) + + expect(screen.getByTestId('ai-workspace-floating-button').querySelector('img')).toBeNull() + }) + + it('shows the AI panel shortcut in the tooltip', async () => { + renderButton() + + act(() => { + fireEvent.focus(screen.getByTestId('ai-workspace-floating-button')) + }) + + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toHaveTextContent('Open the AI panel') + expect(tooltip).toHaveTextContent(getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat) ?? '') + }) +}) diff --git a/src/components/AiWorkspaceFloatingButton.tsx b/src/components/AiWorkspaceFloatingButton.tsx new file mode 100644 index 0000000..27b9ab1 --- /dev/null +++ b/src/components/AiWorkspaceFloatingButton.tsx @@ -0,0 +1,88 @@ +import { Sparkle } from '@phosphor-icons/react' +import { ActionTooltip } from '@/components/ui/action-tooltip' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog' +import { + isAiAgentInstalled, + type AiAgentId, + type AiAgentsStatus, +} from '../lib/aiAgents' +import { + resolveAiTarget, + type AiModelProvider, + type AiTarget, +} from '../lib/aiTargets' +import { translate, type AppLocale } from '../lib/i18n' +import type { Settings } from '../types' +import { AiAgentIcon } from './AiAgentIcon' + +interface AiWorkspaceFloatingButtonProps { + defaultAgent: AiAgentId + defaultTarget?: string + locale?: AppLocale + providers?: AiModelProvider[] + statuses: AiAgentsStatus + updateBannerVisible?: boolean + onOpen: () => void +} + +function selectedTargetForButton({ + defaultAgent, + defaultTarget, + providers, +}: Pick): AiTarget { + return resolveAiTarget({ + default_ai_agent: defaultAgent, + default_ai_target: defaultTarget, + ai_model_providers: providers ?? [], + } as Settings) +} + +function FloatingButtonIcon({ + selectedTarget, + statuses, +}: { + selectedTarget: AiTarget + statuses: AiAgentsStatus +}) { + if (selectedTarget.kind === 'agent' && isAiAgentInstalled(statuses, selectedTarget.agent)) { + return + } + return +} + +export function AiWorkspaceFloatingButton({ + defaultAgent, + defaultTarget, + locale = 'en', + providers = [], + statuses, + updateBannerVisible = false, + onOpen, +}: AiWorkspaceFloatingButtonProps) { + const selectedTarget = selectedTargetForButton({ defaultAgent, defaultTarget, providers }) + const label = translate(locale, 'editor.toolbar.openAi') + const shortcut = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat) + + return ( + + + + ) +} diff --git a/src/components/AiWorkspaceResizeHandles.tsx b/src/components/AiWorkspaceResizeHandles.tsx new file mode 100644 index 0000000..b0c145c --- /dev/null +++ b/src/components/AiWorkspaceResizeHandles.tsx @@ -0,0 +1,62 @@ +import { type MouseEvent as ReactMouseEvent } from 'react' +import type { AiWorkspaceMode } from './aiWorkspaceSizing' + +function startResizeDrag( + event: ReactMouseEvent, + cursor: string, + onDrag: (deltaX: number, deltaY: number) => void, +) { + event.preventDefault() + event.stopPropagation() + + let lastX = event.clientX + let lastY = event.clientY + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + document.body.style.cursor = cursor + document.body.style.userSelect = 'none' + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - lastX + const deltaY = moveEvent.clientY - lastY + lastX = moveEvent.clientX + lastY = moveEvent.clientY + onDrag(deltaX, deltaY) + } + const handleMouseUp = () => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) +} + +export function WorkspaceResizeHandles({ + mode, + onResize, +}: { + mode: AiWorkspaceMode + onResize: (deltaWidth: number, deltaHeight: number) => void +}) { + if (mode === 'window') return null + + return ( + <> +
      startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))} + /> + {mode === 'docked' && ( +
      startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))} + /> + )} + + ) +} diff --git a/src/components/AiWorkspaceSideHeader.tsx b/src/components/AiWorkspaceSideHeader.tsx new file mode 100644 index 0000000..c0316b8 --- /dev/null +++ b/src/components/AiWorkspaceSideHeader.tsx @@ -0,0 +1,381 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ArrowsInLineHorizontal, ArrowsOutLineHorizontal, Plus, SidebarSimple, X } from '@phosphor-icons/react' +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { AgentStatus } from '../hooks/useCliAiAgent' +import { translate, type AppLocale } from '../lib/i18n' +import type { AiConversation } from './aiWorkspaceConversations' + +function SideWorkspaceTitleEditor({ + conversation, + locale, + onCancel, + onRename, +}: { + conversation: AiConversation + locale: AppLocale + onCancel: () => void + onRename: (title: string) => void +}) { + const [draft, setDraft] = useState(conversation.title) + const finishedRef = useRef(false) + const submit = () => { + if (finishedRef.current) return + const nextTitle = draft.trim() + if (!nextTitle) { + finishedRef.current = true + onCancel() + return + } + + finishedRef.current = true + onRename(nextTitle) + onCancel() + } + const cancel = () => { + finishedRef.current = true + onCancel() + } + + return ( + setDraft(event.target.value)} + onBlur={submit} + onKeyDown={(event) => { + if (event.key === 'Enter') submit() + if (event.key === 'Escape') cancel() + }} + onPointerDown={(event) => event.stopPropagation()} + aria-label={translate(locale, 'ai.workspace.renameChat')} + className="h-9 w-[180px] rounded-lg px-3 text-[13px] font-semibold" + autoFocus + /> + ) +} + +function SideWorkspaceTab({ + active, + conversation, + editing, + locale, + onClose, + onCancelRename, + onRename, + onSelect, + onStartRename, + status, +}: { + active: boolean + conversation: AiConversation + editing: boolean + locale: AppLocale + onClose: (id: string) => void + onCancelRename: () => void + onRename: (id: string, title: string) => void + onSelect: (id: string) => void + onStartRename: (id: string) => void + status: AgentStatus | undefined +}) { + const closeLabel = translate(locale, 'ai.workspace.closeChat', { title: conversation.title }) + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: conversation.id, disabled: editing }) + + return ( +
      + {editing ? ( + onRename(conversation.id, title)} + /> + ) : ( + + )} + {!editing && ( + <> +
      + + + )} +
      + ) +} + +function useHorizontalScrollFades(dependencyKey: string) { + const scrollRef = useRef(null) + const [fades, setFades] = useState({ left: false, right: false }) + + const updateFades = useCallback(() => { + const element = scrollRef.current + if (!element) return + + const maxScrollLeft = element.scrollWidth - element.clientWidth + setFades({ + left: element.scrollLeft > 1, + right: maxScrollLeft > 1 && element.scrollLeft < maxScrollLeft - 1, + }) + }, []) + + useEffect(() => { + const element = scrollRef.current + updateFades() + if (!element) return + + element.addEventListener('scroll', updateFades, { passive: true }) + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateFades) + resizeObserver?.observe(element) + if (element.firstElementChild) resizeObserver?.observe(element.firstElementChild) + + return () => { + element.removeEventListener('scroll', updateFades) + resizeObserver?.disconnect() + } + }, [dependencyKey, updateFades]) + + return { + scrollRef, + showLeftFade: fades.left, + showRightFade: fades.right, + } +} + +function SideWorkspaceTabs({ + activeId, + conversations, + locale, + onCloseConversation, + onNewChat, + onRename, + onReorder, + onSelect, + statuses, +}: { + activeId: string + conversations: AiConversation[] + locale: AppLocale + onCloseConversation: (id: string) => void + onNewChat: () => void + onRename: (id: string, title: string) => void + onReorder: (activeId: string, overId: string) => void + onSelect: (id: string) => void + statuses: Record +}) { + const [editingId, setEditingId] = useState(null) + const visibleConversations = conversations.filter((conversation) => !conversation.archived) + const visibleConversationIds = visibleConversations.map((conversation) => conversation.id) + const tabDependencyKey = visibleConversations + .map((conversation) => `${conversation.id}:${conversation.title}`) + .join('\0') + const { scrollRef, showLeftFade, showRightFade } = useHorizontalScrollFades(tabDependencyKey) + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const handleDragEnd = useCallback((event: DragEndEvent) => { + const activeConversationId = String(event.active.id) + const overConversationId = event.over ? String(event.over.id) : '' + if (!overConversationId || activeConversationId === overConversationId) return + + onReorder(activeConversationId, overConversationId) + }, [onReorder]) + + return ( +
      +
      + +
      + + {visibleConversations.map((conversation) => ( + setEditingId(null)} + onClose={onCloseConversation} + onRename={onRename} + onSelect={onSelect} + onStartRename={setEditingId} + status={statuses[conversation.id]} + /> + ))} + + +
      +
      +
      + {showLeftFade && ( +
      + )} + {showRightFade && ( +
      + )} +
      + ) +} + +export function SideWorkspaceHeader({ + activeId, + conversations, + expanded, + locale, + onClose, + onCloseConversation, + onNewChat, + onRename, + onReorder, + onSelect, + onToggleExpanded, + separated, + statuses, +}: { + activeId: string + conversations: AiConversation[] + expanded: boolean + locale: AppLocale + onClose: () => void + onCloseConversation: (id: string) => void + onNewChat: () => void + onRename: (id: string, title: string) => void + onReorder: (activeId: string, overId: string) => void + onSelect: (id: string) => void + onToggleExpanded: () => void + separated: boolean + statuses: Record +}) { + const expandLabel = translate(locale, expanded ? 'ai.workspace.restorePanel' : 'ai.workspace.expandPanel') + + return ( +
      + + + +
      + ) +} diff --git a/src/components/AiWorkspaceSidebar.tsx b/src/components/AiWorkspaceSidebar.tsx new file mode 100644 index 0000000..55a18f1 --- /dev/null +++ b/src/components/AiWorkspaceSidebar.tsx @@ -0,0 +1,437 @@ +import { useRef, useState } from 'react' +import { + Archive, + ArrowSquareIn, + Check, + CircleNotch, + Plus, + SidebarSimple, +} from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { useDragRegion } from '../hooks/useDragRegion' +import { translate, type AppLocale } from '../lib/i18n' +import type { AgentStatus } from '../hooks/useCliAiAgent' +import type { AiConversation } from './AiWorkspace' + +interface ConversationSidebarProps { + activeId: string + collapsed: boolean + conversations: AiConversation[] + locale: AppLocale + onCanArchive: (conversation: AiConversation) => boolean + onArchive: (id: string) => void + onNewChat: () => void + onRename: (id: string, title: string) => void + onRestore: (id: string) => void + onSelect: (id: string) => void + onToggleCollapsed: () => void + setShowArchived: (show: boolean) => void + showArchived: boolean + sidebarWidth: number + statuses: Record +} + +function isRunningStatus(status: AgentStatus | undefined): boolean { + return status === 'thinking' || status === 'tool-executing' +} + +function SidebarStatusIndicator({ status }: { status: AgentStatus | undefined }) { + if (isRunningStatus(status)) { + return + } + + if (status === 'error') { + return + } + + return null +} + +function SidebarHeader({ + collapsed, + locale, + onNewChat, + onToggleCollapsed, +}: { + collapsed: boolean + locale: AppLocale + onNewChat: () => void + onToggleCollapsed: () => void +}) { + const { dragRegionRef } = useDragRegion() + const collapseLabel = translate(locale, collapsed ? 'ai.workspace.expandSidebar' : 'ai.workspace.collapseSidebar') + + return ( +
      +
      + + {!collapsed && ( + + {translate(locale, 'ai.workspace.title')} + + )} +
      + {!collapsed && ( + + )} +
      + ) +} + +function ConversationTitleEditor({ + conversation, + locale, + onCancel, + onRename, +}: { + conversation: AiConversation + locale: AppLocale + onCancel: () => void + onRename: (title: string) => void +}) { + const [draft, setDraft] = useState(conversation.title) + const finishedRef = useRef(false) + const submit = () => { + if (finishedRef.current) return + const nextTitle = draft.trim() + if (!nextTitle) { + finishedRef.current = true + onCancel() + return + } + finishedRef.current = true + onRename(nextTitle) + onCancel() + } + const cancel = () => { + finishedRef.current = true + onCancel() + } + + return ( +
      + setDraft(event.target.value)} + onBlur={submit} + onKeyDown={(event) => { + if (event.key === 'Enter') submit() + if (event.key === 'Escape') cancel() + }} + aria-label={translate(locale, 'ai.workspace.renameChat')} + className="h-7 min-w-0 flex-1 px-2 text-[12px]" + autoFocus + /> + +
      + ) +} + +function CollapsedConversationSidebar({ locale, onNewChat }: { locale: AppLocale; onNewChat: () => void }) { + return ( +
      + +
      + ) +} + +function ConversationArchiveButton({ + disabled, + conversationId, + locale, + onArchive, + onRestore, + showArchived, +}: { + disabled: boolean + conversationId: string + locale: AppLocale + onArchive: (id: string) => void + onRestore: (id: string) => void + showArchived: boolean +}) { + const label = translate(locale, showArchived ? 'ai.workspace.restore' : 'ai.workspace.archive') + + return ( + + ) +} + +function ConversationRow({ + active, + conversation, + editing, + locale, + onCanArchive, + onArchive, + onRename, + onRestore, + onSelect, + onStartEditing, + showArchived, + status, + stopEditing, +}: { + active: boolean + conversation: AiConversation + editing: boolean + locale: AppLocale + onCanArchive: (conversation: AiConversation) => boolean + onArchive: (id: string) => void + onRename: (id: string, title: string) => void + onRestore: (id: string) => void + onSelect: (id: string) => void + onStartEditing: (id: string) => void + showArchived: boolean + status: AgentStatus + stopEditing: () => void +}) { + return ( +
      + {editing ? ( + onRename(conversation.id, title)} + /> + ) : ( + + )} + +
      + ) +} + +function ConversationList({ + activeId, + conversations, + editingId, + locale, + onCanArchive, + onArchive, + onRename, + onRestore, + onSelect, + setEditingId, + showArchived, + statuses, +}: Pick & { + editingId: string | null + setEditingId: (id: string | null) => void +}) { + const visibleConversations = conversations.filter((conversation) => conversation.archived === showArchived) + const emptyLabel = showArchived + ? translate(locale, 'ai.workspace.noArchivedChats') + : translate(locale, 'ai.workspace.noActiveChats') + + if (visibleConversations.length === 0) { + return
      {emptyLabel}
      + } + + return visibleConversations.map((conversation) => ( + setEditingId(null)} + /> + )) +} + +function ArchivedConversationsToggle({ + locale, + setShowArchived, + showArchived, +}: Pick) { + return ( +
      + +
      + ) +} + +function ExpandedConversationSidebar({ + activeId, + conversations, + locale, + onCanArchive, + onArchive, + onRename, + onRestore, + onSelect, + setShowArchived, + showArchived, + statuses, +}: Omit) { + const [editingId, setEditingId] = useState(null) + + return ( + <> +
      + +
      + + + ) +} + +export function ConversationSidebar({ + activeId, + collapsed, + conversations, + locale, + onCanArchive, + onArchive, + onNewChat, + onRename, + onRestore, + onSelect, + onToggleCollapsed, + setShowArchived, + showArchived, + sidebarWidth, + statuses, +}: ConversationSidebarProps) { + return ( +
      + + {collapsed ? ( + + ) : ( + + )} +
      + ) +} diff --git a/src/components/AiWorkspaceWindowApp.tsx b/src/components/AiWorkspaceWindowApp.tsx new file mode 100644 index 0000000..261a2b2 --- /dev/null +++ b/src/components/AiWorkspaceWindowApp.tsx @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { AppPreferencesProvider, useAppPreferences } from '../hooks/useAppPreferences' +import { useAiAgentsStatus } from '../hooks/useAiAgentsStatus' +import { useSettings } from '../hooks/useSettings' +import { useVaultAiGuidanceStatus } from '../hooks/useVaultAiGuidanceStatus' +import { isTauri } from '../mock-tauri' +import { areAiFeaturesEnabled } from '../lib/aiFeatures' +import { + aiWorkspaceWindowSharedContextSnapshot, + subscribeAiWorkspaceWindowSharedContext, +} from '../lib/aiWorkspaceWindowSharedContext' +import type { AiWorkspaceConversationSetting, Settings } from '../types' +import { + AI_WORKSPACE_CONTEXT_UPDATED_EVENT, + closeCurrentAiWorkspaceWindow, + dockCurrentAiWorkspaceWindow, + readAiWorkspaceWindowContext, + type AiWorkspaceWindowContext, +} from '../utils/openAiWorkspaceWindow' +import { cleanupTauriEventListener, type TauriUnlisten } from '../utils/tauriEventCleanup' +import { + AI_WORKSPACE_FILE_CREATED_EVENT, + AI_WORKSPACE_FILE_MODIFIED_EVENT, + AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT, + AI_WORKSPACE_VAULT_CHANGED_EVENT, +} from '../utils/aiPromptBridge' +import { AppAiWorkspaceSurface } from './AppAiWorkspaceSurface' +import { Toast } from './Toast' + +const RESIZE_EDGE = 18 + +type ResizeDirection = + | 'East' + | 'North' + | 'NorthEast' + | 'NorthWest' + | 'South' + | 'SouthEast' + | 'SouthWest' + | 'West' + +interface ResizeEdges { + nearBottom: boolean + nearLeft: boolean + nearRight: boolean + nearTop: boolean +} + +const RESIZE_DIRECTION_CHECKS: Array<{ + direction: ResizeDirection + matches: (edges: ResizeEdges) => boolean +}> = [ + { direction: 'NorthWest', matches: ({ nearTop, nearLeft }) => nearTop && nearLeft }, + { direction: 'NorthEast', matches: ({ nearTop, nearRight }) => nearTop && nearRight }, + { direction: 'SouthWest', matches: ({ nearBottom, nearLeft }) => nearBottom && nearLeft }, + { direction: 'SouthEast', matches: ({ nearBottom, nearRight }) => nearBottom && nearRight }, + { direction: 'North', matches: ({ nearTop }) => nearTop }, + { direction: 'South', matches: ({ nearBottom }) => nearBottom }, + { direction: 'West', matches: ({ nearLeft }) => nearLeft }, + { direction: 'East', matches: ({ nearRight }) => nearRight }, +] + +function useAiWorkspaceWindowContext() { + const [context, setContext] = useState(() => readAiWorkspaceWindowContext()) + + useEffect(() => { + let disposed = false + let unlisten: TauriUnlisten | undefined + + void import('@tauri-apps/api/event') + .then(({ listen }) => listen(AI_WORKSPACE_CONTEXT_UPDATED_EVENT, (event) => { + setContext(event.payload) + })) + .then((nextUnlisten) => { + if (disposed) { + cleanupTauriEventListener(nextUnlisten) + return + } + unlisten = nextUnlisten + }) + .catch(() => undefined) + + return () => { + disposed = true + cleanupTauriEventListener(unlisten) + } + }, []) + + return context +} + +function useTransparentWindowBackground() { + useLayoutEffect(() => { + const previousBodyBackground = document.body.style.background + const previousRootBackground = document.documentElement.style.background + document.documentElement.classList.add('ai-workspace-native-window') + document.body.classList.add('ai-workspace-native-window') + document.body.style.background = 'transparent' + document.documentElement.style.background = 'transparent' + + return () => { + document.documentElement.classList.remove('ai-workspace-native-window') + document.body.classList.remove('ai-workspace-native-window') + document.body.style.background = previousBodyBackground + document.documentElement.style.background = previousRootBackground + } + }, []) +} + +function useAiWorkspaceSettingsSaver( + enabled: boolean, + settings: Settings, + saveSettings: (settings: Settings) => void | Promise, +) { + return useCallback((conversations: AiWorkspaceConversationSetting[]) => { + if (!enabled) return + void saveSettings({ ...settings, ai_workspace_conversations: conversations }) + }, [enabled, saveSettings, settings]) +} + +function resizeDirectionForPoint(x: number, y: number, rect: DOMRect): ResizeDirection | null { + const edges = { + nearLeft: Math.abs(x - rect.left) <= RESIZE_EDGE, + nearRight: Math.abs(x - rect.right) <= RESIZE_EDGE, + nearTop: Math.abs(y - rect.top) <= RESIZE_EDGE, + nearBottom: Math.abs(y - rect.bottom) <= RESIZE_EDGE, + } + return RESIZE_DIRECTION_CHECKS.find(({ matches }) => matches(edges))?.direction ?? null +} + +function isInteractiveResizeTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false + + return target.closest( + 'button, [role="button"], a, input, textarea, select, [contenteditable="true"], [data-radix-popper-content-wrapper]', + ) !== null +} + +function useAiWorkspaceFrameResize() { + useEffect(() => { + if (!isTauri()) return + + const appWindow = getCurrentWindow() + const startResize = (event: globalThis.MouseEvent) => { + if (event.button !== 0) return + if (isInteractiveResizeTarget(event.target)) return + + const frame = document.getElementById('root') + if (!frame) return + + const direction = resizeDirectionForPoint(event.clientX, event.clientY, frame.getBoundingClientRect()) + if (!direction) return + + event.preventDefault() + event.stopPropagation() + void appWindow.startResizeDragging(direction).catch(() => {}) + } + + document.addEventListener('mousedown', startResize, true) + return () => document.removeEventListener('mousedown', startResize, true) + }, []) +} + +function useAiWorkspaceWindowChrome() { + useEffect(() => { + if (!isTauri()) return + + const appWindow = getCurrentWindow() + void appWindow.setAlwaysOnTop(false).catch(() => {}) + void appWindow.setShadow(false).catch(() => {}) + }, []) +} + +function useMainWindowEvent(eventName: string) { + return useCallback((payload: T) => { + if (!isTauri()) return + + void import('@tauri-apps/api/event') + .then(({ emitTo }) => emitTo('main', eventName, payload)) + .catch(() => undefined) + }, [eventName]) +} + +function useAiWorkspaceFrameCursor() { + useEffect(() => { + if (!isTauri()) return + + const syncCursor = (event: globalThis.MouseEvent) => { + if (isInteractiveResizeTarget(event.target)) { + resetCursor() + return + } + + const frame = document.getElementById('root') + const direction = frame ? resizeDirectionForPoint(event.clientX, event.clientY, frame.getBoundingClientRect()) : null + document.body.style.cursor = resizeCursorForDirection(direction) + } + + const resetCursor = () => { + document.body.style.cursor = '' + } + + document.addEventListener('mousemove', syncCursor) + document.addEventListener('mouseleave', resetCursor) + return () => { + document.removeEventListener('mousemove', syncCursor) + document.removeEventListener('mouseleave', resetCursor) + resetCursor() + } + }, []) +} + +function resizeCursorForDirection(direction: ResizeDirection | null): string { + switch (direction) { + case 'North': + case 'South': + return 'ns-resize' + case 'East': + case 'West': + return 'ew-resize' + case 'NorthEast': + case 'SouthWest': + return 'nesw-resize' + case 'NorthWest': + case 'SouthEast': + return 'nwse-resize' + default: + return '' + } +} + +export function AiWorkspaceWindowApp() { + useTransparentWindowBackground() + useAiWorkspaceFrameResize() + useAiWorkspaceFrameCursor() + useAiWorkspaceWindowChrome() + const [toastMessage, setToastMessage] = useState(null) + const context = useAiWorkspaceWindowContext() + const sharedContext = useSyncExternalStore( + subscribeAiWorkspaceWindowSharedContext, + aiWorkspaceWindowSharedContextSnapshot, + aiWorkspaceWindowSharedContextSnapshot, + ) + const { settings, loaded: settingsLoaded, saveSettings } = useSettings() + const aiAgentsStatus = useAiAgentsStatus() + const aiFeaturesEnabled = areAiFeaturesEnabled(settings) + const preferences = useAppPreferences({ + aiAgentsStatus, + onToast: setToastMessage, + saveSettings, + settings, + settingsLoaded, + }) + const vaultPath = context.vaultPath ?? sharedContext.vaultPath ?? '' + const vaultPaths = context.vaultPaths ?? sharedContext.vaultPaths ?? (vaultPath ? [vaultPath] : []) + const activeConversationId = context.activeConversationId ?? sharedContext.activeConversationId + const { status: vaultAiGuidanceStatus } = useVaultAiGuidanceStatus( + aiFeaturesEnabled && vaultPath ? vaultPath : null, + vaultPath, + ) + const handleConversationSettingsChange = useAiWorkspaceSettingsSaver(settingsLoaded, settings, saveSettings) + const handleDock = useCallback(() => { + void dockCurrentAiWorkspaceWindow().catch((err) => { + console.warn('[ai] Failed to dock workspace window:', err) + }) + }, []) + const handleClose = useCallback(() => { + void closeCurrentAiWorkspaceWindow().catch((err) => { + console.warn('[ai] Failed to close workspace window:', err) + }) + }, []) + const handleOpenNote = useMainWindowEvent(AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT) + const handleFileCreated = useMainWindowEvent(AI_WORKSPACE_FILE_CREATED_EVENT) + const handleFileModified = useMainWindowEvent(AI_WORKSPACE_FILE_MODIFIED_EVENT) + const handleVaultChanged = useMainWindowEvent(AI_WORKSPACE_VAULT_CHANGED_EVENT) + + return ( + +
      + {settingsLoaded ? ( + handleVaultChanged(null)} + vaultAiGuidanceStatus={vaultAiGuidanceStatus} + vaultPath={vaultPath} + vaultPaths={vaultPaths} + locale={preferences.appLocale} + /> + ) : ( +
      + )} + setToastMessage(null)} /> +
      + + ) +} diff --git a/src/components/AppAiWorkspaceSurface.tsx b/src/components/AppAiWorkspaceSurface.tsx new file mode 100644 index 0000000..490d55c --- /dev/null +++ b/src/components/AppAiWorkspaceSurface.tsx @@ -0,0 +1,120 @@ +import type { AiAgentId, AiAgentReadiness, AiAgentsStatus } from '../lib/aiAgents' +import type { AiModelProvider, AiTarget } from '../lib/aiTargets' +import type { AppLocale } from '../lib/i18n' +import type { NoteListItem } from '../utils/ai-context' +import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' +import type { AiWorkspaceConversationSetting, VaultEntry } from '../types' +import { AiWorkspace } from './AiWorkspace' + +interface AppAiWorkspaceSurfaceProps { + activeEntry?: VaultEntry | null + activeNoteContent?: string | null + aiAgentsStatus: AiAgentsStatus + aiModelProviders?: AiModelProvider[] + conversationSettings?: AiWorkspaceConversationSetting[] | null + conversationSettingsReady?: boolean + defaultAiAgent: AiAgentId + defaultAiAgentReadiness: AiAgentReadiness + defaultAiAgentReady: boolean + defaultAiTarget?: AiTarget + entries: VaultEntry[] + initialActiveConversationId?: string + locale: AppLocale + mode: 'docked' | 'side' | 'window' + noteList: NoteListItem[] + noteListFilter: { type: string | null; query: string } + onActiveConversationChange?: (id: string) => void + onActiveTargetChange?: (target: AiTarget) => void + onClose: () => void + onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void + onDock?: () => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void + onOpenAiSettings?: () => void + onOpenNote?: (path: string) => void + onPopOut?: (context?: { activeConversationId?: string }) => void + onRestoreVaultAiGuidance?: () => void + onUnsupportedAiPaste?: (message: string) => void + onVaultChanged?: () => void + open: boolean + openTabs: VaultEntry[] + vaultAiGuidanceStatus?: VaultAiGuidanceStatus + vaultPath: string + vaultPaths?: string[] +} + +export function AppAiWorkspaceSurface({ + activeEntry, + activeNoteContent, + aiAgentsStatus, + aiModelProviders, + conversationSettings, + conversationSettingsReady, + defaultAiAgent, + defaultAiAgentReadiness, + defaultAiAgentReady, + defaultAiTarget, + entries, + initialActiveConversationId, + locale, + mode, + noteList, + noteListFilter, + onActiveConversationChange, + onActiveTargetChange, + onClose, + onConversationSettingsChange, + onDock, + onFileCreated, + onFileModified, + onOpenAiSettings, + onOpenNote, + onPopOut, + onRestoreVaultAiGuidance, + onUnsupportedAiPaste, + onVaultChanged, + open, + openTabs, + vaultAiGuidanceStatus, + vaultPath, + vaultPaths, +}: AppAiWorkspaceSurfaceProps) { + return ( + + ) +} diff --git a/src/components/ArchivedNoteBanner.test.tsx b/src/components/ArchivedNoteBanner.test.tsx new file mode 100644 index 0000000..f6d2d05 --- /dev/null +++ b/src/components/ArchivedNoteBanner.test.tsx @@ -0,0 +1,26 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { ArchivedNoteBanner } from './ArchivedNoteBanner' + +describe('ArchivedNoteBanner', () => { + it('renders archive icon and label', () => { + render() + expect(screen.getByTestId('archived-note-banner')).toBeTruthy() + expect(screen.getByText('Archived')).toBeTruthy() + }) + + it('renders unarchive button without the stale archive shortcut hint', () => { + render() + const btn = screen.getByTestId('unarchive-btn') + expect(btn).toBeTruthy() + expect(btn.textContent).toContain('Unarchive') + expect(btn.title).toBe('Unarchive') + }) + + it('calls onUnarchive when button is clicked', () => { + const onUnarchive = vi.fn() + render() + fireEvent.click(screen.getByTestId('unarchive-btn')) + expect(onUnarchive).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/ArchivedNoteBanner.tsx b/src/components/ArchivedNoteBanner.tsx new file mode 100644 index 0000000..ee172c7 --- /dev/null +++ b/src/components/ArchivedNoteBanner.tsx @@ -0,0 +1,50 @@ +import { Archive, ArrowUUpLeft } from '@phosphor-icons/react' +import { translate, type AppLocale } from '../lib/i18n' + +interface ArchivedNoteBannerProps { + onUnarchive: () => void + locale?: AppLocale +} + +export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteBannerProps) { + return ( +
      + + {translate(locale, 'editor.banner.archived')} + +
      + ) +} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx new file mode 100644 index 0000000..d8824df --- /dev/null +++ b/src/components/BreadcrumbBar.test.tsx @@ -0,0 +1,895 @@ +import type { ComponentProps } from 'react' +import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { BreadcrumbBar } from './BreadcrumbBar' +import { formatShortcutDisplay } from '../hooks/appCommandCatalog' +import type { VaultEntry } from '../types' + +const dragRegionMouseDown = vi.fn() + +vi.mock('../hooks/useDragRegion', () => ({ + useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }), +})) + +const baseEntry: VaultEntry = { + path: '/vault/note/test.md', + filename: 'test.md', + title: 'Test Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + outgoingLinks: [], + template: null, + sort: null, + sidebarLabel: null, + view: null, + visible: null, + properties: {}, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + hasH1: false, +} + +const archivedEntry: VaultEntry = { + ...baseEntry, + archived: true, +} + +const defaultProps = { + wordCount: 100, + showDiffToggle: false, + diffMode: false, + diffLoading: false, + onToggleDiff: vi.fn(), +} + +type BreadcrumbBarRenderProps = Omit, 'entry'> + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { ...baseEntry, ...overrides } +} + +function renderBreadcrumb( + entryOverrides: Partial = {}, + props: Partial = {}, +) { + const entry = makeEntry(entryOverrides) + return { + entry, + ...render(), + } +} + +function renderEditableFilenameBreadcrumb( + entryOverrides: Partial = {}, + props: Partial = {}, +) { + const onRenameFilename = vi.fn() + const result = renderBreadcrumb(entryOverrides, { ...props, onRenameFilename }) + return { ...result, onRenameFilename } +} + +function startFilenameRename() { + fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) + return screen.getByTestId('breadcrumb-filename-input') +} + +function expectDisplayTitleState( + entryOverrides: Partial, + expected: { displayTitle: string | null; filenameStem: string }, + props: Partial = {}, +) { + renderEditableFilenameBreadcrumb(entryOverrides, props) + + if (expected.displayTitle) { + expect(screen.getByTestId('breadcrumb-display-title')).toHaveTextContent(expected.displayTitle) + } else { + expect(screen.queryByTestId('breadcrumb-display-title')).not.toBeInTheDocument() + } + expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent(expected.filenameStem) +} + +async function expectTooltip(trigger: HTMLElement, ...parts: string[]) { + act(() => { + fireEvent.focus(trigger) + }) + const tooltip = await screen.findByRole('tooltip') + for (const part of parts) { + expect(tooltip).toHaveTextContent(part) + } + act(() => { + fireEvent.blur(trigger) + }) +} + +async function openOverflowMenu() { + fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), { + button: 0, + ctrlKey: false, + }) + return screen.findByRole('menu') +} + +function mockCollapsedBreadcrumbOverflow() { + const requestFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0) + return 1 + }) + const cancelFrame = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}) + const rects = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('breadcrumb-bar__actions')) { + return DOMRect.fromRect({ x: 200, y: 0, width: 20, height: 52 }) + } + return DOMRect.fromRect({ x: 0, y: 0, width: 500, height: 52 }) + }) + const scrollWidths = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function () { + return this.classList.contains('breadcrumb-bar__actions') ? 400 : 500 + }) + + return () => { + requestFrame.mockRestore() + cancelFrame.mockRestore() + rects.mockRestore() + scrollWidths.mockRestore() + } +} + +function mockOscillatingBreadcrumbOverflow() { + const requestFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0) + return 1 + }) + const cancelFrame = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}) + const rects = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.classList.contains('breadcrumb-bar__actions')) { + const collapsed = this.getAttribute('data-overflow-collapsed') === 'true' + return DOMRect.fromRect({ x: collapsed ? 1000 : 200, y: 0, width: 20, height: 52 }) + } + return DOMRect.fromRect({ x: 0, y: 0, width: 500, height: 52 }) + }) + const scrollWidths = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function () { + return this.classList.contains('breadcrumb-bar__actions') ? 400 : 500 + }) + + return () => { + requestFrame.mockRestore() + cancelFrame.mockRestore() + rects.mockRestore() + scrollWidths.mockRestore() + } +} + +describe('BreadcrumbBar — drag region', () => { + it('forwards mousedown events to the shared drag-region hook', () => { + const { container } = render() + const bar = container.querySelector('.breadcrumb-bar') as HTMLElement + + fireEvent.mouseDown(bar, { button: 0 }) + + expect(dragRegionMouseDown).toHaveBeenCalledOnce() + }) + + it('has data-tauri-drag-region on the container', () => { + const { container } = render() + const bar = container.firstElementChild as HTMLElement + expect(bar.dataset.tauriDragRegion).toBeDefined() + }) + + it('marks the center spacer as a drag region', () => { + const { container } = render() + const spacer = container.querySelector('.breadcrumb-bar__drag-spacer') + expect(spacer).toHaveAttribute('data-tauri-drag-region') + expect(spacer).toHaveAttribute('aria-hidden', 'true') + }) +}) + +describe('BreadcrumbBar — delete', () => { + it('shows delete in the overflow menu', async () => { + render() + const menu = await openOverflowMenu() + expect(within(menu).getByRole('menuitem', { name: 'Delete this note' })).toBeInTheDocument() + }) + + it('calls onDelete from the overflow menu', async () => { + const onDelete = vi.fn() + render() + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Delete this note' })) + expect(onDelete).toHaveBeenCalledOnce() + }) +}) + +describe('BreadcrumbBar — archive/unarchive', () => { + it('shows archive in the overflow menu for non-archived note', async () => { + render() + const menu = await openOverflowMenu() + expect(within(menu).getByRole('menuitem', { name: 'Archive this note' })).toBeInTheDocument() + expect(within(menu).queryByRole('menuitem', { name: 'Restore this archived note' })).not.toBeInTheDocument() + }) + + it('shows unarchive in the overflow menu for archived note', async () => { + render() + const menu = await openOverflowMenu() + expect(within(menu).getByRole('menuitem', { name: 'Restore this archived note' })).toBeInTheDocument() + expect(within(menu).queryByRole('menuitem', { name: 'Archive this note' })).not.toBeInTheDocument() + }) + + it('calls onArchive from the overflow menu', async () => { + const onArchive = vi.fn() + render() + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Archive this note' })) + expect(onArchive).toHaveBeenCalledOnce() + }) + + it('calls onUnarchive from the overflow menu', async () => { + const onUnarchive = vi.fn() + render() + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Restore this archived note' })) + expect(onUnarchive).toHaveBeenCalledOnce() + }) +}) + +describe('BreadcrumbBar — file actions', () => { + it('reveals the current file from the breadcrumb toolbar', () => { + const onRevealFile = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Reveal in Finder' })) + + expect(onRevealFile).toHaveBeenCalledWith('/vault/note/test.md') + }) + + it('copies the current file path from the breadcrumb toolbar', () => { + const onCopyFilePath = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Copy file path' })) + + expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md') + }) + + it('copies the current note deep link from the overflow menu', async () => { + const onCopyDeepLink = vi.fn() + render() + + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })) + + expect(onCopyDeepLink).toHaveBeenCalledWith(baseEntry) + }) + + it('copies the current note git URL from the overflow menu when available', async () => { + const onCopyGitUrl = vi.fn() + render() + + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Copy git URL' })) + + expect(onCopyGitUrl).toHaveBeenCalledWith(baseEntry) + }) + + it('does not show the note git URL action without a remote-backed handler', async () => { + render() + + const menu = await openOverflowMenu() + + expect(within(menu).queryByRole('menuitem', { name: 'Copy git URL' })).not.toBeInTheDocument() + }) + + it('exports the current note as PDF from the overflow menu', async () => { + const onExportPdf = vi.fn() + render() + + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Export note as PDF' })) + + expect(onExportPdf).toHaveBeenCalledOnce() + }) +}) + +describe('BreadcrumbBar — organized shortcut hint', () => { + it('shows Cmd+E on the organized toggle tooltip', async () => { + render() + await expectTooltip( + screen.getByRole('button', { name: 'Set note as organized' }), + 'Set note as organized', + formatShortcutDisplay({ display: '⌘E' }), + ) + }) + + it('hides the organized toggle when the workflow is disabled', () => { + render() + expect(screen.queryByRole('button', { name: 'Set note as organized' })).not.toBeInTheDocument() + }) +}) + +describe('BreadcrumbBar — neighborhood action', () => { + it("opens the current note's neighborhood from the map button", () => { + const onEnterNeighborhood = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: "Open note's neighborhood" })) + + expect(onEnterNeighborhood).toHaveBeenCalledWith(baseEntry) + }) + + it('uses the requested neighborhood tooltip copy', async () => { + render() + + await expectTooltip(screen.getByRole('button', { name: "Open note's neighborhood" }), "Open note's neighborhood") + }) +}) + +describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => { + it('always renders title elements in the DOM', () => { + render() + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('›')).toBeInTheDocument() + expect(screen.getByText('test')).toBeInTheDocument() + }) + + it('shows the workspace initials label before the note type when workspace metadata is present', () => { + renderBreadcrumb({ + isA: 'Responsibility', + workspace: { + id: 'brian', + label: 'Brian', + alias: 'brian', + path: '/brian', + shortLabel: 'BR', + color: 'purple', + icon: null, + mounted: true, + available: true, + defaultForNewNotes: false, + }, + }) + + const workspaceLabel = screen.getByTestId('breadcrumb-workspace-label') + const typeLabel = screen.getByText('Responsibility') + expect(workspaceLabel).toHaveTextContent('BR') + expect(workspaceLabel).toHaveAttribute('title', 'Brian (brian)') + expect(workspaceLabel.compareDocumentPosition(typeLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('does not render emoji note icons in the breadcrumb filename', () => { + const entryWithEmoji = { ...baseEntry, icon: '🚀' } + render() + expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test') + expect(screen.queryByText('🚀')).not.toBeInTheDocument() + }) + + it('does not render Phosphor note icons in the breadcrumb filename', () => { + const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' } + render() + expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test') + expect(screen.queryByTestId('breadcrumb-note-icon')).not.toBeInTheDocument() + }) + + it('falls back to "Note" when isA is null', () => { + const entryNoType = { ...baseEntry, isA: null } + render() + expect(screen.getByText('Note')).toBeInTheDocument() + }) + + it('separator visibility is controlled by data-title-hidden while using the shared border chrome', () => { + const { container } = render() + const bar = container.querySelector('.breadcrumb-bar')! + expect(bar).toHaveClass('border-b', 'border-transparent') + expect(bar).toHaveAttribute('data-title-hidden') + }) + + it('keeps the breadcrumb title visible in raw mode', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden') + }) +}) + +describe('BreadcrumbBar — filename controls', () => { + it('shows a legacy display title while keeping the filename visible', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'ref-570.md', + hasH1: false, + }, + { displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' }, + ) + }) + + it('uses opened content when stale metadata marks a legacy note as H1-titled', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'ref-570.md', + hasH1: true, + }, + { displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' }, + { + content: '---\ntitle: Reference Planning Notes\ntype: Note\n---\n\nBody without an H1.', + }, + ) + }) + + it('keeps content-derived H1 notes focused on the filename breadcrumb', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'manual-filename.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'manual-filename' }, + { + content: '---\ntitle: Reference Planning Notes\n---\n\n# Canonical H1\n\nBody.', + }, + ) + }) + + it('does not duplicate the display title when the filename already matches it', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'reference-planning-notes.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'reference-planning-notes' }, + ) + }) + + it('does not duplicate the display title when the filename matches with spaces', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'Reference Planning Notes.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'Reference Planning Notes' }, + ) + }) + + it('keeps H1-titled notes focused on the filename breadcrumb', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'manual-filename.md', + hasH1: true, + }, + { displayTitle: null, filenameStem: 'manual-filename' }, + ) + }) + + it('shows the sync button when the filename diverges from the title slug', () => { + renderEditableFilenameBreadcrumb({ title: 'Fresh Title', filename: 'untitled-note-123.md' }) + expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument() + }) + + it('hides the sync button when the filename already matches the title slug', () => { + renderEditableFilenameBreadcrumb({ title: 'Test Note', filename: 'test-note.md' }) + expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument() + }) + + it('uses live content title state to hide stale entry-title sync actions', () => { + renderEditableFilenameBreadcrumb( + { title: 'Old Title', filename: 'fresh-title.md', hasH1: false }, + { content: '# Fresh Title\n\nBody' }, + ) + + expect(screen.queryByTestId('breadcrumb-display-title')).not.toBeInTheDocument() + expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument() + }) + + it('uses the live H1 as the sync target while the entry title is stale', () => { + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb( + { title: 'Old Title', filename: 'old-title.md', hasH1: false }, + { content: '# Fresh Title\n\nBody' }, + ) + + fireEvent.click(screen.getByTestId('breadcrumb-sync-button')) + + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title') + }) + + it('clicking the sync button renames the file to the title slug', () => { + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb({ + title: 'Fresh Title', + filename: 'untitled-note-123.md', + }) + + fireEvent.click(screen.getByTestId('breadcrumb-sync-button')) + + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title') + }) + + it('lets keyboard users press Enter on the filename to start editing', () => { + renderEditableFilenameBreadcrumb() + + fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' }) + + expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test') + }) + + it('double-clicking the filename enters edit mode and Enter confirms the rename', () => { + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb() + + const input = startFilenameRename() + fireEvent.change(input, { target: { value: 'renamed-file' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-file') + }) + + it('pressing Escape while editing cancels the inline rename', () => { + const { onRenameFilename } = renderEditableFilenameBreadcrumb() + + const input = startFilenameRename() + fireEvent.change(input, { target: { value: 'renamed-file' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(onRenameFilename).not.toHaveBeenCalled() + expect(screen.queryByTestId('breadcrumb-filename-input')).not.toBeInTheDocument() + }) + + it('blur confirms the inline rename when the value changed', () => { + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb() + + const input = startFilenameRename() + fireEvent.change(input, { target: { value: 'renamed-on-blur' } }) + fireEvent.blur(input) + + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-on-blur') + }) +}) + +describe('BreadcrumbBar — action buttons always right-aligned', () => { + it('actions container has ml-auto so buttons are always right-aligned', () => { + const { container } = render() + const actions = container.querySelector('.breadcrumb-bar__actions') + expect(actions).toBeInTheDocument() + expect(actions).toHaveClass('ml-auto') + expect(actions).toHaveStyle({ gap: '8px' }) + }) + + it('keeps grouped action buttons evenly spaced', () => { + render( + , + ) + + const fileActionsGroup = screen.getByTestId('breadcrumb-reveal-file').closest('.breadcrumb-bar__overflowable-action') + expect(fileActionsGroup).toHaveClass('gap-2') + const widthActionGroup = screen.getByRole('button', { name: 'Switch to wide note width' }).closest('.breadcrumb-bar__overflowable-action') + expect(widthActionGroup).toHaveClass('gap-2') + }) + + it('end-aligns toolbar action tooltips so zoomed windows keep them inside the right edge', async () => { + render( + , + ) + + act(() => { + fireEvent.focus(screen.getByRole('button', { name: 'Add to favorites' })) + }) + + const tooltip = await screen.findByRole('tooltip') + expect(document.querySelector('[data-slot="tooltip-content"]')).toHaveAttribute('data-align', 'end') + expect(tooltip).toHaveTextContent('Add to favorites') + }) + + it('updates the visible tooltip when the pointer slides between adjacent action icons', async () => { + render( + , + ) + + const favoriteButton = screen.getByRole('button', { name: 'Add to favorites' }) + const organizedButton = screen.getByRole('button', { name: 'Set note as organized' }) + + act(() => { + fireEvent.pointerEnter(favoriteButton) + }) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Add to favorites') + + act(() => { + fireEvent.pointerMove(organizedButton) + }) + + await waitFor(() => { + const visibleTooltips = screen.getAllByRole('tooltip') + expect(visibleTooltips).toHaveLength(1) + expect(visibleTooltips[0]).toHaveTextContent('Set note as organized') + expect(visibleTooltips[0]).not.toHaveTextContent('Add to favorites') + }) + }) + + it('lets the title use the free space before the fixed drag gap', () => { + const { container } = render() + + expect(container.querySelector('.breadcrumb-bar__title')).toHaveClass('flex-1') + expect(container.querySelector('.breadcrumb-bar__drag-spacer')).toHaveClass('w-6', 'shrink-0') + expect(container.querySelector('.breadcrumb-bar__drag-spacer')).not.toHaveClass('flex-1') + }) + + it('does not render the unused backlinks or more-actions placeholders', () => { + render() + expect(screen.queryByRole('button', { name: 'Backlinks are coming soon' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'More note actions are coming soon' })).not.toBeInTheDocument() + }) + + it('keeps git diff first while placing archive and delete at the bottom', async () => { + const restoreMeasurement = mockCollapsedBreadcrumbOverflow() + + try { + const { container } = render( + , + ) + + await waitFor(() => { + expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true') + }) + + const menu = await openOverflowMenu() + const menuLabels = within(menu).getAllByRole('menuitem').map((item) => item.textContent) + expect(menuLabels[0]).toBe('Git diff') + expect(menuLabels.slice(-3)).toEqual(['Copy note deeplink', 'Archive this note', 'Delete this note']) + } finally { + restoreMeasurement() + } + }) + + it('does not duplicate visible lower-priority toolbar actions in the permanent overflow menu', async () => { + render( + , + ) + + const menu = await openOverflowMenu() + expect(within(menu).queryByRole('menuitem', { name: 'Switch to wide note width' })).not.toBeInTheDocument() + expect(within(menu).queryByRole('menuitem', { name: 'Reveal in Finder' })).not.toBeInTheDocument() + expect(within(menu).queryByRole('menuitem', { name: 'Copy file path' })).not.toBeInTheDocument() + expect(within(menu).queryByRole('menuitem', { name: "Open note's neighborhood" })).not.toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })).toBeInTheDocument() + }) + + it('exposes lower-priority actions when overflow hides their toolbar buttons', async () => { + const restoreMeasurement = mockCollapsedBreadcrumbOverflow() + + try { + const { container } = render( + , + ) + + await waitFor(() => { + expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true') + }) + + const menu = await openOverflowMenu() + expect(within(menu).getByRole('menuitem', { name: 'Switch to wide note width' })).toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Reveal in Finder' })).toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Copy file path' })).toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: "Open note's neighborhood" })).toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })).toBeInTheDocument() + } finally { + restoreMeasurement() + } + }) + + it('settles overflow measurement when collapsed layout would otherwise oscillate', () => { + const restoreMeasurement = mockOscillatingBreadcrumbOverflow() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(() => { + render( + , + ) + }).not.toThrow() + } finally { + consoleError.mockRestore() + restoreMeasurement() + } + }) +}) + +describe('BreadcrumbBar — raw editor toggle', () => { + it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => { + const onToggleRaw = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument() + }) + + it('shows "Back to editor" tooltip when rawMode is on', () => { + const onToggleRaw = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Return to the editor' })).toBeInTheDocument() + }) + + it('calls onToggleRaw when raw button is clicked', () => { + const onToggleRaw = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Open the raw editor' })) + expect(onToggleRaw).toHaveBeenCalledOnce() + }) + + it('hides raw toggle when forceRawMode is true (non-markdown file)', () => { + const onToggleRaw = vi.fn() + render() + expect(screen.queryByRole('button', { name: 'Open the raw editor' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Return to the editor' })).not.toBeInTheDocument() + }) + + it('shows raw toggle when forceRawMode is false (markdown file)', () => { + const onToggleRaw = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument() + }) +}) + +describe('BreadcrumbBar — note width toggle', () => { + it('shows the wide width action while normal', () => { + render() + + expect(screen.getByRole('button', { name: 'Switch to wide note width' })).toBeInTheDocument() + }) + + it('shows the normal width action while wide', () => { + render() + + expect(screen.getByRole('button', { name: 'Switch to normal note width' })).toBeInTheDocument() + }) + + it('calls onToggleNoteWidth when the width button is clicked', () => { + const onToggleNoteWidth = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Switch to wide note width' })) + + expect(onToggleNoteWidth).toHaveBeenCalledOnce() + }) +}) + +describe('BreadcrumbBar — AI panel toggle', () => { + it('keeps the AI panel action out of the breadcrumb bar', () => { + render() + expect(screen.queryByRole('button', { name: 'Open the AI panel' })).not.toBeInTheDocument() + }) + + it('does not render the breadcrumb AI panel action when a toggle callback is available', () => { + const onToggleAIChat = vi.fn() + render() + + expect(screen.queryByRole('button', { name: 'Open the AI panel' })).not.toBeInTheDocument() + expect(onToggleAIChat).not.toHaveBeenCalled() + }) +}) + +describe('BreadcrumbBar — table of contents toggle', () => { + it('shows the table of contents action and calls the toggle handler', () => { + const onToggleTableOfContents = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' })) + + expect(onToggleTableOfContents).toHaveBeenCalledOnce() + }) + + it('uses the close label while the table of contents panel is active', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Close table of contents' })).toBeInTheDocument() + }) + + it('shows the table of contents shortcut in the button tooltip', async () => { + render() + await expectTooltip( + screen.getByRole('button', { name: 'Open table of contents' }), + 'Open table of contents', + formatShortcutDisplay({ display: '⌘⇧T' }), + ) + }) + + it('offers the table of contents action from the overflow menu', async () => { + const onToggleTableOfContents = vi.fn() + const restoreMeasurement = mockCollapsedBreadcrumbOverflow() + + try { + const { container } = render( + , + ) + + await waitFor(() => { + expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true') + }) + + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open table of contents' })) + + expect(onToggleTableOfContents).toHaveBeenCalledOnce() + } finally { + restoreMeasurement() + } + }) +}) diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx new file mode 100644 index 0000000..553f400 --- /dev/null +++ b/src/components/BreadcrumbBar.tsx @@ -0,0 +1,1277 @@ +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type Dispatch, + type KeyboardEvent, + type ReactNode, + type SetStateAction, +} from 'react' +import type { NoteWidthMode, VaultEntry } from '../types' +import { cn } from '@/lib/utils' +import { translate, type AppLocale } from '../lib/i18n' +import { APP_COMMAND_IDS, formatShortcutDisplay, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog' +import { extractFrontmatterTitleFromContent, extractH1TitleFromContent } from '../utils/noteTitle' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip' +import { TooltipProvider } from '@/components/ui/tooltip' +import { WorkspaceInitialsBadge } from './WorkspaceInitialsBadge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + GitBranch, + Code, + ListBullets, + SidebarSimple, + Trash, + Archive, + ArrowUUpLeft, + ClipboardText, + FilePdf, + FolderOpen, + Link, + MapTrifold, + Star, + CheckCircle, + ArrowsClockwise, + ArrowsInLineHorizontal, + ArrowsOutLineHorizontal, + DotsThree, +} from '@phosphor-icons/react' +import { slugify } from '../hooks/useNoteCreation' +import { useDragRegion } from '../hooks/useDragRegion' + +interface BreadcrumbBarProps { + entry: VaultEntry + wordCount: number + showDiffToggle: boolean + diffMode: boolean + diffLoading: boolean + onToggleDiff: () => void + rawMode?: boolean + onToggleRaw?: () => void + /** When true, raw mode is forced (non-markdown file) — hide the toggle. */ + forceRawMode?: boolean + showAIChat?: boolean + onToggleAIChat?: () => void + showTableOfContents?: boolean + onToggleTableOfContents?: () => void + inspectorCollapsed?: boolean + onToggleInspector?: () => void + onToggleFavorite?: () => void + onToggleOrganized?: () => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void + onCopyDeepLink?: (entry: VaultEntry) => void + onCopyGitUrl?: (entry: VaultEntry) => void + onExportPdf?: () => void + onDelete?: () => void + onArchive?: () => void + onUnarchive?: () => void + onEnterNeighborhood?: (entry: VaultEntry) => void + onRenameFilename?: (path: string, newFilenameStem: string) => void + noteWidth?: NoteWidthMode + onToggleNoteWidth?: () => void + /** Ref for direct DOM manipulation — avoids re-render on scroll. */ + barRef?: React.Ref + locale?: AppLocale + loadingTitle?: boolean + content?: string | null +} + +const BREADCRUMB_ICON_CLASS = 'size-[16px]' +const TITLE_ACTION_GAP_PX = 24 + +interface BreadcrumbTooltipController { + activeTooltipLabel: string | null + setActiveTooltipLabel: Dispatch> +} + +interface BreadcrumbTooltipControl { + open?: boolean + onOpenChange?: (open: boolean) => void + onPointerEnter?: () => void + onPointerMove?: () => void + onPointerLeave?: () => void + onFocus?: () => void + onBlur?: () => void +} + +const BreadcrumbTooltipContext = createContext(null) + +function clearBreadcrumbTooltip(controller: BreadcrumbTooltipController, label: string) { + controller.setActiveTooltipLabel((current) => current === label ? null : current) +} + +function setBreadcrumbTooltipOpen(controller: BreadcrumbTooltipController, label: string, open: boolean) { + if (open) { + controller.setActiveTooltipLabel(label) + return + } + clearBreadcrumbTooltip(controller, label) +} + +function useBreadcrumbTooltipControl(label: string): BreadcrumbTooltipControl { + const controller = useContext(BreadcrumbTooltipContext) + const activate = useCallback(() => { + controller?.setActiveTooltipLabel(label) + }, [controller, label]) + const deactivate = useCallback(() => { + if (!controller) return + clearBreadcrumbTooltip(controller, label) + }, [controller, label]) + const onOpenChange = useCallback((open: boolean) => { + if (!controller) return + setBreadcrumbTooltipOpen(controller, label, open) + }, [controller, label]) + + if (!controller) { + return {} + } + + return { + open: controller.activeTooltipLabel === label, + onOpenChange, + onPointerEnter: activate, + onPointerMove: activate, + onPointerLeave: deactivate, + onFocus: activate, + onBlur: deactivate, + } +} + +function focusFilenameInput( + isEditing: boolean, + inputRef: React.RefObject, +) { + if (!isEditing) return + inputRef.current?.focus() + inputRef.current?.select() +} + +function beginFilenameEditing( + onRenameFilename: BreadcrumbBarProps['onRenameFilename'], + filenameStem: string, + setDraftStem: (value: string) => void, + setIsEditing: (value: boolean) => void, +) { + if (!onRenameFilename) return + setDraftStem(filenameStem) + setIsEditing(true) +} + +function resolveFilenameRenameTarget(draftStem: string, filenameStem: string): string | null { + const nextStem = normalizeFilenameStemInput(draftStem) + if (!nextStem || nextStem === filenameStem) return null + return nextStem +} + +function handleFilenameInputKeyDown( + event: KeyboardEvent, + submitRename: () => void, + cancelEditing: () => void, +) { + switch (event.key) { + case 'Enter': + event.preventDefault() + submitRename() + return + case 'Escape': + event.preventDefault() + cancelEditing() + return + default: + return + } +} + +function IconActionButton({ + copy, + onClick, + className, + style, + children, + testId, + tooltipAlign = 'end', +}: { + copy: ActionTooltipCopy + onClick?: () => void + className?: string + style?: CSSProperties + children: ReactNode + testId?: string + tooltipAlign?: 'start' | 'center' | 'end' +}) { + const tooltipControl = useBreadcrumbTooltipControl(copy.label) + + return ( + + + + ) +} + +interface ToggleIconActionProps { + active: boolean + activeClassName: string + activeLabel: string + children: ReactNode + inactiveClassName?: string + inactiveLabel: string + onClick?: () => void + shortcut: string +} + +interface TranslatedToggleIconActionProps extends Omit { + activeLabelKey: Parameters[1] + inactiveLabelKey: Parameters[1] + locale?: AppLocale +} + +function ToggleIconAction({ + active, + activeClassName, + activeLabel, + children, + inactiveClassName = 'hover:text-foreground', + inactiveLabel, + onClick, + shortcut, +}: ToggleIconActionProps) { + return ( + + {children} + + ) +} + +function TranslatedToggleIconAction({ + activeLabelKey, + inactiveLabelKey, + locale = 'en', + ...props +}: TranslatedToggleIconActionProps) { + return ( + + ) +} + +const TOGGLE_ACTION_CONFIGS = { + raw: { + activeClassName: 'text-foreground', + activeLabelKey: 'editor.toolbar.rawReturn', + inactiveLabelKey: 'editor.toolbar.rawOpen', + shortcut: '⌘\\', + renderIcon: () => , + }, + favorite: { + activeClassName: 'text-[var(--accent-yellow)]', + activeLabelKey: 'editor.toolbar.removeFavorite', + inactiveLabelKey: 'editor.toolbar.addFavorite', + shortcut: '⌘D', + renderIcon: (active: boolean) => , + }, + organized: { + activeClassName: 'text-[var(--accent-green)]', + activeLabelKey: 'editor.toolbar.markUnorganized', + inactiveLabelKey: 'editor.toolbar.markOrganized', + shortcut: '⌘E', + renderIcon: (active: boolean) => , + }, +} satisfies Record[1] + inactiveLabelKey: Parameters[1] + shortcut: string + renderIcon: (active: boolean) => ReactNode +}> + +function ConfiguredToggleAction({ + active, + config, + locale = 'en', + onClick, +}: { + active: boolean + config: (typeof TOGGLE_ACTION_CONFIGS)[keyof typeof TOGGLE_ACTION_CONFIGS] + locale?: AppLocale + onClick?: () => void +}) { + return ( + + {config.renderIcon(active)} + + ) +} + +function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: boolean; locale?: AppLocale; onToggleRaw?: () => void }) { + return +} + +function NoteWidthAction({ + noteWidth = 'normal', + locale = 'en', + onToggleNoteWidth, +}: { + noteWidth?: NoteWidthMode + locale?: AppLocale + onToggleNoteWidth?: () => void +}) { + if (!onToggleNoteWidth) return null + + const isWide = noteWidth === 'wide' + return ( + + {isWide + ? + : } + + ) +} + +function FavoriteAction({ favorite, locale = 'en', onToggleFavorite }: { favorite: boolean; locale?: AppLocale; onToggleFavorite?: () => void }) { + return +} + +function OrganizedAction({ + organized, + locale = 'en', + onToggleOrganized, +}: { + organized: boolean + locale?: AppLocale + onToggleOrganized?: () => void +}) { + if (!onToggleOrganized) return null + return +} + +function NeighborhoodAction({ + entry, + locale = 'en', + onEnterNeighborhood, +}: Pick) { + if (!onEnterNeighborhood) return null + + return ( + onEnterNeighborhood(entry)} + className="hover:text-foreground" + > + + + ) +} + +function TableOfContentsAction({ + showTableOfContents, + locale = 'en', + onToggleTableOfContents, +}: Pick) { + if (!onToggleTableOfContents) return null + + return ( + + + + ) +} + +function FilePathActions({ + entry, + locale = 'en', + onRevealFile, + onCopyFilePath, +}: Pick) { + return ( + <> + {onRevealFile && ( + onRevealFile(entry.path)} + className="hover:text-foreground" + testId="breadcrumb-reveal-file" + > + + + )} + {onCopyFilePath && ( + onCopyFilePath(entry.path)} + className="hover:text-foreground" + testId="breadcrumb-copy-file-path" + > + + + )} + + ) +} + +function InspectorAction({ + inspectorCollapsed, + locale = 'en', + onToggleInspector, +}: Pick) { + if (!inspectorCollapsed) return null + return ( + + + + ) +} + +function OverflowToolbarAction({ children }: { children: ReactNode }) { + return {children} +} + +function availableDiffAction(showDiffToggle: boolean, onToggleDiff: () => void): (() => void) | undefined { + return showDiffToggle ? onToggleDiff : undefined +} + +function noteWidthLabelKey(noteWidth: NoteWidthMode = 'normal'): Parameters[1] { + return noteWidth === 'wide' ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide' +} + +function NoteWidthMenuIcon({ noteWidth = 'normal' }: { noteWidth?: NoteWidthMode }) { + return noteWidth === 'wide' ? : +} + +function archiveLabelKey(archived: boolean): Parameters[1] { + return archived ? 'editor.toolbar.restoreArchived' : 'editor.toolbar.archive' +} + +function archiveAction( + archived: boolean, + onArchive?: () => void, + onUnarchive?: () => void, +): (() => void) | undefined { + return archived ? onUnarchive : onArchive +} + +function pathAction(action: ((path: string) => void) | undefined, path: string): (() => void) | undefined { + return action ? () => action(path) : undefined +} + +function entryAction(action: ((entry: VaultEntry) => void) | undefined, entry: VaultEntry): (() => void) | undefined { + return action ? () => action(entry) : undefined +} + +function ArchiveMenuIcon({ archived }: { archived: boolean }) { + return archived ? : +} + +function neighborhoodAction( + entry: VaultEntry, + onEnterNeighborhood?: (entry: VaultEntry) => void, +): (() => void) | undefined { + return onEnterNeighborhood ? () => onEnterNeighborhood(entry) : undefined +} + +function readElementWidth(element: HTMLElement): number { + return element.getBoundingClientRect().width || element.scrollWidth || element.clientWidth +} + +function prepareTitleMeasurementClone(clone: HTMLElement) { + clone.setAttribute('aria-hidden', 'true') + clone.style.position = 'absolute' + clone.style.visibility = 'hidden' + clone.style.pointerEvents = 'none' + clone.style.width = 'max-content' + clone.style.minWidth = 'max-content' + clone.style.maxWidth = 'none' + clone.style.overflow = 'visible' + clone.style.whiteSpace = 'nowrap' +} + +function removeCloneTruncation(clone: HTMLElement) { + for (const node of clone.querySelectorAll('.truncate')) { + node.style.overflow = 'visible' + node.style.textOverflow = 'clip' + node.style.whiteSpace = 'nowrap' + node.style.width = 'max-content' + node.style.minWidth = 'max-content' + node.style.maxWidth = 'none' + } +} + +function measureNaturalTitleWidth(title: HTMLDivElement): number { + const titleContent = title.querySelector('.breadcrumb-bar__title-content') + if (!(titleContent instanceof HTMLElement)) return readElementWidth(title) + + const clone = titleContent.cloneNode(true) + if (!(clone instanceof HTMLElement)) return readElementWidth(title) + + prepareTitleMeasurementClone(clone) + removeCloneTruncation(clone) + title.appendChild(clone) + const width = readElementWidth(clone) + clone.remove() + return width +} + +function expandedActionsLeft(actions: HTMLDivElement, expandedActionsWidth: number): number { + const actionsRight = actions.getBoundingClientRect().right + return actionsRight - expandedActionsWidth +} + +function shouldCollapseBreadcrumbOverflow( + title: HTMLDivElement, + actions: HTMLDivElement, + expandedActionsWidth: number, +) { + const titleLeft = title.getBoundingClientRect().left + const availableTitleWidth = expandedActionsLeft(actions, expandedActionsWidth) - titleLeft - TITLE_ACTION_GAP_PX + return measureNaturalTitleWidth(title) > availableTitleWidth +} + +function withExpandedActionMeasurement( + actions: HTMLDivElement, + collapsed: boolean, + measure: () => T, +): T { + if (!collapsed) return measure() + + const previousValue = actions.getAttribute('data-overflow-collapsed') + // Probe the expanded geometry; measuring the collapsed layout can oscillate. + actions.setAttribute('data-overflow-collapsed', 'false') + try { + return measure() + } finally { + if (previousValue === null) { + actions.removeAttribute('data-overflow-collapsed') + } else { + actions.setAttribute('data-overflow-collapsed', previousValue) + } + } +} + +function useBreadcrumbOverflow( + titleRef: React.RefObject, + actionsRef: React.RefObject, +) { + const [collapsed, setCollapsed] = useState(false) + const expandedActionsWidthRef = useRef(0) + + useLayoutEffect(() => { + const title = titleRef.current + const actions = actionsRef.current + const bar = title?.closest('.breadcrumb-bar') + if (!title || !actions || !(bar instanceof HTMLDivElement)) return undefined + + let frame = 0 + const measure = () => { + const nextCollapsed = withExpandedActionMeasurement(actions, collapsed, () => { + const expandedActionsWidth = actions.scrollWidth || expandedActionsWidthRef.current + expandedActionsWidthRef.current = expandedActionsWidth + return shouldCollapseBreadcrumbOverflow(title, actions, expandedActionsWidth) + }) + setCollapsed((current) => current === nextCollapsed ? current : nextCollapsed) + } + const scheduleMeasure = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(measure) + } + + scheduleMeasure() + if (typeof ResizeObserver === 'undefined') { + return () => cancelAnimationFrame(frame) + } + + const resizeObserver = new ResizeObserver(scheduleMeasure) + resizeObserver.observe(bar) + resizeObserver.observe(title) + resizeObserver.observe(actions) + + return () => { + cancelAnimationFrame(frame) + resizeObserver.disconnect() + } + }) + + return collapsed +} + +function normalizeFilenameStemInput(value: string): string { + const trimmed = value.trim() + return trimmed.replace(/\.md$/i, '').trim() +} + +function deriveSyncStem(entry: VaultEntry, content?: string | null): string | null { + const titleState = deriveContentDisplayTitleState(content) ?? deriveEntryDisplayTitleState(entry) + const expectedStem = slugify(titleState.title.trim()) + const filenameStem = entry.filename.replace(/\.md$/, '') + if (!expectedStem || expectedStem === filenameStem) return null + return expectedStem +} + +interface BreadcrumbDisplayTitleState { + hasH1: boolean + title: string +} + +function deriveContentDisplayTitleState(content?: string | null): BreadcrumbDisplayTitleState | null { + if (typeof content !== 'string') return null + const h1Title = extractH1TitleFromContent(content) + if (h1Title) return { title: h1Title, hasH1: true } + + const frontmatterTitle = extractFrontmatterTitleFromContent(content) + return frontmatterTitle ? { title: frontmatterTitle, hasH1: false } : null +} + +function deriveEntryDisplayTitleState(entry: VaultEntry): BreadcrumbDisplayTitleState { + return { + title: entry.title.trim(), + hasH1: entry.hasH1, + } +} + +function deriveBreadcrumbDisplayTitle(entry: VaultEntry, filenameStem: string, content?: string | null): string | null { + const displayState = deriveContentDisplayTitleState(content) ?? deriveEntryDisplayTitleState(entry) + const displayTitle = displayState.title.trim() + if (!displayTitle || displayState.hasH1) return null + if (slugify(displayTitle) === slugify(filenameStem)) return null + return displayTitle +} + +function FilenameInput({ + inputRef, + draftStem, + locale = 'en', + onDraftStemChange, + onBlur, + onKeyDown, +}: { + inputRef: React.RefObject + draftStem: string + locale?: AppLocale + onDraftStemChange: (nextValue: string) => void + onBlur: () => void + onKeyDown: (event: KeyboardEvent) => void +}) { + return ( + onDraftStemChange(event.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + className="h-7 w-[180px] text-sm" + data-testid="breadcrumb-filename-input" + aria-label={translate(locale, 'editor.filename.rename')} + /> + ) +} + +function FilenameTrigger({ + filenameStem, + locale = 'en', + onStartEditing, +}: { + filenameStem: string + locale?: AppLocale + onStartEditing: () => void +}) { + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'Enter') return + event.preventDefault() + onStartEditing() + }, [onStartEditing]) + + return ( + + ) +} + +function SyncFilenameButton({ + entryPath, + syncStem, + locale = 'en', + onRenameFilename, +}: { + entryPath: string + syncStem: string | null + locale?: AppLocale + onRenameFilename?: (path: string, newFilenameStem: string) => void +}) { + const tooltipLabel = translate(locale, 'editor.filename.renameToTitle') + const tooltipControl = useBreadcrumbTooltipControl(tooltipLabel) + + if (!syncStem || !onRenameFilename) return null + return ( + + + + ) +} + +function FilenameDisplay({ + content, + entry, + filenameStem, + syncStem, + locale, + onRenameFilename, + onStartEditing, +}: { + content?: string | null + entry: VaultEntry + filenameStem: string + syncStem: string | null + locale?: AppLocale + onRenameFilename?: (path: string, newFilenameStem: string) => void + onStartEditing: () => void +}) { + const displayTitle = deriveBreadcrumbDisplayTitle(entry, filenameStem, content) + + return ( +
      + {displayTitle && ( + <> + + {displayTitle} + + + + )} + + +
      + ) +} + +function FilenameCrumb({ content, entry, locale = 'en', onRenameFilename }: Pick) { + const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename]) + const syncStem = useMemo(() => deriveSyncStem(entry, content), [entry, content]) + const [isEditing, setIsEditing] = useState(false) + const [draftStem, setDraftStem] = useState(filenameStem) + const inputRef = useRef(null) + + useEffect(() => { + focusFilenameInput(isEditing, inputRef) + }, [isEditing]) + + const startEditing = useCallback(() => { + beginFilenameEditing(onRenameFilename, filenameStem, setDraftStem, setIsEditing) + }, [onRenameFilename, filenameStem]) + + const cancelEditing = useCallback(() => { + setDraftStem(filenameStem) + setIsEditing(false) + }, [filenameStem]) + + const submitRename = useCallback(() => { + setIsEditing(false) + const nextStem = resolveFilenameRenameTarget(draftStem, filenameStem) + if (!nextStem) return + onRenameFilename?.(entry.path, nextStem) + }, [draftStem, filenameStem, onRenameFilename, entry.path]) + + const handleInputKeyDown = useCallback((event: KeyboardEvent) => { + handleFilenameInputKeyDown(event, submitRename, cancelEditing) + }, [submitRename, cancelEditing]) + + if (isEditing) { + return ( + + ) + } + + return ( + + ) +} + +function BreadcrumbTitleSkeleton() { + return ( +