feat(dashboard): uuid auto-match, push-all, auto-cc, link picker, rec filters

Bring every scripts/-only capability into the dashboard UI so nothing
requires a manual shell command, plus dev-mode overlay + UX fixes.

- indexAll: fall back to foundry.cc_uuid when basename doesn't match
  (basename wins; cc claimed once). Resolves refined-only orphans whose
  filename differs from the Foundry entry (e.g. "Angro Harn" vs
  "Angro Harn - Journal") with no new UI.
- Push all changed: POST /api/push-all + header button. Dry-run lists
  vault-newer notes; apply pushes each (live entry backed up to <out>/bak),
  baselines foundry.contentHash, concurrency 4. Replaces scripts/resync.ts.
  pushNote gains skipImageUpload for fast dry-run previews.
- Auto-build cc dir at launch: --cc is now optional; cmdUi synthesizes cc
  stubs from the journal via new src/ccdir.ts (generateCcDir). Replaces
  running scripts/gen-cc-dir.ts by hand.
- Refined-only table + Link-to-Foundry picker: surface the previously
  hidden refined-only rows; GET /api/entries + POST /api/link inject the
  foundry block for a chosen entry and re-index. For notes with no link.
- Dev-mode mirror overlay: index/detail/push read mirror-first so dev
  writes preview as apply would. Overlay is mtime-aware (mirror wins only
  if >= real mtime) so Obsidian edits to the real vault still surface as
  changed instead of being masked by a stale baselined mirror copy.
- Re-scan button + auto-refresh on tab focus so edits show without reload.
- Recommendation panel: "See N" buttons filter the table to a bucket, with
  an active-filter chip and filtered counts in the section headers.
- Row badge wording: short status nouns (cc-only / vault newer / ...) so
  the tag doesn't read like a button.
- Dashboard binds 0.0.0.0 by default (tailnet-reachable); --host to restrict.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-20 22:05:57 +00:00
parent ce8040c41b
commit 533f4fdc6b
8 changed files with 546 additions and 88 deletions

View File

@@ -65,8 +65,9 @@ npx tsx src/cli.ts to-foundry --dry-run --journal ... --vault ... --out ...
## Batch dashboard — connect the whole vault at once
For more than one entry, the `ui` command starts a **local-only** review dashboard
(127.0.0.1, no external network) over the batch engine. It indexes the journal
For more than one entry, the `ui` command starts a review dashboard over the batch
engine (binds 0.0.0.0 by default so it's reachable on your tailnet at this VM's IP,
port 7788; pass `--host 127.0.0.1` to restrict to localhost). It indexes the journal
LevelDB + your cc export + your refined vault, shows every file's match status and
diff, and runs the three "connect" operations:

View File

@@ -10,9 +10,8 @@
*
* npx tsx scripts/gen-cc-dir.ts --journal <snapshot> --cc <out-cc-dir>
*/
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { JournalDb } from "../src/db.js";
import { generateCcDir } from "../src/ccdir.js";
function parseArgs(argv: string[]) {
const o: { journal: string; cc: string } = { journal: "", cc: "" };
@@ -29,16 +28,7 @@ async function main() {
const { journal, cc } = parseArgs(process.argv);
const db = await JournalDb.open(journal);
try {
await mkdir(cc, { recursive: true });
const entries = db.all();
let n = 0;
for (const e of entries) {
const type = e.flags?.["campaign-codex"]?.type ?? "shop";
const safe = e.name.replace(/\//g, "-");
const body = `---\ncc_id: ${e._id}\ncc_type: ${type}\n---\n\n# ${e.name}\n`;
await writeFile(join(cc, `${safe}.md`), body, "utf8");
n++;
}
const n = await generateCcDir(db, cc);
console.log(`wrote ${n} cc stubs -> ${cc}`);
} finally {
await db.close();

View File

@@ -89,7 +89,36 @@ function basenameOf(p: string): string { return p.split(sep).pop() ?? p; }
function nameOf(p: string): string { return basenameOf(p).replace(/\.md$/i, ""); }
function rel(root: string, p: string): string { return relative(root, p); }
async function readRefinedMeta(path: string): Promise<{ type: string | null; seeded: boolean; storedHash: string | null; bodyHash: string; mtime: number | null; body: string; raw: string | null }> {
/** Walk primary, then overlay on top by relative path. The mirror shares the primary's
* relPath namespace, so a mirror-only file (e.g. a dev-mode import) is included, and a
* mirrored file replaces the real one — BUT only if the mirror copy is at least as new
* (by mtime) as the real file. If the real vault was edited in Obsidian after the dev
* write, the real file is newer and wins, so the edit shows as changed instead of being
* masked by a stale baselined mirror copy. Returns {abs, rel} so callers read abs but
* store rel (relative to the file's own root). */
async function mergedWalk(primary: string, overlay?: string): Promise<{ abs: string; rel: string }[]> {
const map = new Map<string, { abs: string; rel: string }>();
for (const p of await walkMd(primary)) map.set(rel(primary, p), { abs: p, rel: rel(primary, p) });
if (overlay) {
for (const p of await walkMd(overlay)) {
const rp = rel(overlay, p);
const cur = map.get(rp);
if (!cur) {
map.set(rp, { abs: p, rel: rp });
} else if (await mtimeOf(p) >= await mtimeOf(cur.abs)) {
// both exist: the newer-mtime one is current.
map.set(rp, { abs: p, rel: rp });
}
}
}
return [...map.values()];
}
async function mtimeOf(p: string): Promise<number> {
try { return (await stat(p)).mtimeMs; } catch { return -1; }
}
async function readRefinedMeta(path: string): Promise<{ type: string | null; seeded: boolean; storedHash: string | null; ccUuid: string | null; bodyHash: string; mtime: number | null; body: string; raw: string | null }> {
try {
const raw = await readFile(path, "utf8");
const { fm, body } = splitFrontmatter(raw);
@@ -98,16 +127,20 @@ async function readRefinedMeta(path: string): Promise<{ type: string | null; see
type: typeof fm.type === "string" ? fm.type : null,
seeded: !!foundry,
storedHash: foundry?.contentHash ?? null,
ccUuid: typeof foundry?.cc_uuid === "string" ? foundry.cc_uuid : null,
bodyHash: contentHash(body),
mtime: (await stat(path)).mtimeMs,
body,
raw,
};
} catch {
return { type: null, seeded: false, storedHash: null, bodyHash: "", mtime: null, body: "", raw: null };
return { type: null, seeded: false, storedHash: null, ccUuid: null, bodyHash: "", mtime: null, body: "", raw: null };
}
}
/** Strip the JournalEntry. prefix from a foundry.cc_uuid so it compares against a cc file's cc_id. */
function ccIdFromUuid(uuid: string): string { return uuid.replace(/^JournalEntry\./, ""); }
async function readCcMeta(path: string): Promise<{ ccId: string | null; ccType: string | null; syncHash: string | null; bodyHash: string; mtime: number | null; raw: string | null }> {
try {
const raw = await readFile(path, "utf8");
@@ -148,37 +181,59 @@ export function recommend(params: {
return "in-sync";
}
/** Index both corpora against the journal DB and produce match rows + recommendations. */
export async function indexAll(db: JournalDb, ccDir: string, refinedDir: string): Promise<IndexResult> {
const [refinedFiles, ccFiles] = await Promise.all([walkMd(refinedDir), walkMd(ccDir)]);
const ccByBasename = new Map<string, string>();
for (const p of ccFiles) ccByBasename.set(basenameOf(p), p);
/** Index both corpora against the journal DB and produce match rows + recommendations.
* In dev mode the server passes the <out>/refined and <out>/cc mirrors as overlays so
* dev-mode writes (which land in the mirror, not the real dirs) are reflected in the
* index — a dev import then shows as matched instead of still cc-only. */
export async function indexAll(
db: JournalDb, ccDir: string, refinedDir: string,
refinedOverlayDir?: string, ccOverlayDir?: string,
): Promise<IndexResult> {
const [refinedFiles, ccFiles] = await Promise.all([mergedWalk(refinedDir, refinedOverlayDir), mergedWalk(ccDir, ccOverlayDir)]);
const ccByBasename = new Map<string, { abs: string; rel: string }>();
for (const f of ccFiles) ccByBasename.set(basenameOf(f.abs), f);
// Read every cc file's meta once (used for cc_id lookup, matching, and cc-only rows).
const ccMetaByAbs = new Map<string, { ccId: string | null; ccType: string | null; syncHash: string | null; bodyHash: string; mtime: number | null; raw: string | null }>();
for (const f of ccFiles) ccMetaByAbs.set(f.abs, await readCcMeta(f.abs));
const ccById = new Map<string, { abs: string; rel: string }>();
for (const f of ccFiles) { const id = ccMetaByAbs.get(f.abs)!.ccId; if (id) ccById.set(id, f); }
// Basename matching is canonical (the exporter names cc files by entry name), so
// precompute which cc files a basename match claims before any uuid fallback —
// that way a uuid can never steal a cc that a same-named refined note owns.
const claimedCc = new Set<string>();
for (const rf of refinedFiles) { const cf = ccByBasename.get(basenameOf(rf.abs)); if (cf) claimedCc.add(cf.rel); }
const matched: FileRow[] = [];
const refinedNames = new Set<string>();
for (const rp of refinedFiles) {
const base = basenameOf(rp);
refinedNames.add(base);
const cp = ccByBasename.get(base) ?? null;
const rmeta = await readRefinedMeta(rp);
for (const rf of refinedFiles) {
const base = basenameOf(rf.abs);
let cf = ccByBasename.get(base) ?? null;
const rmeta = await readRefinedMeta(rf.abs);
// No basename match -> fall back to the refined note's foundry.cc_uuid. This
// resolves curated notes whose filename differs from the Foundry entry (e.g.
// "Angro Harn" vs "Angro Harn - Journal") but which are already linked by uuid.
if (!cf && rmeta.ccUuid) {
const byId = ccById.get(ccIdFromUuid(rmeta.ccUuid));
if (byId && !claimedCc.has(byId.rel)) { cf = byId; claimedCc.add(byId.rel); }
}
let ccId: string | null = null, ccType: string | null = null, ccSyncHash: string | null = null;
let ccChanged = false, ccMtime: number | null = null;
if (cp) {
const cmeta = await readCcMeta(cp);
if (cf) {
const cmeta = ccMetaByAbs.get(cf.abs)!;
ccId = cmeta.ccId; ccType = cmeta.ccType; ccSyncHash = cmeta.syncHash; ccMtime = cmeta.mtime;
ccChanged = ccSyncHash !== null && ccSyncHash !== cmeta.bodyHash;
}
const entry = ccId ? (db.byId(ccId) ?? null) : null;
const status: RowStatus = cp ? (entry ? "matched" : "matched-unlinked") : "refined-only";
const status: RowStatus = cf ? (entry ? "matched" : "matched-unlinked") : "refined-only";
const refinedChanged = rmeta.seeded && rmeta.storedHash !== null && rmeta.storedHash !== rmeta.bodyHash;
const recommendation = recommend({
status, seeded: rmeta.seeded, hasCc: !!cp, ccSynced: ccSyncHash !== null,
status, seeded: rmeta.seeded, hasCc: !!cf, ccSynced: ccSyncHash !== null,
refinedChanged, ccChanged, entry,
});
matched.push({
name: nameOf(rp), basename: base, status,
refinedPath: rel(refinedDir, rp), ccPath: cp ? rel(ccDir, cp) : null,
name: nameOf(rf.abs), basename: base, status,
refinedPath: rf.rel, ccPath: cf ? cf.rel : null,
ccId, ccType, curatedType: rmeta.type, entry,
recommendation, refinedChanged, ccChanged,
refinedMtime: rmeta.mtime, ccMtime,
@@ -187,15 +242,15 @@ export async function indexAll(db: JournalDb, ccDir: string, refinedDir: string)
}
const ccOnly: FileRow[] = [];
for (const cp of ccFiles) {
const base = basenameOf(cp);
if (refinedNames.has(base)) continue;
const cmeta = await readCcMeta(cp);
for (const cf of ccFiles) {
if (claimedCc.has(cf.rel)) continue; // claimed by a basename OR uuid match above
const base = basenameOf(cf.abs);
const cmeta = ccMetaByAbs.get(cf.abs)!;
const entry = cmeta.ccId ? (db.byId(cmeta.ccId) ?? null) : null;
const status: RowStatus = entry ? "cc-only" : "cc-only-unlinked";
ccOnly.push({
name: nameOf(cp), basename: base, status,
refinedPath: null, ccPath: rel(ccDir, cp),
name: nameOf(cf.abs), basename: base, status,
refinedPath: null, ccPath: cf.rel,
ccId: cmeta.ccId, ccType: cmeta.ccType, curatedType: null, entry,
recommendation: entry ? "import" : "review",
refinedChanged: false, ccChanged: false,
@@ -283,10 +338,12 @@ export function seedBlockContent(md: string, block: FoundryBlock): string {
return `${open}${lines.join("\n")}${close}${body}`;
}
/** Read a refined note and return its seeded content (foundry: block only). */
export async function seedRow(row: FileRow, refinedDir: string, syncedAt: string): Promise<{ filename: string; content: string } | null> {
/** Read a refined note and return its seeded content (foundry: block only).
* refinedAbs (optional) overrides the resolved path — used in dev mode to read
* the mirror copy when the note only exists under <out>/refined. */
export async function seedRow(row: FileRow, refinedDir: string, syncedAt: string, refinedAbs?: string): Promise<{ filename: string; content: string } | null> {
if (!row.refinedPath || !row.entry) return null;
const md = await readFile(join(refinedDir, row.refinedPath), "utf8");
const md = await readFile(refinedAbs ?? join(refinedDir, row.refinedPath), "utf8");
const { body } = splitFrontmatter(md);
return { filename: row.basename, content: seedBlockContent(md, buildBlock(row.entry, body, syncedAt)) };
}
@@ -308,10 +365,9 @@ export function stampCcSyncHash(ccContent: string, syncHash: string): string {
* Returns both files' content. Establishes the per-file ancestor for future
* direction detection with no external state.
*/
export async function syncRow(row: FileRow, refinedDir: string, db: JournalDb, exportedAt: string): Promise<{ refined: { filename: string; content: string } | null; cc: { filename: string; content: string } | null }> {
export async function syncRow(row: FileRow, refinedDir: string, db: JournalDb, exportedAt: string, refinedAbs?: string): Promise<{ refined: { filename: string; content: string } | null; cc: { filename: string; content: string } | null }> {
if (!row.refinedPath) return { refined: null, cc: null };
const full = join(refinedDir, row.refinedPath);
const md = await readFile(full, "utf8");
const md = await readFile(refinedAbs ?? join(refinedDir, row.refinedPath), "utf8");
const { body } = splitFrontmatter(md);
const cc = obsidianToCc(md, row.name, db, exportedAt);
if (!cc) return { refined: null, cc: null };
@@ -332,9 +388,9 @@ export async function syncRow(row: FileRow, refinedDir: string, db: JournalDb, e
* foundry: block with contentHash of the new body. The curated `type` is NEVER
* downgraded to Foundry's cc_type (the type-drift edge case).
*/
export async function rePullRow(row: FileRow, refinedDir: string, db: JournalDb, syncedAt: string): Promise<{ filename: string; content: string } | null> {
export async function rePullRow(row: FileRow, refinedDir: string, db: JournalDb, syncedAt: string, refinedAbs?: string): Promise<{ filename: string; content: string } | null> {
if (!row.refinedPath || !row.entry) return null;
const existing = await readFile(join(refinedDir, row.refinedPath), "utf8");
const existing = await readFile(refinedAbs ?? join(refinedDir, row.refinedPath), "utf8");
const ex = splitFrontmatter(existing);
const pulled = entryToObsidian(row.entry, db, syncedAt);
const pu = splitFrontmatter(pulled.content);

29
src/ccdir.ts Normal file
View File

@@ -0,0 +1,29 @@
// Synthesize a Campaign Codex cc export dir from a journal LevelDB snapshot, when no
// real CC export exists on disk. Writes one cc.md per CC entry, named by entry name
// (so indexAll matches refined notes by basename) with frontmatter cc_id + cc_type —
// the minimum indexAll needs to link a note to its Foundry entry (db.byId(cc_id)) and
// for seed to build the foundry: block. cc bodies are stubs; a real CC export would
// have full bodies + cc_sync_hash, but those aren't needed for the seed/push flow.
//
// Used by `cli.ts ui` (auto-build at launch when --cc is omitted) and by
// scripts/gen-cc-dir.ts.
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { JournalDb } from "./db.js";
/** Write one cc.md stub per CC entry in the journal into ccDir. Returns the count.
* Names are sanitized only of path separators (Foundry entry names are otherwise
* filename-safe in practice). */
export async function generateCcDir(db: JournalDb, ccDir: string): Promise<number> {
await mkdir(ccDir, { recursive: true });
let n = 0;
for (const e of db.all()) {
const type = e.flags?.["campaign-codex"]?.type ?? "shop";
const safe = e.name.replace(/\//g, "-");
const body = `---\ncc_id: ${e._id}\ncc_type: ${type}\n---\n\n# ${e.name}\n`;
await writeFile(join(ccDir, `${safe}.md`), body, "utf8");
n++;
}
return n;
}

View File

@@ -4,6 +4,7 @@ import { JournalDb } from "./db.js";
import { entryToObsidian } from "./toObsidian.js";
import { obsidianToCc, obsidianToFoundryJson } from "./toFoundry.js";
import { indexAll } from "./batch.js";
import { generateCcDir } from "./ccdir.js";
import { loadRelayConfig, loadFoundryConfig } from "./config.js";
import { RelayClient } from "./relay/client.js";
import { docker, acquireLock, releaseLock, lockPathFor } from "./foundry/docker.js";
@@ -168,18 +169,31 @@ function basenameNoExt(p: string): string {
export async function cmdUi(opts: CliOptions): Promise<void> {
const out = await ensureOut(opts);
if (!opts.vault) throw new Error("--vault <refined-dir> is required for ui");
if (!opts.cc) throw new Error("--cc <cc-dir> is required for ui");
if (!opts.journal) throw new Error("--journal <path> is required for ui");
// --cc is optional: if omitted, synthesize cc stubs from the journal snapshot so the
// dashboard can run without a real Campaign Codex export (or running gen-cc-dir.ts).
let ccDir = opts.cc;
let ccAutoBuilt = false;
if (!ccDir) {
ccDir = join(out, "cc-auto");
const db = await JournalDb.open(opts.journal);
try {
const n = await generateCcDir(db, ccDir);
ccAutoBuilt = true;
console.log(`ui: no --cc given — auto-built ${n} cc stubs from journal -> ${ccDir}`);
} finally { await db.close(); }
}
const { startServer } = await import("./server.js");
const relayCfg = loadRelayConfig({ url: opts.relayUrl, apiKey: opts.relayApiKey, clientId: opts.relayClientId });
const foundryCfg = loadFoundryConfig({ container: opts.foundryContainer, dataDir: opts.foundryDataDir, world: opts.foundryWorld });
const cfg = {
journal: opts.journal,
refinedDir: opts.vault,
ccDir: opts.cc,
ccDir,
outDir: out,
mode: opts.mode,
port: opts.port ?? 7788,
host: opts.host ?? "127.0.0.1",
host: opts.host ?? "0.0.0.0",
relayCfg: relayCfg.apiKey ? relayCfg : undefined,
foundryCfg: foundryCfg.dataDir ? foundryCfg : undefined,
};
@@ -188,7 +202,7 @@ export async function cmdUi(opts: CliOptions): Promise<void> {
console.log(`foundry-obsidian dashboard → http://${shown}:${cfg.port} [${cfg.mode}${opts.dryRun ? " dry-run" : ""}]`);
if (cfg.host === "0.0.0.0") console.log(` bound 0.0.0.0 — reachable on your tailnet at this VM's IP, port ${cfg.port}`);
console.log(` refined: ${cfg.refinedDir}`);
console.log(` cc: ${cfg.ccDir}`);
console.log(` cc: ${cfg.ccDir}${ccAutoBuilt ? " (auto-built from journal)" : ""}`);
console.log(` out: ${cfg.outDir}`);
console.log(` push: ${cfg.relayCfg ? `enabled (relay ${cfg.relayCfg.url})` : "disabled (set RELAY_API_KEY to enable)"}`);
// Keep the process alive serving until killed.

View File

@@ -57,6 +57,20 @@
.rec-group .label { flex:1; }
.rec-group .label small { color:var(--mut); display:block; font-size:11px; }
.rec-group button { font-size:12px; padding:3px 9px; }
.rec-group.active { background: rgba(124,156,255,.10); }
.rec-group .btns { display:flex; gap:6px; }
.rec-chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; background:var(--panel); border:1px solid var(--acc); border-radius:10px; padding:2px 4px 2px 8px; white-space:nowrap; }
.rec-chip button { padding:1px 6px; font-size:11px; border:none; background:transparent; color:var(--mut); }
.rec-chip button:hover { color:var(--bad); }
h2 .cnt { color:var(--mut); font-weight:400; font-size:11px; }
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:10; }
.modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; width:min(560px,92vw); max-height:80vh; display:flex; flex-direction:column; box-shadow:0 10px 40px rgba(0,0,0,.5); }
.modal h3 { margin:0; padding:12px 16px; border-bottom:1px solid var(--line); font-size:14px; }
.modal .modal-body { padding:10px 16px; overflow:auto; }
.modal .entry { padding:7px 10px; border-radius:6px; cursor:pointer; display:flex; gap:8px; align-items:baseline; }
.modal .entry:hover { background:var(--bg); }
.modal .entry .en { font-weight:500; }
.modal .entry .et { font-size:11px; color:var(--mut); }
.legend { margin:10px 16px; font-size:12px; color:var(--mut); line-height:1.6; }
.legend b { color:var(--txt); }
</style>
@@ -66,18 +80,21 @@
<h1>Foundry ⇄ Obsidian merge</h1>
<div class="counts" id="counts">loading…</div>
<span class="mode-tag" id="modeTag">dev</span>
<button onclick="refreshIndex()" title="Re-scan the vault + cc from disk so edits made in Obsidian show as changed. Also happens automatically when you switch back to this tab.">Re-scan</button>
<div class="spacer"></div>
<label><input type="checkbox" id="dryRun" checked /> dry-run</label>
<button class="primary" onclick="act('seedAll')" title="Inject the foundry: identity block into every matched refined note that lacks one. Curation (type/tags/aliases/body) is left untouched. Safe to run repeatedly.">Seed all</button>
<button class="primary" onclick="act('syncAll')" title="For every matched note: regenerate cc.md from the refined note AND refresh the refined foundry: block. Baselines both sides — writes cc_sync_hash into cc and refreshes foundry.contentHash. Curation flows back to cc.">Sync→cc all</button>
<button onclick="act('repullAll')" title="For every matched note: regenerate the refined note body from Foundry, preserving curated type/aliases/status tags. Use when Foundry/cc is newer than your vault.">Re-pull all</button>
<button onclick="act('importAll')" title="Pull every cc-only Foundry entry (not yet in your vault) into a new refined note under refined/imported/<folder>. Un-curated staging for review.">Import all</button>
<button onclick="pushAll()" title="Push every vault-newer (sync→cc) note into the LIVE Foundry world via the relay in one run. dry-run (default) lists what would be pushed; uncheck dry-run to apply — each live entry is backed up to <out>/bak first and the note's foundry.contentHash is baselined so a re-run only catches new edits. Replaces scripts/resync.ts.">Push all changed</button>
<button onclick="refreshLive()" title="Rebuild the cached name↔uuid map (for link resolution in pushes) via the relay /search — zero Foundry downtime. The heavy docker-stop full index is CLI-only: npx tsx src/cli.ts refresh --full-index.">Refresh live index</button>
</header>
<main>
<section class="list">
<div class="toolbar">
<input type="text" id="filter" placeholder="filter by name…" oninput="render()" />
<span id="recFilterChip" class="rec-chip" style="display:none"></span>
</div>
<details class="rec-panel" id="recPanel" open>
@@ -91,26 +108,30 @@
<p><b>Priority:</b> content missing from your vault first (import), then unlinked notes (seed), then vault-newer (sync→cc), then cc-newer (re-pull), then both-changed (conflict — review manually).</p>
</details>
<h2>Matched (refined ⇄ cc)</h2>
<h2 id="matchedH2">Matched (refined ⇄ cc)</h2>
<table id="matchedTable"><tbody></tbody></table>
<h2>cc-only (import candidates)</h2>
<h2 id="ccOnlyH2">cc-only (import candidates)</h2>
<table id="ccOnlyTable"><tbody></tbody></table>
<h2 id="refinedOnlyH2">refined-only (no Foundry link)</h2>
<table id="refinedOnlyTable"><tbody></tbody></table>
</section>
<section class="detail" id="detail">Select a row to inspect.</section>
</main>
<script>
let INDEX = null, STATUS = null, SEL = null;
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null;
const dryEl = () => document.getElementById('dryRun');
// Recommendation -> display label, badge class, bulk op, and one-line guidance.
// `tag` is the short status noun shown on each row (a state, not an action — so it
// doesn't read like a button); `label` is the fuller heading used in the rec panel.
const REC = {
'import': { label: 'Import into vault', badge: 'acc', bulk: 'importAll', tip: 'In Foundry/cc but not in your vault. Pull it in as a new (un-curated) refined note to review.' },
'seed': { label: 'Seed / link', badge: 'warn', bulk: 'seedAll', tip: 'In your vault and in Foundry, but not yet linked. Inject the foundry: identity block. Curation untouched.' },
'sync-cc': { label: 'Push to cc', badge: 'acc', bulk: 'syncAll', tip: 'Your vault copy is newer than the last sync. Regenerate cc.md from the refined note and baseline both sides.' },
'repull': { label: 'Re-pull', badge: 'pur', bulk: 'repullAll', tip: 'Foundry/cc is newer than your vault. Regenerate the refined body from Foundry, preserving your curation.' },
'conflict': { label: 'Conflict', badge: 'bad', bulk: null, tip: 'Both sides changed since last sync. Open the file and decide which side wins manually.' },
'in-sync': { label: 'In sync', badge: 'ok', bulk: null, tip: 'Neither side changed since last sync. Nothing to do.' },
'review': { label: 'Review', badge: 'warn', bulk: null, tip: 'Unlinked or ambiguous — needs a human decision before any action.' },
'import': { label: 'Import into vault', tag: 'cc-only', badge: 'acc', bulk: 'importAll', tip: 'In Foundry/cc but not in your vault. Pull it in as a new (un-curated) refined note to review.' },
'seed': { label: 'Seed / link', tag: 'unlinked', badge: 'warn', bulk: 'seedAll', tip: 'In your vault and in Foundry, but not yet linked. Inject the foundry: identity block. Curation untouched.' },
'sync-cc': { label: 'Push to cc', tag: 'vault newer', badge: 'acc', bulk: 'syncAll', tip: 'Your vault copy is newer than the last sync. Regenerate cc.md from the refined note and baseline both sides.' },
'repull': { label: 'Re-pull', tag: 'cc newer', badge: 'pur', bulk: 'repullAll', tip: 'Foundry/cc is newer than your vault. Regenerate the refined body from Foundry, preserving your curation.' },
'conflict': { label: 'Conflict', tag: 'conflict', badge: 'bad', bulk: null, tip: 'Both sides changed since last sync. Open the file and decide which side wins manually.' },
'in-sync': { label: 'In sync', tag: 'in sync', badge: 'ok', bulk: null, tip: 'Neither side changed since last sync. Nothing to do.' },
'review': { label: 'Review', tag: 'review', badge: 'warn', bulk: null, tip: 'Unlinked or ambiguous — needs a human decision before any action.' },
};
// Order in which the guided panel shows groups (missing-from-vault first).
const REC_ORDER = ['import','seed','sync-cc','repull','conflict','in-sync','review'];
@@ -131,7 +152,7 @@ function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;'
function attr(s){ return (s==null?'':String(s)).replace(/[&"]/g,c=>({'&':'&amp;','"':'&quot;'}[c])); }
function recBadge(r){
const m = REC[r.recommendation] || REC['review'];
return `<span class="badge ${m.badge}" title="${attr(m.tip)}">${m.label}</span>`;
return `<span class="badge ${m.badge}" title="${attr(m.tip)}">${m.tag || m.label}</span>`;
}
function fmtMtime(ms){ if (!ms) return ''; const d = new Date(ms); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }
function mtimeHint(r){
@@ -148,19 +169,47 @@ function renderRecPanel(){
if (!total){ el.innerHTML = '<div class="rec-group"><span class="label">Everything is in sync. ✨</span></div>'; return; }
el.innerHTML = REC_ORDER.filter(k => (b[k]||0) > 0).map(k => {
const m = REC[k];
const btn = m.bulk
const see = `<button data-op="seeRec" data-rec="${k}" title="Filter the table down to just these ${b[k]} file(s) in this bucket.">See ${b[k]}</button>`;
const run = m.bulk
? `<button class="primary" data-op="${m.bulk}" title="${attr(m.tip)}">Run ${m.bulk.replace('All',' all')}</button>`
: `<button disabled title="${attr(m.tip)}">manual</button>`;
return `<div class="rec-group"><span class="num">${b[k]}</span>
<span class="label">${m.label}<small>${esc(m.tip)}</small></span>${btn}</div>`;
return `<div class="rec-group${REC_FILTER === k ? ' active' : ''}"><span class="num">${b[k]}</span>
<span class="label">${m.label}<small>${esc(m.tip)}</small></span><span class="btns">${see}${run}</span></div>`;
}).join('');
}
// Filter the table to a single recommendation bucket (set by the "See N" buttons).
function seeRec(k){
REC_FILTER = REC_FILTER === k ? null : k; // toggle off if clicked again
renderRecPanel(); render();
document.getElementById('matchedTable').scrollIntoView({block:'start'});
}
function clearRecFilter(){ REC_FILTER = null; renderRecPanel(); render(); }
function renderRecChip(){
const el = document.getElementById('recFilterChip');
if (!REC_FILTER){ el.style.display='none'; el.innerHTML=''; return; }
const m = REC[REC_FILTER] || REC['review'];
const count = (INDEX.byRecommendation[REC_FILTER]||0);
el.style.display='inline-flex';
el.innerHTML = `<span class="badge ${m.badge}">${m.label}</span><span class="meta">${count} shown</span><button data-op="clearRecFilter" title="Clear this filter (show all files).">✕</button>`;
}
function render(){
const q = document.getElementById('filter').value.toLowerCase().trim();
const m = INDEX.matched.filter(r => !q || r.name.toLowerCase().includes(q));
const c = INDEX.ccOnly.filter(r => !q || r.name.toLowerCase().includes(q));
const pass = r => (!q || r.name.toLowerCase().includes(q)) && (!REC_FILTER || r.recommendation === REC_FILTER);
const m = INDEX.matched.filter(pass);
const c = INDEX.ccOnly.filter(pass);
const ro = (INDEX.refinedOnly || []).filter(pass);
document.querySelector('#matchedTable tbody').innerHTML = m.map(rowHtml).join('');
document.querySelector('#ccOnlyTable tbody').innerHTML = c.map(rowHtml).join('');
document.querySelector('#refinedOnlyTable tbody').innerHTML = ro.map(refinedOnlyRowHtml).join('');
// Reflect filtered counts in the section headers when a filter narrows the view.
const totM = INDEX.matched.length, totC = INDEX.ccOnly.length, totRO = (INDEX.refinedOnly||[]).length;
document.getElementById('matchedH2').innerHTML =
`Matched (refined ⇄ cc)${(q||REC_FILTER) ? ` <span class="cnt">${m.length}/${totM}</span>` : ''}`;
document.getElementById('ccOnlyH2').innerHTML =
`cc-only (import candidates)${(q||REC_FILTER) ? ` <span class="cnt">${c.length}/${totC}</span>` : ''}`;
document.getElementById('refinedOnlyH2').innerHTML =
`refined-only (no Foundry link)${(q||REC_FILTER) ? ` <span class="cnt">${ro.length}/${totRO}</span>` : totRO ? ` <span class="cnt">${totRO}</span>` : ''}`;
renderRecChip();
}
// The single recommended action button for a row (its primary op), plus a "view" button.
function recAction(r){
@@ -186,6 +235,16 @@ function rowHtml(r){
<td style="color:var(--mut)">${esc(meta)}<br/>${mtimeHint(r)}</td>
<td class="rowbtns">${recAction(r)} <button data-op="push" data-name="${attr(r.name)}" title="Push this refined note into the LIVE Foundry world via the relay (Foundry keeps running). dry-run shows the diff; uncheck dry-run to apply (writes a .bak first). This updates Foundry — unlike sync→cc, which only writes the local cc.md artifact.">push</button> <button data-op="view" data-name="${attr(r.name)}" title="Open this file in the detail panel: see refined + cc side by side, the seed/sync/re-pull previews, and the Foundry entry.">view</button></td></tr>`;
}
// A refined-only note (in the vault but not linked to any Foundry entry). Offer a
// "link" button that opens the Foundry entry picker; once linked by uuid it matches.
function refinedOnlyRowHtml(r){
const cls = SEL === r.name ? 'sel' : '';
return `<tr class="${cls}" data-name="${attr(r.name)}">
<td class="name">${esc(r.name)}</td>
<td>${recBadge(r)}</td>
<td style="color:var(--mut)">${esc(r.refinedPath || '')}</td>
<td class="rowbtns"><button class="rec" data-op="link" data-name="${attr(r.name)}" title="Link this note to a Foundry journal entry by name — injects the foundry: identity block (cc_uuid) so it matches. Curation untouched.">link</button> <button data-op="view" data-name="${attr(r.name)}" title="Open this file in the detail panel.">view</button></td></tr>`;
}
async function select(name){
SEL = name; render();
const d = document.getElementById('detail');
@@ -259,15 +318,110 @@ async function refreshLive(){
if (r.error){ toast('error: ' + r.error); return; }
toast(`live index refreshed: ${r.pairs} name↔uuid pairs cached`);
}
// Push every vault-newer (sync-cc) note into live Foundry in one run. dry-run (default)
// lists what would be pushed; apply pushes each, backs up the live entry, and baselines
// the note so a re-run only catches new edits. Replaces scripts/resync.ts.
async function pushAll(){
const dryRun = dryEl().checked;
toast(`push all changed ${dryRun?'(dry-run)':'(apply)'}… this may take a moment`);
const r = await fetch('/api/push-all', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({dryRun})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
renderPushAllResults(r);
if (r.dryRun){
toast(`[dry-run] push all: ${r.wouldPush} note(s) would be pushed into live Foundry`);
} else {
toast(`pushed ${r.pushed}/${r.total}${r.failed?', '+r.failed+' failed':''} · baselined ${r.baselined}`);
INDEX = await fetch('/api/index').then(r => r.json());
renderRecPanel(); render();
}
}
function renderPushAllResults(r){
const d = document.getElementById('detail');
const head = r.dryRun
? `<div class="panel"><h3>Push all — dry-run preview</h3><p class="meta">${r.wouldPush} vault-newer note(s) would be pushed into live Foundry. Uncheck dry-run and click "Push all changed" again to apply.</p></div>`
: `<div class="panel"><h3>Push all — applied</h3><p class="meta">pushed ${r.pushed}/${r.total}, failed ${r.failed}, baselined ${r.baselined}. Live-entry backups in <code>${STATUS.outDir}/bak</code>. Re-run to retry any failures (baselined notes are skipped).</p></div>`;
const items = (r.preview || r.results || []).map(it => {
const stat = r.dryRun
? `<span class="meta">${it.ccUuid ? esc(it.ccUuid) : '(no foundry link — would be skipped)'}</span>`
: (it.ok
? `<span class="badge ok">pushed</span>${it.imageNote && it.imageNote !== 'no portrait field' ? ' · ' + esc(it.imageNote) : ''}`
: `<span class="badge bad">failed</span> ${esc(it.error || '')}`);
return `<div class="panel"><h3>${esc(it.name)}</h3><p class="meta">${stat}</p>${it.logs ? `<pre>${esc(it.logs.join('\n'))}</pre>` : ''}</div>`;
}).join('');
d.innerHTML = head + items;
}
// Link a refined-only note to a Foundry entry: open a picker modal of journal entries,
// filtered by name. Selecting one POSTs /api/link (injects the foundry: block) and
// re-indexes so the note leaves refined-only and becomes matched.
let LINK_ENTRIES = null, LINK_NAME = null;
async function linkPicker(name){
LINK_NAME = name;
if (!LINK_ENTRIES) {
const r = await fetch('/api/entries').then(r => r.json());
if (r.error) { toast('error: ' + r.error); return; }
LINK_ENTRIES = r.entries || [];
}
const bg = document.createElement('div'); bg.className = 'modal-bg';
bg.innerHTML = `<div class="modal">
<h3>Link "${esc(name)}" to a Foundry entry</h3>
<div class="modal-body">
<input type="text" id="linkFilter" placeholder="filter by entry name…" oninput="renderLinkList()" style="margin-bottom:8px" />
<div id="linkList"></div>
</div>
</div>`;
document.body.appendChild(bg);
bg.addEventListener('click', (e) => { if (e.target === bg) bg.remove(); });
document.getElementById('linkFilter').focus();
renderLinkList();
}
function renderLinkList(){
const q = (document.getElementById('linkFilter')?.value || '').toLowerCase().trim();
const list = document.getElementById('linkList');
if (!list) return;
const hits = LINK_ENTRIES.filter(e => !q || e.name.toLowerCase().includes(q)).slice(0, 200);
list.innerHTML = hits.length ? hits.map(e =>
`<div class="entry" data-uuid="${attr(e.uuid)}"><span class="en">${esc(e.name)}</span><span class="et">${esc(e.type || '')} · ${esc(e.uuid)}</span></div>`).join('')
: '<p class="meta">no entries match</p>';
list.querySelectorAll('.entry').forEach(el => el.onclick = () => doLink(LINK_NAME, el.dataset.uuid));
}
async function doLink(name, uuid){
document.querySelector('.modal-bg')?.remove();
const dryRun = dryEl().checked;
toast(`link ${name} ${dryRun?'(dry-run)':'(apply)'}`);
const r = await fetch('/api/link', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, uuid, dryRun})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
if (r.dryRun){ toast(`[dry-run] link ${name} -> ${uuid}`); }
else {
toast(r.message || `linked ${name}`);
INDEX = await fetch('/api/index').then(r => r.json());
renderRecPanel(); render();
}
}
init();
// Re-scan the vault + cc from disk and re-render, preserving the current filter/selection.
// Called by the "Re-scan" button and automatically when the tab regains focus (so edits
// made in Obsidian show up without a manual refresh).
async function refreshIndex(){
INDEX = await fetch('/api/index').then(r => r.json());
const c = INDEX.counts;
document.getElementById('counts').innerHTML =
`matched <b>${c.matched}</b> · cc-only <b>${c.ccOnly}</b> · refined-only <b>${c.refinedOnly}</b> · unlinked <b>${c.unlinked}</b>`;
renderRecPanel(); render();
if (SEL) select(SEL);
}
// Switching back from Obsidian (or another tab) re-scans automatically.
window.addEventListener('focus', refreshIndex);
// Delegated clicks for dynamically-rendered rows/buttons (data-name is HTML-escaped,
// so names with spaces/quotes can't break the attribute the way inline onclick + JSON could).
document.querySelector('section.list').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-op]');
if (btn) {
e.stopPropagation();
const op = btn.dataset.op, name = btn.dataset.name;
const op = btn.dataset.op, name = btn.dataset.name, rec = btn.dataset.rec;
if (op === 'view') select(name);
else if (op === 'seeRec') seeRec(rec);
else if (op === 'clearRecFilter') clearRecFilter();
else if (op === 'link' && name) linkPicker(name);
else if (op === 'push' && name) pushRow(name);
else if (name) act(op, [name]);
else act(op); // bulk ops (seedAll/syncAll/repullAll/importAll) carry no data-name

View File

@@ -39,6 +39,10 @@ export interface PushDeps {
dryRun: boolean;
/** Optional preloaded resolver; else load <outDir>/name-uuid.json (or build via /search). */
resolver?: NameResolver;
/** Skip the portrait copy into Foundry's uploads dir (used for a fast batch dry-run
* preview that shouldn't touch the real Foundry data dir). imageNote still reports
* whether a portrait is present. */
skipImageUpload?: boolean;
log?: (msg: string) => void;
}
@@ -141,7 +145,10 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
let imageOverride: string | null | undefined = undefined; // undefined = keep existing
let imageNote = "no portrait field";
if (portrait) {
if (!deps.foundryDataDir || !deps.world) {
if (deps.skipImageUpload) {
imageNote = "portrait present (skipped in dry-run preview)";
log(`push: image ${imageNote}`);
} else if (!deps.foundryDataDir || !deps.world) {
imageNote = "portrait present but FOUNDRY_DATA_DIR/WORLD unset — keeping existing image";
log(`push: image ${imageNote}`);
} else {

View File

@@ -1,6 +1,7 @@
// Local-only review dashboard for the batch connector.
//
// Binds 127.0.0.1 only (never 0.0.0.0) — no external network exposure. Serves a
// Binds 0.0.0.0 by default (reachable on the tailnet at this VM's IP); pass
// --host 127.0.0.1 to restrict to localhost. Serves a
// single static dashboard page and a small JSON API over the batch engine. The
// journal LevelDB is opened read-only. Writes go only to --out in dev mode; in
// apply mode they back up then write to the real refined/cc dirs. dry-run never
@@ -8,14 +9,16 @@
// unless the server was started with --apply.
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
import { readFile, writeFile, mkdir, copyFile, access } from "node:fs/promises";
import { readFile, writeFile, mkdir, copyFile, access, stat } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { JournalDb } from "./db.js";
import { indexAll, seedRow, syncRow, rePullRow, importRow, type FileRow, type IndexResult } from "./batch.js";
import { indexAll, seedRow, syncRow, rePullRow, importRow, buildBlock, seedBlockContent, type FileRow, type IndexResult } from "./batch.js";
import { RelayClient } from "./relay/client.js";
import { pushNote } from "./push.js";
import { nameUuidIndexFromEntries, saveNameUuidIndex } from "./resolver.js";
import { nameUuidIndexFromEntries, saveNameUuidIndex, loadNameUuidIndex, MapNameResolver, type NameResolver } from "./resolver.js";
import { splitFrontmatter } from "./frontmatter.js";
import { contentHash } from "./normalize.js";
import { backupStamp } from "./write.js";
import type { RelayConfig, FoundryHostConfig } from "./config.js";
import type { Mode } from "./types.js";
@@ -30,7 +33,7 @@ export interface ServerConfig {
outDir: string;
mode: Mode; // dev | apply — fixed at startup
port: number;
host: string; // bind address (127.0.0.1 by default; 0.0.0.0 to expose on the network)
host: string; // bind address (0.0.0.0 by default to expose on the tailnet; 127.0.0.1 to restrict to localhost)
// Live push path (optional). When relayCfg is set, /api/push and /api/refresh work.
relayCfg?: RelayConfig;
foundryCfg?: FoundryHostConfig;
@@ -73,6 +76,41 @@ function targetPath(state: State, bucket: "refined" | "cc", relPath: string): st
return join(state.cfg.outDir, bucket, relPath);
}
/** Resolve where to READ a file: in dev mode the mirror wins if it has this relPath
* AND is at least as new (mtime) as the real file — otherwise the real vault was edited
* (e.g. in Obsidian) after the dev write and is current. This is what makes dev mode a
* true preview without masking live vault edits. */
async function resolveRefined(state: State, relPath: string): Promise<string> {
const real = join(state.cfg.refinedDir, relPath);
if (state.cfg.mode === "dev") {
const mirror = join(state.cfg.outDir, "refined", relPath);
try {
const mOv = (await stat(mirror)).mtimeMs;
try { if (mOv >= (await stat(real)).mtimeMs) return mirror; }
catch { return mirror; } // real missing -> mirror
} catch { /* mirror missing -> real */ }
}
return real;
}
async function resolveCc(state: State, relPath: string): Promise<string> {
const real = join(state.cfg.ccDir, relPath);
if (state.cfg.mode === "dev") {
const mirror = join(state.cfg.outDir, "cc", relPath);
try {
const mOv = (await stat(mirror)).mtimeMs;
try { if (mOv >= (await stat(real)).mtimeMs) return mirror; }
catch { return mirror; }
} catch { /* mirror missing -> real */ }
}
return real;
}
/** The mirror dirs to overlay on the index in dev mode (undefined in apply mode). */
function overlayDirs(state: State): { refinedOverlay?: string; ccOverlay?: string } {
if (state.cfg.mode !== "dev") return {};
return { refinedOverlay: join(state.cfg.outDir, "refined"), ccOverlay: join(state.cfg.outDir, "cc") };
}
async function writeWithBackup(target: string, content: string, state: State): Promise<{ path: string; bytes: number }> {
if (state.cfg.mode === "apply") {
try {
@@ -124,19 +162,19 @@ async function runAction(state: State, op: string, names: string[] | null, dryRu
for (const row of rows) {
if (op === "seed" || op === "seedAll") {
if (!row.refinedPath || !row.entry) { result.skipped.push({ name: row.name, reason: "no refined note or no journal entry (unlinked)" }); continue; }
const out = await seedRow(row, state.cfg.refinedDir, stamp);
const out = await seedRow(row, state.cfg.refinedDir, stamp, await resolveRefined(state, row.refinedPath));
if (!out) { result.skipped.push({ name: row.name, reason: "seed produced no output" }); continue; }
await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.filename, out.content, dryRun, result);
} else if (op === "sync" || op === "syncAll") {
if (!row.refinedPath || !row.ccPath) { result.skipped.push({ name: row.name, reason: "no refined/cc counterpart" }); continue; }
const out = await syncRow(row, state.cfg.refinedDir, state.db, stamp);
const out = await syncRow(row, state.cfg.refinedDir, state.db, stamp, await resolveRefined(state, row.refinedPath));
if (!out || (!out.refined && !out.cc)) { result.skipped.push({ name: row.name, reason: "sync produced no output (no Foundry match)" }); continue; }
// sync baselines BOTH sides: write the refreshed refined note AND the regenerated cc.
if (out.refined) await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.refined.filename, out.refined.content, dryRun, result);
if (out.cc) await writeOrPreview(state, targetPath(state, "cc", row.ccPath), out.cc.filename, out.cc.content, dryRun, result);
} else if (op === "repull" || op === "repullAll") {
if (!row.refinedPath || !row.entry) { result.skipped.push({ name: row.name, reason: "no refined note or no journal entry (unlinked)" }); continue; }
const out = await rePullRow(row, state.cfg.refinedDir, state.db, stamp);
const out = await rePullRow(row, state.cfg.refinedDir, state.db, stamp, await resolveRefined(state, row.refinedPath));
if (!out) { result.skipped.push({ name: row.name, reason: "re-pull produced no output" }); continue; }
await writeOrPreview(state, targetPath(state, "refined", row.refinedPath), out.filename, out.content, dryRun, result);
} else if (op === "import" || op === "importAll") {
@@ -160,14 +198,15 @@ async function fileDetail(state: State, name: string): Promise<unknown> {
const row = state.index.matched.find((r) => r.name === name || r.basename === name)
?? state.index.ccOnly.find((r) => r.name === name || r.basename === name);
if (!row) throw new Error(`no row for ${name}`);
const refined = row.refinedPath ? await readFile(join(state.cfg.refinedDir, row.refinedPath), "utf8").catch(() => null) : null;
const cc = row.ccPath ? await readFile(join(state.cfg.ccDir, row.ccPath), "utf8").catch(() => null) : null;
const refined = row.refinedPath ? await readFile(await resolveRefined(state, row.refinedPath), "utf8").catch(() => null) : null;
const cc = row.ccPath ? await readFile(await resolveCc(state, row.ccPath), "utf8").catch(() => null) : null;
const stamp = new Date().toISOString();
const seedPreview = row.refinedPath && row.entry ? (await seedRow(row, state.cfg.refinedDir, stamp))?.content ?? null : null;
const synced = row.refinedPath ? await syncRow(row, state.cfg.refinedDir, state.db, stamp) : null;
const refinedAbs = row.refinedPath ? await resolveRefined(state, row.refinedPath) : undefined;
const seedPreview = row.refinedPath && row.entry ? (await seedRow(row, state.cfg.refinedDir, stamp, refinedAbs))?.content ?? null : null;
const synced = row.refinedPath ? await syncRow(row, state.cfg.refinedDir, state.db, stamp, refinedAbs) : null;
const syncPreview = synced?.cc?.content ?? null;
const syncRefinedPreview = synced?.refined?.content ?? null;
const repullPreview = row.refinedPath && row.entry ? (await rePullRow(row, state.cfg.refinedDir, state.db, stamp))?.content ?? null : null;
const repullPreview = row.refinedPath && row.entry ? (await rePullRow(row, state.cfg.refinedDir, state.db, stamp, refinedAbs))?.content ?? null : null;
return { row, refined, cc, entry: row.entry, seedPreview, syncPreview, syncRefinedPreview, repullPreview };
}
@@ -204,7 +243,7 @@ async function handlePush(state: State, req: IncomingMessage, res: ServerRespons
const relay = relayClient(state);
const logs: string[] = [];
const outcome = await pushNote({
notePath: join(state.cfg.refinedDir, row.refinedPath),
notePath: await resolveRefined(state, row.refinedPath),
noteName: row.name,
outDir: state.cfg.outDir,
relay,
@@ -233,11 +272,168 @@ async function handleRefresh(state: State, req: IncomingMessage, res: ServerResp
}
}
interface PushAllItem {
name: string;
ok: boolean;
ccUuid?: string;
backupPath?: string;
imageNote?: string;
error?: string;
logs?: string[];
}
/** Rewrite the foundry: block's contentHash + syncedAt in-place (text surgery; leaves
* cc_uuid/cc_type/folder_path and all curation untouched). Mirrors resync.ts. */
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++;
let changed = false;
while (i < lines.length && lines[i].startsWith(" ")) {
if (lines[i].startsWith(" contentHash:")) { lines[i] = ` contentHash: ${newHash}`; changed = true; }
else if (lines[i].startsWith(" syncedAt:")) { lines[i] = ` syncedAt: ${newSyncedAt}`; changed = true; }
i++;
}
return changed ? lines.join("\n") : md;
}
/** After a successful live push, baseline the note's foundry.contentHash to the current
* body hash so a re-run of push-all skips it (idempotent). Writes via the mirror-aware
* target (dev: mirror; apply: real vault with .bak). Returns true if it wrote. */
async function baselineNote(state: State, relPath: string, absPath: string): Promise<boolean> {
const md = await readFile(absPath, "utf8");
const { body } = splitFrontmatter(md);
const updated = baselineFoundryBlock(md, contentHash(body), new Date().toISOString());
if (updated === md) return false;
await writeWithBackup(targetPath(state, "refined", relPath), updated, state);
return true;
}
/** Run fn over items with bounded concurrency (preserves item order in results). */
async function mapPool<T>(items: T[], concurrency: number, fn: (item: T, i: number) => Promise<void>): Promise<void> {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (next < items.length) { const i = next++; await fn(items[i], i); }
});
await Promise.all(workers);
}
/** POST /api/push-all { dryRun } — push every vault-newer (sync-cc) matched note into
* live Foundry via the relay in one run. dryRun (default) just lists what would be
* pushed (no relay calls). apply pushes each (live entry backed up to <out>/bak first)
* and baselines the note's foundry.contentHash so a re-run only catches new edits.
* Replaces scripts/resync.ts. */
async function handlePushAll(state: State, req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });
const dryRun = body.dryRun !== false; // default to dry-run from the dashboard (safety)
if (!state.index) throw new Error("index not loaded");
try {
// Rebuild the index first so push-all reflects current file state (the user may
// have edited notes in Obsidian since the UI last fetched /api/index).
const ov = overlayDirs(state);
state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir, ov.refinedOverlay, ov.ccOverlay);
const rows = state.index.matched.filter((r) => r.recommendation === "sync-cc" && r.refinedPath);
if (dryRun) {
const preview = rows.map((r) => ({ name: r.name, ccUuid: r.ccId ? `JournalEntry.${r.ccId}` : null, refinedPath: r.refinedPath }));
return send(res, 200, { dryRun: true, total: rows.length, wouldPush: rows.length, preview });
}
const relay = relayClient(state);
// Preload the name↔uuid resolver once (cache, else build via /search) so we don't
// rebuild it per note.
let resolver: NameResolver;
try {
resolver = await loadNameUuidIndex(join(state.cfg.outDir, "name-uuid.json"));
} catch {
const results = await relay.searchJournalEntries();
const idx = nameUuidIndexFromEntries(results.map((r) => ({ name: r.name, uuid: r.uuid })));
await saveNameUuidIndex(idx, join(state.cfg.outDir, "name-uuid.json"));
resolver = new MapNameResolver(idx);
}
const foundryDataDir = state.cfg.foundryCfg?.dataDir ?? "";
const world = state.cfg.foundryCfg?.world ?? "";
const results: PushAllItem[] = [];
let pushed = 0, failed = 0, baselined = 0;
await mapPool(rows, 4, async (row) => {
const item: PushAllItem = { name: row.name, ok: false };
const logs: string[] = [];
try {
const notePath = await resolveRefined(state, row.refinedPath!);
const outcome = await pushNote({
notePath, noteName: row.name, outDir: state.cfg.outDir, relay,
foundryDataDir, world, dryRun: false, resolver, log: (m) => logs.push(m),
});
item.ok = true; item.ccUuid = outcome.ccUuid; item.backupPath = outcome.backupPath; item.imageNote = outcome.imageNote;
pushed++;
if (await baselineNote(state, row.refinedPath!, notePath)) baselined++;
} catch (e) {
item.error = (e as Error).message; failed++;
}
if (logs.length) item.logs = logs;
results.push(item);
});
send(res, 200, { dryRun: false, total: rows.length, pushed, failed, baselined, results });
} catch (e) {
send(res, 500, { error: (e as Error).message });
}
}
/** GET /api/entries — list Foundry journal entries (name + uuid + type) from the
* journal snapshot, for the refined-only "Link to Foundry" picker. */
async function handleEntries(state: State, res: ServerResponse): Promise<void> {
const entries = state.db.all().map((e) => ({
name: e.name,
uuid: `JournalEntry.${e._id}`,
type: e.flags?.["campaign-codex"]?.type ?? null,
}));
send(res, 200, { entries });
}
/** POST /api/link { name, uuid, dryRun } — inject the foundry: identity block for the
* chosen Foundry entry into a refined-only (unlinked) note, linking it by uuid. The
* next index then matches it (uuid fallback) and it leaves refined-only. Reuses the
* same buildBlock/seedBlockContent the seed op uses; curation is untouched. */
async function handleLink(state: State, req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });
const name = typeof body.name === "string" ? body.name : "";
const uuid = typeof body.uuid === "string" ? body.uuid : "";
if (!name || !uuid) return send(res, 400, { error: "missing name or uuid" });
if (!state.index) throw new Error("index not loaded");
try {
const row = (state.index.refinedOnly ?? []).find((r) => r.name === name)
?? state.index.matched.find((r) => r.name === name);
if (!row?.refinedPath) return send(res, 400, { error: `no refined note for "${name}"` });
const ccId = uuid.replace(/^JournalEntry\./, "");
const entry = state.db.byId(ccId);
if (!entry) return send(res, 400, { error: `no Foundry entry for uuid ${uuid}` });
const abs = await resolveRefined(state, row.refinedPath);
const md = await readFile(abs, "utf8");
const { body: noteBody } = splitFrontmatter(md);
const content = seedBlockContent(md, buildBlock(entry, noteBody, new Date().toISOString()));
if (body.dryRun === true) {
return send(res, 200, { dryRun: true, name, uuid, preview: content });
}
const target = targetPath(state, "refined", row.refinedPath);
const written = await writeWithBackup(target, content, state);
// Re-index so the note shows as matched (linked by uuid) instead of refined-only.
const ov = overlayDirs(state);
state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir, ov.refinedOverlay, ov.ccOverlay);
send(res, 200, { dryRun: false, name, uuid, written: [written], message: `linked "${name}" -> ${uuid}` });
} catch (e) {
send(res, 500, { error: (e as Error).message });
}
}
export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> {
const db = await JournalDb.open(cfg.journal);
const state: State = { db, cfg, index: null };
// Build the index once at startup (the corpora are static while the server runs).
state.index = await indexAll(db, cfg.ccDir, cfg.refinedDir);
// Build the index once at startup. In dev mode overlay the <out> mirror so dev
// writes are reflected (the corpora are otherwise static while the server runs).
const ov = overlayDirs(state);
state.index = await indexAll(db, cfg.ccDir, cfg.refinedDir, ov.refinedOverlay, ov.ccOverlay);
const server = createServer(async (req, res) => {
try {
@@ -260,9 +456,11 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
}
if (req.method === "GET" && url.pathname === "/api/index") {
// Rebuild on every request: in apply mode the source files change after an
// action, so a cached index would show stale recommendations. Indexing is
// hash-only (no per-row conversion), so this stays cheap.
state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir);
// action, so a cached index would show stale recommendations; in dev mode the
// <out> mirror changes after an action. Indexing is hash-only (no per-row
// conversion), so this stays cheap.
const ov = overlayDirs(state);
state.index = await indexAll(state.db, state.cfg.ccDir, state.cfg.refinedDir, ov.refinedOverlay, ov.ccOverlay);
return send(res, 200, state.index);
}
if (req.method === "GET" && url.pathname === "/api/status") {
@@ -279,6 +477,15 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
if (req.method === "POST" && url.pathname === "/api/push") {
return handlePush(state, req, res);
}
if (req.method === "POST" && url.pathname === "/api/push-all") {
return handlePushAll(state, req, res);
}
if (req.method === "GET" && url.pathname === "/api/entries") {
return handleEntries(state, res);
}
if (req.method === "POST" && url.pathname === "/api/link") {
return handleLink(state, req, res);
}
if (req.method === "POST" && url.pathname === "/api/refresh") {
return handleRefresh(state, req, res);
}