import { formatMonth } from "../core/covers"; import type { HostId, HostOptions } from "../core/hosting/host"; import { HOSTS } from "../core/hosting/registry"; import type { Granularity, InspectedCover } from "../core/model"; import type { Stage } from "../core/pipeline"; import type { ReviewCard } from "../core/session"; export interface ServerConfig { storage: boolean; storageFull: boolean; } export interface HostChoice { id: HostId; label: string; lifetime: string; /** Why it can't be used right now, or null. */ disabled: string | null; warning: string | null; } /** Spec §4.6 / §4.10: server storage only when configured; a full store is shown disabled with the reason. */ export function hostChoices(config: ServerConfig | null, opts: HostOptions, front: { catboxNeedsServer: boolean }): HostChoice[] { const choices: HostChoice[] = []; if (config?.storage) { choices.push({ id: "server", label: "This server", lifetime: HOSTS.server.lifetime(opts), disabled: config.storageFull ? "Storage is full until older covers expire. Use litterbox or catbox." : null, warning: null, }); } choices.push( { id: "litterbox", label: "litterbox", lifetime: HOSTS.litterbox.lifetime(opts), disabled: null, warning: "A MangaBaka review can take longer than this; the links then stop working.", }, { id: "catbox", label: "catbox.moe", lifetime: HOSTS.catbox.lifetime(opts), disabled: front.catboxNeedsServer && !config ? "Needs this site's server, which isn't answering." : null, warning: null, }, { id: "direct", label: "No rehosting", lifetime: HOSTS.direct.lifetime(opts), disabled: null, warning: "Submits WEBTOON's own image URL. Covers only on archive.org can't use this.", }, ); return choices; } export const defaultHost = (choices: readonly HostChoice[]): HostId | null => choices.find((c) => !c.disabled)?.id ?? null; const STAGE_TEXT: Readonly> = { series: "Reading the MangaBaka series", source: "Reading the source page", history: "Looking through the Wayback Machine", inspect: "Checking images", compare: "Comparing with MangaBaka", }; export function stageText(stage: Stage, done?: number, total?: number): string { return total ? `${STAGE_TEXT[stage]}… ${done ?? 0}/${total}` : `${STAGE_TEXT[stage]}…`; } export const GRANULARITY_LABELS: Readonly> = { 1: "every month", 3: "every 3 months", 6: "every 6 months", 12: "every year", }; /** The evidence shown on each card (PRODUCT.md: show where and when each cover was seen). */ export function cardFacts(card: ReviewCard): string[] { const c = card.cover; const facts = [`${c.width}×${c.height}`, `${Math.max(1, Math.round(c.bytes.byteLength / 1024))} KB`]; if (c.shape === "tall") facts.push("landing-page art"); if (c.candidate.current) { facts.push("on the page now"); } else { const from = formatMonth(c.candidate.seenAt ?? ""); const to = formatMonth(c.candidate.lastSeenAt ?? c.candidate.seenAt ?? ""); facts.push(from === to ? `seen ${from}` : `seen ${from} – ${to}`); } if (!c.live) facts.push("only on archive.org"); return facts; } /** Months since year 0, from an archive timestamp (YYYYMM…) or a date. */ const monthOf = (ts: string) => Number(ts.slice(0, 4)) * 12 + Number(ts.slice(4, 6)) - 1; const monthNow = (now: Date) => now.getUTCFullYear() * 12 + now.getUTCMonth(); export interface Span { from: number; to: number; } /** From the earliest sighting of any cover to now, in months. */ export function seenSpan(covers: readonly InspectedCover[], now = new Date()): Span { const to = monthNow(now); const starts = covers.flatMap((c) => (c.candidate.seenAt ? [monthOf(c.candidate.seenAt)] : [])); return { from: Math.min(to, ...starts), to }; } function rangeOf(c: InspectedCover, now: Date): Span { const to = monthNow(now); const { seenAt, lastSeenAt, current } = c.candidate; const start = seenAt ? monthOf(seenAt) : to; return { from: start, to: current ? to : lastSeenAt ? monthOf(lastSeenAt) : start }; } /** Where a cover's seen-period sits on the series' history, in percent, drawn to scale. */ export function seenBar(c: InspectedCover, span: Span, now = new Date()): { left: number; width: number } { const total = Math.max(1, span.to - span.from + 1); const r = rangeOf(c, now); const left = ((r.from - span.from) / total) * 100; return { left, width: Math.max(1.5, Math.min(100 - left, ((r.to - r.from + 1) / total) * 100)) }; } /** Timeline view: covers grouped by the year they were last seen (now, for current ones), newest first. */ export function timelineGroups(cards: readonly ReviewCard[], now = new Date()): { year: number; cards: ReviewCard[] }[] { const groups = new Map(); const sorted = [...cards].sort((a, b) => rangeOf(b.cover, now).to - rangeOf(a.cover, now).to); for (const card of sorted) { const year = Math.floor(rangeOf(card.cover, now).to / 12); groups.set(year, [...(groups.get(year) ?? []), card]); } return [...groups].map(([year, list]) => ({ year, cards: list })); }