/** * Library suggestions: a series Greg moved to Reading in PersonalReader that * has no Library item yet. The Suwayomi sync job keeps the list in its cache * (entry `_suggestions`); this module serves it to the admin page, leaves * out series that now have a Library item, and remembers dismissals in a * small table, so a dismissed series stays dismissed across runs. * * Adding one is not done here: the admin page creates the Library item with * the normal write API (`POST /api/changes`), so every rule applies. * * - `GET /api/suggestions` (editor, admin, agent): `[{ key, anilistId, title, medium, cover? }]` * - `POST /api/suggestions//dismiss` (editor, admin) */ import type { IncomingMessage, ServerResponse } from "node:http"; import type { DatabaseSync } from "node:sqlite"; import { can, type Role } from "./config.ts"; import type { Store } from "./store.ts"; import { SUGGESTIONS_KEY } from "./sync/jobs/reader.ts"; import type { Suggestion } from "./sync/jobs/suwayomi.ts"; /** Jobs whose cache may hold suggestions. */ const SOURCES = ["suwayomi"]; export interface SuggestionView extends Suggestion { /** Stable id for dismissing: `anilist-`. */ key: string; } const tables = new WeakMap(); function ready(db: DatabaseSync) { if (!tables.has(db)) { db.exec(`create table if not exists suggestion_dismissals (key text primary key, at text not null);`); tables.set(db, true); } return db; } export function listSuggestions(store: Store): SuggestionView[] { const db = ready(store.db); const dismissed = new Set((db.prepare("select key from suggestion_dismissals").all() as { key: string }[]).map((r) => r.key)); const linked = new Set(store.list({ type: "library-item" }).map((n) => n.data.anilistId).filter((id) => typeof id === "number")); const out = new Map(); for (const job of SOURCES) { const items = (store.syncCache.get(job, SUGGESTIONS_KEY)?.values.items as Suggestion[] | undefined) ?? []; for (const s of items) { if (typeof s?.anilistId !== "number" || linked.has(s.anilistId)) continue; const key = `anilist-${s.anilistId}`; if (!dismissed.has(key) && !out.has(key)) out.set(key, { ...s, key }); } } return [...out.values()].sort((a, b) => a.title.localeCompare(b.title)); } export function dismissSuggestion(store: Store, key: string, at = new Date().toISOString()) { ready(store.db).prepare("insert into suggestion_dismissals (key, at) values (?, ?) on conflict(key) do nothing").run(key, at); } export async function handleSuggestions( _req: IncomingMessage, res: ServerResponse, o: { path: string; method: string; role: Role; store: Store }, ): Promise { const send = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(body)); return true; }; if (o.path === "/api/suggestions" && o.method === "GET") { if (!can.readDrafts(o.role)) return send(403, { error: `The ${o.role} token cannot do this` }); return send(200, listSuggestions(o.store)); } const m = o.path.match(/^\/api\/suggestions\/(anilist-\d{1,10})\/dismiss$/); if (m && o.method === "POST") { if (!can.review(o.role)) return send(403, { error: `The ${o.role} token cannot do this` }); dismissSuggestion(o.store, m[1]!); return send(200, { ok: true }); } return false; }