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:
@@ -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;
|
||||
|
||||
@@ -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
120
tests/e2-5-import.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user