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 , drop non-local refs gracefully. mdToHtml renders  -> <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>
204 lines
8.8 KiB
TypeScript
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); }); |