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>
126 lines
5.6 KiB
TypeScript
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); }); |