7.1 KiB
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) — frontmattertype/tags/aliases/ faction/region/race/portrait, italic tagline,## Appearance / Personality / Background / Goals / Secrets,[[Name|Display]]links. - Campaign Codex native export (
Roland Raventhornecc.md) — frontmattercc_id/cc_uuid/cc_type/cc_folder_path/cc_exported_at,# Title,## Information, body sections,#### Bio/#### Socialboxes,## 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.
cp .env.example .envand fill inFOUNDRY_URL,FOUNDRY_WORLD,RELAY_USER/RELAY_PASSWORD, andVAULT(or let the/setupskill do all of this for you — it runs the commands itself; you only paste values it can't know).docker compose up -d relay— relay on:3010.- Open
http://localhost:3010, sign up, and copy the API key into.envasRELAY_API_KEY. In Foundry, point the rest-api module atws(s)://<host-Foundry-can-reach>:3010. node scripts/start-relay-session.js— launches a headless Foundry session the relay drives../sync.sh refresh --out ./outthen./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
db.tsopens the journal LevelDB read-only withclassic-level, indexes every Campaign Codex JournalEntry, and resolves@UUID⇄ note name.htmlMd.tsports theMacros/cc-4-export.jsHTML→Markdown logic (tagline, sections, Bio/Social boxes) but resolves@UUIDto[[Name|Display]]via the index.toObsidian.ts/toFoundry.tsconvert each direction.toFoundrypullscc_id/associates/image from the matched Foundry entry (byfoundry:block, then name).normalize.tscanonicalizes 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--outequal to--journalor 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 (curatedtype,aliases, status tags, hand-edited body, links) untouched. - Sync → cc — regenerate each
cc.mdfrom 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 andaliasesare curation-only on the Obsidian side; they are preserved across sync, never sourced from or written to Foundry.--emit-jsonreconstructs 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.