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 = (id: string): T => { const found = o.root.querySelector(`#${id}`); if (!found) throw new Error(`mbseed: #${id} is missing from the template`); return found; }; const ui = { form: q("run-form"), mbLink: q("mb-link"), sourceLink: q("source-link"), historyOn: q("history-on"), historyEvery: q("history-every"), ratingAll: q("rating-all"), run: q("run"), cancel: q("cancel"), skipHistory: q("skip-history"), stage: q("stage"), progress: q("progress"), warnings: q("warnings"), review: q("review"), seriesTitle: q("series-title"), seriesSource: q("series-source"), matureWarning: q("mature-warning"), linkWarning: q("link-warning"), linkConfirm: q("link-confirm"), makeDefault: q("make-default"), defaultNote: q("default-note"), selectedCount: q("selected-count"), cards: q("cards"), present: q("present-list"), hosts: q("hosts"), litterboxTime: q("litterbox-time"), userhash: q("catbox-userhash"), userhashRemember: q("catbox-remember"), rehost: q("rehost"), rehostStatus: q("rehost-status"), manualText: q("manual-text"), download: q("download"), manualStatus: q("manual-status"), copy: q("copy"), formLink: q("form-link"), token: q("token"), tokenRemember: q("token-remember"), preview: q("preview"), previewDiff: q("preview-diff"), confirm: q("confirm"), submitStatus: q("submit-status"), recent: q("recent"), recentList: q("recent-list"), recentStatus: q("recent-status"), recentClear: q("recent-clear"), recentShelfWrap: q("recent-shelf-wrap"), recentShelf: q("recent-shelf"), foundLine: q("found-line"), foundWhen: q("found-when"), searchAgain: q("search-again"), }; const plates = [...o.root.querySelectorAll(".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(".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('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 { 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 { 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 { 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 { 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(); }