fix: final review — cached links keep their expiry; history removal sticks; saves flushed

- A link reused from the rehost cache keeps the expiry it was uploaded with, and
  links near expiry are uploaded again (a dead litterbox link could be filed).
- History saving moves into HistoryKeeper (tested): removing an entry or clearing
  the history cancels a pending save and stops tracking the series on screen;
  a pending edit is saved before a new search, before opening another entry and
  when the page is hidden; opening an entry no longer re-saves the current one.
- The "couldn't be saved" line sits outside the History list, so it shows even
  when the list is empty; a refused IndexedDB says history is off.
- Recently searched bands wrap their meta line instead of cutting the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 11:45:48 +02:00
1 parent 1cf31b26cf
commit 0d4a1a61fa
8 files changed
+362 -68

No files matched your search

+34 -60
View File
@@ -3,10 +3,10 @@ import { HOSTS } from "../core/hosting/registry";
import { coverFormUrl } from "../core/mangabaka/client";
import { CONTENT_RATINGS, GRANULARITIES, type ContentRating, type Granularity } from "../core/model";
import type { RunResult } from "../core/pipeline";
import { carryEdits, fromSaved, saveAndPrune, toSaved, type HistoryStore, type Marks } from "../core/saved";
import type { HistoryStore } from "../core/saved";
import { loadToken, saveToken, type KeyValueStore, type PreparedSubmission, type Session } from "../core/session";
import { SOURCES } from "../core/sources/registry";
import { zipStore } from "../core/zip";
import { HistoryKeeper } from "./history-keeper";
import { renderRecent } from "./recent";
import { renderCards, renderPresent, renderPreview } from "./render";
import { saveFile } from "./save-file";
@@ -24,7 +24,7 @@ export interface AppOptions {
catboxNeedsServer: boolean;
/** Userscript: the MangaBaka series page it runs on. */
seriesLink?: string;
/** The page's history of searches (spec 002 §3); the userscript has none. */
/** The page's history of searches (spec 002 §3): null when the browser refused storage; the userscript has none. */
history?: HistoryStore | null;
}
@@ -105,11 +105,15 @@ export function mountApp(o: AppOptions): void {
let running: AbortController | null = null;
let stopHistory: AbortController | null = null;
let prepared: PreparedSubmission | null = null;
/** The history entry on screen: what was done with it, and when its search ran. */
let marks: Marks = {};
let foundAt = "";
let currentId: number | null = null;
let saveTimer: ReturnType<typeof setTimeout> | undefined;
const keeper = o.history
? new HistoryKeeper(o.history, o.session, {
rating: () => ui.ratingAll.value as ContentRating,
onStatus: (error) => {
status(ui.recentStatus, error ?? "", error !== null);
void refreshRecent();
},
})
: null;
const fill = (select: HTMLSelectElement, values: readonly (string | number)[], label: (v: string) => string, selected: string) =>
select.replaceChildren(...values.map(String).map((v) => new Option(label(v), v, false, v === selected)));
@@ -181,7 +185,7 @@ export function mountApp(o: AppOptions): void {
ui.confirm.hidden = true;
ui.previewDiff.replaceChildren();
draw();
scheduleSave();
keeper?.edited();
}
async function refreshRecent(): Promise<void> {
@@ -189,33 +193,7 @@ export function mountApp(o: AppOptions): void {
const items = await o.history.list().catch(() => []);
ui.recent.hidden = items.length === 0;
ui.recentShelfWrap.hidden = items.length === 0;
renderRecent(ui.recentList, ui.recentShelf, items, currentId, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
}
/** Spec 002 §3.1: the entry is rewritten after the search, each edit, rehosting, a download and filing. */
function save(): void {
clearTimeout(saveTimer);
const r = o.session.result;
const input = o.session.input;
if (!o.history || !r || !input) return;
const entry = toSaved({
result: r, cards: o.session.cards, makeDefault: o.session.makeDefault, input,
rating: ui.ratingAll.value as ContentRating, marks, foundAt, now: new Date(),
});
saveAndPrune(o.history, entry)
.then(() => status(ui.recentStatus, ""))
.catch(() => status(ui.recentStatus, "This search couldn't be saved: the browser refused the storage.", true))
.finally(() => void refreshRecent());
}
function scheduleSave(): void {
clearTimeout(saveTimer);
if (o.history) saveTimer = setTimeout(save, 500);
}
function mark(patch: Marks): void {
marks = { ...marks, ...patch };
save();
renderRecent(ui.recentList, ui.recentShelf, items, keeper?.currentId ?? null, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
}
/** Shows a finished search: plates, notes, the series head and the shelf. */
@@ -252,22 +230,15 @@ export function mountApp(o: AppOptions): void {
}
async function open(seriesId: number): Promise<void> {
if (!o.history || running) return;
save();
if (!keeper || running) return;
try {
const entry = await o.history.get(seriesId);
if (!entry) throw new Error("That entry is no longer in the history.");
const back = fromSaved(entry, SOURCES, Date.now());
o.session.restore(back.result, back.cards, back.makeDefault, back.input);
const back = await keeper.open(seriesId, Date.now());
resetReview();
if (!ui.mbLink.readOnly) ui.mbLink.value = back.input.mangabakaLink;
ui.sourceLink.value = back.input.sourceLink;
ui.historyOn.checked = back.input.history !== null;
if (back.input.history !== null) ui.historyEvery.value = String(back.input.history);
ui.ratingAll.value = back.rating;
marks = back.marks;
foundAt = back.foundAt;
currentId = seriesId;
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
show(back.result, back.input.history === null);
ui.foundWhen.textContent = `Found ${relativeTime(back.foundAt)}.`;
@@ -280,9 +251,7 @@ export function mountApp(o: AppOptions): void {
}
async function forget(seriesId: number): Promise<void> {
if (!o.history) return;
await o.history.remove(seriesId).catch(() => undefined);
if (currentId === seriesId) currentId = null;
await keeper?.forget(seriesId);
void refreshRecent();
}
@@ -309,7 +278,10 @@ export function mountApp(o: AppOptions): void {
}
async function run(): Promise<void> {
if (running) return;
running = new AbortController();
// An edit still waiting to be saved must reach the history before this search reads it back.
await keeper?.flush();
stopHistory = new AbortController();
prepared = null;
ui.run.disabled = true;
@@ -347,14 +319,9 @@ export function mountApp(o: AppOptions): void {
ui.ratingAll.value as ContentRating,
);
// Searching a series again keeps what the user did with it (spec 002 §3.3).
const previous = o.history ? await o.history.get(r.seriesId).catch(() => null) : null;
if (previous) carryEdits(previous.cards, o.session.cards, Date.now());
marks = previous?.marks ?? {};
foundAt = new Date().toISOString();
currentId = r.seriesId;
await keeper?.searched(r, Date.now());
show(r, !ui.historyOn.checked);
status(ui.stage, `Found ${r.fresh.length} new cover(s); ${r.present.length} already on MangaBaka.`);
save();
} catch (err) {
if (running?.signal.aborted) {
markStage(currentStage, "reset");
@@ -415,7 +382,7 @@ export function mountApp(o: AppOptions): void {
if (res.allFailed) status(ui.rehostStatus, `Every upload failed: ${res.failures[0]?.message ?? "unknown error"}`, true);
else {
const done = host === "direct" ? `${res.hosted.size} ready with their source URLs (not rehosted)` : `${res.hosted.size} rehosted`;
marks = { ...marks, rehosted: { at: new Date().toISOString(), host } };
keeper?.mark({ rehosted: { at: new Date().toISOString(), host } });
status(ui.rehostStatus, `${done}${res.failures.length ? `, ${res.failures.length} failed (see the covers)` : ""}.`);
}
} catch (err) {
@@ -430,7 +397,7 @@ export function mountApp(o: AppOptions): void {
const bundle = o.session.bundle();
saveFile(zipStore(bundle.files), bundle.zipName, "application/zip", ui.download);
status(ui.manualStatus, `Saved ${bundle.zipName} (${bundle.files.length - 1} covers and covers.txt).`);
mark({ downloaded: new Date().toISOString() });
keeper?.mark({ downloaded: new Date().toISOString() });
} catch (err) {
status(ui.manualStatus, errorText(err), true);
}
@@ -473,7 +440,7 @@ export function mountApp(o: AppOptions): void {
const outcome = await o.session.submit(t, prepared, host, hostOptions());
if (outcome.status === "saved") {
status(ui.submitStatus, `Filed edit request #${outcome.result.submission_id} (${outcome.result.status}). A MangaBaka moderator will review it.`);
mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
keeper?.mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
prepared = null;
ui.confirm.hidden = true;
} else {
@@ -491,12 +458,19 @@ export function mountApp(o: AppOptions): void {
ui.searchAgain.addEventListener("click", () => void run());
ui.recentClear.addEventListener("click", async () => {
if (!o.history || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
await o.history.clear().catch(() => undefined);
currentId = null;
if (!keeper || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
await keeper.clear();
void refreshRecent();
});
// Edits made just before the tab closes or is hidden still reach the history.
if (keeper) {
addEventListener("pagehide", () => void keeper.flush());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") void keeper.flush();
});
}
drawHosts();
if (o.history === null) status(ui.recentStatus, "History is off: this browser doesn't let the page store data.");
void refreshRecent();
}