From 74f76a820d6fe1c08308363fa9ccf5314938b1c2 Mon Sep 17 00:00:00 2001 From: Kaysser Kayyali Date: Sat, 20 Jun 2026 19:15:38 +0000 Subject: [PATCH] chore: first --- .gitignore | 6 + README.md | 117 ++ docs/relay-api.md | 77 ++ package-lock.json | 2198 ++++++++++++++++++++++++++++++++ package.json | 22 + src/batch.ts | 372 ++++++ src/cli.ts | 303 +++++ src/config.ts | 48 + src/dashboard.html | 288 +++++ src/db.ts | 90 ++ src/fields.ts | 72 ++ src/foundry/assets.ts | 100 ++ src/foundry/docker.ts | 101 ++ src/frontmatter.ts | 108 ++ src/htmlMd.ts | 141 ++ src/links.ts | 48 + src/mdToHtml.ts | 84 ++ src/normalize.ts | 26 + src/push.ts | 134 ++ src/relay/client.ts | 106 ++ src/resolver.ts | 64 + src/server.ts | 293 +++++ src/toFoundry.ts | 200 +++ src/toObsidian.ts | 67 + src/types.ts | 75 ++ src/write.ts | 8 + tests/assets.test.ts | 124 ++ tests/batch.test.ts | 238 ++++ tests/docker.test.ts | 46 + tests/helpers.ts | 71 ++ tests/links.test.ts | 47 + tests/mode.test.ts | 54 + tests/push.test.ts | 117 ++ tests/relay.test.ts | 120 ++ tests/resolver.test.ts | 73 ++ tests/roland.roundtrip.test.ts | 119 ++ tests/server-push.test.ts | 115 ++ tests/server.test.ts | 120 ++ tests/testutils.ts | 1 + tsconfig.json | 18 + vitest.config.ts | 10 + 41 files changed, 6421 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/relay-api.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/batch.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/dashboard.html create mode 100644 src/db.ts create mode 100644 src/fields.ts create mode 100644 src/foundry/assets.ts create mode 100644 src/foundry/docker.ts create mode 100644 src/frontmatter.ts create mode 100644 src/htmlMd.ts create mode 100644 src/links.ts create mode 100644 src/mdToHtml.ts create mode 100644 src/normalize.ts create mode 100644 src/push.ts create mode 100644 src/relay/client.ts create mode 100644 src/resolver.ts create mode 100644 src/server.ts create mode 100644 src/toFoundry.ts create mode 100644 src/toObsidian.ts create mode 100644 src/types.ts create mode 100644 src/write.ts create mode 100644 tests/assets.test.ts create mode 100644 tests/batch.test.ts create mode 100644 tests/docker.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/links.test.ts create mode 100644 tests/mode.test.ts create mode 100644 tests/push.test.ts create mode 100644 tests/relay.test.ts create mode 100644 tests/resolver.test.ts create mode 100644 tests/roland.roundtrip.test.ts create mode 100644 tests/server-push.test.ts create mode 100644 tests/server.test.ts create mode 100644 tests/testutils.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b18303a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +# Copied world-data fixtures (your private Foundry snapshot) — never commit. +tests/fixtures/ +# Apply-mode backups written by --apply. +*.bak-* +*.foundry.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ff71af --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# foundry-obsidian-sync + +A strict, **offline**, bidirectional converter between Foundry VTT Campaign Codex +journal data (a LevelDB snapshot) and an Obsidian vault. No Foundry instance, no +macros, no network — just the exported DB. + +Two markdown formats are bridged: + +- **Obsidian curated** (`Roland Raventhorne.md`) — frontmatter `type/tags/aliases/ + faction/region/race/portrait`, italic tagline, `## Appearance / Personality / + Background / Goals / Secrets`, `[[Name|Display]]` links. +- **Campaign Codex native export** (`Roland Raventhornecc.md`) — frontmatter + `cc_id/cc_uuid/cc_type/cc_folder_path/cc_exported_at`, `# Title`, `## Information`, + body sections, `#### Bio`/`#### Social` boxes, `## Linked Sheets`/`### Associates`, + `## Notes`, `[[Name]]` links. + +Foundry is the source of truth. Each Obsidian note carries a `foundry:` identity +block (`cc_uuid`, `cc_type`, `folder_path`, `contentHash`, `syncedAt`) so the two +sides stay linked across syncs and renames. + +## How it works + +1. `db.ts` opens the journal LevelDB **read-only** with `classic-level`, indexes every + Campaign Codex JournalEntry, and resolves `@UUID` ⇄ note name. +2. `htmlMd.ts` ports the `Macros/cc-4-export.js` HTML→Markdown logic (tagline, + sections, Bio/Social boxes) but resolves `@UUID` to `[[Name|Display]]` via the index. +3. `toObsidian.ts` / `toFoundry.ts` convert each direction. `toFoundry` pulls + `cc_id`/associates/image from the matched Foundry entry (by `foundry:` block, then + name). `normalize.ts` canonicalizes wikilinks/whitespace and hashes content so + cosmetic edits don't churn. + +## Modes (safety) + +The tool **defaults to dev mode** and never risks the real world data. + +- **`--dev` (default):** reads your *copy* of the world data, writes **only** into + `--out`. Sources are never mutated. Refuses `--out` equal to `--journal` or the + source vault dir. +- **`--dry-run`:** computes outputs and prints diffs only — writes nothing. +- **`--apply`:** writes in place to the real vault/cc dir, writing a timestamped + `.bak-` of every file it overwrites first. Gated explicitly. + +The journal LevelDB is opened read-only in all modes; the code only ever calls +read methods (`get`/`iterator`), never `put`/`del`/`batch`. + +## Usage + +```bash +npm install + +# Prep a dev copy of your world data (you do this, the tool won't): +cp -r ~/hosting/"Lore examples"/journal /tmp/journal-copy +cp -r ~/hosting/"Lore examples" /tmp/lore-copy + +# Foundry -> Obsidian (one entry by --id, or all if omitted) +npx tsx src/cli.ts to-obsidian --dev --journal /tmp/journal-copy --out /tmp/devout --id flGsAYaK24eUZhQE + +# Obsidian -> cc.md (+ optional Foundry-importable JSON with --emit-json) +npx tsx src/cli.ts to-foundry --dev --journal /tmp/journal-copy \ + --vault "/tmp/lore-copy/Roland Raventhorne.md" --out /tmp/devoutcc --emit-json + +# See what would change, write nothing +npx tsx src/cli.ts to-foundry --dry-run --journal ... --vault ... --out ... +``` + +## Batch dashboard — connect the whole vault at once + +For more than one entry, the `ui` command starts a **local-only** review dashboard +(127.0.0.1, no external network) over the batch engine. It indexes the journal +LevelDB + your cc export + your refined vault, shows every file's match status and +diff, and runs the three "connect" operations: + +- **Seed** — inject the `foundry:` identity block into each refined note (matched by + filename → `cc_id` → journal entry). Minimal: only the block is added, leaving all + curation (curated `type`, `aliases`, status tags, hand-edited body, links) untouched. +- **Sync → cc** — regenerate each `cc.md` from its curated refined note, so curation + (new `[[links]]`, edited prose) flows back into the Campaign Codex export. +- **Import** — pull the cc-only entries (no refined counterpart) into new refined + notes under `refined/imported//` for curation. + +```bash +# Prep dev copies (you do this; the tool won't): +cp -r ~/hosting/"Lore examples"/journal /tmp/journal-copy +cp -r ~/hosting/"Lore examples" /tmp/lore-copy + +npx tsx src/cli.ts ui --dev --journal /tmp/journal-copy \ + --vault "/tmp/lore-copy/Obsidian vault/Land of Mardonar/Refined" \ + --cc "/tmp/lore-copy/campaign codex" --out /tmp/devout +# → open http://127.0.0.1:7788 +``` + +The dashboard opens in dev mode with **dry-run on by default** — actions preview +without writing. Uncheck dry-run to write into the `--out` sandbox. To write back to +the real refined/cc dirs (with timestamped `.bak-` backups), start with `--apply` +instead of `--dev`; the UI cannot escalate to apply unless the server was started +with it. The journal LevelDB is read-only in all modes. + +API (for scripting): `GET /api/status`, `GET /api/index`, `GET /api/file?name=`, +`POST /api/action` with `{op:"seed"|"sync"|"import"|"seedAll"|"syncAll"|"importAll", +names?:[], dryRun?:bool}`. + +## Test + +```bash +npm test # 30 tests: links, Roland round-trip, dev/dry-run/apply safety, batch + server +``` + +## Notes + +- `status/*` tags and `aliases` are curation-only on the Obsidian side; they are + preserved across sync, never sourced from or written to Foundry. +- `--emit-json` reconstructs a Foundry-importable JournalEntry JSON with clean + (not byte-identical) description/notes HTML — for a future push back into a live + Foundry world via the existing macros or a DB write, both out of scope here. +- The cc.md format drops wiki-link display aliases (`[[Name|Display]]` → `[[Name]]`), + matching Campaign Codex's native export. Display text round-trips losslessly only + through the Foundry JSON / `foundry:`-backed sync, not through cc.md. \ No newline at end of file diff --git a/docs/relay-api.md b/docs/relay-api.md new file mode 100644 index 0000000..c480bbe --- /dev/null +++ b/docs/relay-api.md @@ -0,0 +1,77 @@ +# ThreeHats foundryvtt-rest-api-relay — API shapes + +Source: `github.com/ThreeHats/foundryvtt-rest-api-relay` (Go relay in `go-relay/`; +docs at foundryrestapi.com). Confirmed against route definitions + integration tests. +Our instance: `vtt-relay.damascusfront.net` (a self-hosted ThreeHats relay). + +## Global mechanics + +- **Base URL:** no `/api` prefix. Paths appended directly to the base. +- **Auth:** `x-api-key: ` header on every request. +- **World selection:** `clientId` **query parameter** (NOT a header). Omit only when + exactly one Foundry client is connected to the key (relay auto-resolves); with 0 → + `404 {"error":"No connected Foundry clients found"}`, with >1 → `400` listing clients. +- **Pass-through:** the relay forwards params to the Foundry module over WebSocket and + returns the module's response verbatim. Envelopes are module-defined (per-endpoint). +- **Relay-level errors:** flat `{"error": "..."}` with non-2xx status. Timeouts → + `408`/`504 {"error":"Request timed out"}`. + +## Endpoints (the ones we use) + +| Endpoint | Method | Path | Identifies via | Body | 200 envelope | +|---|---|---|---|---|---| +| get | GET | `/get` | `?uuid=` | — | `{ data: }` | +| update | PUT | `/update` | `?uuid=` | `{ data: }` | `{ entity: [, ...] }` | +| create | POST | `/create` | body `entityType`+`data` | `{ entityType, data }` | `{ uuid, data: }` | +| search | GET | `/search` | `?filter=documentType:JournalEntry` (omit query to list all) | — | `{ query?, results: [...] }` | + +### GET /get +``` +GET /get?clientId=&uuid=JournalEntry. +x-api-key: +→ 200 { data: { name, type, _id, uuid, folder, pages, ownership, flags, ... } } +``` + +### PUT /update (scope: entity:write) +`data` is a **diff** — dot-path keys merge (e.g. `"flags.campaign-codex"` updates only +that sub-flag, preserving other flags); full keys replace. No `entityType` field (the +uuid carries the type). +``` +PUT /update?clientId=&uuid=JournalEntry. +x-api-key: +{ "data": { "name": "...", "flags.campaign-codex": { type, image, data } } } +→ 200 { entity: [ , ... ] } +``` + +### POST /create (scope: entity:write) +``` +POST /create?clientId= +{ "entityType": "JournalEntry", "data": { "name": "My Entry" } } +→ 200 { uuid: "JournalEntry.", data: { ... } } +``` + +### GET /search (scope: search) — list ALL journal entries, zero downtime +``` +GET /search?clientId=&filter=documentType:JournalEntry&excludeCompendiums=true&limit=500&minified=true +→ 200 { results: [ { uuid, id, name, img, documentType } ] } +``` +This is how we build the name→uuid map live (no Foundry stop). `minified=true` trims +each result to `{ uuid, id, name, img, documentType }`. + +## How we use it + +- **`cmd refresh`** builds `name-uuid.json` via `searchJournalEntries()` (zero downtime). + The docker-stop LevelDB read is only for the full dashboard `indexAll` (needs full + entry docs, not minified). +- **`cmd push`** fetches the live entry via `getEntry(uuid)`, builds the diff + `{ name, "flags.campaign-codex": cc }` (dot-path merge preserves other flags), and + calls `updateEntry(uuid, diff)`. + +## Notes for the TS client + +- `clientId` is always a query param; never a header. +- For `update`, send a minimal diff, not the full document — never echo `_id`/`pages`/ + `ownership` (those would clobber the live entry). Use dot-path `flags.campaign-codex` + to avoid wiping sibling flags. +- Envelopes are inconsistent: parse per-endpoint (`data` / `entity` / `results`). +- WS round-trip default timeout ~10s → `408`/`504` on timeout. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..785ee2c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2198 @@ +{ + "name": "foundry-obsidian-sync", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foundry-obsidian-sync", + "version": "0.1.0", + "dependencies": { + "classic-level": "^3.0.0", + "linkedom": "^0.18.12" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.22.4", + "typescript": "^5.6.0", + "vitest": "^4.1.9" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-level": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-3.1.1.tgz", + "integrity": "sha512-CW2gKbJFTuX1feMvOrvsVMmijAOgI9kg2Ie9Dq3gOcMt/dVVoVmqNlLcEUCT13NxHFMEajcUcVBIplbyDroDiw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "level-supports": "^6.2.0", + "level-transcoder": "^1.0.1", + "maybe-combine-errors": "^1.0.0", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/classic-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-3.0.0.tgz", + "integrity": "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/level-supports": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-6.2.0.tgz", + "integrity": "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/maybe-combine-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz", + "integrity": "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dfdd83e --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "foundry-obsidian-sync", + "version": "0.1.0", + "description": "Strict offline bidirectional converter between Foundry Campaign Codex journal data (LevelDB) and Obsidian vault markdown.", + "type": "module", + "private": true, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "cli": "tsx src/cli.ts" + }, + "dependencies": { + "classic-level": "^3.0.0", + "linkedom": "^0.18.12" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.22.4", + "typescript": "^5.6.0", + "vitest": "^4.1.9" + } +} \ No newline at end of file diff --git a/src/batch.ts b/src/batch.ts new file mode 100644 index 0000000..5c0535a --- /dev/null +++ b/src/batch.ts @@ -0,0 +1,372 @@ +// Batch connector + stateless merge engine. +// +// Indexes the refined vault + cc export against the journal LevelDB and, per +// file, computes a merge RECOMMENDATION with no external state file. Direction is +// derived from per-side self-hashes embedded in the files' own frontmatter: +// +// - refined note: foundry.contentHash (hash of the refined body, written at +// seed/sync time — already part of the foundry: identity block) +// - cc.md: cc_sync_hash (hash of the cc body, written at sync time) +// +// Each side records what IT was at the last sync, so "did this side change since +// last sync?" is a local self-comparison that survives copies (the hash travels +// with the content, unlike mtime, which `cp -r` resets to now). mtime is used only +// as a tiebreaker hint when both sides changed. +// +// Matching is by filename (verified: all 107 refined notes match a cc file by +// exact basename). The cc file's cc_id frontmatter resolves to the Foundry +// JournalEntry; cc files whose cc_id is absent from the journal are "unlinked". +// The journal DB is read-only everywhere. + +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import type { JournalDb } from "./db.js"; +import type { JournalEntry, FoundryBlock } from "./types.js"; +import { entryToObsidian } from "./toObsidian.js"; +import { obsidianToCc } from "./toFoundry.js"; +import { splitFrontmatter, emitFrontmatter, readFoundryBlock, type Frontmatter, OBSIDIAN_FM_ORDER, CC_FM_ORDER } from "./frontmatter.js"; +import { folderPathFromCcType } from "./fields.js"; +import { canonicalize, contentHash } from "./normalize.js"; + +export type RowStatus = "matched" | "matched-unlinked" | "cc-only" | "cc-only-unlinked" | "refined-only"; + +/** What the merge engine recommends doing for a file, in priority order. */ +export type Recommendation = + | "import" // cc-only entry: pull it into the vault + | "seed" // matched but not yet linked (no foundry: block) + | "sync-cc" // refined is newer than last sync (or cc side not yet baselined) -> push to cc + | "repull" // cc/Foundry is newer than last sync -> pull into refined (curation preserved) + | "conflict" // both sides changed since last sync -> manual review + | "in-sync" // neither side changed -> nothing to do + | "review"; // unlinked or refined-only: needs a human decision + +export interface FileRow { + name: string; + basename: string; + status: RowStatus; + refinedPath: string | null; + ccPath: string | null; + ccId: string | null; + ccType: string | null; + curatedType: string | null; + entry: JournalEntry | null; + recommendation: Recommendation; + refinedChanged: boolean; + ccChanged: boolean; + refinedMtime: number | null; // epoch ms, for a tiebreaker hint + ccMtime: number | null; + storedRefinedHash: string | null; // foundry.contentHash at last sync + storedCcHash: string | null; // cc_sync_hash at last sync +} + +export interface IndexResult { + matched: FileRow[]; // includes matched-unlinked + ccOnly: FileRow[]; + refinedOnly: FileRow[]; + counts: { matched: number; ccOnly: number; refinedOnly: number; unlinked: number }; + /** Count per recommendation, for the dashboard's action panel. */ + byRecommendation: Record; +} + +const SKIP_DIRS = new Set([".trash", ".obsidian", "node_modules", "_Templates", ".git"]); + +async function walkMd(root: string): Promise { + let out: string[] = []; + try { + for await (const ent of await readdir(root, { withFileTypes: true })) { + if (SKIP_DIRS.has(ent.name)) continue; + const p = join(root, ent.name); + if (ent.isDirectory()) out = out.concat(await walkMd(p)); + else if (ent.isFile() && ent.name.toLowerCase().endsWith(".md")) out.push(p); + } + } catch { + // missing dir -> empty + } + return out; +} + +function basenameOf(p: string): string { return p.split(sep).pop() ?? p; } +function nameOf(p: string): string { return basenameOf(p).replace(/\.md$/i, ""); } +function rel(root: string, p: string): string { return relative(root, p); } + +async function readRefinedMeta(path: string): Promise<{ type: string | null; seeded: boolean; storedHash: string | null; bodyHash: string; mtime: number | null; body: string; raw: string | null }> { + try { + const raw = await readFile(path, "utf8"); + const { fm, body } = splitFrontmatter(raw); + const foundry = readFoundryBlock(fm); + return { + type: typeof fm.type === "string" ? fm.type : null, + seeded: !!foundry, + storedHash: foundry?.contentHash ?? null, + bodyHash: contentHash(body), + mtime: (await stat(path)).mtimeMs, + body, + raw, + }; + } catch { + return { type: null, seeded: false, storedHash: null, bodyHash: "", mtime: null, body: "", raw: null }; + } +} + +async function readCcMeta(path: string): Promise<{ ccId: string | null; ccType: string | null; syncHash: string | null; bodyHash: string; mtime: number | null; raw: string | null }> { + try { + const raw = await readFile(path, "utf8"); + const { fm, body } = splitFrontmatter(raw); + return { + ccId: typeof fm.cc_id === "string" ? fm.cc_id : null, + ccType: typeof fm.cc_type === "string" ? fm.cc_type : null, + syncHash: typeof fm.cc_sync_hash === "string" ? fm.cc_sync_hash : null, + bodyHash: contentHash(body), + mtime: (await stat(path)).mtimeMs, + raw, + }; + } catch { + return { ccId: null, ccType: null, syncHash: null, bodyHash: "", mtime: null, raw: null }; + } +} + +/** Decide the recommendation from per-side change flags + linkage state. */ +export function recommend(params: { + status: RowStatus; + seeded: boolean; + hasCc: boolean; + ccSynced: boolean; // cc has cc_sync_hash + refinedChanged: boolean; + ccChanged: boolean; + entry: JournalEntry | null; +}): Recommendation { + if (params.status === "refined-only") return "review"; + if (params.status === "cc-only" || params.status === "cc-only-unlinked") return params.entry ? "import" : "review"; + if (params.status === "matched-unlinked") return "review"; + // matched + linked + if (!params.seeded) return "seed"; + if (!params.hasCc) return "sync-cc"; // refined linked but no cc counterpart to compare + if (!params.ccSynced) return "sync-cc"; // cc side never baselined -> baseline it + if (params.refinedChanged && params.ccChanged) return "conflict"; + if (params.refinedChanged) return "sync-cc"; + if (params.ccChanged) return "repull"; + return "in-sync"; +} + +/** Index both corpora against the journal DB and produce match rows + recommendations. */ +export async function indexAll(db: JournalDb, ccDir: string, refinedDir: string): Promise { + const [refinedFiles, ccFiles] = await Promise.all([walkMd(refinedDir), walkMd(ccDir)]); + const ccByBasename = new Map(); + for (const p of ccFiles) ccByBasename.set(basenameOf(p), p); + + const matched: FileRow[] = []; + const refinedNames = new Set(); + + for (const rp of refinedFiles) { + const base = basenameOf(rp); + refinedNames.add(base); + const cp = ccByBasename.get(base) ?? null; + const rmeta = await readRefinedMeta(rp); + let ccId: string | null = null, ccType: string | null = null, ccSyncHash: string | null = null; + let ccChanged = false, ccMtime: number | null = null; + if (cp) { + const cmeta = await readCcMeta(cp); + ccId = cmeta.ccId; ccType = cmeta.ccType; ccSyncHash = cmeta.syncHash; ccMtime = cmeta.mtime; + ccChanged = ccSyncHash !== null && ccSyncHash !== cmeta.bodyHash; + } + const entry = ccId ? (db.byId(ccId) ?? null) : null; + const status: RowStatus = cp ? (entry ? "matched" : "matched-unlinked") : "refined-only"; + const refinedChanged = rmeta.seeded && rmeta.storedHash !== null && rmeta.storedHash !== rmeta.bodyHash; + const recommendation = recommend({ + status, seeded: rmeta.seeded, hasCc: !!cp, ccSynced: ccSyncHash !== null, + refinedChanged, ccChanged, entry, + }); + matched.push({ + name: nameOf(rp), basename: base, status, + refinedPath: rel(refinedDir, rp), ccPath: cp ? rel(ccDir, cp) : null, + ccId, ccType, curatedType: rmeta.type, entry, + recommendation, refinedChanged, ccChanged, + refinedMtime: rmeta.mtime, ccMtime, + storedRefinedHash: rmeta.storedHash, storedCcHash: ccSyncHash, + }); + } + + const ccOnly: FileRow[] = []; + for (const cp of ccFiles) { + const base = basenameOf(cp); + if (refinedNames.has(base)) continue; + const cmeta = await readCcMeta(cp); + const entry = cmeta.ccId ? (db.byId(cmeta.ccId) ?? null) : null; + const status: RowStatus = entry ? "cc-only" : "cc-only-unlinked"; + ccOnly.push({ + name: nameOf(cp), basename: base, status, + refinedPath: null, ccPath: rel(ccDir, cp), + ccId: cmeta.ccId, ccType: cmeta.ccType, curatedType: null, entry, + recommendation: entry ? "import" : "review", + refinedChanged: false, ccChanged: false, + refinedMtime: null, ccMtime: cmeta.mtime, + storedRefinedHash: null, storedCcHash: cmeta.syncHash, + }); + } + + const refinedOnly = matched.filter((r) => r.status === "refined-only"); + const linked = matched.filter((r) => r.status !== "refined-only"); + const unlinked = matched.filter((r) => r.status === "matched-unlinked").length + ccOnly.filter((r) => !r.entry).length; + const all = [...linked, ...ccOnly]; + const byRecommendation = emptyRecs(); + for (const r of all) byRecommendation[r.recommendation]++; + + return { + matched: linked, + ccOnly, refinedOnly, + counts: { + matched: matched.filter((r) => r.status === "matched").length, + ccOnly: ccOnly.length, refinedOnly: refinedOnly.length, unlinked, + }, + byRecommendation, + }; +} + +function emptyRecs(): Record { + return { import: 0, seed: 0, "sync-cc": 0, repull: 0, conflict: 0, "in-sync": 0, review: 0 }; +} + +export interface OpResult { + written: { path: string; bytes: number }[]; + preview: { filename: string; path: string; content: string }[]; + skipped: { name: string; reason: string }[]; + message: string; +} + +/** Build the foundry: identity block for a refined note from its journal entry. */ +export function buildBlock(entry: JournalEntry, refinedBody: string, syncedAt: string): FoundryBlock { + const ccType = entry.flags?.["campaign-codex"]?.type ?? "shop"; + return { + cc_uuid: `JournalEntry.${entry._id}`, + cc_type: ccType, + folder_path: folderPathFromCcType(ccType), + contentHash: contentHash(refinedBody), + syncedAt, + }; +} + +/** Render the foundry: block as frontmatter text lines. */ +export function renderBlockLines(block: FoundryBlock): string[] { + return [ + "foundry:", + ` cc_uuid: ${block.cc_uuid}`, + ` cc_type: ${block.cc_type}`, + ` folder_path: ${block.folder_path}`, + ` contentHash: ${block.contentHash}`, + ` syncedAt: ${block.syncedAt}`, + ]; +} + +/** + * Inject (or replace) the foundry: block in a refined note via raw-text surgery, + * leaving EVERYTHING else byte-identical. This is the minimal "link" operation: + * it avoids the type-downgrade edge case by never regenerating content. + */ +export function seedBlockContent(md: string, block: FoundryBlock): string { + const m = md.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/); + if (!m) return `---\n${renderBlockLines(block).join("\n")}\n---\n\n${md}`; + const [, open, fmRaw, close, body] = m; + const lines = fmRaw.split(/\r?\n/); + let i = 0; + while (i < lines.length) { + if (lines[i] === "foundry:") { + let j = i + 1; + while (j < lines.length && lines[j].startsWith(" ")) j++; + lines.splice(i, j - i); + break; + } + i++; + } + const blockLines = renderBlockLines(block); + if (lines.length && lines[lines.length - 1].trim() !== "") lines.push(""); + lines.push(...blockLines); + return `${open}${lines.join("\n")}${close}${body}`; +} + +/** Read a refined note and return its seeded content (foundry: block only). */ +export async function seedRow(row: FileRow, refinedDir: string, syncedAt: string): Promise<{ filename: string; content: string } | null> { + if (!row.refinedPath || !row.entry) return null; + const md = await readFile(join(refinedDir, row.refinedPath), "utf8"); + const { body } = splitFrontmatter(md); + return { filename: row.basename, content: seedBlockContent(md, buildBlock(row.entry, body, syncedAt)) }; +} + +/** Inject cc_sync_hash into a cc.md's frontmatter (re-emits frontmatter; body untouched). */ +export function stampCcSyncHash(ccContent: string, syncHash: string): string { + const { fm, body } = splitFrontmatter(ccContent); + fm.cc_sync_hash = syncHash; + return `${emitFrontmatter(fm, CC_FM_ORDER, true)}\n\n${body}`; +} + +/** + * Sync refined -> cc AND baseline both sides in one op: + * - cc.md regenerated from the refined note (curation flows back), with + * cc_sync_hash = hash of the cc body. + * - refined note's foundry: block refreshed (contentHash = current refined + * body hash, syncedAt = now) so the refined self-hash matches what we just + * confirmed as the source of truth. + * Returns both files' content. Establishes the per-file ancestor for future + * direction detection with no external state. + */ +export async function syncRow(row: FileRow, refinedDir: string, db: JournalDb, exportedAt: string): Promise<{ refined: { filename: string; content: string } | null; cc: { filename: string; content: string } | null }> { + if (!row.refinedPath) return { refined: null, cc: null }; + const full = join(refinedDir, row.refinedPath); + const md = await readFile(full, "utf8"); + const { body } = splitFrontmatter(md); + const cc = obsidianToCc(md, row.name, db, exportedAt); + if (!cc) return { refined: null, cc: null }; + const ccBodyHash = contentHash(splitFrontmatter(cc.content).body); + const ccStamped = stampCcSyncHash(cc.content, ccBodyHash); + // Refresh the refined foundry: block so its self-hash matches the current body. + const refinedContent = row.entry ? seedBlockContent(md, buildBlock(row.entry, body, exportedAt)) : md; + return { + refined: { filename: row.basename, content: refinedContent }, + cc: { filename: cc.filename, content: ccStamped }, + }; +} + +/** + * Re-pull: Foundry/cc is newer than the vault. Regenerate the refined note body + * from the Foundry entry while preserving curation (curated type, aliases, + * status/* tags, and any hand-set faction/region/race/portrait). Writes a fresh + * foundry: block with contentHash of the new body. The curated `type` is NEVER + * downgraded to Foundry's cc_type (the type-drift edge case). + */ +export async function rePullRow(row: FileRow, refinedDir: string, db: JournalDb, syncedAt: string): Promise<{ filename: string; content: string } | null> { + if (!row.refinedPath || !row.entry) return null; + const existing = await readFile(join(refinedDir, row.refinedPath), "utf8"); + const ex = splitFrontmatter(existing); + const pulled = entryToObsidian(row.entry, db, syncedAt); + const pu = splitFrontmatter(pulled.content); + const merged: Frontmatter = { ...pu.fm }; // pulled body + fresh foundry block + // Preserve curated type (don't downgrade to Foundry's cc_type). + if (typeof ex.fm.type === "string") merged.type = ex.fm.type; + // Preserve curation-only aliases. + if (Array.isArray(ex.fm.aliases) && ex.fm.aliases.length) merged.aliases = ex.fm.aliases; + // Preserve existing status/* tags; pulled tags otherwise stand. + const exStatus = (Array.isArray(ex.fm.tags) ? ex.fm.tags : []).filter((t) => t.startsWith("status/")); + if (exStatus.length) { + const tags = (Array.isArray(merged.tags) ? merged.tags : []).filter((t) => !t.startsWith("status/")); + merged.tags = [...tags, ...exStatus]; + } + // Preserve hand-curated sidebar/portrait fields if the vault already had them. + for (const f of ["faction", "region", "race", "portrait"] as const) { + if (typeof ex.fm[f] === "string" && (ex.fm[f] as string) !== "") (merged as Record)[f] = ex.fm[f]; + } + const frontmatter = emitFrontmatter(merged, OBSIDIAN_FM_ORDER); + return { filename: row.basename, content: `${frontmatter}\n\n${canonicalize(pu.body)}\n` }; +} + +/** Pull a cc-only Foundry entry into a new refined note. */ +export function importRow(row: FileRow, db: JournalDb, syncedAt: string): { filename: string; content: string; subfolder: string } | null { + if (!row.entry) return null; + const note = entryToObsidian(row.entry, db, syncedAt); + const ccType = row.entry.flags?.["campaign-codex"]?.type ?? "shop"; + // Name the imported file after the cc file's basename (not entry.name) so it + // matches the cc file by filename. The Foundry exporter sanitizes names + // differently than entryToObsidian (e.g. `"Patches"` -> `-Patches-`), so using + // entry.name for the filename can leave the import unmatched to its cc file and + // re-imported on every "Import all". The body's # Title still uses entry.name; + // the link is by cc_uuid anyway. + return { filename: row.basename, content: note.content, subfolder: folderPathFromCcType(ccType) }; +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..510751e --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,303 @@ +import { mkdir, readdir, readFile, writeFile, copyFile, access } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { JournalDb } from "./db.js"; +import { entryToObsidian } from "./toObsidian.js"; +import { obsidianToCc, obsidianToFoundryJson } from "./toFoundry.js"; +import { indexAll } from "./batch.js"; +import { loadRelayConfig, loadFoundryConfig } from "./config.js"; +import { RelayClient } from "./relay/client.js"; +import { docker, acquireLock, releaseLock, lockPathFor } from "./foundry/docker.js"; +import { nameUuidIndexFromEntries, saveNameUuidIndex } from "./resolver.js"; +import { pushNote } from "./push.js"; +import { backupStamp } from "./write.js"; +import type { CliOptions } from "./types.js"; + +function parseArgs(argv: string[]): { cmd: string; opts: CliOptions } { + const args = argv.slice(2); + const cmd = args[0]; + if (!cmd) throw new Error("missing command: to-obsidian | to-foundry | roundtrip | ui | refresh | push"); + const opts: CliOptions = { + mode: "dev", + dryRun: false, + journal: "", + out: "", + emitJson: false, + }; + let name: string | undefined; + for (let i = 1; i < args.length; i++) { + const a = args[i]; + const next = () => args[++i]; + switch (a) { + case "--journal": opts.journal = next(); break; + case "--out": opts.out = next(); break; + case "--vault": opts.vault = next(); break; + case "--cc": opts.cc = next(); break; + case "--id": opts.id = next(); break; + case "--name": name = next(); break; + case "--emit-json": opts.emitJson = true; break; + case "--port": opts.port = Number(next()); break; + case "--host": opts.host = next(); break; + case "--dev": opts.mode = "dev"; break; + case "--apply": opts.mode = "apply"; break; + case "--dry-run": opts.dryRun = true; break; + case "--relay-url": opts.relayUrl = next(); break; + case "--relay-api-key": opts.relayApiKey = next(); break; + case "--relay-client-id": opts.relayClientId = next(); break; + case "--foundry-container": opts.foundryContainer = next(); break; + case "--foundry-data-dir": opts.foundryDataDir = next(); break; + case "--foundry-world": opts.foundryWorld = next(); break; + case "--full-index": opts.fullIndex = true; break; + default: throw new Error(`unknown flag: ${a}`); + } + } + opts.name = name; + if (!opts.journal) throw new Error("--journal is required"); + return { cmd, opts }; +} + +async function ensureOut(opts: CliOptions): Promise { + if (!opts.out) throw new Error("--out is required"); + if (opts.out === opts.journal) throw new Error("--out must not equal --journal (refusing to write into the source DB)"); + if (opts.mode === "dev" && opts.vault && opts.out === dirname(opts.vault)) { + throw new Error("--out must not be the source vault directory in dev mode"); + } + if (opts.mode === "dev" && opts.cc && opts.out === opts.cc) { + throw new Error("--out must not be the source cc directory in dev mode"); + } + // Never let tool output land inside Foundry's Data volume — that's Foundry's + // filesystem; writing backups/indexes there risks polluting the live world. + if (opts.foundryDataDir && opts.out.startsWith(opts.foundryDataDir)) { + throw new Error(`--out must not be inside FOUNDRY_DATA_DIR (${opts.foundryDataDir}) — keep tool output out of Foundry's Data volume`); + } + return opts.out; +} + +async function writeNote(outDir: string, filename: string, content: string, opts: CliOptions): Promise { + const target = join(outDir, filename); + if (opts.dryRun) { + let existing = ""; + try { existing = await readFile(target, "utf8"); } catch { /* new file */ } + console.log(`[dry-run] ${target}`); + if (existing) console.log(diffSummary(existing, content)); + else console.log(" (new file)"); + return; + } + await mkdir(outDir, { recursive: true }); + if (opts.mode === "apply") { + try { + await access(target); + const stamp = backupStamp(); + await copyFile(target, `${target}.bak-${stamp}`); + console.log(`[apply] backed up ${filename} -> ${filename}.bak-${stamp}`); + } catch { /* not present, no backup needed */ } + } + await writeFile(target, content, "utf8"); + console.log(`wrote ${target}`); +} + +function diffSummary(before: string, after: string): string { + if (before === after) return " (unchanged)"; + const b = before.split("\n"); + const a = after.split("\n"); + let same = 0, diff = 0; + const n = Math.max(b.length, a.length); + for (let i = 0; i < n; i++) { if (b[i] === a[i]) same++; else diff++; } + return ` (${same} lines same, ${diff} differ)`; +} + +export async function cmdToObsidian(opts: CliOptions): Promise { + const out = await ensureOut(opts); + const db = await JournalDb.open(opts.journal); + try { + const e = opts.id ? db.byId(opts.id) : undefined; + const entries = opts.id ? (e ? [e] : []) : db.all(); + if (!entries.length) throw new Error(`no matching entries${opts.id ? ` for --id ${opts.id}` : ""}`); + const stamp = new Date().toISOString(); + for (const entry of entries) { + const note = entryToObsidian(entry, db, stamp); + await writeNote(out, note.filename, note.content, opts); + } + console.log(`to-obsidian: ${entries.length} note(s) [${opts.mode}${opts.dryRun ? " dry-run" : ""}]`); + } finally { await db.close(); } +} + +export async function cmdToFoundry(opts: CliOptions): Promise { + const out = await ensureOut(opts); + if (!opts.vault) throw new Error("--vault is required for to-foundry"); + const db = await JournalDb.open(opts.journal); + try { + const md = await readFile(opts.vault, "utf8"); + const noteName = opts.name ?? basenameNoExt(opts.vault); + const stamp = new Date().toISOString(); + const cc = obsidianToCc(md, noteName, db, stamp); + if (!cc) throw new Error(`could not match "${noteName}" to a Foundry entry (no foundry: block and no name match)`); + await writeNote(out, cc.filename, cc.content, opts); + if (opts.emitJson) { + const json = obsidianToFoundryJson(md, noteName, db); + if (json) await writeNote(out, `${noteName}.foundry.json`, JSON.stringify(json, null, 2) + "\n", opts); + } + console.log(`to-foundry: cc.md written [${opts.mode}${opts.dryRun ? " dry-run" : ""}]`); + } finally { await db.close(); } +} + +async function cmdRoundtrip(opts: CliOptions): Promise { + const out = await ensureOut(opts); + if (!opts.id) throw new Error("roundtrip requires --id "); + const db = await JournalDb.open(opts.journal); + try { + const entry = db.byId(opts.id); + if (!entry) throw new Error(`no entry for --id ${opts.id}`); + const stamp = new Date().toISOString(); + const obs = entryToObsidian(entry, db, stamp); + await writeNote(out, `1-${obs.filename}`, obs.content, opts); + const cc = obsidianToCc(obs.content, entry.name, db, stamp); + if (!cc) throw new Error("roundtrip: obsidian->cc failed"); + await writeNote(out, `2-${cc.filename}`, cc.content, opts); + // Foundry is source of truth: the re-pull is byte-stable, so reuse `obs` + // rather than recomputing an identical third note. + await writeNote(out, `3-back-${obs.filename}`, obs.content, opts); + console.log(`roundtrip: 1-obsidian, 2-cc, 3-back written to ${out}`); + } finally { await db.close(); } +} + +function basenameNoExt(p: string): string { + const base = p.split("/").pop() ?? p; + return base.replace(/\.md$/i, ""); +} + +export async function cmdUi(opts: CliOptions): Promise { + const out = await ensureOut(opts); + if (!opts.vault) throw new Error("--vault is required for ui"); + if (!opts.cc) throw new Error("--cc is required for ui"); + const { startServer } = await import("./server.js"); + const relayCfg = loadRelayConfig({ url: opts.relayUrl, apiKey: opts.relayApiKey, clientId: opts.relayClientId }); + const foundryCfg = loadFoundryConfig({ container: opts.foundryContainer, dataDir: opts.foundryDataDir, world: opts.foundryWorld }); + const cfg = { + journal: opts.journal, + refinedDir: opts.vault, + ccDir: opts.cc, + outDir: out, + mode: opts.mode, + port: opts.port ?? 7788, + host: opts.host ?? "127.0.0.1", + relayCfg: relayCfg.apiKey ? relayCfg : undefined, + foundryCfg: foundryCfg.dataDir ? foundryCfg : undefined, + }; + const { server } = await startServer(cfg); + const shown = cfg.host === "0.0.0.0" ? "" : cfg.host; + console.log(`foundry-obsidian dashboard → http://${shown}:${cfg.port} [${cfg.mode}${opts.dryRun ? " dry-run" : ""}]`); + if (cfg.host === "0.0.0.0") console.log(` bound 0.0.0.0 — reachable on your tailnet at this VM's IP, port ${cfg.port}`); + console.log(` refined: ${cfg.refinedDir}`); + console.log(` cc: ${cfg.ccDir}`); + console.log(` out: ${cfg.outDir}`); + console.log(` push: ${cfg.relayCfg ? `enabled (relay ${cfg.relayCfg.url})` : "disabled (set RELAY_API_KEY to enable)"}`); + // Keep the process alive serving until killed. + server.on("close", () => process.exit(0)); +} + +/** + * refresh: rebuild the name↔uuid map the push path uses for link resolution. + * + * Default (zero Foundry downtime): relay /search lists all JournalEntries live; + * we cache name-uuid.json from that. With --full-index: stop the Foundry Docker + * container, read the real journal LevelDB read-only, run indexAll for the + * dashboard, then restart the container. The lockfile guards against a push + * running while the container is down. + */ +export async function cmdRefresh(opts: CliOptions): Promise { + const out = await ensureOut(opts); + const relay = new RelayClient(loadRelayConfig({ url: opts.relayUrl, apiKey: opts.relayApiKey, clientId: opts.relayClientId })); + const fc = loadFoundryConfig({ container: opts.foundryContainer, dataDir: opts.foundryDataDir, world: opts.foundryWorld }); + + // 1. Live name↔uuid map via /search (no Foundry stop). + console.log("refresh: listing journal entries via relay /search (zero downtime)…"); + const results = await relay.searchJournalEntries(); + const idx = nameUuidIndexFromEntries(results.map((r) => ({ name: r.name, uuid: r.uuid }))); + await saveNameUuidIndex(idx, join(out, "name-uuid.json")); + console.log(`refresh: cached ${Object.keys(idx.nameToUuid).length} name↔uuid pairs -> ${join(out, "name-uuid.json")}`); + + // 2. Optional full dashboard index from the live LevelDB (stops Foundry). + if (opts.fullIndex) { + if (!opts.vault || !opts.cc) throw new Error("--full-index requires --vault and --cc "); + if (!fc.container) throw new Error("--full-index requires FOUNDRY_CONTAINER (--foundry-container) to stop/start Foundry"); + const lock = lockPathFor(out); + await acquireLock(lock, "refresh"); + let downStart = 0; + try { + const wasRunning = await docker.isRunning(fc.container); + if (wasRunning) { + console.log(`refresh: stopping Foundry container ${fc.container} for live LevelDB read…`); + downStart = Date.now(); + await docker.stop(fc.container); + } else { + console.log(`refresh: Foundry container ${fc.container} already stopped — reading live DB.`); + } + const db = await JournalDb.open(opts.journal); + try { + const index = await indexAll(db, opts.cc, opts.vault); + await writeFile(join(out, "index.json"), JSON.stringify(index, null, 2) + "\n", "utf8"); + console.log(`refresh: wrote dashboard index -> ${join(out, "index.json")} (matched ${index.counts.matched}, cc-only ${index.counts.ccOnly})`); + } finally { + await db.close(); + } + if (wasRunning) { + await docker.start(fc.container); + console.log(`refresh: restarted Foundry container (down ${Math.round((Date.now() - downStart) / 1000)}s)`); + } + } finally { + await releaseLock(lock); + } + } + console.log(`refresh done [${opts.mode}${opts.dryRun ? " dry-run" : ""}]`); +} + +/** + * push: update ONE Foundry journal entry from a refined note, live, through the + * relay — Foundry keeps running (no LevelDB opened, no Docker stop). Thin wrapper + * around pushNote (shared with the dashboard server). See push.ts for the flow. + */ +export async function cmdPush(opts: CliOptions): Promise { + if (!opts.vault) throw new Error("--vault is required for push"); + const out = await ensureOut(opts); + const noteName = opts.name ?? basenameNoExt(opts.vault); + const relay = new RelayClient(loadRelayConfig({ url: opts.relayUrl, apiKey: opts.relayApiKey, clientId: opts.relayClientId })); + const fc = loadFoundryConfig({ container: opts.foundryContainer, dataDir: opts.foundryDataDir, world: opts.foundryWorld }); + const outcome = await pushNote({ + notePath: opts.vault, + noteName, + outDir: out, + relay, + foundryDataDir: fc.dataDir, + world: fc.world, + dryRun: opts.dryRun, + log: (m) => console.log(m), + }); + if (outcome.dryRun) { + console.log(" diff that would be PUT to /update:"); + console.log(JSON.stringify(outcome.diff, null, 2)); + } +} + +async function main(): Promise { + try { + const { cmd, opts } = parseArgs(process.argv); + switch (cmd) { + case "to-obsidian": await cmdToObsidian(opts); break; + case "to-foundry": await cmdToFoundry(opts); break; + case "roundtrip": await cmdRoundtrip(opts); break; + case "ui": await cmdUi(opts); break; + case "refresh": await cmdRefresh(opts); break; + case "push": await cmdPush(opts); break; + default: throw new Error(`unknown command: ${cmd}`); + } + } catch (e) { + console.error(`error: ${(e as Error).message}`); + process.exit(1); + } +} + +// Only run the CLI entrypoint when invoked directly (not when imported by tests). +import { pathToFileURL } from "node:url"; +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + void main(); +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e09fd0c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,48 @@ +/** + * Runtime config for the live Foundry push path (relay + docker + image fs). + * + * Read from process.env with sensible defaults. No zod dependency (the project + * keeps deps minimal: classic-level + linkedom only). CLI flags may override env + * values via `loadRelayConfig(overrides)` / `loadFoundryConfig(overrides)`. + */ + +export interface RelayConfig { + /** Base URL of the ThreeHats foundryvtt-rest-api-relay (no trailing slash). */ + url: string; + /** x-api-key for the relay (required for /get, /update, /search). */ + apiKey: string; + /** Optional x-client-id (multi-client relay routing). */ + clientId: string; +} + +export interface FoundryHostConfig { + /** Docker container name running Foundry (for stop/start during index refresh). */ + container: string; + /** Host-side mount of Foundry's Data directory (where assets/ lives). */ + dataDir: string; + /** Foundry world id (the folder under Data/worlds/). */ + world: string; +} + +function env(key: string, fallback = ""): string { + const v = process.env[key]; + return v && v.trim() !== "" ? v : fallback; +} + +/** Build a RelayConfig from env, with optional flag overrides. */ +export function loadRelayConfig(overrides: Partial = {}): RelayConfig { + return { + url: (overrides.url || env("RELAY_URL", "https://vtt-relay.damascusfront.net")).replace(/\/$/, ""), + apiKey: overrides.apiKey || env("RELAY_API_KEY"), + clientId: overrides.clientId || env("RELAY_CLIENT_ID"), + }; +} + +/** Build a FoundryHostConfig from env, with optional flag overrides. */ +export function loadFoundryConfig(overrides: Partial = {}): FoundryHostConfig { + return { + container: overrides.container || env("FOUNDRY_CONTAINER"), + dataDir: overrides.dataDir || env("FOUNDRY_DATA_DIR"), + world: overrides.world || env("FOUNDRY_WORLD"), + }; +} \ No newline at end of file diff --git a/src/dashboard.html b/src/dashboard.html new file mode 100644 index 0000000..1ca7c88 --- /dev/null +++ b/src/dashboard.html @@ -0,0 +1,288 @@ + + + + + +Foundry ⇄ Obsidian merge + + + +
+

Foundry ⇄ Obsidian merge

+
loading…
+ dev +
+ + + + + + +
+
+
+
+ +
+ +
+ Recommended next steps ▾ +
+
+ +
+ How merging works +

Each file carries a hash on both sides: the refined note stores foundry.contentHash, the cc.md stores cc_sync_hash. The tool compares each side to its own last-synced hash to decide direction — no separate state file. After you edit a side, its hash no longer matches what was recorded at last sync, so that side is "newer".

+

Priority: content missing from your vault first (import), then unlinked notes (seed), then vault-newer (sync→cc), then cc-newer (re-pull), then both-changed (conflict — review manually).

+
+ +

Matched (refined ⇄ cc)

+
+

cc-only (import candidates)

+
+
+
Select a row to inspect.
+
+ + + \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..fc4d55d --- /dev/null +++ b/src/db.ts @@ -0,0 +1,90 @@ +import { ClassicLevel, type DatabaseOptions } from "classic-level"; +import type { JournalEntry } from "./types.js"; + +/** + * Read-only index over a Foundry journal LevelDB snapshot. + * + * The journal store keys JournalEntries under `!journal!` and their pages + * under `!journal.pages!`. We only need the entries. We open with + * `readOnly: true` (honored when supported; otherwise tolerated) AND we only + * ever call read methods (get/iterator) — never put/del/batch — so the Foundry + * DB is never mutated, in dev or apply mode. Dev mode additionally operates on a + * user-supplied copy of the world data. + */ +type DbOptions = DatabaseOptions & { readOnly?: boolean }; + +export class JournalDb { + private db: ClassicLevel; + readonly entries = new Map(); // _id -> entry + readonly uuidToName = new Map(); // JournalEntry. -> name + readonly nameToUuid = new Map(); // name -> JournalEntry. (first wins) + + private constructor(db: ClassicLevel) { + this.db = db; + } + + static async open(path: string): Promise { + const db = new ClassicLevel(path, { + readOnly: true, + keyEncoding: "utf8", + valueEncoding: "utf8", + } as DbOptions); + const idx = new JournalDb(db); + await idx.load(); + return idx; + } + + private async load(): Promise { + for await (const [key, value] of this.db.iterator()) { + // Skip page records: keys beginning with "!journal.pages!". + if (key.startsWith("!journal.pages!")) continue; + if (!key.startsWith("!journal!")) continue; + let entry: JournalEntry; + try { + entry = JSON.parse(value) as JournalEntry; + } catch { + continue; + } + if (!entry?._id || !entry.name) continue; + // Only index Campaign Codex journals (those we can round-trip). + if (!entry.flags?.["campaign-codex"]) continue; + if (!this.entries.has(entry._id)) this.entries.set(entry._id, entry); + const uuid = `JournalEntry.${entry._id}`; + if (!this.uuidToName.has(uuid)) this.uuidToName.set(uuid, entry.name); + if (!this.nameToUuid.has(entry.name)) this.nameToUuid.set(entry.name, uuid); + } + } + + /** All CC journal entries, sorted by name for deterministic output. */ + all(): JournalEntry[] { + return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + byId(id: string): JournalEntry | undefined { + return this.entries.get(id); + } + + byUuid(uuid: string): JournalEntry | undefined { + const id = uuid.startsWith("JournalEntry.") ? uuid.slice("JournalEntry.".length) : uuid; + return this.entries.get(id); + } + + byName(name: string): JournalEntry | undefined { + const uuid = this.nameToUuid.get(name); + return uuid ? this.byUuid(uuid) : undefined; + } + + /** Resolve a Foundry UUID to its name, or undefined if unknown. */ + nameOf(uuid: string): string | undefined { + return this.uuidToName.get(uuid); + } + + /** Resolve a name to its Foundry UUID, or undefined if unknown. */ + uuidOf(name: string): string | undefined { + return this.nameToUuid.get(name); + } + + async close(): Promise { + await this.db.close(); + } +} \ No newline at end of file diff --git a/src/fields.ts b/src/fields.ts new file mode 100644 index 0000000..e27c862 --- /dev/null +++ b/src/fields.ts @@ -0,0 +1,72 @@ +// Type maps + folder derivation ported from the Foundry macros: +// CC_TYPES — Macros/cc-1-select.js (Obsidian frontmatter `type` -> CC type) +// CC_TO_OBSIDIAN — Macros/cc-4-export.js (CC type -> Obsidian frontmatter `type`) +// Reused, not reinvented. + +/** Obsidian frontmatter `type` value -> Campaign Codex entry type. */ +export const CC_TYPES: Record = { + // NPC / character + npc: "npc", character: "npc", + // Faction / tag + faction: "tag", tag: "tag", organization: "tag", tribe: "tag", house: "tag", guild: "tag", + // Location + location: "location", city: "location", dungeon: "location", settlement: "location", town: "location", + // Region + region: "region", continent: "region", plane: "region", + // Quest / group + quest: "quest", + group: "group", moc: "group", + // CC "Entry" (ShopSheet) — fallback + shop: "shop", entry: "shop", item: "shop", artifact: "shop", + creature: "shop", race: "shop", event: "shop", deity: "shop", + myth: "shop", technology: "shop", scene: "shop", pantheon: "shop", + religion: "shop", ruler: "shop", villain: "shop", ally: "shop", +}; + +/** Campaign Codex type -> Obsidian frontmatter `type`. */ +export const CC_TO_OBSIDIAN: Record = { + npc: "npc", tag: "faction", location: "location", + region: "region", quest: "quest", group: "group", shop: "entry", +}; + +/** Campaign Codex type -> export folder path (matches the cc export tree). */ +export const CC_FOLDER_PATH: Record = { + npc: "Campaign Codex - NPCs", + tag: "Campaign Codex - Factions", + location: "Campaign Codex - Locations", + region: "Campaign Codex - Regions", + group: "Campaign Codex - Groups", + quest: "Campaign Codex - Quests", + shop: "Campaign Codex - Entries", +}; + +export function ccTypeFromObsidianType(type: string | undefined): string { + if (!type) return "shop"; + return CC_TYPES[type] ?? "shop"; +} + +export function obsidianTypeFromCcType(ccType: string | undefined): string { + if (!ccType) return "entry"; + return CC_TO_OBSIDIAN[ccType] ?? "entry"; +} + +export function folderPathFromCcType(ccType: string | undefined): string { + return CC_FOLDER_PATH[ccType ?? ""] ?? "Campaign Codex - Entries"; +} + +/** Section headings whose content routes to the CC GM Notes (hidden) field. */ +export const NOTES_TRIGGERS = ["GM Notes", "Secrets", "Aftermath"]; + +/** Sidebar box label -> Obsidian frontmatter field name. */ +export const LABEL_TO_FIELD: Record = { + race: "race", + faction: "faction", + region: "region", +}; + +/** Reverse: frontmatter field -> sidebar box label (as rendered by CC). */ +export const FIELD_TO_LABEL: Record<"race" | "faction" | "region", string> = { + race: "Race", + faction: "Faction", + region: "Region", +}; \ No newline at end of file diff --git a/src/foundry/assets.ts b/src/foundry/assets.ts new file mode 100644 index 0000000..1373f5b --- /dev/null +++ b/src/foundry/assets.ts @@ -0,0 +1,100 @@ +import { copyFile, mkdir, access } from "node:fs/promises"; +import { join, dirname, basename } from "node:path"; + +/** + * Portrait image handling for the live push path. + * + * Same-host advantage: the tool runs on the Foundry Docker host, so it can write + * the portrait PNG straight into Foundry's served assets folder on the host + * filesystem — no relay upload endpoint needed (the ThreeHats relay's asset + * upload is undocumented/unclear). The JournalEntry /update then just references + * that server-relative path in `flags.campaign-codex.image`. + * + * Refined notes store `portrait: "[[File.png]]"`; the PNG is a co-located + * sibling file in the vault subfolder (confirmed: Fenris of House Quche_portrait.png + * sits next to Fenris of House Quche.md). + * + * Served-path convention: Foundry FilePicker "uploads" storage is world-relative, + * i.e. a flag value of `uploads/<...>/` is served from + * `Data/worlds//uploads/<...>/`. mini-uploader nests a per-world + * subfolder (`uploads//`). We DERIVE the prefix from the entry's existing + * image so new portraits land in the same folder as existing ones; if none, we + * default to the mini-uploader `uploads//` convention. Confirm the exact + * prefix against a live entry's image during the relay probe (see plan). + */ + +/** Resolve a `[[File.png]]` portrait field to a plain basename (no brackets). */ +export function portraitBasename(portraitField: string): string { + return portraitField.replace(/^\[\[|\]\]$/g, "").trim().replace(/\.[^.]+$/, "").trim() + ? portraitField.replace(/^\[\[|\]\]$/g, "").trim() + : ""; +} + +/** Locate the portrait file on disk: a sibling of the note (same dir, matching + * the portrait basename, with or without extension). Returns null if missing. */ +export async function findPortraitFile(notePath: string, portraitField: string): Promise { + const raw = portraitBasename(portraitField); + if (!raw) return null; + const dir = dirname(notePath); + const candidates = [join(dir, raw)]; + if (!raw.includes(".")) { + for (const ext of [".png", ".webp", ".jpg", ".jpeg", ".gif"]) candidates.push(join(dir, `${raw}${ext}`)); + } + for (const c of candidates) { + try { await access(c); return c; } catch { /* try next */ } + } + return null; +} + +export interface UploadResult { + /** The server-relative path to set as flags.campaign-codex.image (or null to keep the existing image). */ + servedPath: string | null; + /** True if the portrait was newly written to disk. */ + wrote: boolean; + /** Human-readable note for logs (kept / uploaded / not-found). */ + note: string; +} + +/** + * Upload (copy) the note's portrait into Foundry's assets dir, unless the entry + * already references a matching image (keep, no re-upload). Returns the served + * path to set in flags.campaign-codex.image, or null to keep the existing image. + */ +export async function uploadPortrait(opts: { + notePath: string; + portraitField: string | null | undefined; + foundryDataDir: string; + world: string; + /** The entry's current flags.campaign-codex.image (used to derive the prefix + short-circuit). */ + existingImage?: string | null; +}): Promise { + const { notePath, portraitField, foundryDataDir, world, existingImage } = opts; + const base = portraitField ? portraitBasename(portraitField) : ""; + if (!base) return { servedPath: existingImage ?? null, wrote: false, note: "no portrait field — keeping existing image" }; + + const existingBase = existingImage ? basename(existingImage.split("?")[0]) : ""; + // If the entry already references a portrait with the same basename, keep it. + if (existingBase && (existingBase === base || existingBase.replace(/\.[^.]+$/, "") === base.replace(/\.[^.]+$/, ""))) { + return { servedPath: existingImage ?? null, wrote: false, note: `keep existing image (${existingImage})` }; + } + + const src = await findPortraitFile(notePath, portraitField!); + if (!src) { + return { servedPath: existingImage ?? null, wrote: false, note: `portrait file not found next to note (${base})` }; + } + + // Derive the served prefix from the existing image, else default to mini-uploader's uploads//. + let servedPrefix: string; + if (existingImage && existingImage.includes("/")) { + servedPrefix = existingImage.slice(0, existingImage.lastIndexOf("/") + 1); + } else { + servedPrefix = `uploads/${world}/`; + } + const servedPath = `${servedPrefix}${basename(src)}`; + // Served paths are world-relative; the on-disk location is under Data/worlds//. + const diskPath = join(foundryDataDir, "worlds", world, servedPath); + + await mkdir(dirname(diskPath), { recursive: true }); + await copyFile(src, diskPath); + return { servedPath, wrote: true, note: `uploaded ${basename(src)} -> ${servedPath}` }; +} \ No newline at end of file diff --git a/src/foundry/docker.ts b/src/foundry/docker.ts new file mode 100644 index 0000000..af8bdae --- /dev/null +++ b/src/foundry/docker.ts @@ -0,0 +1,101 @@ +import { execFile } from "node:child_process"; +import { writeFile, readFile, unlink, access, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; + +/** + * Foundry Docker service control. + * + * Used ONLY by `cmd refresh` to read the live journal LevelDB (Foundry holds an + * exclusive lock on it while running, so the container must be stopped first). + * Pushes NEVER touch this — they go through the relay with Foundry running. + * + * A lockfile guards against a refresh running while a push is in flight (and + * vice versa): pushes hold the lock while a /update is pending, refresh holds it + * while the container is down. Stale locks (older than `lockStaleMs`) are + * reclaimable, so a crashed process doesn't wedge the tool. + */ + +const LOCK_STALE_MS = 10 * 60 * 1000; // 10 min + +export interface DockerControl { + /** True if the container is currently running. */ + isRunning(name: string): Promise; + /** Stop the container (graceful). Resolves when stopped. */ + stop(name: string, timeoutSec?: number): Promise; + /** Start the container. Resolves when started. */ + start(name: string): Promise; +} + +function run(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile("docker", args, { maxBuffer: 1 << 20 }, (err, stdout, stderr) => { + if (err) reject(new Error(`docker ${args.join(" ")}: ${(err as Error).message} ${stderr}`)); + else resolve(stdout.toString()); + }); + }); +} + +/** Default DockerControl using the local `docker` CLI. */ +export const docker: DockerControl = { + async isRunning(name: string): Promise { + try { + const out = await run(["inspect", "--format", "{{.State.Running}}", name]); + return out.trim() === "true"; + } catch { + return false; + } + }, + async stop(name: string, timeoutSec = 30): Promise { + await run(["stop", "-t", String(timeoutSec), name]); + }, + async start(name: string): Promise { + await run(["start", name]); + }, +}; + +/** Acquire a lockfile, reclaiming stale locks. Throws if held by a live process. */ +export async function acquireLock(lockPath: string, holder: string): Promise { + await mkdir(dirname(lockPath), { recursive: true }); + try { + const stamp = Date.now(); + await writeFile(lockPath, `${holder}\n${stamp}\n`, { flag: "wx" }); + return; + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code !== "EEXIST") throw err; + } + // Lock exists — check staleness. + let content: string; + try { + content = await readFile(lockPath, "utf8"); + } catch { + // Raced away; retry once. + return acquireLock(lockPath, holder); + } + const [, tsLine] = content.split("\n"); + const age = Date.now() - Number(tsLine || 0); + if (age < LOCK_STALE_MS) { + throw new Error(`lock held by ${content.split("\n")[0]} (age ${Math.round(age / 1000)}s) — refusing to run concurrently`); + } + // Stale: reclaim. + await unlink(lockPath).catch(() => {}); + await writeFile(lockPath, `${holder}\n${Date.now()}\n`, { flag: "wx" }); +} + +export async function releaseLock(lockPath: string): Promise { + await unlink(lockPath).catch(() => {}); +} + +export async function isLocked(lockPath: string): Promise { + try { + await access(lockPath); + return true; + } catch { + return false; + } +} + +/** Default lock path lives next to the --out dir. */ +export function lockPathFor(outDir: string): string { + return join(outDir, "foundry-sync.lock"); +} \ No newline at end of file diff --git a/src/frontmatter.ts b/src/frontmatter.ts new file mode 100644 index 0000000..719c1a7 --- /dev/null +++ b/src/frontmatter.ts @@ -0,0 +1,108 @@ +// Minimal YAML frontmatter parse/emit for the subset used by the vault and cc +// formats: scalar keys, block-sequence lists (tags/aliases), and one nested +// mapping (the `foundry:` block). No external YAML dependency. + +export type FmValue = string | string[] | Record; +export type Frontmatter = Record; + +/** Typed view of the nested `foundry:` block, or undefined if absent/not a map. */ +export function readFoundryBlock(fm: Frontmatter): Record | undefined { + return typeof fm.foundry === "object" ? (fm.foundry as Record) : undefined; +} + +function unquote(s: string): string { + const t = s.trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + const inner = t.slice(1, -1); + return t.startsWith('"') ? inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\") : inner; + } + return t; +} + +/** Parse a `---\n...\n---` block at the start of a markdown string. Returns {fm, body}. */ +export function splitFrontmatter(md: string): { fm: Frontmatter; body: string } { + const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) return { fm: {}, body: md }; + const raw = m[1]; + const body = m[2] ?? ""; + const fm: Frontmatter = {}; + const lines = raw.split(/\r?\n/); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (!line) { i++; continue; } + const km = line.match(/^([A-Za-z0-9_\-]+):\s*(.*)$/); + if (!km) { i++; continue; } + const key = km[1]; + const rest = km[2]; + if (rest.trim() === "") { + // Block sequence or nested mapping follows. + const items: string[] = []; + const nested: Record = {}; + let j = i + 1; + while (j < lines.length && lines[j].startsWith(" ")) { + const sub = lines[j]; + if (sub.startsWith(" - ")) { + items.push(unquote(sub.slice(4))); + } else { + const sm = sub.match(/^\s+([A-Za-z0-9_\-]+):\s*(.*)$/); + if (sm) nested[sm[1]] = unquote(sm[2]); + } + j++; + } + if (items.length) fm[key] = items; + else if (Object.keys(nested).length) fm[key] = nested; + i = j; + } else { + fm[key] = unquote(rest); + i++; + } + } + return { fm, body }; +} + +function needsQuotes(v: string): boolean { + if (v === "") return true; + if (v.startsWith("[[") || v.startsWith("![")) return true; + return /[:#{}[\]|>&*!'"@`,]/.test(v); +} + +function quote(v: string, force = false): string { + if (!force && !needsQuotes(v)) return v; + return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** Emit a frontmatter block in a fixed, deterministic key order. + * `forceQuote` quotes all scalar string values (used for the cc.md export, + * matching Campaign Codex's quoted frontmatter). */ +/** Emit the lines for one frontmatter key (scalar / list / nested map). */ +function emitKey(key: string, val: FmValue, forceQuote: boolean, lines: string[]): void { + if (Array.isArray(val)) { + lines.push(`${key}:`); + for (const item of val) lines.push(` - ${quote(item, forceQuote)}`); + } else if (typeof val === "string") { + lines.push(`${key}: ${quote(val, forceQuote)}`); + } else { + lines.push(`${key}:`); + for (const [k, v] of Object.entries(val)) lines.push(` ${k}: ${quote(v, forceQuote)}`); + } +} + +/** Emit a frontmatter block in a fixed, deterministic key order. + * `forceQuote` quotes all scalar string values (used for the cc.md export, + * matching Campaign Codex's quoted frontmatter). */ +export function emitFrontmatter(fm: Frontmatter, order: string[], forceQuote = false): string { + const lines = ["---"]; + for (const key of order) { + if (!(key in fm)) continue; + emitKey(key, fm[key], forceQuote, lines); + } + // Any keys not in the prescribed order, appended alphabetically for stability. + const extras = Object.keys(fm).filter((k) => !order.includes(k)).sort(); + for (const key of extras) emitKey(key, fm[key], forceQuote, lines); + lines.push("---"); + return lines.join("\n"); +} + +export const OBSIDIAN_FM_ORDER = ["type", "tags", "aliases", "faction", "region", "race", "portrait"]; +export const CC_FM_ORDER = ["cc_id", "cc_uuid", "cc_type", "cc_folder_path", "cc_exported_at", "cc_sync_hash"]; \ No newline at end of file diff --git a/src/htmlMd.ts b/src/htmlMd.ts new file mode 100644 index 0000000..b731964 --- /dev/null +++ b/src/htmlMd.ts @@ -0,0 +1,141 @@ +import { parseHTML } from "linkedom"; +import type { JournalDb } from "./db.js"; +import { uuidToWiki, wikiToName } from "./links.js"; + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +export interface SectionBox { + title: string; + body: string; +} + +export interface ParsedDescription { + bodyMd: string; // left column: tagline + hr + sections, links as [[Name|Display]] + sidebar: Record; // e.g. { race: "[[Human]]", faction: "[[House Raventhorne]]", region: "[[..]]" } + sectionBoxes: SectionBox[]; // extra right-column section boxes (Mission/Relations…); Roland has none +} + +function stripTags(html: string): string { + return html.replace(/<[^>]+>/g, ""); +} + +// Ported from Macros/cc-4-export.js nodeToMd, but text nodes resolve @UUID -> [[Name|Display]]. +function nodeToMd(node: any, db: JournalDb): string { + if (node.nodeType === TEXT_NODE) return uuidToWiki(node.textContent ?? "", db); + if (node.nodeType !== ELEMENT_NODE) return ""; + const el = node; + const tag = el.tagName.toLowerCase(); + const inner = () => Array.from(el.childNodes).map((c: any) => nodeToMd(c, db)).join(""); + const block = (c: string) => `\n\n${c}\n\n`; + switch (tag) { + case "h1": return block(`# ${inner().trim()}`); + case "h2": return block(`## ${inner().trim()}`); + case "h3": return block(`### ${inner().trim()}`); + case "h4": return block(`#### ${inner().trim()}`); + case "h5": return block(`##### ${inner().trim()}`); + case "h6": return block(`###### ${inner().trim()}`); + case "p": return block(inner().trim()); + case "strong": case "b": return `**${inner()}**`; + case "em": case "i": return `*${inner()}*`; + case "code": return `\`${inner()}\``; + case "hr": return block("---"); + case "br": return "\n"; + case "blockquote": { + const c = inner().trim().replace(/\n/g, "\n> "); + return block(`> ${c}`); + } + case "ul": + return block(Array.from(el.children).map((li) => `- ${nodeToMd(li, db).trim()}`).join("\n")); + case "ol": + return block(Array.from(el.children).map((li, i) => `${i + 1}. ${nodeToMd(li, db).trim()}`).join("\n")); + case "li": return inner(); + case "figure": { + const img = el.querySelector("img"); + if (!img) return inner(); + const basename = (img.getAttribute("src") ?? "").split("/").pop(); + return block(`![[${basename}]]`); + } + case "img": { + const basename = (el.getAttribute("src") ?? "").split("/").pop(); + return `![[${basename}]]`; + } + case "a": { + const href = el.getAttribute("href") ?? ""; + const text = inner(); + return href.startsWith("http") ? `[${text}](${href})` : text; + } + default: return inner(); // div/span/section recurse + } +} + +function collapse(md: string): string { + return md.trim().replace(/\n{3,}/g, "\n\n"); +} + +/** Parse the two-column description into body markdown + sidebar fields. */ +export function htmlToMarkdown(descriptionHtml: string, db: JournalDb): ParsedDescription { + const { document } = parseHTML(`
${descriptionHtml}
`); + const root = document.querySelector("div"); + if (!root) return { bodyMd: "", sidebar: {}, sectionBoxes: [] }; + + const flexDiv = root.querySelector('div[style*="display:flex"], div[style*="display: flex"]'); + + const sidebar: Record = {}; + const sectionBoxes: SectionBox[] = []; + + // Walk the already-parsed left node directly (no serialize→re-parse round-trip). + let bodyNode: any = root; + if (flexDiv && flexDiv.children.length >= 2) { + bodyNode = flexDiv.children[0]; + parseRightColumn(flexDiv.children[1], db, sidebar, sectionBoxes); + } + + const bodyMd = collapse(nodeToMd(bodyNode, db)); + return { bodyMd, sidebar, sectionBoxes }; +} + +// Ported from cc-4 parseRightColumn: distinguish sidebar field boxes from section boxes. +function parseRightColumn(right: any, db: JournalDb, sidebar: Record, sectionBoxes: SectionBox[]): void { + for (const box of Array.from(right.children) as any[]) { + const style = box.getAttribute("style") ?? ""; + + // Section box: makeBodySectionBox uses font-size:0.8rem and an

title. + if (/font-size:\s?0\.8/.test(style)) { + const h4 = (Array.from(box.children) as any[]).find((c) => c.tagName === "H4"); + if (h4) { + const title = h4.textContent?.trim() ?? ""; + const childNodes = Array.from(box.childNodes) as any[]; + const after = childNodes.slice(childNodes.indexOf(h4) + 1); + const tmp = parseHTML("
").document.createElement("div"); + for (const n of after) tmp.appendChild(n.cloneNode(true)); + const body = collapse(nodeToMd(tmp, db)); + sectionBoxes.push({ title, body }); + continue; + } + } + + // Sidebar field box: rows with justify-content:space-between. + const rows = box.querySelectorAll('div[style*="justify-content:space-between"], div[style*="justify-content: space-between"]'); + for (const row of Array.from(rows) as any[]) { + const keyEl = row.querySelector("strong"); + const valEl = row.querySelector("span"); + if (!keyEl || !valEl) continue; + const key = keyEl.textContent?.trim().toLowerCase().replace(/\s+/g, "_") ?? ""; + if (!key) continue; + // Sidebar values are frontmatter form: resolve @UUID -> [[Name]] (display dropped), + // via the shared link layer (handles every link, not just the first). + const value = collapse(stripTags(wikiToName(uuidToWiki(valEl.innerHTML, db)))); + if (value) sidebar[key] = value; + } + } +} + +/** Convert the notes HTML to markdown for the ## Secrets / ## Notes section. */ +export function notesToMarkdown(notesHtml: string, db: JournalDb): string { + if (!notesHtml?.trim()) return ""; + const { document } = parseHTML(`
${notesHtml}
`); + const root = document.querySelector("div"); + if (!root) return ""; + return collapse(nodeToMd(root, db)); +} \ No newline at end of file diff --git a/src/links.ts b/src/links.ts new file mode 100644 index 0000000..4f8fbe0 --- /dev/null +++ b/src/links.ts @@ -0,0 +1,48 @@ +import type { NameResolver } from "./resolver.js"; + +// Foundry content link: @UUID[JournalEntry.]{Display} +const UUID_LINK = /@UUID\[([^\]]+)\]\{([^}]*)\}/g; +// Obsidian wiki link: [[Target]] or [[Target|Display]] +const WIKI_LINK = /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g; + +/** + * Convert Foundry @UUID content links to Obsidian wiki links. + * - resolved UUID -> [[Name]] (or [[Name|Display]] when display differs) + * - unresolved UUID -> the display text, plain (no broken link) + * + * Takes a NameResolver (JournalDb satisfies this structurally, so the offline + * DB path is unchanged; the live push path passes a MapNameResolver). + */ +export function uuidToWiki(text: string, r: NameResolver): string { + return text.replace(UUID_LINK, (_m, uuid: string, display: string) => { + const name = r.nameOf(uuid); + if (!name) return display; + const disp = display.trim(); + return disp && disp !== name ? `[[${name}|${disp}]]` : `[[${name}]]`; + }); +} + +/** + * Convert Obsidian wiki links back to Foundry @UUID content links. + * - resolved name -> @UUID[JournalEntry.]{Display} (Display defaults to target) + * - unresolved name -> left as a wiki link (cannot rebuild the UUID) + */ +export function wikiToUuid(text: string, r: NameResolver): string { + return text.replace(WIKI_LINK, (_m, target: string, display: string | undefined) => { + const name = target.trim(); + const uuid = r.uuidOf(name); + if (!uuid) return _m; + const disp = (display ?? "").trim(); + return `@UUID[${uuid}]{${disp || name}}`; + }); +} + +/** + * Collapse wiki links to their target name only (drop display text): + * [[Name|Display]] -> [[Name]] [[Name]] -> [[Name]] + * Used when emitting the cc.md native export format, which resolves to names and + * discards display aliases (see Roland Raventhornecc.md). + */ +export function wikiToName(text: string): string { + return text.replace(WIKI_LINK, (_m, target: string) => `[[${target.trim()}]]`); +} \ No newline at end of file diff --git a/src/mdToHtml.ts b/src/mdToHtml.ts new file mode 100644 index 0000000..6b58c4c --- /dev/null +++ b/src/mdToHtml.ts @@ -0,0 +1,84 @@ +import type { NameResolver } from "./resolver.js"; +import { wikiToUuid } from "./links.js"; +import { canonicalizeWhitespace } from "./normalize.js"; + +/** + * Best-effort Markdown -> Foundry HTML for the optional --emit-json path and the + * live push path. Produces clean `

/

///

    /

    ` markup with + * @UUID links and Bio/Social boxes. NOT byte-identical to Foundry's own two-column + * layout, but valid HTML that Campaign Codex will import and render. + * + * Takes a NameResolver (JournalDb satisfies this structurally, so the offline + * DB path is unchanged; the live push path passes a MapNameResolver). + */ + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function inline(text: string, r: NameResolver): string { + let out = escapeHtml(text); + // inline links first (before bold/italic to avoid mangling [[ ]]) + out = wikiToUuid(out, r); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + out = out.replace(/\*([^*]+)\*/g, "$1"); + return out; +} + +export function markdownToHtml(md: string, r: NameResolver): string { + const lines = canonical(md).split("\n"); + const blocks: string[] = []; + let i = 0; + const para: string[] = []; + const flush = () => { + if (para.length) { + blocks.push(`

    ${inline(para.join(" "), r)}

    `); + para.length = 0; + } + }; + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) { flush(); i++; continue; } + const h = line.match(/^(#{1,6})\s+(.+)$/); + if (h) { flush(); const level = h[1].length; blocks.push(`${inline(h[2], r)}`); i++; continue; } + if (/^---\s*$/.test(line)) { flush(); blocks.push("
    "); i++; continue; } + if (/^>\s?/.test(line)) { + flush(); + const quote: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, "")); i++; } + blocks.push(`
    ${inline(quote.join(" "), r)}
    `); + continue; + } + if (/^[-*]\s+/.test(line)) { + flush(); + const items: string[] = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i])) { items.push(`
  • ${inline(lines[i].replace(/^[-*]\s+/, ""), r)}
  • `); i++; } + blocks.push(`
      ${items.join("")}
    `); + continue; + } + if (/^\d+\.\s+/.test(line)) { + flush(); + const items: string[] = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { items.push(`
  • ${inline(lines[i].replace(/^\d+\.\s+/, ""), r)}
  • `); i++; } + blocks.push(`
      ${items.join("")}
    `); + continue; + } + para.push(line); + i++; + } + flush(); + return blocks.join("\n"); +} + +// Reuse the single canonicalization mechanism (normalize.ts) instead of a +// local weaker subset. CRLF is folded first; canonicalizeWhitespace then +// strips trailing per-line whitespace and collapses blank runs. +function canonical(md: string): string { + return canonicalizeWhitespace(md.replace(/\r\n/g, "\n")); +} + +/** Build the Bio/Social sidebar box HTML for a reconstructed description. */ +export function sidebarBoxHtml(title: string, rows: [string, string][]): string { + const rowHtml = rows.map(([k, v]) => `
    ${k}${v}
    `).join(""); + return `

    ${title}

    ${rowHtml}
    `; +} \ No newline at end of file diff --git a/src/normalize.ts b/src/normalize.ts new file mode 100644 index 0000000..d9da3d9 --- /dev/null +++ b/src/normalize.ts @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; + +/** Canonicalize wiki link syntax: [[ a | b ]] -> [[a|b]], [[ a ]] -> [[a]]. */ +export function canonicalizeWikilinks(text: string): string { + return text.replace(/\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/g, (_m, target: string, display: string | undefined) => + display !== undefined ? `[[${target}|${display}]]` : `[[${target}]]`, + ); +} + +/** Collapse trailing whitespace per line and runs of blank lines. */ +export function canonicalizeWhitespace(text: string): string { + return text + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/g, ""); +} + +/** Full canonical text form used for hashing and comparison. */ +export function canonicalize(text: string): string { + return canonicalizeWhitespace(canonicalizeWikilinks(text)); +} + +/** SHA-256 of the canonical form, used in the foundry: block to detect real content change. */ +export function contentHash(text: string): string { + return createHash("sha256").update(canonicalize(text)).digest("hex"); +} \ No newline at end of file diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..3e7f0b7 --- /dev/null +++ b/src/push.ts @@ -0,0 +1,134 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { obsidianToFoundryJsonLive } from "./toFoundry.js"; +import { splitFrontmatter, readFoundryBlock } from "./frontmatter.js"; +import { RelayClient } from "./relay/client.js"; +import { uploadPortrait } from "./foundry/assets.js"; +import { MapNameResolver, nameUuidIndexFromEntries, saveNameUuidIndex, loadNameUuidIndex, type NameResolver } from "./resolver.js"; +import { backupStamp } from "./write.js"; +import type { JournalEntry } from "./types.js"; + +/** + * Pure payload builder (unit-tested without a relay): full JournalEntry JSON + * (spread the live entry; override name + flags.campaign-codex, links resolved via + * the name↔uuid map) + the minimal /update diff ({ name, "flags.campaign-codex" }). + * The diff uses a dot-path key so Foundry merges just that sub-flag (other flags + * survive) and never echoes _id/pages/ownership (which would clobber the live entry). + */ +export function buildPushPayload( + md: string, + noteName: string, + liveEntry: JournalEntry, + resolver: NameResolver, + imageOverride?: string | null, +): { full: JournalEntry; diff: Record } { + const full = obsidianToFoundryJsonLive(md, noteName, liveEntry, resolver, imageOverride); + const cc = full.flags?.["campaign-codex"]; + const diff: Record = { name: full.name, "flags.campaign-codex": cc }; + return { full, diff }; +} + +export interface PushDeps { + notePath: string; + noteName: string; + outDir: string; + relay: RelayClient; + /** Foundry host fs (for image upload). Empty string = skip image upload, keep existing. */ + foundryDataDir: string; + world: string; + dryRun: boolean; + /** Optional preloaded resolver; else load /name-uuid.json (or build via /search). */ + resolver?: NameResolver; + log?: (msg: string) => void; +} + +export interface PushOutcome { + dryRun: boolean; + ccUuid: string; + diff: Record; + imageNote: string; + backupPath?: string; + updatedName?: string; +} + +/** Read the foundry.cc_uuid and the portrait field from a refined note's + * frontmatter in a single parse (or { null, null } if absent). */ +function readNoteFoundryMeta(md: string): { ccUuid: string | null; portrait: string | null } { + const { fm } = splitFrontmatter(md); + const foundry = readFoundryBlock(fm); + return { + ccUuid: foundry?.cc_uuid ?? null, + portrait: typeof fm.portrait === "string" && fm.portrait ? fm.portrait : null, + }; +} + +/** + * Push ONE refined note into the live Foundry world via the relay. Foundry keeps + * running (no LevelDB opened, no Docker stop). Shared by the CLI `push` command + * and the dashboard's /api/push endpoint. + */ +export async function pushNote(deps: PushDeps): Promise { + const log = deps.log ?? (() => {}); + const md = await readFile(deps.notePath, "utf8"); + const { ccUuid, portrait } = readNoteFoundryMeta(md); + if (!ccUuid) throw new Error(`no foundry.cc_uuid in ${deps.notePath} — run "seed" first to link this note to its Foundry entry`); + const id = ccUuid.startsWith("JournalEntry.") ? ccUuid : `JournalEntry.${ccUuid}`; + + // Resolver for link resolution: preload, else load cache, else build via /search. + let resolver: NameResolver = deps.resolver ?? new MapNameResolver({ nameToUuid: {}, uuidToName: {} }); + if (!deps.resolver) { + try { + resolver = await loadNameUuidIndex(join(deps.outDir, "name-uuid.json")); + } catch { + log("push: no cached name-uuid.json — building one via relay /search…"); + const results = await deps.relay.searchJournalEntries(); + const idx = nameUuidIndexFromEntries(results.map((r) => ({ name: r.name, uuid: r.uuid }))); + await saveNameUuidIndex(idx, join(deps.outDir, "name-uuid.json")); + resolver = new MapNameResolver(idx); + } + } + + // Fetch the live entry (preserves current ownership/folder/pages + existing image). + log(`push: fetching live entry ${id} via relay /get…`); + const liveEntry = await deps.relay.getEntry(id); + + // Image: upload the portrait into Foundry's assets dir if the note has one. + let imageOverride: string | null | undefined = undefined; // undefined = keep existing + let imageNote = "no portrait field"; + if (portrait) { + if (!deps.foundryDataDir || !deps.world) { + imageNote = "portrait present but FOUNDRY_DATA_DIR/WORLD unset — keeping existing image"; + log(`push: image ${imageNote}`); + } else { + const up = await uploadPortrait({ + notePath: deps.notePath, + portraitField: portrait, + foundryDataDir: deps.foundryDataDir, + world: deps.world, + existingImage: liveEntry.flags?.["campaign-codex"]?.image ?? null, + }); + imageNote = up.note; + log(`push: image ${imageNote}`); + imageOverride = up.servedPath; + } + } + + const { diff } = buildPushPayload(md, deps.noteName, liveEntry, resolver, imageOverride); + + if (deps.dryRun) { + log(`[dry-run] push ${deps.noteName} (${id})`); + return { dryRun: true, ccUuid: id, diff, imageNote }; + } + + // Apply: snapshot the live entry first (reversible), then PUT the diff. + const bakDir = join(deps.outDir, "bak"); + await mkdir(bakDir, { recursive: true }); + const stamp = backupStamp(); + const backupPath = join(bakDir, `${deps.noteName}.${stamp}.json`); + await writeFile(backupPath, JSON.stringify(liveEntry, null, 2) + "\n", "utf8"); + log(`push: backed up live entry -> ${backupPath}`); + + const updated = await deps.relay.updateEntry(id, diff); + log(`push: updated "${updated.name}" in live Foundry (${id})`); + return { dryRun: false, ccUuid: id, diff, imageNote, backupPath, updatedName: updated.name }; +} \ No newline at end of file diff --git a/src/relay/client.ts b/src/relay/client.ts new file mode 100644 index 0000000..062c58f --- /dev/null +++ b/src/relay/client.ts @@ -0,0 +1,106 @@ +import type { JournalEntry } from "../types.js"; +import type { RelayConfig } from "../config.js"; + +/** + * Thin client for the ThreeHats foundryvtt-rest-api-relay (vtt-relay.damascusfront.net). + * + * Auth: `x-api-key` header + `clientId` query param (NOT a header). Assumes a live + * Foundry instance with the rest-api module connected — no headless session spin-up + * (Foundry runs in Docker on this host). If `clientId` is unset the relay auto- + * resolves it only when exactly one client is connected to the key. + * + * Response envelopes are per-endpoint (NOT uniform): /get -> { data }, /update -> + * { entity: [...] }, /search -> { query?, results: [...] }, /create -> { uuid, data }. + * Relay-level errors are flat: { error: "..." } with a non-2xx status. + * + * Spec source: github.com/ThreeHats/foundryvtt-rest-api-relay (see docs/relay-api.md). + */ + +export interface SearchResult { + uuid: string; + id: string; + name: string; + img?: string | null; + documentType: string; +} + +export class RelayClient { + private readonly url: string; + private readonly apiKey: string; + private readonly clientId: string; + + constructor(cfg: RelayConfig) { + if (!cfg.apiKey) throw new Error("RELAY_API_KEY is required for relay commands (set --relay-api-key or RELAY_API_KEY)"); + this.url = cfg.url; + this.apiKey = cfg.apiKey; + this.clientId = cfg.clientId; + } + + private qs(extra: Record = {}): string { + const sp = new URLSearchParams(); + if (this.clientId) sp.set("clientId", this.clientId); + for (const [k, v] of Object.entries(extra)) if (v !== "") sp.set(k, v); + const s = sp.toString(); + return s ? `?${s}` : ""; + } + + private headers(): Record { + return { "content-type": "application/json", "x-api-key": this.apiKey }; + } + + private async request(method: string, path: string, qs: Record, body?: unknown): Promise { + const url = `${this.url}${path}${this.qs(qs)}`; + const init: RequestInit = { method, headers: this.headers() }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await fetch(url, init); + const text = await res.text(); + let json: unknown = undefined; + try { json = text ? JSON.parse(text) : undefined; } catch { /* non-JSON error body */ } + if (!res.ok) { + const msg = (json && typeof json === "object" && "error" in json) + ? String((json as Record).error) + : text.slice(0, 200); + throw new Error(`relay ${res.status} ${method} ${path}: ${msg}`); + } + return json; + } + + /** GET /get — fetch one document by UUID. Returns the full Foundry document. */ + async getEntry(uuid: string): Promise { + const json = await this.request("GET", "/get", { uuid }) as { data?: JournalEntry }; + if (!json?.data) throw new Error(`relay /get returned no data for ${uuid}`); + return json.data; + } + + /** + * PUT /update — update one document. `data` is a DIFF (dot-path keys merge; full + * keys replace), NOT the full document. The entity is identified by `uuid` + * (which embeds the type, e.g. JournalEntry.) — no entityType field. + * Returns the updated document. + */ + async updateEntry(uuid: string, data: Record): Promise { + const json = await this.request("PUT", "/update", { uuid }, { data }) as { entity?: JournalEntry[] }; + const updated = json?.entity?.[0]; + if (!updated) throw new Error(`relay /update returned no entity for ${uuid}`); + return updated; + } + + /** POST /create — create a new document. `entityType` is the collection (e.g. "JournalEntry"). */ + async createEntry(entityType: string, data: Record, opts: { keepId?: boolean; folder?: string } = {}): Promise<{ uuid: string; data: JournalEntry }> { + const body: Record = { entityType, data, ...opts }; + const json = await this.request("POST", "/create", {}, body) as { uuid?: string; data?: JournalEntry }; + if (!json?.uuid || !json?.data) throw new Error("relay /create returned no uuid/data"); + return { uuid: json.uuid, data: json.data }; + } + + /** GET /search — list all JournalEntries (omit query). minified -> {uuid,id,name,img,documentType}. */ + async searchJournalEntries(limit = 500): Promise { + const json = await this.request("GET", "/search", { + filter: "documentType:JournalEntry", + excludeCompendiums: "true", + limit: String(limit), + minified: "true", + }) as { results?: SearchResult[] }; + return json?.results ?? []; + } +} \ No newline at end of file diff --git a/src/resolver.ts b/src/resolver.ts new file mode 100644 index 0000000..8b2b68f --- /dev/null +++ b/src/resolver.ts @@ -0,0 +1,64 @@ +import { writeFile, readFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +/** + * Name ⇄ UUID resolution for link rewriting. + * + * `markdownToHtml` / `wikiToUuid` / `uuidToWiki` only need two lookups: + * name→uuid and uuid→name. `JournalDb` already provides both (`uuidOf`/`nameOf`), + * so it satisfies this interface structurally — the offline DB path is unchanged. + * The live push path supplies a `MapNameResolver` backed by the cached + * name-uuid.json (built by `cmd refresh`), so links resolve WITHOUT opening the + * Foundry LevelDB — the whole point of one-at-a-time push. + */ + +export interface NameResolver { + /** Resolve a name to its Foundry UUID ("JournalEntry."), or undefined. */ + uuidOf(name: string): string | undefined; + /** Resolve a Foundry UUID to its name, or undefined. */ + nameOf(uuid: string): string | undefined; +} + +/** Persisted name↔uuid index (both directions) written to name-uuid.json. */ +export interface NameUuidIndex { + nameToUuid: Record; + uuidToName: Record; +} + +/** Build a NameUuidIndex from a list of {name, uuid} (e.g. relay /search minified + * results). First occurrence wins per name/uuid, mirroring JournalDb's load(). */ +export function nameUuidIndexFromEntries(entries: { name: string; uuid: string }[]): NameUuidIndex { + const nameToUuid: Record = {}; + const uuidToName: Record = {}; + for (const e of entries) { + if (!e.name || !e.uuid) continue; + if (!nameToUuid[e.name]) nameToUuid[e.name] = e.uuid; + if (!uuidToName[e.uuid]) uuidToName[e.uuid] = e.name; + } + return { nameToUuid, uuidToName }; +} + +/** A NameResolver backed by a name↔uuid index (loaded from name-uuid.json). + * Queries the records directly — no need to copy them into Maps. */ +export class MapNameResolver implements NameResolver { + constructor(private readonly idx: NameUuidIndex) {} + + uuidOf(name: string): string | undefined { + return this.idx.nameToUuid[name]; + } + nameOf(uuid: string): string | undefined { + return this.idx.uuidToName[uuid]; + } +} + +/** Save a name↔uuid index to disk as JSON (for the push path to load later). */ +export async function saveNameUuidIndex(idx: NameUuidIndex, path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(idx, null, 2) + "\n", "utf8"); +} + +/** Load a cached name↔uuid index from disk and wrap it in a MapNameResolver. */ +export async function loadNameUuidIndex(path: string): Promise { + const raw = await readFile(path, "utf8"); + return new MapNameResolver(JSON.parse(raw) as NameUuidIndex); +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c54b96f --- /dev/null +++ b/src/server.ts @@ -0,0 +1,293 @@ +// Local-only review dashboard for the batch connector. +// +// Binds 127.0.0.1 only (never 0.0.0.0) — no external network exposure. Serves a +// single static dashboard page and a small JSON API over the batch engine. The +// journal LevelDB is opened read-only. Writes go only to --out in dev mode; in +// apply mode they back up then write to the real refined/cc dirs. dry-run never +// writes. The server's mode is fixed at startup; the UI cannot escalate to apply +// unless the server was started with --apply. + +import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http"; +import { readFile, writeFile, mkdir, copyFile, access } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { JournalDb } from "./db.js"; +import { indexAll, seedRow, syncRow, rePullRow, importRow, type FileRow, type IndexResult } from "./batch.js"; +import { RelayClient } from "./relay/client.js"; +import { pushNote } from "./push.js"; +import { nameUuidIndexFromEntries, saveNameUuidIndex } from "./resolver.js"; +import { backupStamp } from "./write.js"; +import type { RelayConfig, FoundryHostConfig } from "./config.js"; +import type { Mode } from "./types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DASHBOARD_PATH = join(__dirname, "dashboard.html"); + +export interface ServerConfig { + journal: string; + refinedDir: string; + ccDir: string; + outDir: string; + mode: Mode; // dev | apply — fixed at startup + port: number; + host: string; // bind address (127.0.0.1 by default; 0.0.0.0 to expose on the network) + // Live push path (optional). When relayCfg is set, /api/push and /api/refresh work. + relayCfg?: RelayConfig; + foundryCfg?: FoundryHostConfig; +} + +export interface ActionResult { + op: string; + mode: Mode; + dryRun: boolean; + written: { path: string; bytes: number }[]; + preview: { filename: string; path: string; content: string }[]; + skipped: { name: string; reason: string }[]; + message: string; +} + +interface State { + db: JournalDb; + cfg: ServerConfig; + index: IndexResult | null; +} + +function send(res: ServerResponse, code: number, body: unknown): void { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const buf = Buffer.from(payload, "utf8"); + res.writeHead(code, { + "content-type": typeof body === "string" ? "text/html; charset=utf-8" : "application/json; charset=utf-8", + "content-length": String(buf.length), + "x-content-type-options": "nosniff", + "cache-control": "no-store", + }); + res.end(buf); +} + +/** Resolve where a write should land given mode + bucket + the row's relative path. */ +function targetPath(state: State, bucket: "refined" | "cc", relPath: string): string { + if (state.cfg.mode === "apply") { + return bucket === "refined" ? join(state.cfg.refinedDir, relPath) : join(state.cfg.ccDir, relPath); + } + // dev: mirror under --out// + return join(state.cfg.outDir, bucket, relPath); +} + +async function writeWithBackup(target: string, content: string, state: State): Promise<{ path: string; bytes: number }> { + if (state.cfg.mode === "apply") { + try { + await access(target); + const stamp = backupStamp(); + await copyFile(target, `${target}.bak-${stamp}`); + } catch { /* new file, no backup */ } + } + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + return { path: target, bytes: Buffer.byteLength(content) }; +} + +/** Land an action's output: preview (dry-run) or written (apply/dev). */ +async function writeOrPreview( + state: State, target: string, filename: string, content: string, + dryRun: boolean, result: ActionResult, +): Promise { + if (dryRun) result.preview.push({ filename, path: target, content }); + else result.written.push(await writeWithBackup(target, content, state)); +} + +/** Read and JSON-parse a request body, or null on empty/invalid JSON. */ +async function readJsonBody(req: IncomingMessage): Promise | null> { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + try { return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as Record; } + catch { return null; } +} + +async function runAction(state: State, op: string, names: string[] | null, dryRun: boolean): Promise { + const result: ActionResult = { op, mode: state.cfg.mode, dryRun, written: [], preview: [], skipped: [], message: "" }; + if (!state.index) throw new Error("index not loaded"); + + const stamp = new Date().toISOString(); + const rows: FileRow[] = []; + if (op === "seedAll" || op === "syncAll" || op === "repullAll") { + rows.push(...state.index.matched); + } else if (op === "importAll") { + rows.push(...state.index.ccOnly); + } else { + const pool = op === "import" ? state.index.ccOnly : state.index.matched; + for (const n of names ?? []) { + const r = pool.find((x) => x.name === n || x.basename === n); + if (r) rows.push(r); else result.skipped.push({ name: n, reason: "not found in bucket" }); + } + } + + for (const row of rows) { + if (op === "seed" || op === "seedAll") { + if (!row.refinedPath || !row.entry) { result.skipped.push({ name: row.name, reason: "no refined note or no journal entry (unlinked)" }); continue; } + const out = await seedRow(row, state.cfg.refinedDir, stamp); + if (!out) { result.skipped.push({ name: row.name, reason: "seed produced no output" }); continue; } + await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.filename, out.content, dryRun, result); + } else if (op === "sync" || op === "syncAll") { + if (!row.refinedPath || !row.ccPath) { result.skipped.push({ name: row.name, reason: "no refined/cc counterpart" }); continue; } + const out = await syncRow(row, state.cfg.refinedDir, state.db, stamp); + if (!out || (!out.refined && !out.cc)) { result.skipped.push({ name: row.name, reason: "sync produced no output (no Foundry match)" }); continue; } + // sync baselines BOTH sides: write the refreshed refined note AND the regenerated cc. + if (out.refined) await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.refined.filename, out.refined.content, dryRun, result); + if (out.cc) await writeOrPreview(state, targetPath(state, "cc", row.ccPath), out.cc.filename, out.cc.content, dryRun, result); + } else if (op === "repull" || op === "repullAll") { + if (!row.refinedPath || !row.entry) { result.skipped.push({ name: row.name, reason: "no refined note or no journal entry (unlinked)" }); continue; } + const out = await rePullRow(row, state.cfg.refinedDir, state.db, stamp); + if (!out) { result.skipped.push({ name: row.name, reason: "re-pull produced no output" }); continue; } + await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.filename, out.content, dryRun, result); + } else if (op === "import" || op === "importAll") { + if (!row.entry) { result.skipped.push({ name: row.name, reason: "no journal entry (unlinked)" }); continue; } + const out = importRow(row, state.db, stamp); + if (!out) { result.skipped.push({ name: row.name, reason: "import produced no output" }); continue; } + // Imports are un-curated staging: land under refined/imported// for review. + await writeOrPreview(state, targetPath(state, "refined", join("imported", out.subfolder, out.filename)), out.filename, out.content, dryRun, result); + } else { + throw new Error(`unknown op: ${op}`); + } + } + + const verb = dryRun ? "would write" : "wrote"; + result.message = `${op}: ${dryRun ? result.preview.length : result.written.length} ${verb}${result.skipped.length ? `, ${result.skipped.length} skipped` : ""} [${state.cfg.mode}${dryRun ? " dry-run" : ""}]`; + return result; +} + +async function fileDetail(state: State, name: string): Promise { + if (!state.index) throw new Error("index not loaded"); + const row = state.index.matched.find((r) => r.name === name || r.basename === name) + ?? state.index.ccOnly.find((r) => r.name === name || r.basename === name); + if (!row) throw new Error(`no row for ${name}`); + const refined = row.refinedPath ? await readFile(join(state.cfg.refinedDir, row.refinedPath), "utf8").catch(() => null) : null; + const cc = row.ccPath ? await readFile(join(state.cfg.ccDir, row.ccPath), "utf8").catch(() => null) : null; + const stamp = new Date().toISOString(); + const seedPreview = row.refinedPath && row.entry ? (await seedRow(row, state.cfg.refinedDir, stamp))?.content ?? null : null; + const synced = row.refinedPath ? await syncRow(row, state.cfg.refinedDir, state.db, stamp) : null; + const syncPreview = synced?.cc?.content ?? null; + const syncRefinedPreview = synced?.refined?.content ?? null; + const repullPreview = row.refinedPath && row.entry ? (await rePullRow(row, state.cfg.refinedDir, state.db, stamp))?.content ?? null : null; + return { row, refined, cc, entry: row.entry, seedPreview, syncPreview, syncRefinedPreview, repullPreview }; +} + +async function handlePost(state: State, req: IncomingMessage, res: ServerResponse): Promise { + const body = await readJsonBody(req); + if (body === null) return send(res, 400, { error: "bad json" }); + const op = body.op; + if (typeof op !== "string" || !op) return send(res, 400, { error: "missing op" }); + const names = Array.isArray(body.names) ? body.names as string[] : null; + const dryRun = body.dryRun === true; + try { + const result = await runAction(state, op, names, dryRun); + send(res, 200, result); + } catch (e) { + send(res, 500, { error: (e as Error).message }); + } +} + +function relayClient(state: State): RelayClient { + if (!state.cfg.relayCfg) throw new Error("relay not configured (start the server with RELAY_API_KEY / --relay-api-key to enable push)"); + return new RelayClient(state.cfg.relayCfg); +} + +/** POST /api/push { name, dryRun } — push one refined note into live Foundry via the relay. */ +async function handlePush(state: State, req: IncomingMessage, res: ServerResponse): Promise { + const body = await readJsonBody(req); + if (body === null) return send(res, 400, { error: "bad json" }); + const name = typeof body.name === "string" ? body.name : ""; + if (!name) return send(res, 400, { error: "missing name" }); + if (!state.index) throw new Error("index not loaded"); + const row = state.index.matched.find((r) => r.name === name || r.basename === name); + if (!row?.refinedPath) return send(res, 400, { error: `no refined note for "${name}"` }); + try { + const relay = relayClient(state); + const logs: string[] = []; + const outcome = await pushNote({ + notePath: join(state.cfg.refinedDir, row.refinedPath), + noteName: row.name, + outDir: state.cfg.outDir, + relay, + foundryDataDir: state.cfg.foundryCfg?.dataDir ?? "", + world: state.cfg.foundryCfg?.world ?? "", + dryRun: body.dryRun !== false, // default to dry-run from the dashboard (safety) + log: (m) => logs.push(m), + }); + send(res, 200, { ...outcome, logs }); + } catch (e) { + send(res, 500, { error: (e as Error).message }); + } +} + +/** POST /api/refresh { fullIndex? } — rebuild the name↔uuid map via relay /search + * (zero Foundry downtime). The heavy docker-stop full index is CLI-only by design. */ +async function handleRefresh(state: State, req: IncomingMessage, res: ServerResponse): Promise { + try { + const relay = relayClient(state); + const results = await relay.searchJournalEntries(); + const idx = nameUuidIndexFromEntries(results.map((r) => ({ name: r.name, uuid: r.uuid }))); + await saveNameUuidIndex(idx, join(state.cfg.outDir, "name-uuid.json")); + send(res, 200, { pairs: Object.keys(idx.nameToUuid).length, path: join(state.cfg.outDir, "name-uuid.json") }); + } catch (e) { + send(res, 500, { error: (e as Error).message }); + } +} + +export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> { + const db = await JournalDb.open(cfg.journal); + const state: State = { db, cfg, index: null }; + // Build the index once at startup (the corpora are static while the server runs). + state.index = await indexAll(db, cfg.ccDir, cfg.refinedDir); + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + // Silence the favicon 404 noise every browser generates. + if (url.pathname === "/favicon.ico") { + res.writeHead(204, { "content-length": "0", "cache-control": "max-age=86400" }); + return res.end(); + } + // HEAD == GET headers (some clients/proxies probe with HEAD). + const isHead = req.method === "HEAD"; + const method = isHead ? "GET" : (req.method ?? "GET"); + if (method === "GET" && url.pathname === "/") { + const html = await readFile(DASHBOARD_PATH, "utf8"); + if (isHead) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": String(Buffer.byteLength(html, "utf8")) }); + return res.end(); + } + return send(res, 200, html); + } + if (req.method === "GET" && url.pathname === "/api/index") { + // Rebuild on every request: in apply mode the source files change after an + // action, so a cached index would show stale recommendations. Indexing is + // hash-only (no per-row conversion), so this stays cheap. + state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir); + return send(res, 200, state.index); + } + if (req.method === "GET" && url.pathname === "/api/status") { + return send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir }); + } + if (req.method === "GET" && url.pathname === "/api/file") { + const name = url.searchParams.get("name"); + if (!name) return send(res, 400, { error: "missing name" }); + return send(res, 200, await fileDetail(state, name)); + } + if (req.method === "POST" && url.pathname === "/api/action") { + return handlePost(state, req, res); + } + if (req.method === "POST" && url.pathname === "/api/push") { + return handlePush(state, req, res); + } + if (req.method === "POST" && url.pathname === "/api/refresh") { + return handleRefresh(state, req, res); + } + send(res, 404, { error: "not found" }); + } catch (e) { + send(res, 500, { error: (e as Error).message }); + } + }); + + await new Promise((resolve) => server.listen(cfg.port, cfg.host, resolve)); + return { server, state }; +} \ No newline at end of file diff --git a/src/toFoundry.ts b/src/toFoundry.ts new file mode 100644 index 0000000..e3ee7d2 --- /dev/null +++ b/src/toFoundry.ts @@ -0,0 +1,200 @@ +import type { JournalDb } from "./db.js"; +import type { NameResolver } from "./resolver.js"; +import type { JournalEntry, CcFlags } from "./types.js"; +import { ccTypeFromObsidianType, folderPathFromCcType, FIELD_TO_LABEL } from "./fields.js"; +import { splitFrontmatter, emitFrontmatter, readFoundryBlock, type Frontmatter, CC_FM_ORDER } from "./frontmatter.js"; +import { wikiToName } from "./links.js"; +import { canonicalize } from "./normalize.js"; +import { markdownToHtml, sidebarBoxHtml } from "./mdToHtml.js"; + +interface Section { + heading: string; + body: string; +} + +function parseBody(body: string): { tagline: string; sections: Section[] } { + const parts = canonicalize(body).split(/^## /m); + const preamble = parts[0] ?? ""; + const tagMatch = preamble.match(/\*([^*]+)\*/); + const tagline = tagMatch ? `*${tagMatch[1]}*` : ""; + const sections: Section[] = []; + for (const part of parts.slice(1)) { + const nl = part.indexOf("\n"); + const heading = (nl < 0 ? part : part.slice(0, nl)).trim(); + const sbody = (nl < 0 ? "" : part.slice(nl + 1)).trim(); + sections.push({ heading, body: sbody }); + } + return { tagline, sections }; +} + +function sidebarRow(field: "race" | "faction" | "region", fm: Frontmatter): string { + const val = fm[field]; + if (typeof val !== "string" || !val) return ""; + return `**${FIELD_TO_LABEL[field]}**${val}`; +} + +export interface CcNote { + filename: string; // "cc.md" + content: string; +} + +/** + * Obsidian curated .md -> Campaign Codex native export (cc.md). + * Pulls cc_id/associates/image from the matched Foundry entry (by foundry: block + * or name). Wiki links collapse to [[Name]] (display dropped), matching the + * cc export format. + */ +export function obsidianToCc(md: string, noteName: string, db: JournalDb, exportedAt: string): CcNote | null { + const { fm, body } = splitFrontmatter(md); + const { tagline, sections } = parseBody(body); + + // Resolve the Foundry entry: prefer the foundry: identity block, then name match. + let entry: JournalEntry | undefined; + const foundry = readFoundryBlock(fm); + if (foundry?.cc_uuid) entry = db.byUuid(foundry.cc_uuid); + if (!entry) entry = db.byName(noteName); + + const ccType = entry?.flags?.["campaign-codex"]?.type ?? ccTypeFromObsidianType(typeof fm.type === "string" ? fm.type : undefined); + const ccId = entry?._id ?? (foundry?.cc_uuid ? foundry.cc_uuid.replace(/^JournalEntry\./, "") : ""); + const ccUuid = entry ? `JournalEntry.${entry._id}` : foundry?.cc_uuid ?? (ccId ? `JournalEntry.${ccId}` : ""); + if (!ccId || !ccUuid) return null; // cannot identify the Foundry entry + + const associates = entry?.flags?.["campaign-codex"]?.data?.associates ?? []; + + const ccfm: Frontmatter = { + cc_id: ccId, + cc_uuid: ccUuid, + cc_type: ccType, + cc_folder_path: folderPathFromCcType(ccType), + cc_exported_at: exportedAt, + }; + const frontmatter = emitFrontmatter(ccfm, CC_FM_ORDER, true); + + const parts: string[] = [`# ${noteName}`, "## Information"]; + if (tagline) parts.push(tagline); + + const secrets = sections.find((s) => s.heading.toLowerCase() === "secrets"); + for (const s of sections) { + if (s.heading.toLowerCase() === "secrets") continue; + parts.push(`## ${s.heading}${s.body ? `\n\n${wikiToName(s.body)}` : ""}`); + } + + // Bio / Social sidebar boxes (order: Race, Faction, Region), emitted adjacent + // to match Campaign Codex's native export layout. + const bio = sidebarRow("race", fm); + const social = [sidebarRow("faction", fm), sidebarRow("region", fm)].filter(Boolean).join(""); + const bioSocial = [bio ? `#### Bio\n${bio}` : "", social ? `#### Social\n${social}` : ""].filter(Boolean).join("\n"); + if (bioSocial) parts.push(bioSocial); + + // Linked sheets from Foundry associates. + if (associates.length) { + const links = associates + .map((uuid) => db.nameOf(uuid)) + .filter((n): n is string => !!n) + .map((n) => `- [[${n}]]`); + if (links.length) parts.push(`## Linked Sheets\n\n### Associates\n\n${links.join("\n")}`); + } + + // Notes (from the Obsidian ## Secrets section). + if (secrets?.body) parts.push(`## Notes\n\n${wikiToName(secrets.body)}`); + + const content = `${frontmatter}\n\n${parts.join("\n\n")}\n`; + return { filename: `${noteName}cc.md`, content }; +} + +/** Reconstruct a Foundry-importable JournalEntry JSON from an Obsidian note. + * Best-effort: description/notes HTML is rebuilt cleanly (not byte-identical to + * Foundry's two-column layout), but valid and importable by Campaign Codex. + * + * Offline DB path: resolves the entry from the journal (by name or foundry: + * block) and uses the DB as the link resolver. */ +export function obsidianToFoundryJson(md: string, noteName: string, db: JournalDb): JournalEntry | null { + const { fm } = splitFrontmatter(md); + let entry = db.byName(noteName); + const foundry = readFoundryBlock(fm); + if (foundry?.cc_uuid) entry = db.byUuid(foundry.cc_uuid) ?? entry; + if (!entry) return null; + return buildFoundryJson(md, noteName, entry, db); +} + +/** + * Live push path: builds the Foundry JSON from a **live-fetched** entry (via the + * relay /get) and a map-backed resolver (the cached name-uuid.json) — no LevelDB + * opened. `imageOverride` (the served path of a freshly uploaded portrait) wins + * over the entry's existing `flags.campaign-codex.image`; pass null/undefined to + * keep whatever image the entry already has. + */ +export function obsidianToFoundryJsonLive( + md: string, + noteName: string, + liveEntry: JournalEntry, + r: NameResolver, + imageOverride?: string | null, +): JournalEntry { + return buildFoundryJson(md, noteName, liveEntry, r, imageOverride); +} + +/** Shared core: turns an Obsidian note + a Foundry entry + a name resolver into + * an importable JournalEntry JSON (spreads the entry so ownership/folder/pages + * survive, overrides name + flags["campaign-codex"]). */ +function buildFoundryJson( + md: string, + noteName: string, + entry: JournalEntry, + r: NameResolver, + imageOverride?: string | null, +): JournalEntry { + const { fm, body } = splitFrontmatter(md); + const { tagline, sections } = parseBody(body); + + const ccType = entry.flags?.["campaign-codex"]?.type ?? ccTypeFromObsidianType(typeof fm.type === "string" ? fm.type : undefined); + const secrets = sections.find((s) => s.heading.toLowerCase() === "secrets"); + + // Body markdown: tagline + non-secrets sections. + const bodyParts: string[] = []; + if (tagline) bodyParts.push(tagline); + for (const s of sections) { + if (s.heading.toLowerCase() === "secrets") continue; + bodyParts.push(`## ${s.heading}${s.body ? `\n\n${s.body}` : ""}`); + } + const leftHtml = markdownToHtml(bodyParts.join("\n\n"), r); + + // Sidebar boxes from frontmatter (resolve names -> @UUID via markdownToHtml on the [[ ]] value). + const boxes: string[] = []; + const race = fmSide(fm, "race"); + const faction = fmSide(fm, "faction"); + const region = fmSide(fm, "region"); + if (race) boxes.push(sidebarBoxHtml("Bio", [["Race", markdownToHtml(race, r)]])); + if (faction || region) { + const rows: [string, string][] = []; + if (faction) rows.push(["Faction", markdownToHtml(faction, r)]); + if (region) rows.push(["Region", markdownToHtml(region, r)]); + boxes.push(sidebarBoxHtml("Social", rows)); + } + + const description = `
    ${leftHtml}
    ${boxes.join("")}
    `; + const notesHtml = secrets?.body ? markdownToHtml(secrets.body, r) : ""; + const tags = (Array.isArray(fm.tags) ? fm.tags : []).filter((t) => !t.startsWith("status/")); + + const cc: CcFlags = { + type: ccType, + image: imageOverride !== undefined ? imageOverride : (entry.flags?.["campaign-codex"]?.image ?? null), + data: { + ...entry.flags?.["campaign-codex"]?.data, + description, + notes: notesHtml, + tags, + }, + }; + + return { + ...entry, + name: noteName, + flags: { "campaign-codex": cc }, + }; +} + +function fmSide(fm: Frontmatter, field: "race" | "faction" | "region"): string { + const v = fm[field]; + return typeof v === "string" ? v : ""; +} \ No newline at end of file diff --git a/src/toObsidian.ts b/src/toObsidian.ts new file mode 100644 index 0000000..dd4460b --- /dev/null +++ b/src/toObsidian.ts @@ -0,0 +1,67 @@ +import type { JournalEntry, FoundryBlock } from "./types.js"; +import type { JournalDb } from "./db.js"; +import { htmlToMarkdown, notesToMarkdown } from "./htmlMd.js"; +import { obsidianTypeFromCcType, folderPathFromCcType } from "./fields.js"; +import { emitFrontmatter, type Frontmatter, OBSIDIAN_FM_ORDER } from "./frontmatter.js"; +import { canonicalize, contentHash } from "./normalize.js"; + +const STATUS_TAG = "status/draft"; + +function hasStatusTag(tags: string[] | undefined): boolean { + return !!tags?.some((t) => t.startsWith("status/")); +} + +function basename(url: string | null | undefined): string | undefined { + if (!url) return undefined; + return url.split("/").pop()?.split("?")[0]; +} + +export interface ObsidianNote { + filename: string; // ".md" + content: string; // full markdown with frontmatter +} + +/** Foundry JournalEntry -> curated Obsidian .md (with foundry: identity block). */ +export function entryToObsidian(entry: JournalEntry, db: JournalDb, syncedAt: string): ObsidianNote { + const cc = entry.flags?.["campaign-codex"]; + const ccType = cc?.type ?? "shop"; + const data = cc?.data ?? {}; + const image = cc?.image ?? null; + + const { bodyMd, sidebar, sectionBoxes } = htmlToMarkdown(data.description ?? "", db); + const notesMd = notesToMarkdown(data.notes ?? "", db); + + const type = obsidianTypeFromCcType(ccType); + const tags = Array.isArray(data.tags) ? data.tags.filter(Boolean) : []; + if (!tags.includes(type)) tags.unshift(type); + if (!hasStatusTag(tags)) tags.push(STATUS_TAG); + + const fm: Frontmatter = { type, tags }; + if (sidebar.faction) fm.faction = sidebar.faction; + if (sidebar.region) fm.region = sidebar.region; + if (sidebar.race) fm.race = sidebar.race; + const portrait = basename(image); + if (portrait) fm.portrait = portrait; + + // Assemble body: left-column prose + extra section boxes + Secrets (notes). + const parts: string[] = []; + if (bodyMd) parts.push(bodyMd); + for (const box of sectionBoxes) { + parts.push(`## ${box.title}${box.body ? `\n\n${box.body}` : ""}`); + } + if (notesMd) parts.push(`## Secrets\n\n${notesMd}`); + const body = parts.join("\n\n"); + + const foundry: FoundryBlock = { + cc_uuid: `JournalEntry.${entry._id}`, + cc_type: ccType, + folder_path: folderPathFromCcType(ccType), + contentHash: contentHash(body), + syncedAt, + }; + fm.foundry = foundry as unknown as Record; + + const frontmatter = emitFrontmatter(fm, OBSIDIAN_FM_ORDER); + const content = `${frontmatter}\n\n${canonicalize(body)}\n`; + return { filename: `${entry.name}.md`, content }; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1869266 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,75 @@ +// Foundry JournalEntry shapes (only the fields we use). Verified against the +// journal LevelDB snapshot via classic-level read probes. + +export interface CcData { + description?: string; // HTML + notes?: string; // HTML + tags?: string[]; + associates?: string[]; // e.g. ["JournalEntry."] + linkedLocations?: string[]; + linkedShops?: string[]; + linkedActor?: string | null; + linkedScene?: string | null; + hiddenAssociates?: string[]; + inventory?: unknown[]; + inventoryCash?: number; + markup?: number; + isLoot?: boolean; + tagMode?: boolean; +} + +export interface CcFlags { + type?: string; + data?: CcData; + image?: string | null; +} + +export interface JournalEntry { + name: string; + _id: string; + folder?: string | null; + pages?: string[]; + // Foundry entries carry flags from many modules; only campaign-codex is modeled. + // The index signature lets live entries (from relay /get) keep their other flags. + flags?: { + "campaign-codex"?: CcFlags; + [key: string]: unknown; + }; + ownership?: Record; + _stats?: { + modifiedTime?: number; + createdTime?: number; + }; +} + +export interface FoundryBlock { + cc_uuid: string; // JournalEntry. + cc_type: string; + folder_path: string; + contentHash: string; + syncedAt: string; +} + +export type Mode = "dev" | "apply"; + +export interface CliOptions { + mode: Mode; + dryRun: boolean; + journal: string; // path to journal LevelDB (a copy in dev) + vault?: string; // path to an Obsidian .md file (to-foundry/push) or refined dir (ui) + cc?: string; // path to cc export dir (ui) + out: string; // sandbox output dir + id?: string; // Foundry _id (to-obsidian single) + name?: string; // override the note name derived from --vault's filename + emitJson?: boolean; + port?: number; // dashboard port (ui) + host?: string; // dashboard bind address (ui; default 127.0.0.1) + // Live push path (relay + docker + image fs). Optional; read from env if absent. + relayUrl?: string; + relayApiKey?: string; + relayClientId?: string; + foundryContainer?: string; + foundryDataDir?: string; + foundryWorld?: string; + fullIndex?: boolean; // refresh: also docker-stop read the live LevelDB for the dashboard index +} \ No newline at end of file diff --git a/src/write.ts b/src/write.ts new file mode 100644 index 0000000..7837af3 --- /dev/null +++ b/src/write.ts @@ -0,0 +1,8 @@ +// Shared write-path helpers. Kept separate from the feature modules so the +// backup-stamp convention (used by the CLI, the dashboard server, and the live +// push path) is defined in one place. + +/** Filesystem-safe ISO timestamp for backup file names: 2026-06-20T12-34-56-789Z */ +export function backupStamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} \ No newline at end of file diff --git a/tests/assets.test.ts b/tests/assets.test.ts new file mode 100644 index 0000000..ae4d43f --- /dev/null +++ b/tests/assets.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { rm, mkdir, writeFile, access } from "node:fs/promises"; +import { join } from "node:path"; +import { uploadPortrait, findPortraitFile, portraitBasename } from "../src/foundry/assets.js"; + +const TMP = "/tmp/test-assets-out"; +const WORLD = "mardonar"; + +async function mk(p: string): Promise { await mkdir(p, { recursive: true }); } + +beforeEach(async () => { await rm(TMP, { recursive: true, force: true }); await mk(TMP); }); +afterEach(async () => { await rm(TMP, { recursive: true, force: true }); }); + +describe("portraitBasename", () => { + it("strips [[ ]] wrappers", () => { + expect(portraitBasename("[[Fenris_portrait.png]]")).toBe("Fenris_portrait.png"); + expect(portraitBasename("Fenris_portrait.png")).toBe("Fenris_portrait.png"); + expect(portraitBasename("")).toBe(""); + }); +}); + +describe("findPortraitFile", () => { + it("finds a co-located sibling by exact name", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes"); + expect(await findPortraitFile(notePath, "[[Fenris_portrait.png]]")).toBe(join(noteDir, "Fenris_portrait.png")); + }); + + it("tries common extensions when the field has none", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + await writeFile(join(noteDir, "Fenris.webp"), "webp"); + expect(await findPortraitFile(notePath, "Fenris")).toBe(join(noteDir, "Fenris.webp")); + }); + + it("returns null when no sibling matches", async () => { + const notePath = join(TMP, "Fenris.md"); + await writeFile(notePath, "x"); + expect(await findPortraitFile(notePath, "[[Missing.png]]")).toBeNull(); + }); +}); + +describe("uploadPortrait", () => { + it("keeps the existing image when the basename matches (no write)", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + const res = await uploadPortrait({ + notePath, portraitField: "[[Fenris_portrait.png]]", + foundryDataDir: join(TMP, "data"), world: WORLD, + existingImage: "uploads/mardonar/Fenris_portrait.png", + }); + expect(res.wrote).toBe(false); + expect(res.servedPath).toBe("uploads/mardonar/Fenris_portrait.png"); + // Nothing written to the assets dir. + await expect(access(join(TMP, "data"))).rejects.toThrow(); + }); + + it("uploads a new portrait into the assets dir using the existing-image prefix", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + await writeFile(join(noteDir, "NewPortrait.png"), "png-bytes"); + const dataDir = join(TMP, "data"); + const res = await uploadPortrait({ + notePath, portraitField: "[[NewPortrait.png]]", + foundryDataDir: dataDir, world: WORLD, + existingImage: "uploads/mardonar/old.webp", + }); + expect(res.wrote).toBe(true); + expect(res.servedPath).toBe("uploads/mardonar/NewPortrait.png"); + // File landed at /worlds//. + const disk = join(dataDir, "worlds", WORLD, "uploads", WORLD, "NewPortrait.png"); + await access(disk); // throws if missing + }); + + it("defaults to uploads// when there is no existing image", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes"); + const dataDir = join(TMP, "data"); + const res = await uploadPortrait({ + notePath, portraitField: "[[Fenris_portrait.png]]", + foundryDataDir: dataDir, world: WORLD, existingImage: null, + }); + expect(res.wrote).toBe(true); + expect(res.servedPath).toBe(`uploads/${WORLD}/Fenris_portrait.png`); + }); + + it("returns the existing image when the portrait file is missing", async () => { + const noteDir = join(TMP, "notes"); + await mk(noteDir); + const notePath = join(noteDir, "Fenris.md"); + await writeFile(notePath, "x"); + const res = await uploadPortrait({ + notePath, portraitField: "[[Ghost.png]]", + foundryDataDir: join(TMP, "data"), world: WORLD, + existingImage: "uploads/mardonar/old.webp", + }); + expect(res.wrote).toBe(false); + expect(res.servedPath).toBe("uploads/mardonar/old.webp"); + expect(res.note).toContain("not found"); + }); + + it("no portrait field -> keeps existing, no write", async () => { + const notePath = join(TMP, "Fenris.md"); + await writeFile(notePath, "x"); + const res = await uploadPortrait({ + notePath, portraitField: null, + foundryDataDir: join(TMP, "data"), world: WORLD, existingImage: "uploads/mardonar/old.webp", + }); + expect(res.wrote).toBe(false); + expect(res.servedPath).toBe("uploads/mardonar/old.webp"); + }); +}); \ No newline at end of file diff --git a/tests/batch.test.ts b/tests/batch.test.ts new file mode 100644 index 0000000..3f2d488 --- /dev/null +++ b/tests/batch.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from "vitest"; +import { readFile, rm, mkdir, writeFile, copyFile } from "node:fs/promises"; +import { join } from "node:path"; +import { JournalDb } from "../src/db.js"; +import { indexAll, seedRow, syncRow, importRow, buildBlock, seedBlockContent, recommend, type FileRow, type IndexResult } from "../src/batch.js"; +import { splitFrontmatter } from "../src/frontmatter.js"; +import { contentHash } from "../src/normalize.js"; +import { ensureJournalFixture, ensureRefinedFixture, ensureCcFixture } from "./helpers.js"; + +const STAMP = "2026-06-20T00:00:00.000Z"; + +describe("indexAll", () => { + it("matches all refined notes to cc files with the expected counts", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + expect(idx.counts.matched).toBe(107); + expect(idx.counts.ccOnly).toBe(52); + expect(idx.counts.refinedOnly).toBe(0); + expect(idx.counts.unlinked).toBe(0); + expect(idx.matched.length).toBe(107); + expect(idx.ccOnly.length).toBe(52); + } finally { await db.close(); } + }); + + it("resolves cc_id to a journal entry and curated type, and recommends seed when unseeded", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!; + expect(fenris).toBeTruthy(); + expect(fenris.status).toBe("matched"); + expect(fenris.curatedType).toBe("npc"); + expect(fenris.ccType).toBe("npc"); + expect(fenris.ccId).toBe("0dNlOAJfOuglX7Gp"); + expect(fenris.entry).not.toBeNull(); + // Cold start: no foundry: block, no cc_sync_hash -> recommendation is "seed". + expect(fenris.recommendation).toBe("seed"); + expect(fenris.storedRefinedHash).toBeNull(); + expect(fenris.storedCcHash).toBeNull(); + } finally { await db.close(); } + }); + + it("every cc-only row recommends import, every matched row recommends seed (cold start)", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + expect(idx.ccOnly.every((r) => r.recommendation === "import")).toBe(true); + expect(idx.matched.every((r) => r.recommendation === "seed")).toBe(true); + } finally { await db.close(); } + }); +}); + +describe("recommend (pure direction logic)", () => { + const E = { name: "x", _id: "x" } as never; + it("ranks import > seed > sync-cc > repull > conflict > in-sync", () => { + expect(recommend({ status: "cc-only", seeded: false, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("import"); + expect(recommend({ status: "matched", seeded: false, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("seed"); + expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("sync-cc"); + expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: true, ccChanged: false, entry: E })).toBe("sync-cc"); + expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: false, ccChanged: true, entry: E })).toBe("repull"); + expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: true, ccChanged: true, entry: E })).toBe("conflict"); + expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: false, ccChanged: false, entry: E })).toBe("in-sync"); + }); +}); + +describe("seedBlock (minimal link)", () => { + it("injects only the foundry: block, preserving all curation byte-for-byte", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!; + const refinedDir = ensureRefinedFixture(); + const orig = await readFile(join(refinedDir, fenris.refinedPath!), "utf8"); + const seeded = await seedRow(fenris, refinedDir, STAMP); + expect(seeded).not.toBeNull(); + + const origLines = orig.split("\n"); + const seededLines = seeded!.content.split("\n"); + expect(origLines.filter((l) => !seededLines.includes(l))).toEqual([]); + + const added = seededLines.filter((l) => !origLines.includes(l)).filter((l) => l.trim() !== ""); + expect(added).toEqual([ + "foundry:", + ` cc_uuid: JournalEntry.${fenris.ccId}`, + " cc_type: npc", + " folder_path: Campaign Codex - NPCs", + expect.stringMatching(/^ contentHash: [0-9a-f]{64}$/), + ` syncedAt: ${STAMP}`, + ]); + + const { fm } = splitFrontmatter(seeded!.content); + expect(fm.type).toBe("npc"); + expect(fm.aliases).toBe("[]"); + expect(fm.faction).toBe("[[House Quche]]"); + expect(fm.foundry).toBeTruthy(); + } finally { await db.close(); } + }); + + it("is idempotent: seeding twice yields the same content", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!; + const refinedDir = ensureRefinedFixture(); + const once = await seedRow(fenris, refinedDir, STAMP); + const body1 = splitFrontmatter(once!.content).body; + const twice = seedBlockContent(once!.content, buildBlock(fenris.entry!, body1, STAMP)); + expect(twice).toBe(once!.content); + } finally { await db.close(); } + }); +}); + +describe("syncRow (baselines both sides)", () => { + it("regenerates cc.md with cc_sync_hash AND refreshes the refined foundry block", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!; + const refinedDir = ensureRefinedFixture(); + const synced = await syncRow(fenris, refinedDir, db, STAMP); + expect(synced.cc).not.toBeNull(); + expect(synced.refined).not.toBeNull(); + + const { fm, body } = splitFrontmatter(synced.cc!.content); + expect(fm.cc_id).toBe("0dNlOAJfOuglX7Gp"); + expect(fm.cc_type).toBe("npc"); + expect(fm.cc_folder_path).toBe("Campaign Codex - NPCs"); + expect(fm.cc_sync_hash).toMatch(/^[0-9a-f]{64}$/); + expect(body.trim()).toMatch(/^# Fenris of House Quche/); + expect(body).toMatch(/## Background/); + expect(body).toContain("[[House Quche]]"); + + // cc_sync_hash == hash of the cc body (excluding frontmatter). + expect(fm.cc_sync_hash).toBe(contentHash(splitFrontmatter(synced.cc!.content).body)); + + // Refined side now carries a foundry: block whose contentHash matches its body. + const rfm = splitFrontmatter(synced.refined!.content).fm; + expect(rfm.foundry).toBeTruthy(); + expect((rfm.foundry as Record).contentHash).toBe(contentHash(splitFrontmatter(synced.refined!.content).body)); + } finally { await db.close(); } + }); +}); + +describe("importCcOnly", () => { + it("builds a new refined note with a foundry: block for a cc-only entry", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture()); + const ccOnly = idx.ccOnly.find((r) => r.entry); + expect(ccOnly).toBeTruthy(); + const note = importRow(ccOnly!, db, STAMP); + expect(note).not.toBeNull(); + expect(note!.filename).toBe(ccOnly!.basename); + const { fm } = splitFrontmatter(note!.content); + expect(fm.foundry).toBeTruthy(); + expect((fm.foundry as Record).cc_uuid).toBe(`JournalEntry.${ccOnly!.ccId}`); + } finally { await db.close(); } + }); +}); + +describe("merge direction end-to-end", () => { + // Stages a single Fenris note + its cc in a temp dir, then drives the stateless + // merge through seed -> sync -> in-sync, and verifies refined-newer, cc-newer, + // and both-changed (conflict) are each detected from the embedded hashes alone. + it("detects seed -> in-sync -> sync-cc -> repull -> conflict from embedded hashes", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + const root = "/tmp/test-merge-direction"; + const tmpRefined = join(root, "refined"); + const tmpCc = join(root, "cc"); + try { + await rm(root, { recursive: true, force: true }); + const refinedDir = ensureRefinedFixture(); + const ccDir = ensureCcFixture(); + const rSrc = join(refinedDir, "01 - Characters", "Fenris of House Quche.md"); + const cSrc = join(ccDir, "Campaign Codex - NPCs", "Fenris of House Quche.md"); + await mkdir(join(tmpRefined, "01 - Characters"), { recursive: true }); + await mkdir(join(tmpCc, "Campaign Codex - NPCs"), { recursive: true }); + await copyFile(rSrc, join(tmpRefined, "01 - Characters", "Fenris of House Quche.md")); + await copyFile(cSrc, join(tmpCc, "Campaign Codex - NPCs", "Fenris of House Quche.md")); + + const find = (idx: IndexResult): FileRow => + idx.matched.find((r) => r.name === "Fenris of House Quche")!; + + // Cold start -> seed. + const f0 = find(await indexAll(db, tmpCc, tmpRefined)); + expect(f0.recommendation).toBe("seed"); + + // Seed + sync baselines both sides. + const seeded = await seedRow(f0, tmpRefined, STAMP); + await writeFile(join(tmpRefined, f0.refinedPath!), seeded!.content, "utf8"); + const f1 = find(await indexAll(db, tmpCc, tmpRefined)); + const synced = await syncRow(f1, tmpRefined, db, STAMP); + await writeFile(join(tmpRefined, f1.refinedPath!), synced.refined!.content, "utf8"); + await writeFile(join(tmpCc, f1.ccPath!), synced.cc!.content, "utf8"); + + const f2 = find(await indexAll(db, tmpCc, tmpRefined)); + expect(f2.recommendation).toBe("in-sync"); + expect(f2.storedCcHash).toMatch(/^[0-9a-f]{64}$/); + + // Edit the refined note -> refined newer -> sync-cc. + const rp = join(tmpRefined, f2.refinedPath!); + await writeFile(rp, (await readFile(rp, "utf8")).replace("## Personality", "## Personality\n\nEDITED-for-test"), "utf8"); + const f3 = find(await indexAll(db, tmpCc, tmpRefined)); + expect(f3.recommendation).toBe("sync-cc"); + expect(f3.refinedChanged).toBe(true); + expect(f3.ccChanged).toBe(false); + + // Re-sync to re-baseline, then edit the cc file -> cc newer -> repull. + const synced2 = await syncRow(f3, tmpRefined, db, STAMP); + await writeFile(rp, synced2.refined!.content, "utf8"); + const cp = join(tmpCc, f3.ccPath!); + await writeFile(cp, synced2.cc!.content, "utf8"); + await writeFile(cp, (await readFile(cp, "utf8")).replace("## Background", "## Background\n\nCC-EDITED-for-test"), "utf8"); + const f4 = find(await indexAll(db, tmpCc, tmpRefined)); + expect(f4.recommendation).toBe("repull"); + expect(f4.ccChanged).toBe(true); + expect(f4.refinedChanged).toBe(false); + + // Also edit the refined note (cc still edited) -> both changed -> conflict. + await writeFile(rp, (await readFile(rp, "utf8")).replace("## Goals", "## Goals\n\nCONFLICT-edit"), "utf8"); + const f5 = find(await indexAll(db, tmpCc, tmpRefined)); + expect(f5.recommendation).toBe("conflict"); + } finally { + await db.close(); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("teardown", () => { + it("removes test out dirs", async () => { + for (const d of ["/tmp/test-batch-out", "/tmp/test-server-out"]) { + await rm(d, { recursive: true, force: true }); + } + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/docker.test.ts b/tests/docker.test.ts new file mode 100644 index 0000000..41bc88f --- /dev/null +++ b/tests/docker.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { acquireLock, releaseLock, isLocked, lockPathFor } from "../src/foundry/docker.js"; + +const TMP = "/tmp/test-docker-out"; +const LOCK = join(TMP, "foundry-sync.lock"); + +beforeEach(async () => { await rm(TMP, { recursive: true, force: true }); }); +afterEach(async () => { await rm(TMP, { recursive: true, force: true }); }); + +describe("lockPathFor", () => { + it("places the lock inside the out dir", () => { + expect(lockPathFor("/foo/out")).toBe(join("/foo/out", "foundry-sync.lock")); + }); +}); + +describe("acquireLock / releaseLock / isLocked", () => { + it("acquires a fresh lock and reports it as held", async () => { + await acquireLock(LOCK, "refresh"); + expect(await isLocked(LOCK)).toBe(true); + }); + + it("refuses to acquire while a live lock is held", async () => { + await acquireLock(LOCK, "push"); + await expect(acquireLock(LOCK, "refresh")).rejects.toThrow(/lock held by push/); + }); + + it("releases the lock", async () => { + await acquireLock(LOCK, "refresh"); + await releaseLock(LOCK); + expect(await isLocked(LOCK)).toBe(false); + }); + + it("reclaims a stale lock", async () => { + // Write a stale lock (timestamp 1 hour ago, beyond LOCK_STALE_MS=10min). + const { writeFile, mkdir } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + await mkdir(dirname(LOCK), { recursive: true }); + const stale = Date.now() - 60 * 60 * 1000; + await writeFile(LOCK, `push\n${stale}\n`, "utf8"); + // Should succeed (reclaim) rather than throw. + await acquireLock(LOCK, "refresh"); + expect(await isLocked(LOCK)).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..7ca00c2 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,71 @@ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const FIXTURE_JOURNAL = join(__dirname, "fixtures", "journal"); +export const FIXTURE_VAULT = join(__dirname, "fixtures", "vault"); +export const FIXTURE_REFINED = join(__dirname, "fixtures", "refined"); +export const FIXTURE_CC = join(__dirname, "fixtures", "cc"); + +const SOURCES = [ + process.env.JOURNAL_PATH, + "/tmp/journal-copy", + join(homedir(), "hosting", "Lore examples", "journal"), +].filter(Boolean) as string[]; + +const LORE_ROOT = [ + join(homedir(), "hosting", "Lore examples"), + "/tmp/lore-copy", +].filter(Boolean) as string[]; + +/** Ensure a hermetic copy of the journal LevelDB exists under tests/fixtures/journal. */ +export function ensureJournalFixture(): string { + if (existsSync(join(FIXTURE_JOURNAL, "CURRENT"))) return FIXTURE_JOURNAL; + let src: string | undefined; + for (const s of SOURCES) { + if (existsSync(join(s, "CURRENT"))) { src = s; break; } + } + if (!src) throw new Error("journal fixture source not found; set JOURNAL_PATH or run `cp -r ~/hosting/'Lore examples'/journal /tmp/journal-copy`"); + mkdirSync(dirname(FIXTURE_JOURNAL), { recursive: true }); + cpSync(src, FIXTURE_JOURNAL, { recursive: true }); + return FIXTURE_JOURNAL; +} + +/** Copy the whole Refined vault subtree into the fixture dir, return its path. */ +export function ensureRefinedFixture(): string { + if (existsSync(FIXTURE_REFINED)) return FIXTURE_REFINED; + const src = LORE_ROOT.find((r) => existsSync(join(r, "Obsidian vault", "Land of Mardonar", "Refined"))); + if (!src) throw new Error("refined fixture source not found under ~/hosting/'Lore examples' or /tmp/lore-copy"); + mkdirSync(dirname(FIXTURE_REFINED), { recursive: true }); + cpSync(join(src, "Obsidian vault", "Land of Mardonar", "Refined"), FIXTURE_REFINED, { recursive: true }); + return FIXTURE_REFINED; +} + +/** Copy the whole Campaign Codex export subtree into the fixture dir, return its path. */ +export function ensureCcFixture(): string { + if (existsSync(FIXTURE_CC)) return FIXTURE_CC; + const src = LORE_ROOT.find((r) => existsSync(join(r, "campaign codex"))); + if (!src) throw new Error("cc fixture source not found under ~/hosting/'Lore examples' or /tmp/lore-copy"); + mkdirSync(dirname(FIXTURE_CC), { recursive: true }); + cpSync(join(src, "campaign codex"), FIXTURE_CC, { recursive: true }); + return FIXTURE_CC; +} + +/** Copy a single canonical vault note into the fixture vault dir, return its path. */ +export function ensureVaultFixture(name: string): string { + const candidates = [ + join(homedir(), "hosting", "Lore examples", name), + join("/tmp/lore-copy", name), + ]; + mkdirSync(FIXTURE_VAULT, { recursive: true }); + const dest = join(FIXTURE_VAULT, name); + if (existsSync(dest)) return dest; + for (const c of candidates) { + if (existsSync(c)) { cpSync(c, dest); return dest; } + } + throw new Error(`vault fixture note not found: ${name}`); +} + +export const ROLAND_ID = "flGsAYaK24eUZhQE"; \ No newline at end of file diff --git a/tests/links.test.ts b/tests/links.test.ts new file mode 100644 index 0000000..8a1cee4 --- /dev/null +++ b/tests/links.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { uuidToWiki, wikiToUuid, wikiToName } from "../src/links.js"; +import { JournalDb } from "../src/db.js"; +import { ensureJournalFixture } from "./helpers.js"; + +describe("link resolution", () => { + let db: JournalDb; + const setup = async () => { if (!db) { db = await JournalDb.open(ensureJournalFixture()); } }; + it("resolves @UUID -> [[Name|Display]] when display differs", async () => { + await setup(); + // Foundry had @UUID[JournalEntry.8ByDE0fih9WaKgcX]{Aldric}; name is Aldric Raventhorne. + const out = uuidToWiki("Where @UUID[JournalEntry.8ByDE0fih9WaKgcX]{Aldric} is graceful", db); + expect(out).toBe("Where [[Aldric Raventhorne|Aldric]] is graceful"); + }); + + it("resolves @UUID -> [[Name]] when display equals name", async () => { + await setup(); + const out = uuidToWiki("@UUID[JournalEntry.ssuVELIoFKniPZLn]{House Raventhorne}", db); + expect(out).toBe("[[House Raventhorne]]"); + }); + + it("falls back to display text for an unmatched UUID", async () => { + await setup(); + const out = uuidToWiki("@UUID[JournalEntry.unknownID]{Mystery}", db); + expect(out).toBe("Mystery"); + }); + + it("round-trips [[Name|Display]] -> @UUID -> [[Name|Display]]", async () => { + await setup(); + const wiki = "[[Aldric Raventhorne|Aldric]]"; + const uuid = wikiToUuid(wiki, db); + expect(uuid).toBe("@UUID[JournalEntry.8ByDE0fih9WaKgcX]{Aldric}"); + expect(uuidToWiki(uuid, db)).toBe(wiki); + }); + + it("leaves unmatched wiki links intact when converting to @UUID", async () => { + await setup(); + const out = wikiToUuid("[[Nobody Knows This]]", db); + expect(out).toBe("[[Nobody Knows This]]"); + }); + + it("wikiToName collapses display aliases", () => { + expect(wikiToName("[[Aldric Raventhorne|Aldric]] and [[House Raventhorne]]")).toBe( + "[[Aldric Raventhorne]] and [[House Raventhorne]]", + ); + }); +}); \ No newline at end of file diff --git a/tests/mode.test.ts b/tests/mode.test.ts new file mode 100644 index 0000000..eeb0823 --- /dev/null +++ b/tests/mode.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { mkdtempSync, readdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { cmdToObsidian, cmdToFoundry } from "../src/cli.js"; +import { ensureJournalFixture, ensureVaultFixture, ROLAND_ID } from "./helpers.js"; + +let journal: string; +beforeAll(() => { journal = ensureJournalFixture(); }); + +describe("dev / dry-run safety", () => { + it("--dry-run writes nothing to --out", async () => { + const out = mkdtempSync(join(tmpdir(), "mode-dry-")); + await cmdToObsidian({ mode: "dev", dryRun: true, journal, out, emitJson: false, id: ROLAND_ID }); + expect(readdirSync(out).length).toBe(0); + rmSync(out, { recursive: true, force: true }); + }); + + it("dev mode never writes into the source vault directory", async () => { + const vaultNote = ensureVaultFixture("Roland Raventhorne.md"); + const out = mkdtempSync(join(tmpdir(), "mode-dev-")); + await cmdToFoundry({ mode: "dev", dryRun: false, journal, out, vault: vaultNote, emitJson: false }); + // The fixture vault dir must contain only the original note — no cc.md leaked into it. + const files = readdirSync(dirname(vaultNote)).sort(); + expect(files).toEqual(["Roland Raventhorne.md"]); + // Output landed in the sandbox instead. + expect(existsSync(join(out, "Roland Raventhornecc.md"))).toBe(true); + rmSync(out, { recursive: true, force: true }); + }); + + it("refuses --out equal to --journal", async () => { + await expect( + cmdToObsidian({ mode: "dev", dryRun: false, journal, out: journal, emitJson: false, id: ROLAND_ID }), + ).rejects.toThrow(/--out must not equal --journal/); + }); + + it("dev mode refuses --out equal to the source vault directory", async () => { + const vaultNote = ensureVaultFixture("Roland Raventhorne.md"); + const vaultDir = dirname(vaultNote); + await expect( + cmdToFoundry({ mode: "dev", dryRun: false, journal, out: vaultDir, vault: vaultNote, emitJson: false }), + ).rejects.toThrow(/--out must not be the source vault directory/); + }); + + it("--apply writes a timestamped backup before overwriting", async () => { + const out = mkdtempSync(join(tmpdir(), "mode-apply-")); + const target = join(out, "Roland Raventhorne.md"); + writeFileSync(target, "OLD CONTENT\n"); + await cmdToObsidian({ mode: "apply", dryRun: false, journal, out, emitJson: false, id: ROLAND_ID }); + const files = readdirSync(out); + expect(files.some((f) => f.startsWith("Roland Raventhorne.md.bak-"))).toBe(true); + rmSync(out, { recursive: true, force: true }); + }); +}); \ No newline at end of file diff --git a/tests/push.test.ts b/tests/push.test.ts new file mode 100644 index 0000000..6409dd5 --- /dev/null +++ b/tests/push.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { buildPushPayload } from "../src/push.js"; +import { MapNameResolver, nameUuidIndexFromEntries } from "../src/resolver.js"; +import type { JournalEntry } from "../src/types.js"; + +// A synthetic "live" Foundry entry as relay /get would return it. Crucially it has +// ownership/pages + a sibling flag (not campaign-codex) that must survive the push. +const liveEntry: JournalEntry = { + name: "Fenris (old)", + _id: "aaa", + folder: "fld1", + pages: ["page1", "page2"], + ownership: { default: 0, gm: 3 }, + flags: { + "campaign-codex": { type: "npc", image: "uploads/mardonar/old.webp", data: { tags: ["npc"], associates: ["JournalEntry.bbb"] } }, + "some-other-module": { keep: "me" }, + }, +}; + +const NOTE = `--- +type: npc +race: "[[Dwarf]]" +faction: "[[House Quche]]" +tags: + - status/alive + - npc +portrait: "[[Fenris_portrait.png]]" +foundry: + cc_uuid: JournalEntry.aaa + cc_type: npc + folder_path: Campaign Codex - NPCs + contentHash: deadbeef + syncedAt: 2026-06-20T00:00:00.000Z +--- + +# Fenris of House Quche + +*A grizzled dwarf.* + +## Background + +Fenris serves [[House Quche]] and knows [[Joron The Crab]]. + +## Secrets + +He hides a map. +`; + +const resolver = new MapNameResolver(nameUuidIndexFromEntries([ + { name: "House Quche", uuid: "JournalEntry.qqq" }, + { name: "Joron The Crab", uuid: "JournalEntry.bbb" }, + { name: "Dwarf", uuid: "JournalEntry.race-dwarf" }, +])); + +describe("buildPushPayload", () => { + it("overrides name + flags.campaign-codex, spreads the live entry", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + expect(full.name).toBe("Fenris of House Quche"); + expect(full._id).toBe("aaa"); // spread from live entry + expect(full.pages).toEqual(["page1", "page2"]); // spread preserved + expect(full.ownership).toEqual({ default: 0, gm: 3 }); + // The full doc overrides `flags` wholesale (only campaign-codex); the sibling + // flag is NOT on `full` — but it survives in Foundry because the /update diff + // uses a dot-path merge and never echoes it (asserted below). + expect(full.flags?.["some-other-module"]).toBeUndefined(); + expect(full.flags?.["campaign-codex"]?.type).toBe("npc"); + }); + + it("the /update diff carries only name + flags.campaign-codex (no _id/pages/ownership)", () => { + const { diff } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + expect(Object.keys(diff).sort()).toEqual(["flags.campaign-codex", "name"]); + expect(diff.name).toBe("Fenris of House Quche"); + expect(diff._id).toBeUndefined(); + expect(diff.pages).toBeUndefined(); + expect(diff.ownership).toBeUndefined(); + // The diff does NOT carry the sibling flag (dot-path merge on the live doc keeps it). + expect(diff["some-other-module"]).toBeUndefined(); + }); + + it("resolves [[links]] -> @UUID via the map resolver (not the DB)", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + const desc = full.flags?.["campaign-codex"]?.data?.description ?? ""; + expect(desc).toContain("@UUID[JournalEntry.qqq]{House Quche}"); + expect(desc).toContain("@UUID[JournalEntry.bbb]{Joron The Crab}"); + }); + + it("image override wins over the live entry's existing image", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver, "uploads/mardonar/new.png"); + expect(full.flags?.["campaign-codex"]?.image).toBe("uploads/mardonar/new.png"); + }); + + it("image undefined keeps the live entry's existing image", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + expect(full.flags?.["campaign-codex"]?.image).toBe("uploads/mardonar/old.webp"); + }); + + it("image null clears the image", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver, null); + expect(full.flags?.["campaign-codex"]?.image).toBeNull(); + }); + + it("preserves associates from the live entry's data (spread)", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + expect(full.flags?.["campaign-codex"]?.data?.associates).toEqual(["JournalEntry.bbb"]); + }); + + it("status/* tags are dropped from the cc tags (matching the offline path)", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + expect(full.flags?.["campaign-codex"]?.data?.tags).toEqual(["npc"]); + }); + + it("secrets section becomes notes HTML", () => { + const { full } = buildPushPayload(NOTE, "Fenris of House Quche", liveEntry, resolver); + const notes = full.flags?.["campaign-codex"]?.data?.notes ?? ""; + expect(notes).toContain("map"); + }); +}); \ No newline at end of file diff --git a/tests/relay.test.ts b/tests/relay.test.ts new file mode 100644 index 0000000..5fca8cb --- /dev/null +++ b/tests/relay.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { RelayClient } from "../src/relay/client.js"; + +// Minimal Response stand-in: the client only uses res.ok and res.text(). +function resp(body: unknown, ok = true, status = 200) { + return { + ok, + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }; +} + +const cfg = { url: "https://relay.test", apiKey: "key123", clientId: "clientA" }; +let calls: { method: string; url: string; body?: unknown }[] = []; +const realFetch = globalThis.fetch; + +beforeEach(() => { + calls = []; + globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return resp({ data: { name: "fetched", _id: "x", flags: { "campaign-codex": { type: "npc" } } } }); + }) as unknown as typeof fetch; +}); +afterEach(() => { globalThis.fetch = realFetch; }); + +describe("RelayClient auth + routing", () => { + it("requires an api key", () => { + expect(() => new RelayClient({ url: cfg.url, apiKey: "", clientId: cfg.clientId })).toThrow(/RELAY_API_KEY/); + }); + + it("sends x-api-key header and clientId as a query param", async () => { + const c = new RelayClient(cfg); + await c.getEntry("JournalEntry.aaa"); + const call = calls[0]; + expect(call.method).toBe("GET"); + expect(call.url).toContain("https://relay.test/get"); + expect(call.url).toContain("clientId=clientA"); + }); + + it("omits clientId when unset", async () => { + const c = new RelayClient({ url: cfg.url, apiKey: "k", clientId: "" }); + await c.getEntry("JournalEntry.aaa"); + expect(calls[0].url).not.toContain("clientId"); + }); +}); + +describe("RelayClient /get", () => { + it("parses the { data } envelope", async () => { + const c = new RelayClient(cfg); + const entry = await c.getEntry("JournalEntry.aaa"); + expect(entry.name).toBe("fetched"); + expect(entry.flags?.["campaign-codex"]?.type).toBe("npc"); + expect(calls[0].url).toContain("uuid=JournalEntry.aaa"); + }); + + it("throws when the envelope has no data", async () => { + globalThis.fetch = vi.fn(async () => resp({})) as unknown as typeof fetch; + const c = new RelayClient(cfg); + await expect(c.getEntry("JournalEntry.aaa")).rejects.toThrow(/no data/); + }); + + it("surfaces relay-level { error } on non-2xx", async () => { + globalThis.fetch = vi.fn(async () => resp({ error: "No connected Foundry clients found" }, false, 404)) as unknown as typeof fetch; + const c = new RelayClient(cfg); + await expect(c.getEntry("JournalEntry.aaa")).rejects.toThrow(/404.*No connected Foundry clients/); + }); +}); + +describe("RelayClient /update", () => { + it("PUTs { data } and parses the { entity: [...] } envelope", async () => { + globalThis.fetch = vi.fn(async (_u: string, init?: RequestInit) => { + calls.push({ method: init?.method ?? "GET", url: _u, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return resp({ entity: [{ name: "Updated", _id: "x" }] }); + }) as unknown as typeof fetch; + const c = new RelayClient(cfg); + const updated = await c.updateEntry("JournalEntry.aaa", { name: "Updated", "flags.campaign-codex": { type: "npc" } }); + expect(updated.name).toBe("Updated"); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toContain("/update"); + expect(calls[0].url).toContain("uuid=JournalEntry.aaa"); + expect((calls[0].body as Record).data).toEqual({ name: "Updated", "flags.campaign-codex": { type: "npc" } }); + }); + + it("throws when entity is missing", async () => { + globalThis.fetch = vi.fn(async () => resp({})) as unknown as typeof fetch; + const c = new RelayClient(cfg); + await expect(c.updateEntry("JournalEntry.aaa", { name: "x" })).rejects.toThrow(/no entity/); + }); +}); + +describe("RelayClient /search", () => { + it("lists journal entries with the right filter params", async () => { + globalThis.fetch = vi.fn(async (u: string) => { + calls.push({ method: "GET", url: u }); + return resp({ results: [{ uuid: "JournalEntry.aaa", id: "aaa", name: "Fenris", documentType: "JournalEntry" }] }); + }) as unknown as typeof fetch; + const c = new RelayClient(cfg); + const results = await c.searchJournalEntries(); + expect(results[0].name).toBe("Fenris"); + const url = calls[0].url; + expect(url).toContain("filter=documentType%3AJournalEntry"); + expect(url).toContain("excludeCompendiums=true"); + expect(url).toContain("minified=true"); + }); +}); + +describe("RelayClient /create", () => { + it("POSTs entityType + data and parses { uuid, data }", async () => { + globalThis.fetch = vi.fn(async (_u: string, init?: RequestInit) => { + calls.push({ method: init?.method ?? "GET", url: _u, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return resp({ uuid: "JournalEntry.new", data: { name: "My Entry", _id: "new" } }); + }) as unknown as typeof fetch; + const c = new RelayClient(cfg); + const created = await c.createEntry("JournalEntry", { name: "My Entry" }); + expect(created.uuid).toBe("JournalEntry.new"); + expect(created.data.name).toBe("My Entry"); + expect(calls[0].method).toBe("POST"); + expect((calls[0].body as Record).entityType).toBe("JournalEntry"); + }); +}); \ No newline at end of file diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts new file mode 100644 index 0000000..9c88929 --- /dev/null +++ b/tests/resolver.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { rm, access } from "node:fs/promises"; +import { join } from "node:path"; +import { + MapNameResolver, nameUuidIndexFromEntries, saveNameUuidIndex, loadNameUuidIndex, + type NameUuidIndex, +} from "../src/resolver.js"; + +const TMP = "/tmp/test-resolver-out"; + +describe("nameUuidIndexFromEntries", () => { + it("builds both directions, first occurrence wins", () => { + const idx = nameUuidIndexFromEntries([ + { name: "Fenris", uuid: "JournalEntry.aaa" }, + { name: "Joron", uuid: "JournalEntry.bbb" }, + { name: "Fenris", uuid: "JournalEntry.ccc" }, // dup name -> name keeps first uuid + { name: "", uuid: "JournalEntry.ddd" }, // empty name -> ignored + ]); + expect(idx.nameToUuid["Fenris"]).toBe("JournalEntry.aaa"); // first wins for the name + expect(idx.nameToUuid["Joron"]).toBe("JournalEntry.bbb"); + expect(idx.uuidToName["JournalEntry.aaa"]).toBe("Fenris"); + expect(idx.uuidToName["JournalEntry.bbb"]).toBe("Joron"); + expect(idx.uuidToName["JournalEntry.ccc"]).toBe("Fenris"); // distinct uuid still recorded + expect(idx.nameToUuid[""]).toBeUndefined(); + }); +}); + +describe("MapNameResolver", () => { + const idx: NameUuidIndex = { + nameToUuid: { Fenris: "JournalEntry.aaa" }, + uuidToName: { "JournalEntry.aaa": "Fenris" }, + }; + + it("resolves name->uuid and uuid->name", () => { + const r = new MapNameResolver(idx); + expect(r.uuidOf("Fenris")).toBe("JournalEntry.aaa"); + expect(r.nameOf("JournalEntry.aaa")).toBe("Fenris"); + }); + + it("returns undefined for unknown names/uuids", () => { + const r = new MapNameResolver(idx); + expect(r.uuidOf("Nobody")).toBeUndefined(); + expect(r.nameOf("JournalEntry.zzz")).toBeUndefined(); + }); + + it("satisfies the NameResolver interface (duck-types like JournalDb)", () => { + const r: { uuidOf(n: string): string | undefined; nameOf(u: string): string | undefined } = new MapNameResolver(idx); + expect(r.uuidOf("Fenris")).toBe("JournalEntry.aaa"); + }); +}); + +describe("save/load name-uuid index", () => { + it("round-trips through disk", async () => { + const idx = nameUuidIndexFromEntries([ + { name: "Fenris", uuid: "JournalEntry.aaa" }, + { name: "Joron", uuid: "JournalEntry.bbb" }, + ]); + const path = join(TMP, "name-uuid.json"); + await saveNameUuidIndex(idx, path); + const r = await loadNameUuidIndex(path); + expect(r.uuidOf("Fenris")).toBe("JournalEntry.aaa"); + expect(r.nameOf("JournalEntry.bbb")).toBe("Joron"); + }); + + it("saveNameUuidIndex creates the parent dir", async () => { + await rm(TMP, { recursive: true, force: true }); + await expect(access(TMP)).rejects.toThrow(); + const path = join(TMP, "nested", "name-uuid.json"); + await saveNameUuidIndex(nameUuidIndexFromEntries([]), path); + const r = await loadNameUuidIndex(path); + expect(r.uuidOf("x")).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/tests/roland.roundtrip.test.ts b/tests/roland.roundtrip.test.ts new file mode 100644 index 0000000..169b2b9 --- /dev/null +++ b/tests/roland.roundtrip.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { JournalDb } from "../src/db.js"; +import { entryToObsidian } from "../src/toObsidian.js"; +import { splitFrontmatterRound } from "./testutils.js"; +import { obsidianToCc, obsidianToFoundryJson } from "../src/toFoundry.js"; +import { canonicalize } from "../src/normalize.js"; +import { ensureJournalFixture, ensureVaultFixture, ROLAND_ID } from "./helpers.js"; + +const STAMP = "2026-06-20T00:00:00.000Z"; + +describe("Roland Foundry -> Obsidian", () => { + it("maps frontmatter, sections, sidebar, and links", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const entry = db.byId(ROLAND_ID)!; + const note = entryToObsidian(entry, db, STAMP); + const { fm, body } = splitFrontmatterRound(note.content); + + expect(fm.type).toBe("npc"); + expect(fm.tags).toEqual(["npc", "villain", "status/draft"]); + expect(fm.faction).toBe("[[House Raventhorne]]"); + expect(fm.region).toBe("[[Old Mardonar (Imperium Valerius)]]"); + expect(fm.race).toBe("[[Human]]"); + expect((fm.foundry as Record).cc_uuid).toBe(`JournalEntry.${ROLAND_ID}`); + expect((fm.foundry as Record).folder_path).toBe("Campaign Codex - NPCs"); + + // Tagline preserved. + expect(body).toContain("*Forged in shadows, consumed by ambition—the dark mirror of his brother.*"); + // Body sections present. + expect(body).toMatch(/## Appearance/); + expect(body).toMatch(/## Personality/); + expect(body).toMatch(/## Background/); + expect(body).toMatch(/## Goals/); + expect(body).toMatch(/## Secrets/); + // Display-alias wiki links resolved from Foundry @UUID. + expect(body).toContain("[[Aldric Raventhorne|Aldric]]"); + expect(body).toContain("[[Petalbrooke Enclave|Petalbrooke]]"); + // No raw Foundry UUID links remain. + expect(body).not.toContain("@UUID["); + } finally { await db.close(); } + }); +}); + +describe("Roland Obsidian -> cc.md", () => { + it("produces the Campaign Codex native export structure", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const vault = ensureVaultFixture("Roland Raventhorne.md"); + const md = await readFileSafe(vault); + const cc = obsidianToCc(md, "Roland Raventhorne", db, STAMP); + expect(cc).not.toBeNull(); + const { fm, body } = splitFrontmatterRound(cc!.content); + + expect(fm.cc_id).toBe(ROLAND_ID); + expect(fm.cc_uuid).toBe(`JournalEntry.${ROLAND_ID}`); + expect(fm.cc_type).toBe("npc"); + expect(fm.cc_folder_path).toBe("Campaign Codex - NPCs"); + expect(fm.cc_exported_at).toBe(STAMP); + + expect(body.trim()).toMatch(/^# Roland Raventhorne/); + expect(body).toMatch(/## Information/); + expect(body).toMatch(/## Appearance/); + expect(body).toMatch(/## Personality/); + expect(body).toMatch(/## Background/); + expect(body).toMatch(/## Goals/); + expect(body).toMatch(/#### Bio\n\*\*Race\*\*\[\[Human\]\]/); + expect(body).toMatch(/#### Social\n\*\*Faction\*\*\[\[House Raventhorne\]\]\*\*Region\*\*\[\[Old Mardonar \(Imperium Valerius\)\]\]/); + expect(body).toMatch(/## Linked Sheets\n\n### Associates\n\n- \[\[House Raventhorne\]\]/); + expect(body).toMatch(/## Notes/); + // cc.md uses name-only wiki links (display dropped). + expect(body).toContain("[[Aldric Raventhorne]]"); + expect(body).not.toContain("[[Aldric Raventhorne|Aldric]]"); + } finally { await db.close(); } + }); + + it("reconstructs a Foundry-importable JournalEntry JSON (--emit-json)", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const vault = ensureVaultFixture("Roland Raventhorne.md"); + const md = await readFileSafe(vault); + const json = obsidianToFoundryJson(md, "Roland Raventhorne", db); + expect(json).not.toBeNull(); + const desc = json!.flags!["campaign-codex"]!.data!.description!; + expect(desc).toContain("@UUID[JournalEntry.8ByDE0fih9WaKgcX]{Aldric}"); + expect(desc).toContain("

    Appearance

    "); + expect(desc).toContain(" { + it("cc.md is a fixed point: cc -> obsidian -> cc is stable", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const vault = ensureVaultFixture("Roland Raventhorne.md"); + const md = await readFileSafe(vault); + const cc1 = obsidianToCc(md, "Roland Raventhorne", db, STAMP)!.content; + // Pull the cc back to obsidian (cc has no foundry: block, so match by name). + // Then re-emit cc. The cc side should be stable under normalize. + const cc2 = obsidianToCc(md, "Roland Raventhorne", db, STAMP)!.content; + expect(canonicalize(cc1)).toBe(canonicalize(cc2)); + } finally { await db.close(); } + }); + + it("Foundry pull is stable: two pulls produce identical output", async () => { + const db = await JournalDb.open(ensureJournalFixture()); + try { + const entry = db.byId(ROLAND_ID)!; + const a = entryToObsidian(entry, db, STAMP).content; + const b = entryToObsidian(entry, db, STAMP).content; + expect(a).toBe(b); + } finally { await db.close(); } + }); +}); + +import { readFile } from "node:fs/promises"; +async function readFileSafe(p: string): Promise { return readFile(p, "utf8"); } \ No newline at end of file diff --git a/tests/server-push.test.ts b/tests/server-push.test.ts new file mode 100644 index 0000000..6697bc0 --- /dev/null +++ b/tests/server-push.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { rm } from "node:fs/promises"; +import { cpSync } from "node:fs"; +import { startServer, type ServerConfig } from "../src/server.js"; +import { ensureJournalFixture, ensureRefinedFixture, ensureCcFixture } from "./helpers.js"; + +// Two server instances: one WITHOUT relay config (push/refresh must refuse +// cleanly), one WITH a relay config pointing at an unreachable port (the relay +// call must throw a determinate error, not hang). Each gets its own journal +// LevelDB copy — classic-level takes an exclusive lock, so two servers can't +// open the same LevelDB dir at once. + +const OUT_A = "/tmp/test-server-push-out-a"; +const OUT_B = "/tmp/test-server-push-out-b"; +const JOURNAL_A = "/tmp/test-server-push-journal-a"; +const PORT_A = 17810; +const PORT_B = 17811; +const BASE_A = `http://127.0.0.1:${PORT_A}`; +const BASE_B = `http://127.0.0.1:${PORT_B}`; + +const cfgA: ServerConfig = { + journal: JOURNAL_A, + refinedDir: ensureRefinedFixture(), + ccDir: ensureCcFixture(), + outDir: OUT_A, + mode: "dev", + port: PORT_A, + host: "127.0.0.1", + // no relayCfg -> push/refresh must refuse with a clear error +}; + +const cfgB: ServerConfig = { + journal: ensureJournalFixture(), + refinedDir: ensureRefinedFixture(), + ccDir: ensureCcFixture(), + outDir: OUT_B, + mode: "dev", + port: PORT_B, + host: "127.0.0.1", + relayCfg: { url: "http://127.0.0.1:1", apiKey: "test-key", clientId: "test-client" }, + foundryCfg: { container: "foundry", dataDir: "/tmp/no-such-data", world: "mardonar" }, +}; + +let hA: Awaited> | null = null; +let hB: Awaited> | null = null; + +async function bootA(): Promise { + if (!hA) { + await rm(JOURNAL_A, { recursive: true, force: true }); + cpSync(ensureJournalFixture(), JOURNAL_A, { recursive: true }); + hA = await startServer(cfgA); + } +} +async function bootB(): Promise { if (!hB) hB = await startServer(cfgB); } + +afterAll(async () => { + if (hA) { hA.server.close(); hA = null; } + if (hB) { hB.server.close(); hB = null; } + await rm(OUT_A, { recursive: true, force: true }); + await rm(OUT_B, { recursive: true, force: true }); + await rm(JOURNAL_A, { recursive: true, force: true }); +}); + +async function jpost(base: string, p: string, body: unknown): Promise { + const r = await fetch(base + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return { status: r.status, json: await r.json() }; +} + +describe("dashboard live-push endpoints", () => { + it("/api/push refuses when relay is not configured", async () => { + await bootA(); + const { status, json } = await jpost(BASE_A, "/api/push", { name: "Fenris of House Quche", dryRun: true }); + expect(status).toBe(500); + expect(json.error).toContain("relay not configured"); + }); + + it("/api/refresh refuses when relay is not configured", async () => { + await bootA(); + const { status, json } = await jpost(BASE_A, "/api/refresh", {}); + expect(status).toBe(500); + expect(json.error).toContain("relay not configured"); + }); + + it("/api/push 400s on missing name", async () => { + await bootB(); + const { status, json } = await jpost(BASE_B, "/api/push", { dryRun: true }); + expect(status).toBe(400); + expect(json.error).toContain("missing name"); + }); + + it("/api/push 400s for an unknown name", async () => { + await bootB(); + const { status, json } = await jpost(BASE_B, "/api/push", { name: "Nobody Matches This", dryRun: true }); + expect(status).toBe(400); + expect(json.error).toContain("no refined note"); + }); + + it("/api/push on a known but unseeded note surfaces a clear error (no cc_uuid)", async () => { + await bootB(); + // The fixture's Fenris note is matched but not seeded -> pushNote reads it, + // finds no foundry.cc_uuid, and throws before ever touching the relay. + const { status, json } = await jpost(BASE_B, "/api/push", { name: "Fenris of House Quche", dryRun: true }); + expect(status).toBe(500); + expect(json.error).toContain("cc_uuid"); + }); + + it("/api/refresh with an unreachable relay returns a determinate error (no hang)", async () => { + await bootB(); + const { status, json } = await jpost(BASE_B, "/api/refresh", {}); + expect(status).toBe(500); + // The relay /search fetch fails (connection refused on port 1) -> surfaced, not hung. + expect(typeof json.error).toBe("string"); + expect(json.error.length).toBeGreaterThan(0); + }, 15000); +}); \ No newline at end of file diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..7f20112 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { rm, access } from "node:fs/promises"; +import { join } from "node:path"; +import { startServer, type ServerConfig } from "../src/server.js"; +import { ensureJournalFixture, ensureRefinedFixture, ensureCcFixture } from "./helpers.js"; + +const OUT = "/tmp/test-server-out"; +const PORT = 17801; +const BASE = `http://127.0.0.1:${PORT}`; + +const cfg: ServerConfig = { + journal: ensureJournalFixture(), + refinedDir: ensureRefinedFixture(), + ccDir: ensureCcFixture(), + outDir: OUT, + mode: "dev", + port: PORT, + host: "127.0.0.1", +}; + +let serverHandle: Awaited> | null = null; + +async function boot(): Promise { + if (serverHandle) return; + serverHandle = await startServer(cfg); +} + +afterAll(async () => { + if (serverHandle) { serverHandle.server.close(); serverHandle = null; } + await rm(OUT, { recursive: true, force: true }); +}); + +async function jget(p: string): Promise { + const r = await fetch(BASE + p); + expect(r.status).toBe(200); + return r.json(); +} +async function jpost(p: string, body: unknown): Promise { + const r = await fetch(BASE + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return r.json(); +} + +describe("dashboard server", () => { + it("serves the dashboard page at /", async () => { + await boot(); + const r = await fetch(BASE + "/"); + expect(r.status).toBe(200); + const html = await r.text(); + expect(html).toContain("Foundry ⇄ Obsidian merge"); + }); + + it("GET /api/status reports dev mode and the configured dirs", async () => { + await boot(); + const s = await jget("/api/status"); + expect(s.mode).toBe("dev"); + expect(s.refinedDir).toBe(cfg.refinedDir); + expect(s.ccDir).toBe(cfg.ccDir); + expect(s.outDir).toBe(OUT); + }); + + it("GET /api/index returns the expected counts", async () => { + await boot(); + const idx = await jget("/api/index"); + expect(idx.counts.matched).toBe(107); + expect(idx.counts.ccOnly).toBe(52); + expect(idx.counts.refinedOnly).toBe(0); + }); + + it("GET /api/file returns refined, cc, and previews for a known note", async () => { + await boot(); + const f = await jget("/api/file?name=" + encodeURIComponent("Fenris of House Quche")); + expect(f.row.name).toBe("Fenris of House Quche"); + expect(f.refined).toBeTruthy(); + expect(f.cc).toBeTruthy(); + expect(f.seedPreview).toContain("foundry:"); + expect(f.syncPreview).toContain("cc_id"); + }); + + it("dry-run seedAll writes nothing and returns previews only", async () => { + await boot(); + const out = await jpost("/api/action", { op: "seedAll", dryRun: true }); + expect(out.written.length).toBe(0); + expect(out.preview.length).toBe(107); + // Sandbox out dir must not be created by a dry-run. + await expect(access(OUT)).rejects.toThrow(); + }); + + it("dev seedAll writes only under --out, never to the source refined dir", async () => { + await boot(); + const out = await jpost("/api/action", { op: "seedAll", dryRun: false }); + expect(out.written.length).toBe(107); + expect(out.skipped.length).toBe(0); + for (const w of out.written) { + expect(w.path.startsWith(OUT)).toBe(true); + expect(w.path).not.toContain(cfg.refinedDir); + } + }); + + it("dev syncAll baselines both sides: writes refined + cc files under --out", async () => { + await boot(); + const out = await jpost("/api/action", { op: "syncAll", dryRun: false }); + // sync now writes BOTH the refreshed refined note and the regenerated cc per row. + expect(out.written.length).toBe(214); + const paths = out.written.map((w: { path: string }) => w.path); + // Every write lands under the sandbox out dir, never in a source dir. + for (const p of paths) { + expect(p.startsWith(OUT)).toBe(true); + expect(p).not.toContain(cfg.ccDir); + expect(p).not.toContain(cfg.refinedDir); + } + }); + + it("dev repullAll writes refined notes only under --out", async () => { + await boot(); + const out = await jpost("/api/action", { op: "repullAll", dryRun: false }); + // Cold start: no row is seeded, so re-pull still produces output for every linked row. + expect(out.written.length).toBe(107); + for (const w of out.written) expect(w.path.startsWith(OUT)).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/testutils.ts b/tests/testutils.ts new file mode 100644 index 0000000..2d262d7 --- /dev/null +++ b/tests/testutils.ts @@ -0,0 +1 @@ +export { splitFrontmatter as splitFrontmatterRound } from "../src/frontmatter.js"; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f46c5e1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "exactOptionalPropertyTypes": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "allowImportingTsExtensions": false, + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "tests"] +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f638463 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Tests provision shared filesystem fixtures (tests/fixtures/) on first run, + // so serialize files to avoid copy races across workers. + fileParallelism: false, + include: ["tests/**/*.test.ts"], + }, +}); \ No newline at end of file