c06ffbbdf4
• setupLibraryRoutes(app) — registers GET /api/library/export and
POST /api/library/import
The library module reads through history.js helpers (getHistoryDir,
loadMeta, saveMeta) and reads/writes subscriptions.json directly.
Subscriptions integration is via raw fs because (a) the library merge
logic is library-specific (skip-if-already-exists semantics), and (b)
the subscriptions module hasn't been extracted yet — the only thing
the import path needs is to merge dedupe-by-URL into the file.
server/index.js: 2079 → 1971 lines.
Smoke tested: server boots; /api/license-status, /api/health respond;
/api/library/export still returns 402 license_required for unlicensed
(unchanged Pro-gate behavior). 69 unit tests still pass.
161 lines
5.2 KiB
JavaScript
161 lines
5.2 KiB
JavaScript
// Library export/import — bulk JSON dump + restore of sessions, folder
|
|
// meta, and subscriptions. Only routes; no module state. Pulls history
|
|
// data from history.js helpers; reads/writes the subscriptions file
|
|
// directly because library import predates the subscriptions module
|
|
// having a public 'merge' helper (and the merge logic is library-
|
|
// specific anyway).
|
|
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import express from "express";
|
|
import { getHistoryDir, loadMeta, saveMeta } from "./history.js";
|
|
|
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
// Both routes are gated by the Pro 'library' entitlement (the gate runs
|
|
// upstream in license-middleware.js). They assume initHistory() has
|
|
// already been called.
|
|
export function setupLibraryRoutes(app) {
|
|
// Bulk export everything: meta, sessions, subscriptions.
|
|
app.get("/api/library/export", async (req, res) => {
|
|
try {
|
|
const historyDir = getHistoryDir();
|
|
const meta = await loadMeta();
|
|
const files = await fs.readdir(historyDir);
|
|
const sessions = {};
|
|
for (const file of files) {
|
|
if (
|
|
!file.endsWith(".json") ||
|
|
file === "_meta.json" ||
|
|
file === "subscriptions.json" ||
|
|
file === "auto-queue.json" ||
|
|
file === "skip-list.json" ||
|
|
file === "seen-list.json"
|
|
) continue;
|
|
try {
|
|
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
|
|
const id = file.replace(".json", "");
|
|
sessions[id] = JSON.parse(raw);
|
|
} catch {}
|
|
}
|
|
let subscriptions = [];
|
|
try {
|
|
subscriptions = JSON.parse(
|
|
await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")
|
|
).subscriptions || [];
|
|
} catch {}
|
|
|
|
const exportData = {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
meta,
|
|
sessions,
|
|
subscriptions,
|
|
};
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
'attachment; filename="recap-library.json"'
|
|
);
|
|
res.json(exportData);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Bulk import: skip sessions that already exist (don't overwrite),
|
|
// merge meta folders, add net-new subscriptions.
|
|
app.post(
|
|
"/api/library/import",
|
|
express.json({ limit: "200mb" }),
|
|
async (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
if (!data || !data.sessions) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Invalid library file — missing sessions data" });
|
|
}
|
|
|
|
const historyDir = getHistoryDir();
|
|
let imported = 0;
|
|
let skipped = 0;
|
|
|
|
// Sessions — skip if already present.
|
|
for (const [id, session] of Object.entries(data.sessions)) {
|
|
const filePath = path.join(historyDir, `${id}.json`);
|
|
try {
|
|
await fs.access(filePath);
|
|
skipped++;
|
|
continue;
|
|
} catch {}
|
|
await fs.writeFile(filePath, JSON.stringify(session));
|
|
imported++;
|
|
}
|
|
|
|
// Meta — merge folders + add new uncategorized at the top.
|
|
if (data.meta) {
|
|
const existingMeta = await loadMeta();
|
|
const allExistingIds = new Set([
|
|
...existingMeta.uncategorized,
|
|
...existingMeta.folders.flatMap(f => f.items),
|
|
]);
|
|
|
|
if (data.meta.folders) {
|
|
for (const folder of data.meta.folders) {
|
|
const existingFolder = existingMeta.folders.find(
|
|
f => f.id === folder.id
|
|
);
|
|
if (!existingFolder) {
|
|
existingMeta.folders.push(folder);
|
|
folder.items.forEach(id => allExistingIds.add(id));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.meta.uncategorized) {
|
|
for (const id of data.meta.uncategorized) {
|
|
if (!allExistingIds.has(id)) {
|
|
existingMeta.uncategorized.unshift(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
await saveMeta(existingMeta);
|
|
}
|
|
|
|
// Subscriptions — merge, dedupe by URL.
|
|
if (data.subscriptions && data.subscriptions.length > 0) {
|
|
let existingSubs = [];
|
|
try {
|
|
existingSubs = JSON.parse(
|
|
await fs.readFile(
|
|
path.join(historyDir, "subscriptions.json"),
|
|
"utf-8"
|
|
)
|
|
).subscriptions || [];
|
|
} catch {}
|
|
const existingUrls = new Set(existingSubs.map(s => s.url));
|
|
for (const sub of data.subscriptions) {
|
|
if (!existingUrls.has(sub.url)) {
|
|
existingSubs.push(sub);
|
|
}
|
|
}
|
|
await fs.writeFile(
|
|
path.join(historyDir, "subscriptions.json"),
|
|
JSON.stringify({ subscriptions: existingSubs })
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
imported,
|
|
skipped,
|
|
total: Object.keys(data.sessions).length,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
}
|