Files
obsidian-foundry-sync/scripts/import-missing.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

90 lines
3.9 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Foundry -> vault: create "unlinked items" — Foundry Campaign Codex entries that
* have no vault counterpart — as new refined notes (with the foundry: identity block,
* since entryToObsidian embeds it, so they're immediately pushable). Reads Foundry
* content from a journal LevelDB snapshot (unlocked copy), matches by normalized name
* so "- Journal" variants of an existing note don't get duplicated.
*
* npx tsx scripts/import-missing.ts --vault <Refined-dir> --journal <snapshot> [--apply] [--out <sandbox>]
*
* default (no --apply): write imported notes to <out>/imported/... for review.
* --apply: write into the real vault under <vault>/imported/<folder>/<name>.md.
*/
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, dirname, sep } from "node:path";
import { JournalDb } from "../src/db.js";
import { entryToObsidian } from "../src/toObsidian.js";
import { folderPathFromCcType } from "../src/fields.js";
const SKIP_DIRS = new Set([".trash", ".obsidian", "node_modules", "_Templates", ".git", "imported"]);
function norm(s: string): string {
return s.replace(/\s*-\s*Journal$/i, "").replace(/^"(.*)"$/, "$1").replace(/['']/g, "'").trim().toLowerCase();
}
function safeName(s: string): string {
return s.replace(/\s*-\s*Journal$/i, "").replace(/^"(.*)"$/, "$1").replace(/[/\\]/g, "-").trim();
}
function parseArgs(argv: string[]) {
const o: { vault: string; journal: string; apply: boolean; out: string } = { vault: "", journal: "", apply: false, out: "" };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--vault") o.vault = argv[++i];
else if (argv[i] === "--journal") o.journal = argv[++i];
else if (argv[i] === "--apply") o.apply = true;
else if (argv[i] === "--out") o.out = argv[++i];
else throw new Error(`unknown flag: ${argv[i]}`);
}
if (!o.vault || !o.journal) throw new Error("--vault <Refined-dir> and --journal <snapshot> are required");
if (!o.apply && !o.out) o.out = "/tmp/devout-dev";
return o;
}
async function walkMd(root: string): Promise<string[]> {
let out: string[] = [];
try {
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);
}
} catch { /* missing */ }
return out;
}
async function main() {
const opts = parseArgs(process.argv);
const db = await JournalDb.open(opts.journal);
try {
const files = await walkMd(opts.vault);
const vaultNorm = new Set<string>();
for (const f of files) vaultNorm.add(norm(f.split(sep).pop()!.replace(/\.md$/i, "")));
const entries = db.all();
const stamp = new Date().toISOString();
const toImport = entries.filter((e) => !vaultNorm.has(norm(e.name)));
console.log(`vault notes: ${files.length}`);
console.log(`Foundry CC entries: ${entries.length}`);
console.log(`unlinked (no vault counterpart): ${toImport.length}`);
const dest = opts.apply ? opts.vault : opts.out;
let written = 0;
for (const e of toImport) {
const ccType = e.flags?.["campaign-codex"]?.type ?? "shop";
const note = entryToObsidian(e, db, stamp); // embeds foundry: block
const subfolder = folderPathFromCcType(ccType);
const fname = `${safeName(e.name)}.md`;
const target = join(dest, "imported", subfolder, fname);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, note.content, "utf8");
written++;
console.log(` + ${join("imported", subfolder, fname)}`);
}
console.log(`\nimported: ${written} -> ${opts.apply ? join(opts.vault, "imported") : join(opts.out, "imported")} [${opts.apply ? "APPLY" : "sandbox"}]`);
} finally {
await db.close();
}
}
main().catch((e) => { console.error(`error: ${(e as Error).message}`); process.exit(1); });