import type { EpisodeMappingPreview, EpisodeMappingRuleRow, EpisodeMappingRuleSpec, EpisodeMappingRuleWriteResult, EpisodeMappingTableEntry, EpisodeResolutionMethod, EpisodeResolutionView, EpisodeSpecialKind, EpisodeValidationState, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { EVIDENCE_LEVEL } from "~/components/library/labels.ts"; // 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、 // 表格规则的文本格式、作品页「集数映射」分区的地址。 /** 识别方式的人话 */ export const METHOD_LABEL: Record = { raw: "原号", rule: "按规则", absolute_in_season: "季内绝对集号", absolute_cross_season: "跨季绝对集号", air_date: "按播出日期", tmdb_season: "按 TMDB 默认季集换算", unverified: "元数据未覆盖,先按原号", unresolved: "待确认", skipped: "不适用", }; export const SPECIAL_LABEL: Record = { special: "特别篇", extra: "花絮", ignore: "忽略", }; export const RULE_KIND_LABEL: Record = { expression: "偏移表达式", table: "对照表", special: "特别篇 / 花絮 / 忽略", }; /** 校验状态:状态色只表示状态 */ export const VALIDATION: Record = { pass: { label: "对得上", tone: "success" }, unverified: { label: "未核对", tone: "warning" }, fail: { label: "对不上", tone: "destructive" }, special: { label: "不按集整理", tone: "neutral" }, skipped: { label: "不适用", tone: "neutral" }, }; /** 方式名称:按规则映射成特别篇等时写具体去向 */ export function methodText(r: Pick | null | undefined): string { if (!r) return "未解析"; if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`; return METHOD_LABEL[r.method] ?? r.method; } /** 绝对集号类方式:订阅表单据此提示「这个发布组用的是绝对集号」 */ export const isAbsoluteMethod = (m: EpisodeResolutionMethod) => m === "absolute_in_season" || m === "absolute_cross_season"; const pad = (n: number) => String(n).padStart(2, "0"); /** 季集写法:S01E25、S02E01-E02;只有集号没季号写 E25 */ export function episodeCode(season: number | null | undefined, episodes: { start: number; end: number } | undefined) { if (!episodes) return season != null ? `S${pad(season)}` : ""; const s = season != null ? `S${pad(season)}` : ""; const e = episodes.start === episodes.end ? `E${pad(episodes.start)}` : `E${pad(episodes.start)}-E${pad(episodes.end)}`; return `${s}${e}`; } /** 发布者声称的季集(原始) */ export function sourceText(r: Pick | null | undefined): string { if (!r) return "—"; const { season, episodes, airDate } = r.source; if (episodes) return episodeCode(season, episodes); if (airDate) return airDate.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3"); return "—"; } /** 媒体库应当使用的季集(规范);按规则去特别篇等写去向,换不出写「—」 */ export function canonicalText(r: Pick | null | undefined) { if (!r) return "—"; if (r.special) return SPECIAL_LABEL[r.special]; if (r.canonical) return episodeCode(r.canonical.season, r.canonical.episodes); return "—"; } export const scopeText = (season: number | null | undefined, group: string | null | undefined) => `${season == null ? "任意季" : `第 ${season} 季`} · ${group?.trim() || "任意发布组"}`; /** 规则内容一句话 */ export function ruleText( r: Pick, ) { if (r.kind === "expression") { return `${r.expression ?? ""}${r.targetSeason != null ? ` → 第 ${r.targetSeason} 季` : ""}`; } if (r.kind === "table") { const n = r.tableEntries?.length ?? 0; const first = r.tableEntries?.[0]; return first ? `${tableLine(first)}${n > 1 ? ` 等 ${n} 行` : ""}` : "空对照表"; } return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向"; } /** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */ export function agentRuleInfo( rule: Pick, ): { label: string; reasons: string[] } | null { if (rule.createdByType !== "agent") return null; const raw = (rule.evidence as { reasons?: unknown }).reasons; return { label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`, reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [], }; } /** 停用原因:手动停用 / 规则失效(带当时的来源季集) */ export function disabledReasonText(rule: Pick): string | null { if (rule.status !== "disabled") return null; if (!rule.disabledReason) return "已停用"; try { const v = JSON.parse(rule.disabledReason) as { manual?: boolean; source?: EpisodeResolutionView["source"]; }; if (v.manual) return "手动停用"; if (v.source) return `规则算出的集数对不上季表(来源 ${sourceText({ source: v.source })}),已自动停用`; } catch { // 旧格式:原样显示 } return rule.disabledReason; } // ── 对照表的文本格式:每行 `26=S02E01` 或 `27-28=S02E02` ── export function tableLine(e: EpisodeMappingTableEntry): string { const from = e.from.start === e.from.end ? `${e.from.start}` : `${e.from.start}-${e.from.end}`; return `${from}=S${pad(e.to.season)}E${pad(e.to.episode)}`; } export const formatTable = (entries: readonly EpisodeMappingTableEntry[] | null | undefined) => (entries ?? []).map(tableLine).join("\n"); /** 解析对照表文本;有错返回第一处错误 */ export function parseTable(text: string): { entries: EpisodeMappingTableEntry[]; error: string | null } { const entries: EpisodeMappingTableEntry[] = []; const lines = text .split("\n") .map((l) => l.trim()) .filter(Boolean); for (const [i, line] of lines.entries()) { const m = line.match(/^(\d{1,4})(?:\s*-\s*(\d{1,4}))?\s*(?:=|→|->)\s*S(\d{1,3})\s*E(\d{1,4})$/i); if (!m) return { entries, error: `第 ${i + 1} 行格式不对,写成 26=S02E01 或 27-28=S02E02` }; const start = Number(m[1]); const end = m[2] ? Number(m[2]) : start; if (end < start) return { entries, error: `第 ${i + 1} 行的区间写反了` }; entries.push({ from: { start, end }, to: { season: Number(m[3]), episode: Number(m[4]) } }); } return { entries, error: null }; } // ── 草稿 ── export interface RuleDraft { season: string; releaseGroup: string; kind: EpisodeMappingRuleSpec["kind"]; expression: string; targetSeason: string; table: string; special: EpisodeSpecialKind; } export const EMPTY_DRAFT: RuleDraft = { season: "", releaseGroup: "", kind: "expression", expression: "", targetSeason: "", table: "", special: "ignore", }; export function draftOfRule(r: EpisodeMappingRuleRow): RuleDraft { return { season: r.season != null ? String(r.season) : "", releaseGroup: r.releaseGroup ?? "", kind: r.kind, expression: r.expression ?? "", targetSeason: r.targetSeason != null ? String(r.targetSeason) : "", table: formatTable(r.tableEntries), special: r.special ?? "ignore", }; } const intOrNull = (s: string) => (/^\d{1,3}$/.test(s.trim()) ? Number(s.trim()) : null); /** 草稿 → 接口的规则内容;不完整时返回错误(预览不发请求) */ export function specOfDraft(d: RuleDraft): { spec: EpisodeMappingRuleSpec | null; error: string | null } { if (d.season.trim() && intOrNull(d.season) === null) return { spec: null, error: "来源季写数字,空着表示任意季" }; if (d.targetSeason.trim() && intOrNull(d.targetSeason) === null) return { spec: null, error: "目标季写数字" }; const base = { season: intOrNull(d.season), releaseGroup: d.releaseGroup.trim() || null }; if (d.kind === "expression") { if (!d.expression.trim()) return { spec: null, error: "填偏移表达式,如 EP-12" }; return { spec: { ...base, kind: "expression", expression: d.expression.trim(), targetSeason: intOrNull(d.targetSeason) }, error: null, }; } if (d.kind === "table") { const t = parseTable(d.table); if (t.error) return { spec: null, error: t.error }; if (!t.entries.length) return { spec: null, error: "对照表至少写一行" }; return { spec: { ...base, kind: "table", tableEntries: t.entries }, error: null }; } return { spec: { ...base, kind: "special", special: d.special }, error: null }; } // ── 作品页「集数映射」分区的地址(待处理「修改」跳转并预填草稿) ── export const MAPPING_ANCHOR = "episode-mapping"; export function mappingHref( libraryPath: string, prefill?: { season?: number | null; releaseGroup?: string | null; expression?: string; targetSeason?: number }, ): string { const qs = new URLSearchParams(); if (prefill?.season != null) qs.set("mapSeason", String(prefill.season)); if (prefill?.releaseGroup) qs.set("mapGroup", prefill.releaseGroup); if (prefill?.expression) qs.set("mapExpr", prefill.expression); if (prefill?.targetSeason != null) qs.set("mapTarget", String(prefill.targetSeason)); const s = qs.toString(); return `${libraryPath}${s ? `?${s}` : ""}#${MAPPING_ANCHOR}`; } /** 从地址读预填草稿;没有任何预填返回 null */ export function draftFromSearch(sp: URLSearchParams): RuleDraft | null { const season = sp.get("mapSeason"); const group = sp.get("mapGroup"); const expr = sp.get("mapExpr"); const target = sp.get("mapTarget"); if (season === null && group === null && expr === null) return null; return { ...EMPTY_DRAFT, season: season ?? "", releaseGroup: group ?? "", expression: expr ?? "", targetSeason: target ?? "", }; } // ── 统计 ── /** 按发布组的方式统计一句话:`ANi:跨季绝对集号 12、原号 3`(只列非零) */ export function groupMethodsText(g: EpisodeMappingPreview["groups"][number]): string { const parts = [...g.methods].sort((a, b) => b.count - a.count).map((m) => `${METHOD_LABEL[m.method]} ${m.count}`); return `${g.releaseGroup ?? "未知发布组"}:${parts.join("、")}`; } /** 用绝对集号的发布组(订阅表单提示用) */ export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] { if (!p) return []; return p.groups .filter((g) => g.methods.some((m) => isAbsoluteMethod(m.method))) .map((g) => g.releaseGroup ?? "未知发布组"); } /** 规则写入后的提示:只列非零项 */ export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string { const parts = [`重算 ${r.releases} 条资源`]; if (r.changed) parts.push(`${r.changed} 条有变化`); if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`); return `${verb}:${parts.join(",")}`; }