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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
122
src/batch.ts
122
src/batch.ts
@@ -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
29
src/ccdir.ts
Normal 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;
|
||||
}
|
||||
22
src/cli.ts
22
src/cli.ts
@@ -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.
|
||||
|
||||
@@ -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=>({'&':'&'
|
||||
function attr(s){ return (s==null?'':String(s)).replace(/[&"]/g,c=>({'&':'&','"':'"'}[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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
245
src/server.ts
245
src/server.ts
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user