Files
recap/server/history.js
T
Keysat 85cb641044 Module split: history storage + meta + 9 routes → server/history.js
• initHistory({ dataDir })          — boot setup; mkdir + path init
  • saveToHistory(...)                — write one summary file
  • loadMeta() / saveMeta(meta)       — _meta.json folder structure
  • setupHistoryRoutes(app, deps)     — registers GET /api/history,
                                        GET/PUT/DELETE /api/history/:id,
                                        PUT /api/history/:id/title,
                                        PUT /api/history/meta,
                                        POST/PUT/DELETE /api/history/folders[/:id],
                                        PUT /api/history/folders/:id/collapsed,
                                        PUT /api/history/move
  • getHistoryDir()                   — exposes the directory for code
                                        that hasn't been extracted yet
                                        (subscriptions / library / process
                                        pipeline)

The DELETE route needs to add the deleted videoId to the skip list so
subscriptions don't re-queue it. That's a cross-module concern, so
setupHistoryRoutes takes addToSkipList as a dependency. For now it's
late-bound to the still-local function in index.js (lambda captures
the scope, not the value); when subscriptions are extracted next, the
import flips cleanly.

server/index.js: 2300 → 2079 lines.

Smoke tested: /api/license-status, /api/health, and /api/history (402
license-gated for unlicensed) all respond as expected.
2026-05-08 17:09:10 -05:00

276 lines
10 KiB
JavaScript

// History storage + routes. Sessions are written as one JSON file per
// summary in /data/history/<id>.json. Folder structure / ordering lives
// in a sidecar `_meta.json`.
//
// Module-private state: just the historyDir path, set by initHistory().
// The DELETE route needs to add the deleted videoId to the skip list
// (so subscriptions don't re-queue it) — that's a cross-module concern,
// so it's injected as a callback by setupHistoryRoutes.
import fs from "fs/promises";
import path from "path";
let historyDir = null;
let metaPath = null;
// ── Initialization ──────────────────────────────────────────────────────────
// Call once at boot. Creates the directory and stores the path. Idempotent.
export async function initHistory({ dataDir }) {
historyDir = path.join(dataDir, "history");
metaPath = path.join(historyDir, "_meta.json");
await fs.mkdir(historyDir, { recursive: true }).catch(() => {});
}
// ── Storage ─────────────────────────────────────────────────────────────────
// saveToHistory persists a completed summary. Returns the generated id.
// Used by /api/process. The id encodes the timestamp + a content hint
// (videoId for YouTube, base64-truncated guid/url for podcasts) so files
// sort chronologically by name.
export async function saveToHistory(videoId, url, title, chunks, entries, logs, uploadDate, type) {
const idSuffix = type === "podcast"
? Buffer.from(videoId).toString("base64url").slice(0, 16)
: videoId;
const id = `${Date.now()}-${idSuffix}`;
const record = {
id,
videoId,
url,
title: title || "Untitled",
type: type || "youtube",
topicCount: chunks.length,
segmentCount: entries.length,
createdAt: new Date().toISOString(),
uploadDate: uploadDate || "",
chunks,
entries,
logs,
};
await fs.writeFile(path.join(historyDir, `${id}.json`), JSON.stringify(record));
return id;
}
// ── Meta ────────────────────────────────────────────────────────────────────
// `_meta.json` shape: { folders: [{ id, name, order, collapsed,
// items: [sessionId, ...] }], uncategorized: [sessionId, ...] }
export async function loadMeta() {
try {
return JSON.parse(await fs.readFile(metaPath, "utf-8"));
} catch {
return { folders: [], uncategorized: [] };
}
}
export async function saveMeta(meta) {
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
}
export function getHistoryDir() {
return historyDir;
}
// ── Routes ──────────────────────────────────────────────────────────────────
// Pass `addToSkipList` so the DELETE route can suppress re-queueing of
// videos the user has explicitly removed. Decoupled from subscriptions
// to keep this module standalone.
export function setupHistoryRoutes(app, { addToSkipList } = {}) {
// Get all history: sessions + folder structure
app.get("/api/history", async (req, res) => {
try {
const files = await fs.readdir(historyDir);
const sessionsMap = {};
// Skip the meta + state files; everything else is a session.
for (const file of files.filter(f =>
f.endsWith(".json") &&
!f.startsWith("_") &&
f !== "subscriptions.json" &&
f !== "skip-list.json" &&
f !== "seen-list.json" &&
f !== "auto-queue.json"
)) {
try {
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
const data = JSON.parse(raw);
sessionsMap[data.id] = {
id: data.id, videoId: data.videoId, url: data.url,
title: data.title, topicCount: data.topicCount,
type: data.type || "youtube",
segmentCount: data.segmentCount, createdAt: data.createdAt,
uploadDate: data.uploadDate || "",
};
} catch {}
}
const meta = await loadMeta();
// Clean up: remove references to deleted sessions
for (const folder of meta.folders) {
folder.items = folder.items.filter(id => sessionsMap[id]);
}
meta.uncategorized = meta.uncategorized.filter(id => sessionsMap[id]);
// Add any sessions not in meta (newly created)
const allReferenced = new Set([
...meta.uncategorized,
...meta.folders.flatMap(f => f.items),
]);
const allIds = Object.keys(sessionsMap);
const orphans = allIds.filter(id => !allReferenced.has(id))
.sort((a, b) => new Date(sessionsMap[b].createdAt) - new Date(sessionsMap[a].createdAt));
meta.uncategorized = [...orphans, ...meta.uncategorized];
await saveMeta(meta);
res.json({ sessions: sessionsMap, meta });
} catch (err) {
res.json({ sessions: {}, meta: { folders: [], uncategorized: [] } });
}
});
// Get a single session (full data)
app.get("/api/history/:id", async (req, res) => {
try {
const raw = await fs.readFile(path.join(historyDir, `${req.params.id}.json`), "utf-8");
res.json(JSON.parse(raw));
} catch {
res.status(404).json({ error: "Session not found" });
}
});
// Rename a session title
app.put("/api/history/:id/title", async (req, res) => {
try {
const filePath = path.join(historyDir, `${req.params.id}.json`);
const raw = await fs.readFile(filePath, "utf-8");
const data = JSON.parse(raw);
data.title = req.body.title || data.title;
await fs.writeFile(filePath, JSON.stringify(data));
res.json({ ok: true, title: data.title });
} catch {
res.status(404).json({ error: "Session not found" });
}
});
// Delete a session — also adds the videoId to the skip list so any
// subscriptions don't re-queue it.
app.delete("/api/history/:id", async (req, res) => {
try {
const filePath = path.join(historyDir, `${req.params.id}.json`);
let videoId = null;
try {
const raw = await fs.readFile(filePath, "utf-8");
videoId = JSON.parse(raw).videoId;
} catch {}
await fs.unlink(filePath);
if (videoId && typeof addToSkipList === "function") {
await addToSkipList(videoId);
}
const meta = await loadMeta();
meta.uncategorized = meta.uncategorized.filter(id => id !== req.params.id);
for (const folder of meta.folders) {
folder.items = folder.items.filter(id => id !== req.params.id);
}
await saveMeta(meta);
res.json({ ok: true });
} catch {
res.status(404).json({ error: "Session not found" });
}
});
// Update meta (folders, ordering) — the frontend sends the full structure
app.put("/api/history/meta", async (req, res) => {
try {
const meta = req.body;
await saveMeta(meta);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post("/api/history/folders", async (req, res) => {
try {
const meta = await loadMeta();
const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", collapsed: false, items: [] };
meta.folders.push(folder);
await saveMeta(meta);
res.json(folder);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put("/api/history/folders/:id", async (req, res) => {
try {
const meta = await loadMeta();
const folder = meta.folders.find(f => f.id === req.params.id);
if (!folder) return res.status(404).json({ error: "Folder not found" });
folder.name = req.body.name || folder.name;
await saveMeta(meta);
res.json(folder);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put("/api/history/folders/:id/collapsed", async (req, res) => {
try {
const meta = await loadMeta();
const folder = meta.folders.find(f => f.id === req.params.id);
if (!folder) return res.status(404).json({ error: "Folder not found" });
folder.collapsed = !!req.body.collapsed;
await saveMeta(meta);
res.json({ ok: true, collapsed: folder.collapsed });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete a folder — items move back to uncategorized
app.delete("/api/history/folders/:id", async (req, res) => {
try {
const meta = await loadMeta();
const idx = meta.folders.findIndex(f => f.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: "Folder not found" });
const [folder] = meta.folders.splice(idx, 1);
meta.uncategorized = [...folder.items, ...meta.uncategorized];
await saveMeta(meta);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Move a session to a folder (or uncategorized if folderId is null)
app.put("/api/history/move", async (req, res) => {
try {
const { sessionId, folderId, index } = req.body;
const meta = await loadMeta();
// Remove from current location
meta.uncategorized = meta.uncategorized.filter(id => id !== sessionId);
for (const folder of meta.folders) {
folder.items = folder.items.filter(id => id !== sessionId);
}
// Add to new location
if (folderId) {
const folder = meta.folders.find(f => f.id === folderId);
if (folder) {
const i = typeof index === "number" ? index : folder.items.length;
folder.items.splice(i, 0, sessionId);
}
} else {
const i = typeof index === "number" ? index : 0;
meta.uncategorized.splice(i, 0, sessionId);
}
await saveMeta(meta);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
}