feat(core): saved searches for the history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
414a272d40
commit
2293d2810f
2 files changed
+288
No files matched your search
@@ -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<ReviewCard, "cover" | "error"> & { sha256: string };
|
||||
|
||||
export interface SavedSearch {
|
||||
version: 1;
|
||||
seriesId: number;
|
||||
input: RunInput;
|
||||
rating: ContentRating;
|
||||
result: Omit<RunResult, "source"> & { 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<SearchSummary[]>;
|
||||
get(seriesId: number): Promise<SavedSearch | null>;
|
||||
put(search: SavedSearch): Promise<void>;
|
||||
remove(seriesId: number): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export class SavedSearchError extends Error {
|
||||
override name = "SavedSearchError";
|
||||
}
|
||||
|
||||
export const HISTORY_LIMITS = { count: 30, bytes: 300 * 2 ** 20 };
|
||||
|
||||
const coversOf = (r: Pick<RunResult, "fresh" | "present">): 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<ReviewCard, "hosting">, 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<SearchSummary, "seriesId" | "savedAt" | "bytes">[],
|
||||
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<number[]> {
|
||||
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<number, SavedSearch>();
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user