Kaysser Kayyali 5d96bf1267 feat(E1b-alt): re-baseline ccHash to canonicalize-HTML contract
E1a proved the markdown round-trip unstable (NO-GO). This re-baselines the
E0.2 ccHash contract to hash Foundry HTML directly — the E1b-alt fork — which
sidesteps all 5 E1a failure reasons (no inverse, no resolver, no blank-line/
case/order sensitivity, no parseBody coupling).

- src/canonicalize-html.ts: canonicalizeHtml(html) — linkedom DOM walk that
  absorbs serialization drift (attribute order/quoting, named-vs-numeric
  entities, inter-tag whitespace, tag case, self-closing) while preserving
  content (structure, attr values, meaningful text). Two inputs parsing to the
  same DOM → same canonical string. Mini-gate: tests/canonicalize-html.test.ts
  (9 tests — serialization variants → same canonical; content change → different).
- src/cchash.ts: rewritten to ccHash = contentHash(canonicalizeHtml(
  data.description) + "\n" + canonicalizeHtml(data.notes ?? "") + "\n" + name
  + "\n" + folder). The HtmlToMarkdown seam is DROPPED; a CanonicalizeHtml seam
  (default = canonicalizeHtml) replaces it. CC_HASH_CONTRACT updated + pinned +
  re-derivation-enforced. CcHashError on missing description kept; direction-
  invariance kept (name/folder from liveEntry); folder = Foundry folder ID,
  distinct from Obsidian foundry.folder_path. tests/cchash.test.ts updated (21
  tests incl. serialization-drift-absorption + no-false-negative).
- src/fromFoundry.ts (the E1a markdown inverse) ships unwired — not consumed by
  ccHash; remains as the spike artifact's inverse.

tsc clean; 67 E0+E1a+E1b-alt tests pass; 112 passing project-wide (18 pre-existing
fixture-missing failures unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:35:09 +00:00
2026-06-20 19:15:38 +00:00
2026-06-20 19:15:38 +00:00
2026-06-20 19:39:43 +00:00
2026-06-20 19:15:38 +00:00
2026-06-20 19:15:38 +00:00

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.

Quick start (Docker Compose)

Clone, then bring up the relay wired to your own external Foundry. You need: a running Foundry VTT (license + the rest-api module) and an Obsidian vault on disk that you edit in your own Obsidian desktop app.

  1. cp .env.example .env and fill in FOUNDRY_URL, FOUNDRY_WORLD, RELAY_USER/ RELAY_PASSWORD, and VAULT (or let the /setup skill do all of this for you — it runs the commands itself; you only paste values it can't know).
  2. docker compose up -d relay — relay on :3010.
  3. Open http://localhost:3010, sign up, and copy the API key into .env as RELAY_API_KEY. In Foundry, point the rest-api module at ws(s)://<host-Foundry-can-reach>:3010.
  4. node scripts/start-relay-session.js — launches a headless Foundry session the relay drives.
  5. ./sync.sh refresh --out ./out then ./sync.sh ui --vault "$VAULT/Refined" \ --out ./out --host 0.0.0.0 — opens the dashboard on :7788.

The sync tool stays host-run (it reads the journal LevelDB and shells out to docker); the compose provides only the relay. You edit notes in your own Obsidian desktop app pointed at $VAULT; the dashboard reads/writes the same files on disk. Push edits back to Foundry from the dashboard (/api/push, /api/push-all), or flip on Auto-sync mode to push Obsidian→Foundry automatically the moment you save a note (Foundry→Obsidian stays manual — use Sync / Re-pull for that direction).

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-<iso> 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

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 review dashboard over the batch engine (binds 0.0.0.0 by default so it's reachable on your tailnet at this VM's IP, port 7788; pass --host 127.0.0.1 to restrict to localhost). 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/<cc_folder>/ for curation.
# 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-<iso> 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

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.
Description
No description provided
Readme 591 KiB
Languages
TypeScript 89.8%
HTML 9.2%
JavaScript 0.8%
Shell 0.2%