- 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>
477 lines
20 KiB
TypeScript
477 lines
20 KiB
TypeScript
import { LITTERBOX_TIMES, type HostId, type HostOptions, type LitterboxTime } from "../core/hosting/host";
|
|
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 type { HistoryStore } from "../core/saved";
|
|
import { loadToken, saveToken, type KeyValueStore, type PreparedSubmission, type Session } from "../core/session";
|
|
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";
|
|
import { GRANULARITY_LABELS, defaultHost, hostChoices, relativeTime, stageText, type ServerConfig } from "./view-model";
|
|
|
|
export const USERHASH_KEY = "mbseed.catbox-userhash";
|
|
export const VIEW_KEY = "mbseed.view";
|
|
|
|
export interface AppOptions {
|
|
root: ParentNode;
|
|
session: Session;
|
|
store: KeyValueStore;
|
|
config: ServerConfig | null;
|
|
/** The page reaches catbox through our server; the userscript goes direct. */
|
|
catboxNeedsServer: boolean;
|
|
/** Userscript: the MangaBaka series page it runs on. */
|
|
seriesLink?: string;
|
|
/** The page's history of searches (spec 002 §3): null when the browser refused storage; the userscript has none. */
|
|
history?: HistoryStore | null;
|
|
}
|
|
|
|
/** Wires APP_HTML to a Session. Nothing is uploaded or sent without a click (spec §1). */
|
|
export function mountApp(o: AppOptions): void {
|
|
const q = <T extends Element>(id: string): T => {
|
|
const found = o.root.querySelector<T>(`#${id}`);
|
|
if (!found) throw new Error(`mbseed: #${id} is missing from the template`);
|
|
return found;
|
|
};
|
|
const ui = {
|
|
form: q<HTMLFormElement>("run-form"),
|
|
mbLink: q<HTMLInputElement>("mb-link"),
|
|
sourceLink: q<HTMLInputElement>("source-link"),
|
|
historyOn: q<HTMLInputElement>("history-on"),
|
|
historyEvery: q<HTMLSelectElement>("history-every"),
|
|
ratingAll: q<HTMLSelectElement>("rating-all"),
|
|
run: q<HTMLButtonElement>("run"),
|
|
cancel: q<HTMLButtonElement>("cancel"),
|
|
skipHistory: q<HTMLButtonElement>("skip-history"),
|
|
stage: q<HTMLElement>("stage"),
|
|
progress: q<HTMLProgressElement>("progress"),
|
|
warnings: q<HTMLUListElement>("warnings"),
|
|
review: q<HTMLElement>("review"),
|
|
seriesTitle: q<HTMLElement>("series-title"),
|
|
seriesSource: q<HTMLElement>("series-source"),
|
|
matureWarning: q<HTMLElement>("mature-warning"),
|
|
linkWarning: q<HTMLElement>("link-warning"),
|
|
linkConfirm: q<HTMLInputElement>("link-confirm"),
|
|
makeDefault: q<HTMLInputElement>("make-default"),
|
|
defaultNote: q<HTMLElement>("default-note"),
|
|
selectedCount: q<HTMLElement>("selected-count"),
|
|
cards: q<HTMLElement>("cards"),
|
|
present: q<HTMLElement>("present-list"),
|
|
hosts: q<HTMLElement>("hosts"),
|
|
litterboxTime: q<HTMLSelectElement>("litterbox-time"),
|
|
userhash: q<HTMLInputElement>("catbox-userhash"),
|
|
userhashRemember: q<HTMLInputElement>("catbox-remember"),
|
|
rehost: q<HTMLButtonElement>("rehost"),
|
|
rehostStatus: q<HTMLElement>("rehost-status"),
|
|
manualText: q<HTMLTextAreaElement>("manual-text"),
|
|
download: q<HTMLButtonElement>("download"),
|
|
manualStatus: q<HTMLElement>("manual-status"),
|
|
copy: q<HTMLButtonElement>("copy"),
|
|
formLink: q<HTMLAnchorElement>("form-link"),
|
|
token: q<HTMLInputElement>("token"),
|
|
tokenRemember: q<HTMLInputElement>("token-remember"),
|
|
preview: q<HTMLButtonElement>("preview"),
|
|
previewDiff: q<HTMLElement>("preview-diff"),
|
|
confirm: q<HTMLButtonElement>("confirm"),
|
|
submitStatus: q<HTMLElement>("submit-status"),
|
|
recent: q<HTMLElement>("recent"),
|
|
recentList: q<HTMLElement>("recent-list"),
|
|
recentStatus: q<HTMLElement>("recent-status"),
|
|
recentClear: q<HTMLButtonElement>("recent-clear"),
|
|
recentShelfWrap: q<HTMLElement>("recent-shelf-wrap"),
|
|
recentShelf: q<HTMLElement>("recent-shelf"),
|
|
foundLine: q<HTMLElement>("found-line"),
|
|
foundWhen: q<HTMLElement>("found-when"),
|
|
searchAgain: q<HTMLButtonElement>("search-again"),
|
|
};
|
|
const plates = [...o.root.querySelectorAll<HTMLElement>(".mbseed-plates [data-stage]")];
|
|
let currentStage = "";
|
|
/** Stage plates change state in place: done, active, failed, or pending. */
|
|
/** Each plate shows a short count once its stage has something to say (images, language, n/n…). */
|
|
const setCount = (stage: string, text: string) => {
|
|
const em = plates.find((p) => p.dataset.stage === stage)?.querySelector("em");
|
|
if (em) em.textContent = text;
|
|
};
|
|
const markStage = (stage: string, outcome: "running" | "done" | "failed" | "reset" = "running") => {
|
|
currentStage = stage;
|
|
const at = plates.findIndex((p) => p.dataset.stage === stage);
|
|
plates.forEach((plate, i) => {
|
|
plate.dataset.state =
|
|
outcome === "reset" ? "" : outcome === "done" ? "done" : i < at ? "done" : i === at ? (outcome === "failed" ? "failed" : "active") : "";
|
|
});
|
|
};
|
|
let running: AbortController | null = null;
|
|
let stopHistory: AbortController | null = null;
|
|
let prepared: PreparedSubmission | null = null;
|
|
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)));
|
|
fill(ui.historyEvery, GRANULARITIES, (v) => GRANULARITY_LABELS[Number(v) as Granularity], "6");
|
|
fill(ui.ratingAll, CONTENT_RATINGS, (v) => v, "safe");
|
|
fill(ui.litterboxTime, LITTERBOX_TIMES, (v) => HOSTS.litterbox.lifetime({ litterboxTime: v as LitterboxTime }), "72h");
|
|
if (o.seriesLink) {
|
|
ui.mbLink.value = o.seriesLink;
|
|
ui.mbLink.readOnly = true;
|
|
}
|
|
const savedToken = loadToken(o.store);
|
|
if (savedToken) {
|
|
ui.token.value = savedToken;
|
|
ui.tokenRemember.checked = true;
|
|
}
|
|
const savedUserhash = o.store.get(USERHASH_KEY);
|
|
if (savedUserhash) {
|
|
ui.userhash.value = savedUserhash;
|
|
ui.userhashRemember.checked = true;
|
|
}
|
|
|
|
const viewButtons = [...o.root.querySelectorAll<HTMLButtonElement>(".mbseed-view[data-view]")];
|
|
const showView = (view: string) => {
|
|
ui.cards.dataset.view = view === "timeline" ? "timeline" : "shelf";
|
|
for (const b of viewButtons) b.setAttribute("aria-pressed", String(b.dataset.view === ui.cards.dataset.view));
|
|
};
|
|
showView(o.store.get(VIEW_KEY) ?? "shelf");
|
|
for (const b of viewButtons) {
|
|
b.addEventListener("click", () => {
|
|
showView(b.dataset.view ?? "shelf");
|
|
o.store.set(VIEW_KEY, ui.cards.dataset.view ?? "shelf");
|
|
draw();
|
|
});
|
|
}
|
|
|
|
const hostOptions = (): HostOptions => ({
|
|
litterboxTime: ui.litterboxTime.value as LitterboxTime,
|
|
catboxUserhash: ui.userhash.value.trim() || undefined,
|
|
});
|
|
const selectedHost = (): HostId | null =>
|
|
(o.root.querySelector<HTMLInputElement>('input[name="mbseed-host"]:checked')?.value as HostId | undefined) ?? null;
|
|
|
|
function drawHosts(): void {
|
|
const choices = hostChoices(o.config, hostOptions(), { catboxNeedsServer: o.catboxNeedsServer });
|
|
const keep = selectedHost();
|
|
const current = choices.some((c) => c.id === keep && !c.disabled) ? keep : defaultHost(choices);
|
|
ui.hosts.replaceChildren(...choices.map((c) => {
|
|
const input = Object.assign(document.createElement("input"), {
|
|
type: "radio", name: "mbseed-host", value: c.id, checked: c.id === current, disabled: c.disabled !== null,
|
|
});
|
|
const label = document.createElement("label");
|
|
label.append(input, Object.assign(document.createElement("span"), { textContent: c.label }),
|
|
Object.assign(document.createElement("small"), { textContent: c.lifetime }));
|
|
const why = c.disabled ?? c.warning;
|
|
if (why) label.append(Object.assign(document.createElement("small"), { className: c.disabled ? "is-why" : "is-warn", textContent: why }));
|
|
return label;
|
|
}));
|
|
}
|
|
|
|
const status = (where: HTMLElement, text: string, isError = false) => {
|
|
where.textContent = text;
|
|
where.classList.toggle("is-error", isError);
|
|
};
|
|
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
|
|
|
/** Any edit after a preview invalidates it: what is confirmed must be what was previewed. */
|
|
function edited(): void {
|
|
prepared = null;
|
|
ui.confirm.hidden = true;
|
|
ui.previewDiff.replaceChildren();
|
|
draw();
|
|
keeper?.edited();
|
|
}
|
|
|
|
async function refreshRecent(): Promise<void> {
|
|
if (!o.history) return;
|
|
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, 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. */
|
|
function show(r: RunResult, historyOff: boolean): void {
|
|
markStage("compare", "done");
|
|
setCount("series", `#${r.seriesId}`);
|
|
setCount("source", r.series.language);
|
|
if (historyOff) setCount("history", "off");
|
|
setCount("inspect", String(r.fresh.length + r.present.length));
|
|
setCount("compare", `${r.present.length} on MangaBaka`);
|
|
const notes = [...r.warnings];
|
|
const landing = r.fresh.filter((c) => c.shape === "tall").length;
|
|
if (landing) {
|
|
notes.push(`${landing} tall image${landing === 1 ? " looks" : "s look"} like landing-page art and start${landing === 1 ? "s" : ""} unselected.`);
|
|
}
|
|
ui.warnings.replaceChildren(...notes.map((textContent) => Object.assign(document.createElement("li"), { textContent })));
|
|
ui.progress.max = 1;
|
|
ui.progress.value = 1;
|
|
ui.seriesTitle.textContent = r.mbSeries.title;
|
|
ui.seriesSource.textContent = `← ${r.source.name} “${r.series.title}” · ${r.series.language}`;
|
|
ui.matureWarning.hidden = !r.series.mature;
|
|
ui.formLink.href = coverFormUrl(r.seriesId);
|
|
ui.review.hidden = false;
|
|
draw();
|
|
}
|
|
|
|
/** Clears what belonged to the previous series on screen. */
|
|
function resetReview(): void {
|
|
prepared = null;
|
|
ui.confirm.hidden = true;
|
|
ui.previewDiff.replaceChildren();
|
|
ui.linkConfirm.checked = false;
|
|
for (const line of [ui.rehostStatus, ui.manualStatus, ui.submitStatus]) status(line, "");
|
|
}
|
|
|
|
async function open(seriesId: number): Promise<void> {
|
|
if (!keeper || running) return;
|
|
try {
|
|
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;
|
|
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
|
|
show(back.result, back.input.history === null);
|
|
ui.foundWhen.textContent = `Found ${relativeTime(back.foundAt)}.`;
|
|
ui.foundLine.hidden = false;
|
|
status(ui.stage, "");
|
|
} catch (err) {
|
|
status(ui.stage, errorText(err), true);
|
|
}
|
|
void refreshRecent();
|
|
}
|
|
|
|
async function forget(seriesId: number): Promise<void> {
|
|
await keeper?.forget(seriesId);
|
|
void refreshRecent();
|
|
}
|
|
|
|
function draw(): void {
|
|
const r = o.session.result;
|
|
if (!r) return;
|
|
renderCards(ui.cards, o.session, edited);
|
|
renderPresent(ui.present, r.present);
|
|
const selected = o.session.selected().length;
|
|
ui.selectedCount.textContent = `${selected} of ${o.session.cards.length} new cover(s) selected`;
|
|
ui.linkWarning.hidden = r.linkMatches;
|
|
ui.makeDefault.checked = o.session.makeDefault;
|
|
ui.makeDefault.disabled = !r.suggestedDefault;
|
|
ui.defaultNote.textContent = r.suggestedDefault
|
|
? "One main cover per series: this replaces it for every language."
|
|
: "No current poster was found to suggest as the main cover.";
|
|
ui.rehost.textContent = `Rehost ${selected} cover${selected === 1 ? "" : "s"}`;
|
|
ui.download.textContent = `Download ${selected} cover${selected === 1 ? "" : "s"} (ZIP)`;
|
|
ui.download.disabled = selected === 0;
|
|
ui.manualText.value = o.session.manualText();
|
|
const blocked = selected === 0 || (!r.linkMatches && !ui.linkConfirm.checked);
|
|
ui.rehost.disabled = blocked;
|
|
ui.preview.disabled = blocked;
|
|
}
|
|
|
|
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;
|
|
ui.cancel.hidden = false;
|
|
ui.warnings.replaceChildren();
|
|
ui.review.hidden = true;
|
|
resetReview();
|
|
ui.foundLine.hidden = true;
|
|
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
|
|
status(ui.stage, "Starting…");
|
|
try {
|
|
const r = await o.session.run(
|
|
{
|
|
mangabakaLink: ui.mbLink.value,
|
|
sourceLink: ui.sourceLink.value,
|
|
history: ui.historyOn.checked ? (Number(ui.historyEvery.value) as Granularity) : null,
|
|
},
|
|
{
|
|
signal: running.signal,
|
|
stopHistory: stopHistory.signal,
|
|
onStage: (stage) => {
|
|
markStage(stage);
|
|
status(ui.stage, stageText(stage));
|
|
ui.progress.removeAttribute("value");
|
|
ui.skipHistory.hidden = stage !== "history";
|
|
},
|
|
onProgress: (stage, done, total) => {
|
|
setCount(stage, `${done} / ${total}`);
|
|
status(ui.stage, stageText(stage, done, total));
|
|
ui.progress.max = total;
|
|
ui.progress.value = done;
|
|
},
|
|
onWarning: (message) => ui.warnings.append(Object.assign(document.createElement("li"), { textContent: message })),
|
|
},
|
|
ui.ratingAll.value as ContentRating,
|
|
);
|
|
// Searching a series again keeps what the user did with it (spec 002 §3.3).
|
|
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.`);
|
|
} catch (err) {
|
|
if (running?.signal.aborted) {
|
|
markStage(currentStage, "reset");
|
|
status(ui.stage, "Cancelled.");
|
|
} else {
|
|
markStage(currentStage, "failed");
|
|
status(ui.stage, errorText(err), true);
|
|
}
|
|
} finally {
|
|
running = null;
|
|
stopHistory = null;
|
|
ui.run.disabled = false;
|
|
ui.cancel.hidden = true;
|
|
ui.skipHistory.hidden = true;
|
|
}
|
|
}
|
|
|
|
function token(): string | null {
|
|
const t = ui.token.value.trim();
|
|
saveToken(o.store, ui.tokenRemember.checked && t ? t : null);
|
|
return t || null;
|
|
}
|
|
|
|
function rememberUserhash(): void {
|
|
const hash = ui.userhash.value.trim();
|
|
if (ui.userhashRemember.checked && hash) o.store.set(USERHASH_KEY, hash);
|
|
else o.store.remove(USERHASH_KEY);
|
|
}
|
|
|
|
ui.form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
void run();
|
|
});
|
|
ui.cancel.addEventListener("click", () => running?.abort());
|
|
ui.skipHistory.addEventListener("click", () => stopHistory?.abort());
|
|
ui.linkConfirm.addEventListener("change", draw);
|
|
ui.makeDefault.addEventListener("change", () => {
|
|
o.session.makeDefault = ui.makeDefault.checked;
|
|
edited();
|
|
});
|
|
ui.ratingAll.addEventListener("change", () => {
|
|
if (!o.session.result) return;
|
|
o.session.setRatingForAll(ui.ratingAll.value as ContentRating);
|
|
edited();
|
|
});
|
|
ui.litterboxTime.addEventListener("change", drawHosts);
|
|
ui.userhash.addEventListener("change", rememberUserhash);
|
|
ui.userhashRemember.addEventListener("change", rememberUserhash);
|
|
ui.tokenRemember.addEventListener("change", () => void token());
|
|
|
|
ui.rehost.addEventListener("click", async () => {
|
|
const host = selectedHost();
|
|
if (!host) return status(ui.rehostStatus, "Pick a host first.", true);
|
|
ui.rehost.disabled = true;
|
|
status(ui.rehostStatus, "Uploading…");
|
|
try {
|
|
const res = await o.session.rehost(host, hostOptions(), (done, total) => status(ui.rehostStatus, `Uploading… ${done}/${total}`));
|
|
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`;
|
|
keeper?.mark({ rehosted: { at: new Date().toISOString(), host } });
|
|
status(ui.rehostStatus, `${done}${res.failures.length ? `, ${res.failures.length} failed (see the covers)` : ""}.`);
|
|
}
|
|
} catch (err) {
|
|
status(ui.rehostStatus, errorText(err), true);
|
|
} finally {
|
|
edited();
|
|
}
|
|
});
|
|
|
|
ui.download.addEventListener("click", () => {
|
|
try {
|
|
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).`);
|
|
keeper?.mark({ downloaded: new Date().toISOString() });
|
|
} catch (err) {
|
|
status(ui.manualStatus, errorText(err), true);
|
|
}
|
|
});
|
|
|
|
ui.copy.addEventListener("click", async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(ui.manualText.value);
|
|
ui.copy.textContent = "Copied";
|
|
setTimeout(() => (ui.copy.textContent = "Copy all selected"), 1500);
|
|
} catch {
|
|
ui.manualText.select();
|
|
}
|
|
});
|
|
|
|
ui.preview.addEventListener("click", async () => {
|
|
const t = token();
|
|
if (!t) return status(ui.submitStatus, "Paste your MangaBaka personal access token first.", true);
|
|
ui.preview.disabled = true;
|
|
status(ui.submitStatus, "Asking MangaBaka for a preview…");
|
|
try {
|
|
prepared = await o.session.preview(t);
|
|
renderPreview(ui.previewDiff, prepared.preview);
|
|
ui.confirm.hidden = false;
|
|
status(ui.submitStatus, "Check the changes. Confirming rehosts the covers and files the edit request for review.");
|
|
} catch (err) {
|
|
status(ui.submitStatus, errorText(err), true);
|
|
} finally {
|
|
ui.preview.disabled = false;
|
|
}
|
|
});
|
|
|
|
ui.confirm.addEventListener("click", async () => {
|
|
const t = token();
|
|
const host = selectedHost();
|
|
if (!t || !prepared || !host) return;
|
|
ui.confirm.disabled = true;
|
|
status(ui.submitStatus, "Rehosting and filing the edit request…");
|
|
try {
|
|
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.`);
|
|
keeper?.mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
|
|
prepared = null;
|
|
ui.confirm.hidden = true;
|
|
} else {
|
|
prepared = outcome.prepared;
|
|
renderPreview(ui.previewDiff, prepared.preview);
|
|
status(ui.submitStatus, outcome.reason, true);
|
|
}
|
|
} catch (err) {
|
|
status(ui.submitStatus, errorText(err), true);
|
|
} finally {
|
|
ui.confirm.disabled = false;
|
|
renderCards(ui.cards, o.session, edited);
|
|
}
|
|
});
|
|
|
|
ui.searchAgain.addEventListener("click", () => void run());
|
|
ui.recentClear.addEventListener("click", async () => {
|
|
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();
|
|
}
|