Files
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

126 lines
5.6 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Resync: push every refined note whose body changed since its last sync into live
* Foundry via the relay, in one run. "Changed" = the note's current body hash differs
* from its stored foundry.contentHash (the same self-hash the dashboard uses). After a
* successful (non-dry-run) push, the note's foundry.contentHash + syncedAt are baselined
* to the current body, so a re-run only catches NEW edits (idempotent + re-runnable:
* if the relay session dropped mid-run, just re-run; baselined notes are skipped, the
* few that failed retry).
*
* npx tsx scripts/resync.ts --vault <Refined> --out <dir> [--name-uuid <path>] [--apply] [--limit N]
*
* default (no --apply): dry-run — show which notes changed + a one-line diff summary, write nothing.
* --apply: push each changed note live (entry backed up to <out>/bak first) then baseline its note.
*
* Env: RELAY_URL/RELAY_API_KEY/RELAY_CLIENT_ID + FOUNDRY_DATA_DIR/FOUNDRY_WORLD (same as CLI).
*/
import { readdir, readFile, writeFile, copyFile, access } from "node:fs/promises";
import { join, dirname, sep } from "node:path";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import { contentHash } from "../src/normalize.js";
import { pushNote } from "../src/push.js";
import { RelayClient } from "../src/relay/client.js";
import { loadRelayConfig, loadFoundryConfig } from "../src/config.js";
import { loadNameUuidIndex } from "../src/resolver.js";
import { backupStamp } from "../src/write.js";
const SKIP_DIRS = new Set([".trash", ".obsidian", "node_modules", "_Templates", ".git"]);
function parseArgs(argv: string[]) {
const o: { vault: string; out: string; nameUuid: string; apply: boolean; limit: number } = {
vault: "", out: "", nameUuid: "", apply: false, limit: 0,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === "--vault") o.vault = next();
else if (a === "--out") o.out = next();
else if (a === "--name-uuid") o.nameUuid = next();
else if (a === "--apply") o.apply = true;
else if (a === "--limit") o.limit = Number(next());
else throw new Error(`unknown flag: ${a}`);
}
if (!o.vault || !o.out) throw new Error("--vault <Refined> and --out <dir> are required");
if (!o.nameUuid) o.nameUuid = join(o.out, "name-uuid.json");
return o;
}
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;
}
/** Update the foundry: block's contentHash + syncedAt in-place (text surgery; leaves
* cc_uuid/cc_type/folder_path and all curation untouched). */
function baselineFoundryBlock(md: string, newHash: string, newSyncedAt: string): string {
const lines = md.split(/\n/);
let i = 0;
while (i < lines.length && lines[i] !== "foundry:") i++;
if (i >= lines.length) return md;
i++;
while (i < lines.length && lines[i].startsWith(" ")) {
if (lines[i].startsWith(" contentHash:")) lines[i] = ` contentHash: ${newHash}`;
else if (lines[i].startsWith(" syncedAt:")) lines[i] = ` syncedAt: ${newSyncedAt}`;
i++;
}
return lines.join("\n");
}
async function main() {
const opts = parseArgs(process.argv);
const relay = new RelayClient(loadRelayConfig());
const fc = loadFoundryConfig();
const resolver = await loadNameUuidIndex(opts.nameUuid);
const files = await walkMd(opts.vault);
const changed: { path: string; name: string; md: string; bodyHash: string }[] = [];
for (const f of files) {
const md = await readFile(f, "utf8");
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
if (!fb?.contentHash) continue;
const cur = contentHash(body);
if (cur !== fb.contentHash) changed.push({ path: f, name: f.split(sep).pop()!.replace(/\.md$/i, ""), md, bodyHash: cur });
}
console.log(`vault notes: ${files.length}; changed since last sync: ${changed.length}`);
if (opts.limit) changed.splice(opts.limit);
let pushed = 0, baselined = 0, failed = 0;
for (const c of changed) {
try {
const outcome = await pushNote({
notePath: c.path, noteName: c.name, outDir: opts.out, relay, resolver,
foundryDataDir: fc.dataDir, world: fc.world,
dryRun: !opts.apply,
log: (m) => console.log(` [${c.name}] ${m}`),
});
if (opts.apply) {
// Baseline the note's foundry block so a re-run skips it.
const stamp = new Date().toISOString();
const baselined_md = baselineFoundryBlock(c.md, c.bodyHash, stamp);
try { await access(c.path); await copyFile(c.path, `${c.path}.bak-${stamp}`); } catch { /* new */ }
await writeFile(c.path, baselined_md, "utf8");
baselined++;
console.log(` [${c.name}] baselined contentHash + syncedAt`);
} else {
const keys = Object.keys(outcome.diff);
console.log(` [${c.name}] dry-run diff keys: ${keys.join(", ")}`);
}
pushed++;
} catch (e) {
failed++;
console.log(` [${c.name}] FAILED: ${(e as Error).message}`);
}
}
console.log(`\nresync done [${opts.apply ? "APPLY" : "dry-run"}]: ${pushed} pushed${opts.apply ? `, ${baselined} baselined` : ""}, ${failed} failed`);
if (failed && opts.apply) console.log(" re-run to retry failed notes (baselined ones are skipped).");
}
main().catch((e) => { console.error(`error: ${(e as Error).message}`); process.exit(1); });