feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -1,24 +1,20 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeMappingTableEntry,
|
||||
EpisodeReresolveResult,
|
||||
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):方式名称、季集写法、规则摘要、
|
||||
// 表格规则的文本格式、作品页「集数映射」分区的地址。
|
||||
// 集数映射的界面文案与纯函数:方式名称、季集写法、作品页「集数映射」分区的地址。
|
||||
// 规则本身是作品识别词(components/identify-words),这里不再有结构化规则。
|
||||
|
||||
/** 识别方式的人话 */
|
||||
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
|
||||
raw: "原号",
|
||||
rule: "按规则",
|
||||
rule: "按作品识别词",
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
@@ -34,12 +30,6 @@ export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
|
||||
ignore: "忽略",
|
||||
};
|
||||
|
||||
export const RULE_KIND_LABEL: Record<EpisodeMappingRuleSpec["kind"], string> = {
|
||||
expression: "偏移表达式",
|
||||
table: "对照表",
|
||||
special: "特别篇 / 花絮 / 忽略",
|
||||
};
|
||||
|
||||
/** 校验状态:状态色只表示状态 */
|
||||
export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: StatusTone }> = {
|
||||
pass: { label: "对得上", tone: "success" },
|
||||
@@ -52,7 +42,7 @@ export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: S
|
||||
/** 方式名称:按规则映射成特别篇等时写具体去向 */
|
||||
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
|
||||
if (!r) return "未解析";
|
||||
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
|
||||
if (r.special) return `按作品识别词:${SPECIAL_LABEL[r.special]}`;
|
||||
return METHOD_LABEL[r.method] ?? r.method;
|
||||
}
|
||||
|
||||
@@ -91,166 +81,19 @@ export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "spec
|
||||
export const scopeText = (season: number | null | undefined, group: string | null | undefined) =>
|
||||
`${season == null ? "任意季" : `第 ${season} 季`} · ${group?.trim() || "任意发布组"}`;
|
||||
|
||||
/** 规则内容一句话 */
|
||||
export function ruleText(
|
||||
r: Pick<EpisodeMappingRuleSpec, "kind" | "expression" | "targetSeason" | "tableEntries" | "special">,
|
||||
) {
|
||||
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<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
|
||||
): { 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<EpisodeMappingRuleRow, "status" | "disabledReason">): 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";
|
||||
const LINE_PARAM = "mapLine";
|
||||
|
||||
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}`;
|
||||
export function mappingHref(libraryPath: string, line?: string): string {
|
||||
const qs = line ? `?${new URLSearchParams({ [LINE_PARAM]: line })}` : "";
|
||||
return `${libraryPath}${qs}#${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 ?? "",
|
||||
};
|
||||
/** 从地址读要追加进作品识别词草稿的一行;没有返回 null */
|
||||
export function lineFromSearch(sp: URLSearchParams): string | null {
|
||||
return sp.get(LINE_PARAM)?.trim() || null;
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
@@ -270,7 +113,7 @@ export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
|
||||
}
|
||||
|
||||
/** 规则写入后的提示:只列非零项 */
|
||||
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
|
||||
export function reresolveToast(r: EpisodeReresolveResult, verb = "已保存"): string {
|
||||
const parts = [`重算 ${r.releases} 条资源`];
|
||||
if (r.changed) parts.push(`${r.changed} 条有变化`);
|
||||
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
|
||||
|
||||
Reference in new issue
Block a user