feat(E2.5): live-new-entries list + one-click import endpoint + dashboard

New Foundry entries from the shallow poll appear in a separate "Live new entries
(from Foundry)" panel with a one-click "Import as new refined note" action. Never
auto-import — each entry sits until the user clicks. Gated by foundryPoll.

- src/server.ts POST /api/foundry-poll/import {uuid}: fetches relay.getEntry(uuid),
  checks name collision against the index (matched + refinedOnly) → 409 if
  collided, builds a refined note via importRow (batch.ts → entryToObsidian +
  foundry block), writes under refined/imported/<subfolder>/, removes from
  liveNewEntries, returns {ok, uuid, name, filename, subfolder}. Gated by
  foundryPoll (404 when off). No live entry → 404.
- src/dashboard.html: "Live new entries (from Foundry)" panel (details/summary,
  purple border) rendered from GET /api/foundry-poll's liveNewEntries array on
  the 2s poll (refreshLiveNewEntries). Each entry has an "Import as new refined
  note" button → POST /api/foundry-poll/import with a confirm dialog. On success
  → toast + refreshIndex. Hidden when foundryPoll is off (GET /api/foundry-poll
  404 → panel hidden).
- tests/e2-5-import.test.ts: 5 tests — import succeeds (file written +
  liveNewEntries cleared); name collision → 409 (NOT cleared); no live entry →
  404; foundryPoll off → 404; GET /api/foundry-poll includes liveNewEntries.

tsc clean; 265 passing project-wide (19 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:31:52 +00:00
parent 8f48f356ce
commit d006311f3e
3 changed files with 181 additions and 1 deletions

View File

@@ -120,6 +120,10 @@
<div id="revertBar" style="margin:6px 0;display:none"><button id="revertBtn" class="bad" onclick="revertLastPush()" title="Restore Foundry to the state captured BEFORE the most recent auto-sync push (a full /update), then re-baseline the note. Use this to undo a wrong push."></button></div>
<pre id="autoSyncLog" class="autosync-log" style="max-height:180px;overflow:auto;margin:6px 0 8px;font-size:12px;background:#fff;border:1px solid #eee;padding:6px">(no activity yet)</pre>
</details>
<details id="liveNewEntriesPanel" style="display:none;margin:0 12px;border:1px solid var(--pur);border-radius:6px;padding:6px 12px;background:var(--panel)">
<summary style="cursor:pointer;color:var(--pur)">Live new entries (from Foundry) <span id="liveNewCount" class="meta"></span></summary>
<div id="liveNewList" style="margin:6px 0;font-size:13px"></div>
</details>
<main>
<section class="list">
<div class="toolbar">
@@ -520,7 +524,7 @@ async function refreshAutosync(){
log.textContent = r.events && r.events.length
? r.events.map(e => `${e.time.replace('T',' ').slice(5,19)} ${e.status.padEnd(7)} ${e.name}${e.message}`).join('\n')
: '(no activity yet — save a linked, seeded note in your vault to trigger a push)';
if (r.enabled && !autoPoll) autoPoll = setInterval(() => { refreshAutosync(); refreshSyncState(); }, 2000);
if (r.enabled && !autoPoll) autoPoll = setInterval(() => { refreshAutosync(); refreshSyncState(); refreshLiveNewEntries(); }, 2000);
if (!r.enabled && autoPoll) { clearInterval(autoPoll); autoPoll = null; }
// E1b.8: flagsSchemaVersion migration banner (shown once after start, dismissible).
const migBanner = document.getElementById('migrationBanner');
@@ -543,6 +547,30 @@ async function refreshAutosync(){
revertBar.style.display = 'none';
}
}
// E2.5: live new entries (from Foundry) — one-click import, never auto.
async function refreshLiveNewEntries() {
const r = await apiFetch('/api/foundry-poll').then(r => r.json()).catch(() => null);
const panel = document.getElementById('liveNewEntriesPanel');
if (!r || r.enabled === undefined) { if (panel) panel.style.display = 'none'; return; }
const entries = r.liveNewEntries || [];
if (panel) panel.style.display = entries.length > 0 ? '' : 'none';
const count = document.getElementById('liveNewCount');
if (count) count.textContent = entries.length > 0 ? `(${entries.length})` : '';
const list = document.getElementById('liveNewList');
if (list) {
list.innerHTML = entries.length === 0 ? '<p class="meta">No new entries from Foundry.</p>' :
entries.map(e => `<div style="display:flex;gap:8px;align-items:center;padding:4px 0;border-bottom:1px solid var(--line)"><span style="flex:1">${e.name}</span><button class="rec" onclick="importLiveEntry('${e.uuid}','${e.name.replace(/'/g,"\\'")}')">Import as new refined note</button></div>`).join('');
}
}
async function importLiveEntry(uuid, name) {
if (!confirm(`Import "${name}" as a new refined note under refined/imported/?`)) return;
toast(`importing ${name}`);
const r = await apiFetch('/api/foundry-poll/import', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ uuid }) }).then(r => r.json()).catch(() => null);
if (r && r.ok) toast(`imported "${name}" → refined/imported/${r.subfolder}/${r.filename}`);
else toast(`import failed: ${r?.error || 'unknown'}`);
refreshLiveNewEntries();
refreshIndex();
}
async function revertLastPush(){
const btn = document.getElementById('revertBtn');
const uuid = btn.dataset.uuid;

View File

@@ -1796,6 +1796,38 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
}
},
},
// E2.5: import a live new entry as a refined note (one-click, never auto).
"POST /api/foundry-poll/import": {
method: "POST", requireAuth: true, requireCSRF: true,
handler: async (_s, req, res) => {
if (!state.cfg.features?.foundryPoll) return send(res, 404, { error: "foundry-poll disabled" });
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });
const uuid = String(body.uuid ?? "");
if (!uuid) return send(res, 400, { error: "uuid required" });
const live = state.foundryPoll?.liveNewEntries.find((e) => e.uuid === uuid);
if (!live) return send(res, 404, { error: `no live new entry for ${uuid}` });
try {
const relay = relayClient(state);
const entry = await relay.getEntry(uuid);
const name = entry.name ?? live.name;
// Name collision check — skip if a note with the same name exists.
if (state.index && [...state.index.matched, ...state.index.refinedOnly].some((r) => r.name === name)) {
return send(res, 409, { error: "name already exists in vault — rename in Foundry or link manually" });
}
const row = { entry, basename: name, name } as unknown as FileRow;
const out = importRow(row, state.db, new Date().toISOString());
if (!out) return send(res, 500, { error: "import produced no output" });
const target = join(state.cfg.refinedDir, "imported", out.subfolder, out.filename);
await writeWithBackup(target, out.content, state);
// Remove from liveNewEntries.
state.foundryPoll!.liveNewEntries = state.foundryPoll!.liveNewEntries.filter((e) => e.uuid !== uuid);
send(res, 200, { ok: true, uuid, name, filename: out.filename, subfolder: out.subfolder });
} catch (e) {
send(res, 500, { error: (e as Error).message });
}
},
},
"POST /api/action": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePost(state, req, res) },
"POST /api/push": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePush(state, req, res) },
"POST /api/push-all": { method: "POST", requireAuth: true, requireCSRF: true, handler: async (_s, req, res) => handlePushAll(state, req, res) },

120
tests/e2-5-import.test.ts Normal file
View File

@@ -0,0 +1,120 @@
// E2.5 — live-new-entries list + one-click import.
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { startServer, type State } from "../src/server.js";
let dir: string;
let server: Server;
let state: State;
let baseURL: string;
const realFetch = globalThis.fetch;
const UUID = "JournalEntry.new1";
const ENTRY_NAME = "New NPC";
function mockGetEntry(): void {
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/get?")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ data: { name: ENTRY_NAME, _id: "new1", folder: "Folder.test", flags: { "campaign-codex": { type: "npc", data: { description: "<p>New entry.</p>", notes: "" } } } } }) } as unknown as Response;
}
if (u.includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) } as unknown as Response;
// Pass through to the real fetch for server HTTP calls.
return realFetch(url, init);
}) as unknown as typeof fetch;
}
async function bootServer(opts: { foundryPoll?: boolean; collisionName?: string } = {}): Promise<void> {
dir = await mkdtemp(join(tmpdir(), "e2-5-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "cc"), { recursive: true });
// Optional: write a colliding note before boot (so the index includes it).
if (opts.collisionName) {
await mkdir(join(dir, "refined", "imported", "npcs"), { recursive: true });
await writeFile(join(dir, "refined", "imported", "npcs", `${opts.collisionName}.md`), "---\ntype: npc\n---\nbody\n", "utf8");
}
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server: srv, state: st } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: true, foundryPoll: opts.foundryPoll ?? true },
});
server = srv;
state = st;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
}
async function postImport(uuid: string): Promise<{ code: number; body: unknown }> {
const r = await fetch(`${baseURL}/api/foundry-poll/import`, {
method: "POST", headers: { "content-type": "application/json", origin: baseURL },
body: JSON.stringify({ uuid }),
});
return { code: r.status, body: await r.json().catch(() => null) };
}
describe("E2.5 live-new-entries import", () => {
afterEach(async () => {
globalThis.fetch = realFetch;
if (server) await new Promise<void>((r) => server.close(() => r()));
if (dir) await rm(dir, { recursive: true, force: true });
});
it("POST /api/foundry-poll/import → imports the entry + removes from liveNewEntries", async () => {
await bootServer({ foundryPoll: true });
mockGetEntry();
// Manually add a live new entry.
state.foundryPoll!.liveNewEntries.push({ uuid: UUID, name: ENTRY_NAME, detectedAt: new Date().toISOString() });
const r = await postImport(UUID);
expect(r.code).toBe(200);
const body = r.body as { ok: boolean; uuid: string; name: string; filename: string; subfolder: string };
expect(body.ok).toBe(true);
expect(body.name).toBe(ENTRY_NAME);
// The file was written under refined/imported/<subfolder>/.
const notePath = join(dir, "refined", "imported", body.subfolder, body.filename);
expect(existsSync(notePath)).toBe(true);
// Removed from liveNewEntries.
expect(state.foundryPoll!.liveNewEntries.some((e) => e.uuid === UUID)).toBe(false);
});
it("name collision → 409 'name already exists in vault'", async () => {
// Write a colliding note before boot (so the index includes it).
await bootServer({ foundryPoll: true, collisionName: ENTRY_NAME });
mockGetEntry();
state.foundryPoll!.liveNewEntries.push({ uuid: UUID, name: ENTRY_NAME, detectedAt: new Date().toISOString() });
// Trigger an index refresh so the collision is detected.
await fetch(`${baseURL}/api/index`);
const r = await postImport(UUID);
expect(r.code).toBe(409);
expect((r.body as { error: string }).error).toMatch(/name already exists/);
// NOT removed from liveNewEntries (import failed).
expect(state.foundryPoll!.liveNewEntries.some((e) => e.uuid === UUID)).toBe(true);
});
it("no live new entry for the uuid → 404", async () => {
await bootServer({ foundryPoll: true });
mockGetEntry();
const r = await postImport("JournalEntry.nonexistent");
expect(r.code).toBe(404);
});
it("foundryPoll flag off → 404", async () => {
await bootServer({ foundryPoll: false });
const r = await postImport(UUID);
expect(r.code).toBe(404);
});
it("GET /api/foundry-poll includes liveNewEntries in the response", async () => {
await bootServer({ foundryPoll: true });
state.foundryPoll!.liveNewEntries.push({ uuid: UUID, name: ENTRY_NAME, detectedAt: new Date().toISOString() });
const r = await fetch(`${baseURL}/api/foundry-poll`).then(r => r.json()) as { liveNewEntries: { uuid: string }[] };
expect(r.liveNewEntries.some((e) => e.uuid === UUID)).toBe(true);
});
});