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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user