Files
obsidian-foundry-sync/scripts/seed-by-name.ts
Kaysser Kayyali ce8040c41b feat: file uploads (portrait + body images) + live-relay batch scripts
Portrait upload was broken: it wrote to Data/worlds/<world>/uploads (per-world,
missing the Data/ segment) and did not URL-encode. Fixed to the verified
convention -- copy to the GLOBAL Data/uploads/<world>/ dir (unencoded on disk),
flag stored URL-encoded as uploads/<world>/<encodeURIComponent(file)>. Extracted
a reusable uploadImageAsset helper.

Body ![[image]] embeds were dropped by mdToHtml. Added push-layer pre-processing
(processBodyImages in push.ts): upload co-located files, rewrite the embed to
![](servedPath), drop non-local refs gracefully. mdToHtml renders ![alt](src)
-> <img>. parseBody now returns a preface (preamble content besides the tagline)
so a loose image between the tagline and the first ## section is no longer
dropped before conversion.

scripts/ helpers for the live-relay path (no offline CC export needed):
- seed-by-name: link refined notes to Foundry by name -> uuid, inject foundry block
- import-missing: create Foundry entries with no vault note as new pushable notes
- gen-cc-dir: generate a cc dir from a journal snapshot so the dashboard ui can run
- resync: push every body-changed note and baseline its foundry.contentHash so
  re-runs only catch new edits (idempotent, re-runnable)

Verified end-to-end against the dev Foundry world: portraits + body images
served by Foundry, bidirectional round-trip faithful, push is no-clobber
(diff keys only name + flags.campaign-codex).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 21:01:15 +00:00

204 lines
8.8 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Seed refined Obsidian notes with their `foundry:` identity block by matching
* each note's filename to a Foundry JournalEntry via the cached name↔uuid map
* (built by `refresh` from relay /search), then fetching the live entry for its
* cc_type. Reuses batch.ts buildBlock/seedBlockContent so the block is identical
* to what the dashboard's `seed` produces — only the block is injected, all
* curation (type, aliases, status tags, body, links) is left byte-identical.
*
* This is the relay/name-based seed path for hosts that have no Campaign Codex
* cc export on disk (the dashboard's indexAll seed routes through the cc file's
* cc_id; this bypasses that by resolving names directly).
*
* Usage:
* npx tsx scripts/seed-by-name.ts --vault <refined-dir> --out <sandbox-dir> [--name-uuid <path>] [--apply] [--sample Name]
*
* Modes:
* default: write seeded copies into --out (sandbox) for review; never touch the vault.
* --apply: ALSO write into the real vault in place, with a .bak-<iso> backup of every
* file before overwriting. Foundry/dev is source of truth for cc_type.
*
* Env: reads RELAY_URL/RELAY_API_KEY/RELAY_CLIENT_ID (same as the CLI) for relay /get.
*/
import { readdir, readFile, writeFile, copyFile, access, mkdir } from "node:fs/promises";
import { join, dirname, relative, sep } from "node:path";
import { buildBlock, seedBlockContent } from "../src/batch.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import { RelayClient } from "../src/relay/client.js";
import { loadRelayConfig } from "../src/config.js";
import { backupStamp } from "../src/write.js";
import type { JournalEntry } from "../src/types.js";
const SKIP_DIRS = new Set([".trash", ".obsidian", "node_modules", "_Templates", ".git"]);
/** Match the normalization validated against the relay (107/107). */
function norm(s: string): string {
return s
.replace(/\s*-\s*Journal$/i, "") // strip " - Journal" suffix Foundry adds
.replace(/^"(.*)"$/, "$1") // strip wrapping quotes
.replace(/['']/g, "'") // curly -> straight apostrophe
.trim()
.toLowerCase();
}
function parseArgs(argv: string[]) {
const opts: { vault: string; out: string; nameUuid: string; apply: boolean; sample?: string } = {
vault: "", out: "", nameUuid: "", apply: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === "--vault") opts.vault = next();
else if (a === "--out") opts.out = next();
else if (a === "--name-uuid") opts.nameUuid = next();
else if (a === "--apply") opts.apply = true;
else if (a === "--sample") opts.sample = next();
else throw new Error(`unknown flag: ${a}`);
}
if (!opts.vault) throw new Error("--vault <refined-dir> is required");
if (!opts.out) throw new Error("--out <dir> is required");
if (!opts.nameUuid) opts.nameUuid = join(opts.out, "name-uuid.json");
return opts;
}
async function walkMd(root: string): Promise<string[]> {
let out: string[] = [];
for (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);
}
return out;
}
/** Run an async fn over items with bounded concurrency. */
async function pool<T, R>(items: T[], n: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const ret: R[] = new Array(items.length);
let i = 0;
const workers = Array.from({ length: Math.min(n, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
ret[idx] = await fn(items[idx]);
}
});
await Promise.all(workers);
return ret;
}
async function main() {
const opts = parseArgs(process.argv);
const idx = JSON.parse(await readFile(opts.nameUuid, "utf8")) as { nameToUuid: Record<string, string> };
// Exact raw-name match (preferred) + normalized fallback (strips " - Journal"
// suffix/quotes). Exact-first is deterministic when Foundry has both a plain and a
// "- Journal" entry for the same base name (e.g. Slyvar Forger).
const byExact = new Map<string, string>();
const byNorm = new Map<string, { uuid: string; name: string }>();
const collisions: string[] = [];
for (const [name, uuid] of Object.entries(idx.nameToUuid)) {
if (!byExact.has(name)) byExact.set(name, uuid);
const k = norm(name);
const ex = byNorm.get(k);
if (ex && ex.uuid !== uuid) collisions.push(`"${ex.name}" (${ex.uuid}) vs "${name}" (${uuid})`);
else if (!ex) byNorm.set(k, { uuid, name });
}
if (collisions.length) console.log(`NOTE: ${collisions.length} normalized name collision(s) — resolved by exact-name preference:\n ` + collisions.slice(0, 10).join("\n "));
const relay = new RelayClient(loadRelayConfig());
const files = await walkMd(opts.vault);
type Plan = { file: string; rel: string; uuid: string; name: string; md: string; match: "exact" | "normalized" };
const plans: Plan[] = [];
const unresolved: string[] = [];
let alreadySeeded = 0;
for (const f of files) {
const base = f.split(sep).pop()!.replace(/\.md$/i, "");
const exactUuid = byExact.get(base);
const hit = byNorm.get(norm(base));
const uuid = exactUuid ?? hit?.uuid;
const match = exactUuid ? "exact" : hit ? "normalized" : "none";
const md = await readFile(f, "utf8");
const { fm } = splitFrontmatter(md);
const has = !!readFoundryBlock(fm);
if (!uuid) { unresolved.push(base); continue; }
if (has) { alreadySeeded++; continue; }
plans.push({ file: f, rel: relative(opts.vault, f), uuid, name: base, md, match });
}
console.log(`vault notes: ${files.length}`);
console.log(`already seeded (skipped): ${alreadySeeded}`);
console.log(`to seed: ${plans.length}`);
console.log(`unresolved (no relay match): ${unresolved.length}`);
if (unresolved.length) console.log(" unresolved:\n " + unresolved.sort().join("\n "));
const normMatches = plans.filter((p) => p.match === "normalized").map((p) => `${p.name} -> ${p.uuid}`);
console.log(`matched by exact name: ${plans.length - normMatches.length}; by normalized fallback: ${normMatches.length}`);
if (normMatches.length) console.log(" normalized-fallback matches (spot-check these):\n " + normMatches.sort().join("\n "));
if (!plans.length) { console.log("nothing to seed."); return; }
// Fetch each live entry for its cc_type (Foundry/dev = source of truth).
// Concurrency 4 (the relay WS round-trip ~10s timeouts under higher load), then a
// sequential retry pass with backoff for any that timed out.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const getWithRetry = async (p: Plan, attempts = 3): Promise<JournalEntry | null> => {
for (let a = 1; a <= attempts; a++) {
try { return await relay.getEntry(p.uuid); }
catch (e) {
if (a === attempts) { console.log(` /get FAILED ${p.name} (${p.uuid}): ${(e as Error).message}`); return null; }
await sleep(2000 * a);
}
}
return null;
};
console.log(`fetching ${plans.length} live entries via relay /get (concurrency 4)…`);
let entries = await pool(plans, 4, (p) => getWithRetry(p));
const needRetry = entries.filter((e) => !e).length;
if (needRetry) {
console.log(`retrying ${needRetry} timed-out /get sequentially…`);
for (let i = 0; i < plans.length; i++) {
if (!entries[i]) { entries[i] = await getWithRetry(plans[i], 4); await sleep(500); }
}
}
const stamp = new Date().toISOString();
let written = 0, failed = 0;
let sampleOut = "";
for (let i = 0; i < plans.length; i++) {
const p = plans[i];
const entry = entries[i];
if (!entry) { failed++; continue; }
const { body } = splitFrontmatter(p.md);
const block = buildBlock(entry, body, stamp);
const seeded = seedBlockContent(p.md, block);
// Sandbox copy always (for review).
const sandboxPath = join(opts.out, "seeded", p.rel);
await mkdir(dirname(sandboxPath), { recursive: true });
await writeFile(sandboxPath, seeded, "utf8");
written++;
if (opts.sample && norm(p.name) === norm(opts.sample)) sampleOut = seeded;
if (opts.apply) {
await mkdir(dirname(p.file), { recursive: true });
try {
await access(p.file);
await copyFile(p.file, `${p.file}.bak-${stamp}`);
} catch { /* new file, no backup */ }
await writeFile(p.file, seeded, "utf8");
}
}
console.log(`\nseeded: ${written} (sandbox -> ${join(opts.out, "seeded")})`);
if (failed) console.log(`failed: ${failed}`);
if (opts.apply) console.log(`APPLIED to real vault: ${written} files (backups: *.bak-${stamp})`);
if (sampleOut) {
console.log(`\n=== sample: ${opts.sample} ===`);
console.log(sampleOut.slice(0, 1200));
}
}
main().catch((e) => { console.error(`error: ${(e as Error).message}`); process.exit(1); });