From 2293d2810f1aedbf279e2b8b6e5d32e6f7b0cf38 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 15 Oct 2026 11:31:32 +0200 Subject: [PATCH] feat(core): saved searches for the history Co-Authored-By: Claude Opus 5.5 --- src/core/saved.ts | 161 +++++++++++++++++++++++++++++++++++++++++++++ test/saved.test.ts | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/core/saved.ts create mode 100644 test/saved.test.ts diff --git a/src/core/saved.ts b/src/core/saved.ts new file mode 100644 index 0000000..5f85590 --- /dev/null +++ b/src/core/saved.ts @@ -0,0 +1,161 @@ +import type { HostId } from "./hosting/host"; +import type { ContentRating, InspectedCover } from "./model"; +import type { RunInput, RunResult } from "./pipeline"; +import type { ReviewCard } from "./session"; +import type { Source } from "./sources/source"; + +// Spec 002 §3: the page's history of searches. Plain data, so IndexedDB stores it as is. + +/** What was done with a search, with ISO times. */ +export interface Marks { + downloaded?: string; + rehosted?: { at: string; host: HostId }; + filed?: { at: string; submissionId: number }; +} + +/** A review card without its cover (the cover lives once, in the result) and without its transient error. */ +export type SavedCard = Omit & { sha256: string }; + +export interface SavedSearch { + version: 1; + seriesId: number; + input: RunInput; + rating: ContentRating; + result: Omit & { sourceId: string; sourceName: string }; + cards: SavedCard[]; + makeDefault: boolean; + marks: Marks; + foundAt: string; + savedAt: string; + /** Image bytes in the entry, for pruning. */ + bytes: number; +} + +/** The small part the History list reads. */ +export interface SearchSummary { + seriesId: number; + title: string; + sourceName: string; + language: string; + fresh: number; + present: number; + marks: Marks; + foundAt: string; + savedAt: string; + bytes: number; + thumb: { bytes: Uint8Array; contentType: string } | null; +} + +export interface HistoryStore { + /** Newest first. */ + list(): Promise; + get(seriesId: number): Promise; + put(search: SavedSearch): Promise; + remove(seriesId: number): Promise; + clear(): Promise; +} + +export class SavedSearchError extends Error { + override name = "SavedSearchError"; +} + +export const HISTORY_LIMITS = { count: 30, bytes: 300 * 2 ** 20 }; + +const coversOf = (r: Pick): InspectedCover[] => [...r.fresh, ...r.present.map((p) => p.cover)]; + +export function toSaved(a: { + result: RunResult; + cards: readonly ReviewCard[]; + makeDefault: boolean; + input: RunInput; + rating: ContentRating; + marks: Marks; + foundAt: string; + now: Date; +}): SavedSearch { + const { source, ...rest } = a.result; + return { + version: 1, + seriesId: a.result.seriesId, + input: a.input, + rating: a.rating, + result: { ...rest, sourceId: source.id, sourceName: source.name }, + cards: a.cards.map(({ cover, error: _error, ...card }) => ({ ...card, sha256: cover.sha256 })), + makeDefault: a.makeDefault, + marks: a.marks, + foundAt: a.foundAt, + savedAt: a.now.toISOString(), + bytes: coversOf(a.result).reduce((sum, c) => sum + c.bytes.length, 0), + }; +} + +export function summarize(s: SavedSearch): SearchSummary { + const r = s.result; + const thumb = r.suggestedDefault ?? r.fresh[0] ?? r.present[0]?.cover ?? null; + return { + seriesId: s.seriesId, title: r.mbSeries.title, sourceName: r.sourceName, language: r.series.language, + fresh: r.fresh.length, present: r.present.length, marks: s.marks, foundAt: s.foundAt, savedAt: s.savedAt, bytes: s.bytes, + thumb: thumb ? { bytes: thumb.bytes, contentType: thumb.contentType } : null, + }; +} + +const expired = (card: Pick, now: number) => card.hosting?.until != null && card.hosting.until <= now; + +/** A saved search, ready for `Session.restore`. Links that have expired since are dropped (spec 002 §3.3). */ +export function fromSaved(s: SavedSearch, sources: readonly Source[], now: number) { + const { sourceId, sourceName: _name, ...rest } = s.result; + const source = sources.find((x) => x.id === sourceId); + if (!source) throw new SavedSearchError(`This entry comes from a source this version doesn't know (${sourceId}).`); + const result: RunResult = { ...rest, source }; + const bySha = new Map(result.fresh.map((c) => [c.sha256, c])); + const cards: ReviewCard[] = s.cards.flatMap(({ sha256, ...card }) => { + const cover = bySha.get(sha256); + if (!cover) return []; + const gone = expired(card, now); + return [{ ...card, cover, error: null, hostedUrl: gone ? null : card.hostedUrl, hosting: gone ? undefined : card.hosting }]; + }); + return { result, cards, makeDefault: s.makeDefault, input: s.input, rating: s.rating, marks: s.marks, foundAt: s.foundAt }; +} + +/** After searching a series again: the user's edits of covers that are still there carry over, by hash. */ +export function carryEdits(previous: readonly SavedCard[], next: ReviewCard[], now: number): void { + const before = new Map(previous.map((c) => [c.sha256, c])); + for (const card of next) { + const old = before.get(card.cover.sha256); + if (!old) continue; + Object.assign(card, { selected: old.selected, type: old.type, note: old.note, contentRating: old.contentRating }); + if (old.hostedUrl && !expired(old, now)) Object.assign(card, { hostedUrl: old.hostedUrl, hosting: old.hosting }); + } +} + +/** Ids to drop: beyond the newest `count`, or once the newest entries already hold `bytes`. The newest stays. */ +export function pruneIds( + summaries: readonly Pick[], + limits = HISTORY_LIMITS, +): number[] { + const newest = [...summaries].sort((a, b) => b.savedAt.localeCompare(a.savedAt)); + let total = 0; + return newest.flatMap((s, i) => { + total += s.bytes; + return i > 0 && (i >= limits.count || total > limits.bytes) ? [s.seriesId] : []; + }); +} + +export async function saveAndPrune(store: HistoryStore, search: SavedSearch, limits = HISTORY_LIMITS): Promise { + await store.put(search); + const drop = pruneIds(await store.list(), limits); + for (const id of drop) await store.remove(id); + return drop; +} + +/** For tests, and a stand-in when the browser has no IndexedDB. */ +export function memoryHistory(): HistoryStore { + const m = new Map(); + return { + list: async () => [...m.values()].map(summarize).sort((a, b) => b.savedAt.localeCompare(a.savedAt)), + get: async (id) => m.get(id) ?? null, + put: async (s) => void m.set(s.seriesId, s), + remove: async (id) => void m.delete(id), + clear: async () => m.clear(), + }; +} diff --git a/test/saved.test.ts b/test/saved.test.ts new file mode 100644 index 0000000..0c397ab --- /dev/null +++ b/test/saved.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import type { SourceSeries } from "../src/core/model"; +import type { RunInput, RunResult } from "../src/core/pipeline"; +import { + HISTORY_LIMITS, SavedSearchError, carryEdits, fromSaved, memoryHistory, pruneIds, saveAndPrune, summarize, toSaved, + type SavedSearch, +} from "../src/core/saved"; +import type { ReviewCard } from "../src/core/session"; +import { SOURCES } from "../src/core/sources/registry"; +import { webtoons } from "../src/core/sources/webtoons"; +import { inspected } from "./helpers/covers"; + +const series: SourceSeries = { + source: "webtoons", externalId: "95", title: "Tower of God", language: "en", + canonicalUrl: "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95", archiveUrls: [], mature: false, flags: {}, +}; +const input: RunInput = { mangabakaLink: "https://mangabaka.org/manhwa/9999/Tower-of-God", sourceLink: series.canonicalUrl, history: 6 }; +const NOW = Date.UTC(2026, 9, 15, 12); + +function result(seriesId = 9999): RunResult { + const poster = inspected({ bytes: new Uint8Array(1000) }); + const square = inspected({ candidate: { role: "square" }, shape: "square", bytes: new Uint8Array(500) }); + const already = inspected({ bytes: new Uint8Array(250) }); + return { + seriesId, mbSeries: { id: seriesId, title: "Tower of God", type: "manhwa", content_rating: "safe" }, source: webtoons, series, + linkMatches: true, fresh: [poster, square], + present: [{ cover: already, existing: { id: 8000, sha256: already.sha256, language: "en", type: "volume" } }], + suggestedDefault: poster, warnings: ["a warning"], + }; +} +const cardsOf = (r: RunResult): ReviewCard[] => + r.fresh.map((cover) => ({ cover, selected: true, type: cover.type, note: "n", contentRating: "safe", hostedUrl: null, error: null })); + +function saved(over: { result?: RunResult; cards?: ReviewCard[]; savedAt?: number } = {}): SavedSearch { + const r = over.result ?? result(); + return toSaved({ + result: r, cards: over.cards ?? cardsOf(r), makeDefault: true, input, rating: "suggestive", marks: {}, + foundAt: new Date(NOW - 60_000).toISOString(), now: new Date(over.savedAt ?? NOW), + }); +} + +describe("save and restore", () => { + test("round trip keeps the result, every edit, the inputs and the bytes", () => { + const r = result(); + const cards = cardsOf(r); + cards[1] = { ...cards[1]!, selected: false, note: "my note", type: "other", contentRating: "erotica" }; + const s = toSaved({ result: r, cards, makeDefault: false, input, rating: "suggestive", marks: { downloaded: "2026-10-15T11:00:00.000Z" }, foundAt: "2026-10-15T10:00:00.000Z", now: new Date(NOW) }); + expect(s.bytes).toBe(1750); + expect(s.result.sourceId).toBe("webtoons"); + expect("source" in s.result).toBe(false); + expect(s.cards[0]).not.toHaveProperty("cover"); + + const back = fromSaved(structuredClone(s), SOURCES, NOW); + expect(back.result.source).toBe(webtoons); + expect(back.result.fresh[0]!.bytes).toEqual(r.fresh[0]!.bytes); + expect(back.cards[0]!.cover).toBe(back.result.fresh[0]!); + expect(back.cards[1]).toMatchObject({ selected: false, note: "my note", type: "other", contentRating: "erotica", error: null }); + expect(back).toMatchObject({ makeDefault: false, input, rating: "suggestive", marks: { downloaded: "2026-10-15T11:00:00.000Z" }, foundAt: "2026-10-15T10:00:00.000Z" }); + expect(back.result.warnings).toEqual(["a warning"]); + }); + + test("an entry whose source no longer exists can't be opened", () => { + const s = saved(); + expect(() => fromSaved({ ...s, result: { ...s.result, sourceId: "gone" } }, SOURCES, NOW)).toThrow(SavedSearchError); + }); + + test("drops expired links, keeps the rest of the card", () => { + const r = result(); + const cards = cardsOf(r); + cards[0] = { ...cards[0]!, hostedUrl: "https://litter.catbox.moe/a.jpg", hosting: { by: "litterbox", key: "litterbox:1h:x", until: NOW - 1 } }; + cards[1] = { ...cards[1]!, hostedUrl: "https://files.catbox.moe/b.jpg", hosting: { by: "catbox", key: "catbox:y", until: null } }; + const back = fromSaved(saved({ result: r, cards }), SOURCES, NOW); + expect(back.cards[0]).toMatchObject({ hostedUrl: null, hosting: undefined, note: "n", selected: true }); + expect(back.cards[1]!.hostedUrl).toBe("https://files.catbox.moe/b.jpg"); + }); + + test("summary: title, counts, marks, a thumbnail of the suggested main cover", () => { + const s = saved(); + const sum = summarize(s); + expect(sum).toMatchObject({ seriesId: 9999, title: "Tower of God", sourceName: "WEBTOON", language: "en", fresh: 2, present: 1, bytes: 1750 }); + expect(sum.thumb?.bytes).toBe(s.result.fresh[0]!.bytes); + }); +}); + +describe("searching a series again", () => { + test("carries edits by hash: unticked stays unticked, notes kept, new covers keep defaults", () => { + const r = result(); + const old = cardsOf(r); + old[0] = { ...old[0]!, selected: false, note: "kept note", hostedUrl: "https://files.catbox.moe/a.jpg", hosting: { by: "catbox", key: "catbox:a", until: null } }; + old[1] = { ...old[1]!, hostedUrl: "https://litter.catbox.moe/b.jpg", hosting: { by: "litterbox", key: "l", until: NOW - 1 } }; + const prev = saved({ result: r, cards: old }); + + const fresh = [...cardsOf(r), ...cardsOf({ ...r, fresh: [inspected()] })]; + carryEdits(prev.cards, fresh, NOW); + expect(fresh[0]).toMatchObject({ selected: false, note: "kept note", hostedUrl: "https://files.catbox.moe/a.jpg" }); + expect(fresh[1]).toMatchObject({ selected: true, hostedUrl: null }); + expect(fresh[2]).toMatchObject({ selected: true, note: "n", hostedUrl: null }); + }); +}); + +describe("pruning", () => { + const entry = (seriesId: number, savedAt: number, bytes = 1) => ({ seriesId, savedAt: new Date(savedAt).toISOString(), bytes }); + + test("keeps the 30 most recently saved", () => { + const list = Array.from({ length: 31 }, (_, i) => entry(i, NOW + i * 1000)); + expect(pruneIds(list)).toEqual([0]); + }); + + test("drops the oldest past 300 MB, but always keeps the newest", () => { + const big = HISTORY_LIMITS.bytes / 2; + expect(pruneIds([entry(1, NOW, big), entry(2, NOW + 1, big), entry(3, NOW + 2, big)]).sort()).toEqual([1]); + expect(pruneIds([entry(1, NOW, HISTORY_LIMITS.bytes * 2)])).toEqual([]); + }); + + test("memory history: newest first; saveAndPrune removes what falls out", async () => { + const store = memoryHistory(); + await store.put(saved({ result: result(1), savedAt: NOW })); + await store.put(saved({ result: result(2), savedAt: NOW + 1 })); + expect((await store.list()).map((s) => s.seriesId)).toEqual([2, 1]); + const removed = await saveAndPrune(store, saved({ result: result(3), savedAt: NOW + 2 }), { count: 2, bytes: HISTORY_LIMITS.bytes }); + expect(removed).toEqual([1]); + expect((await store.list()).map((s) => s.seriesId)).toEqual([3, 2]); + expect(await store.get(1)).toBeNull(); + await store.clear(); + expect(await store.list()).toEqual([]); + }); +});