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:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
1 parent 12f8fc59c3
commit 63e94b308e
90 files changed
+6366 -2131

No files matched your search

@@ -0,0 +1,92 @@
import { rowsToText, wordsToRows } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
entityFields,
exprChoice,
exprOf,
globalFields,
printEntityFields,
printGlobalFields,
ruleRow,
switchGlobalType,
} from "./model.ts";
// 可视化编辑的字段 ⇄ 原文:改任何字段都能打印回合法的一行;没改的行原文不动(往返在 contracts 测)。
const KUSURIYA = [
"# 药屋少女的呢喃:TMDB 只有一季,后面的季接着编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb @CHDWEB @黒ネズミたち @Judas @绿茶字幕组 @綠茶字幕組 >> S01 EP",
"",
].join("\n");
describe("作品识别词字段", () => {
it("药屋的三行解析成字段再打印,原样回来", () => {
for (const line of KUSURIYA.split("\n").filter((l) => l && !l.startsWith("#"))) {
expect(printEntityFields(entityFields(line))).toBe(line);
}
expect(entityFields("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
type: "map",
season: "3",
groups: ["ADWeb", "MWeb"],
targetSeason: "1",
expr: "EP",
});
});
it("改字段:换类型、改发布组、来源季留空 = 任意季", () => {
const f = entityFields("S02 >> S01 EP+24");
expect(printEntityFields({ ...f, groups: ["ANi"] })).toBe("S02 @ANi >> S01 EP+24");
expect(printEntityFields({ ...f, season: "" })).toBe("* >> S01 EP+24");
expect(printEntityFields({ ...f, targetSeason: "" })).toBe("S02 >> EP+24");
expect(printEntityFields({ ...f, type: "special", special: "extra" })).toBe("S02 >> 花絮");
expect(printEntityFields({ ...f, type: "table", fromStart: "13", toSeason: "0", toEpisode: "1" })).toBe(
"S02E13 >> S00E01",
);
expect(printEntityFields({ ...entityFields("S01E27-E28 >> S02E02"), fromEnd: "29" })).toBe("S01E27-E29 >> S02E02");
});
it("集号换算快捷选项", () => {
expect(exprChoice("EP")).toEqual({ mode: "same" });
expect(exprChoice("EP+24")).toEqual({ mode: "plus", n: "24" });
expect(exprChoice("ep - 12")).toEqual({ mode: "minus", n: "12" });
expect(exprChoice("EP*2-1")).toEqual({ mode: "custom", expr: "EP*2-1" });
expect(exprOf({ mode: "minus", n: "12" })).toBe("EP-12");
expect(exprOf({ mode: "plus", n: "" })).toBe("EP+0");
});
});
describe("全局识别词字段", () => {
it("四种 MP 写法与指定作品", () => {
expect(globalFields("国语中字")).toMatchObject({ type: "block", pattern: "国语中字" });
expect(globalFields("A => B")).toMatchObject({ type: "replace", pattern: "A", replacement: "B" });
expect(globalFields("第 <> 集 >> EP-12")).toMatchObject({
type: "offset",
front: "第",
back: "集",
offset: "EP-12",
});
const id = globalFields("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
expect(id).toMatchObject({ type: "identify", pattern: "Bakemonogatari", replacement: "物语系列" });
expect(id.tag).toMatchObject({ tmdbId: "46195", type: "tv", season: "1" });
expect(printGlobalFields(id)).toBe("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
expect(printGlobalFields({ ...id, tag: { ...id.tag, episode: "3" } })).toBe(
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1;e=3]}",
);
});
it("换类型保留已填的内容", () => {
const f = globalFields("旧名 => 新名");
expect(printGlobalFields(switchGlobalType(f, "replace_offset"))).toBe("旧名 => 新名 && <> >> EP");
expect(printGlobalFields(switchGlobalType(f, "block"))).toBe("旧名");
});
});
describe("可视化增删改 → 文本", () => {
it("新增一行追加在后面;停用 = 注释掉;原有注释和空行不动", () => {
const rows = wordsToRows(KUSURIYA, "entity");
const next = [...rows, ruleRow("x", "S04 >> S01 EP+72", false, ["第四季还没播"])];
expect(rowsToText(next, true)).toBe(`${KUSURIYA}# 第四季还没播\n# S04 >> S01 EP+72\n`);
});
});
@@ -0,0 +1,220 @@
import {
type EntityWordSpecial,
type IdentityTag,
parseEntityWordLine,
parseGlobalWordLine,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
uncomment,
type WordsRow,
type WordsScope,
} from "@umt/contracts";
// 识别词编辑器的纯函数(docs/design/identify-words.md):可视化里每一行的字段 ⇄ 那一行原文、
// 集号换算的快捷选项、编辑模式的本机记忆。行模型本身(文本 ⇄ 行、注释与停用)在 contracts。
// ── 全局识别词一行的字段 ──
export type GlobalRowType = "block" | "replace" | "offset" | "replace_offset" | "identify";
export const GLOBAL_TYPE_LABEL: Record<GlobalRowType, string> = {
block: "屏蔽词",
replace: "替换",
offset: "集数偏移",
replace_offset: "替换 + 偏移",
identify: "指定作品",
};
export interface GlobalFields {
type: GlobalRowType;
/** 屏蔽词 / 被替换词 */
pattern: string;
/** 替换词(指定作品时是标签以外的部分) */
replacement: string;
front: string;
back: string;
offset: string;
/** 指定作品:{[tmdbid=…;type=…;s=…;e=…]} */
tag: IdentityTag;
}
const EMPTY_TAG: IdentityTag = { extra: [] };
export function globalFields(rule: string): GlobalFields {
const p = parseGlobalWordLine(rule);
const base: GlobalFields = {
type: "block",
pattern: p.pattern ?? "",
replacement: p.replacement ?? "",
front: p.front ?? "",
back: p.back ?? "",
offset: p.offset ?? "",
tag: EMPTY_TAG,
};
if (p.kind === "replace") {
const t = parseIdentityTag(p.replacement ?? "");
if (t?.tag.tmdbId) return { ...base, type: "identify", replacement: `${t.before}${t.after}`.trim(), tag: t.tag };
return { ...base, type: "replace" };
}
if (p.kind === "offset" || p.kind === "replace_offset") return { ...base, type: p.kind };
return base;
}
export function printGlobalFields(f: GlobalFields): string {
if (f.type === "identify") {
const text = f.replacement.trim();
return printGlobalWord({
kind: "replace",
pattern: f.pattern,
replacement: `${text}${text ? " " : ""}${printIdentityTag(f.tag)}`,
});
}
return printGlobalWord({ ...f, kind: f.type });
}
/** 换类型时尽量保留已填的内容 */
export function switchGlobalType(f: GlobalFields, type: GlobalRowType): GlobalFields {
const next = { ...f, type };
if (type === "offset" || type === "replace_offset") next.offset ||= "EP";
if (type === "identify") next.tag = { ...f.tag, type: f.tag.type ?? "tv" };
return next;
}
// ── 作品识别词一行的字段 ──
export type EntityRowType = "map" | "table" | "special";
export const ENTITY_TYPE_LABEL: Record<EntityRowType, string> = {
map: "季集换算",
table: "单集对照",
special: "特别篇 / 花絮 / 忽略",
};
export const SPECIAL_LABEL: Record<EntityWordSpecial, string> = { special: "特别篇", extra: "花絮", ignore: "忽略" };
export interface EntityFields {
type: EntityRowType;
/** 来源季;空 = 任意季 */
season: string;
groups: string[];
/** 目标季;空 = 来源季 */
targetSeason: string;
expr: string;
fromStart: string;
fromEnd: string;
toSeason: string;
toEpisode: string;
special: EntityWordSpecial;
}
const num = (n: number | null | undefined) => (n == null ? "" : String(n));
export function entityFields(rule: string): EntityFields {
const p = parseEntityWordLine(rule);
return {
type: p.kind === "table" || p.kind === "special" ? p.kind : "map",
season: num(p.season),
groups: p.groups ?? [],
targetSeason: num(p.targetSeason),
expr: p.expr ?? "EP",
fromStart: num(p.from?.start),
fromEnd: p.from && p.from.end !== p.from.start ? String(p.from.end) : "",
toSeason: num(p.to?.season),
toEpisode: num(p.to?.episode),
special: p.special ?? "ignore",
};
}
const int = (s: string) => (/^\d+$/.test(s.trim()) ? Number(s.trim()) : null);
export function printEntityFields(f: EntityFields): string {
const season = int(f.season);
if (f.type === "table") {
const start = int(f.fromStart) ?? 1;
return printEntityWord({
kind: "table",
season,
groups: f.groups,
from: { start, end: int(f.fromEnd) ?? start },
to: { season: int(f.toSeason) ?? 0, episode: int(f.toEpisode) ?? 1 },
});
}
if (f.type === "special") return printEntityWord({ kind: "special", season, groups: f.groups, special: f.special });
return printEntityWord({
kind: "map",
season,
groups: f.groups,
targetSeason: int(f.targetSeason),
expr: f.expr.trim() || "EP",
});
}
// ── 集号换算的快捷选项 ──
export type ExprChoice = { mode: "same" } | { mode: "plus" | "minus"; n: string } | { mode: "custom"; expr: string };
export function exprChoice(expr: string): ExprChoice {
const e = expr.replace(/\s+/g, "").toUpperCase();
if (e === "EP" || e === "") return { mode: "same" };
const m = /^EP([+-])(\d+)$/.exec(e);
if (m) return { mode: m[1] === "+" ? "plus" : "minus", n: m[2]! };
return { mode: "custom", expr };
}
export function exprOf(c: ExprChoice): string {
if (c.mode === "same") return "EP";
if (c.mode === "custom") return c.expr;
return `EP${c.mode === "plus" ? "+" : "-"}${c.n || "0"}`;
}
// ── 新行 ──
export function newRuleText(scope: WordsScope): string {
return scope === "entity" ? "S01 >> EP" : "要屏蔽的词";
}
/** 注释行改成规则(可视化里打开开关) */
export const commentToRule = (lines: readonly string[]) => lines.map(uncomment).join(" ").trim();
export function ruleRow(
id: string,
rule: string,
enabled = true,
note: string[] = [],
): Extract<WordsRow, { kind: "rule" }> {
return {
kind: "rule",
id,
note,
enabled,
rule,
lines: [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`],
};
}
// ── 编辑模式记在本机 ──
export type EditorMode = "visual" | "text";
const MODE_KEY = "umt.identify-words.mode";
export function loadMode(): EditorMode {
try {
const v = globalThis.localStorage?.getItem(MODE_KEY);
return v === "text" ? "text" : "visual";
} catch {
return "visual";
}
}
export function saveMode(m: EditorMode): void {
try {
globalThis.localStorage?.setItem(MODE_KEY, m);
} catch {
// 隐私模式等存不了就算了,下次仍是缺省
}
}
/** 文本结尾有没有换行(往返时保持) */
export const endsWithNewline = (text: string) => /\n$/.test(text);
@@ -0,0 +1,71 @@
import type { WordsScope } from "@umt/contracts";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { cn } from "~/lib/utils.ts";
// 语法说明(可折叠)。全局识别词与 MoviePilot 自定义识别词完全一样;作品识别词是本项目的季集换算。
const GLOBAL: [string, string][] = [
["国语中字", "屏蔽词:整行当正则,从标题里删掉"],
["Fake.Show => 假剧", "替换:左边是正则,右边可以用 \\1 引用分组,留空就是删掉"],
["第 <> 集 >> EP-12", "集数偏移:前后定位词之间的数字按表达式换算;标题里没有就换副标题里的集数"],
["旧名 => 新名 && 第 <> 集 >> EP+1", "替换 + 偏移:替换成功才偏移"],
[
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}",
"指定作品:替换词里带 {[tmdbid=…;type=tv;s=1;e=3]},识别直接用这部作品(s 是季、e 是集,可以不写;电影写 type=movie)",
],
["# 注释", "# 开头的行不生效;可视化里关掉开关就是把这一行注释掉"],
];
const ENTITY: [string, string][] = [
["S02 >> S01 EP+24", "第 2 季的资源接着第 1 季编号:第 2 季第 1 集是第 1 季第 25 集"],
["S03 >> S01 EP+48", "第 3 季同理,再往后加 24 集"],
["S03 @ADWeb @MWeb >> S01 EP", "这几个发布组第 3 季已经按第 1 季的集号发:只换季,集号不变"],
["S01 @Lilith-Raws >> S02 EP-12", "第 1 季连续编号的 13 集起是第 2 季"],
["S01E13 >> S00E01", "单集对照:第 1 季第 13 集是特别篇第 1 集;区间写 S01E27-E28 >> S02E02"],
["S01 @SweetSub >> 特别篇", "整季当特别篇;也可以写 花絮、忽略(不参与集数换算)"],
["* @Judas >> EP*2-1", "* = 任意季;集号表达式可以用 + - * / // % 和括号"],
];
export function SyntaxHelp({ scope }: { scope: WordsScope }) {
const [open, setOpen] = useState(false);
const rows = scope === "global" ? GLOBAL : ENTITY;
return (
<div className="rounded-xl border border-border/60">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-caption font-medium text-foreground"
>
{scope === "global" ? "写法(与 MoviePilot 自定义识别词相同)" : "写法"}
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
/>
</button>
{open ? (
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
<dl className="grid gap-x-4 gap-y-1.5 text-meta sm:grid-cols-[minmax(0,22rem)_1fr]">
{rows.map(([code, text]) => (
<div key={code} className="contents">
<dt className="font-mono break-all text-foreground">{code}</dt>
<dd className="text-muted-foreground">{text}</dd>
</div>
))}
</dl>
{scope === "entity" ? (
<p className="text-meta text-muted-foreground">
范围越具体越优先:季 + 发布组 &gt; 只写季 &gt; 只写发布组 &gt;
任意。同一范围只能有一行。发布组不分大小写,繁简写法算同一个。
规则照写的用:算出来对不上季表的资源保持集数待确认,不会自动停用这一行。
</p>
) : (
<p className="text-meta text-muted-foreground">
按行序处理,前一行的结果交给后一行。资源标题、副标题和整理时的文件名、目录名在识别前都会先过一遍。
</p>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,180 @@
import { splitLines, type WordsLineError, type WordsScope } from "@umt/contracts";
import { type ReactNode, useImperativeHandle, useMemo, useRef } from "react";
import { cn } from "~/lib/utils.ts";
// 文本模式:等宽编辑器,左侧行号,按识别词语法着色,写错的行在行号上标红、整行浅红底。
// 实现是透明文字的 textarea 叠在着色的 <pre> 上(同字体、同行高、同内边距,滚动同步),不引编辑器库;
// 不自动换行,行号与原文一一对应。粘贴 MP 的识别词列表就是普通粘贴。
export interface TextEditorHandle {
/** 选中第 n 行并滚到它 */
focusLine(line: number): void;
}
const OPS_GLOBAL = /( => | <> | >> | && )/;
/** 一行的着色片段 */
function highlight(line: string, scope: WordsScope): ReactNode {
if (!line.trim()) return " ";
if (line.trimStart().startsWith("#")) return <span className="text-content-faint italic">{line}</span>;
if (scope === "global") {
return line.split(OPS_GLOBAL).map((part, i) =>
OPS_GLOBAL.test(part) ? (
<span key={i} className="font-semibold text-brand-strong">
{part}
</span>
) : (
tagged(part, i)
),
);
}
const at = line.indexOf(">>");
if (at < 0) return line;
const left = line.slice(0, at);
const right = line.slice(at + 2);
return (
<>
{left.split(/(\s+)/).map((t, i) =>
t.startsWith("@") ? (
<span key={i} className="text-brand-strong">
{t}
</span>
) : /^(S\d+(E\d+(-E?\d+)?)?|E\d+(-E?\d+)?|\*|S\*)$/i.test(t) ? (
<span key={i} className="font-semibold text-content-strong">
{t}
</span>
) : (
t
),
)}
<span className="font-semibold text-brand-strong">{">>"}</span>
{right.split(/(\s+)/).map((t, i) =>
/^S\d+(E\d+)?$/i.test(t) ? (
<span key={i} className="font-semibold text-content-strong">
{t}
</span>
) : /EP/i.test(t) ? (
<span key={i} className="text-content-secondary">
{t}
</span>
) : (
t
),
)}
</>
);
}
/** `{[tmdbid=…]}` 指定作品的标签单独着色 */
function tagged(part: string, key: number): ReactNode {
const m = /\{\[[^\]]*\]\}/.exec(part);
if (!m) return <span key={key}>{part}</span>;
return (
<span key={key}>
{part.slice(0, m.index)}
<span className="text-content-secondary underline decoration-dotted underline-offset-2">{m[0]}</span>
{part.slice(m.index + m[0].length)}
</span>
);
}
export function WordsTextEditor({
value,
onChange,
scope,
errors,
label,
handle,
minRows = 8,
maxRows = 28,
}: {
value: string;
onChange: (v: string) => void;
scope: WordsScope;
errors: readonly WordsLineError[];
label: string;
handle?: React.Ref<TextEditorHandle>;
minRows?: number;
maxRows?: number;
}) {
const area = useRef<HTMLTextAreaElement>(null);
const pre = useRef<HTMLPreElement>(null);
const gutter = useRef<HTMLDivElement>(null);
const lines = useMemo(() => splitLines(value), [value]);
const bad = useMemo(() => new Map(errors.map((e) => [e.line, e.message])), [errors]);
const rows = Math.min(Math.max(lines.length + 1, minRows), maxRows);
useImperativeHandle(handle, () => ({
focusLine(line: number) {
const el = area.current;
if (!el) return;
const start = lines.slice(0, line - 1).reduce((n, l) => n + l.length + 1, 0);
el.focus();
el.setSelectionRange(start, start + (lines[line - 1]?.length ?? 0));
// 行高 1.5rem:把选中行滚到可见区中间附近
const lh = Number.parseFloat(getComputedStyle(el).lineHeight) || 24;
el.scrollTop = Math.max(0, (line - 3) * lh);
},
}));
const sync = () => {
const el = area.current;
if (!el) return;
if (pre.current) {
pre.current.scrollTop = el.scrollTop;
pre.current.scrollLeft = el.scrollLeft;
}
if (gutter.current) gutter.current.scrollTop = el.scrollTop;
};
return (
<div className="field-glass flex min-w-0 overflow-hidden rounded-xl font-mono text-meta leading-6">
<div
ref={gutter}
aria-hidden
className="shrink-0 overflow-hidden border-r border-border/60 py-2 text-right tabular-nums text-muted-foreground select-none"
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
>
{lines.map((_, i) => {
const err = bad.get(i + 1);
return (
<div
key={i}
title={err}
className={cn("min-w-9 px-2", err && "bg-destructive/[0.12] font-semibold text-destructive")}
>
{i + 1}
</div>
);
})}
</div>
<div className="relative min-w-0 flex-1">
<pre
ref={pre}
aria-hidden
className="pointer-events-none absolute inset-0 m-0 overflow-hidden px-3 py-2 whitespace-pre text-foreground"
>
{lines.map((l, i) => (
<div key={i} className={cn(bad.has(i + 1) && "bg-destructive/[0.08]")}>
{highlight(l, scope)}
</div>
))}
<div> </div>
</pre>
<textarea
ref={area}
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={sync}
wrap="off"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="relative block w-full resize-none overflow-auto bg-transparent px-3 py-2 whitespace-pre text-transparent caret-foreground outline-none selection:bg-brand/25"
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,923 @@
import {
type EntityWordSpecial,
evalEpisodeExpr,
lineError,
type WordsRow,
type WordsScope,
wordsToRows,
} from "@umt/contracts";
import { ArrowRight, FileText, GripVertical, Plus, Search, Trash2, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { useWordsTry } from "~/api/episode-mapping.ts";
import { useEntityDetail } from "~/api/library.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { canonicalText, sourceText, VALIDATION } from "~/components/episode-mapping/model.ts";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn } from "~/lib/utils.ts";
import {
commentToRule,
ENTITY_TYPE_LABEL,
type EntityFields,
type EntityRowType,
entityFields,
exprChoice,
exprOf,
GLOBAL_TYPE_LABEL,
type GlobalFields,
type GlobalRowType,
globalFields,
newRuleText,
printEntityFields,
printGlobalFields,
ruleRow,
SPECIAL_LABEL,
switchGlobalType,
} from "./model.ts";
// 可视化模式:一行规则一张卡(开关 = 注释掉与否、拖动排序、备注 = 上方的注释行、删除),字段按类型展开,
// 卡片下面是拿真实资源标题试这一行的示例。和文本模式是同一份原文:每次改动都把这一行重新打印回原文。
// 可视化表达不了的行(写错的)显示成只读的「原文」行,去文本模式改,不会丢。
type RuleRowT = Extract<WordsRow, { kind: "rule" }>;
let seq = 0;
const newId = () => `n${seq++}`;
export function WordsVisualEditor({
rows,
onChange,
scope,
entityId,
knownGroups = [],
onEditText,
}: {
rows: WordsRow[];
onChange: (rows: WordsRow[]) => void;
scope: WordsScope;
entityId?: string | null;
knownGroups?: readonly string[];
/** 原文行的「去文本里改」:切到文本模式并选中这一行 */
onEditText: (line: number) => void;
}) {
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
const list = useRef<HTMLDivElement>(null);
// 行号:每个行模型在原文里从第几行开始(原文行跳过去要用)
const starts = useMemo(() => {
const out: number[] = [];
let n = 1;
for (const r of rows) {
out.push(n);
n += r.lines.length;
}
return out;
}, [rows]);
const replace = (i: number, row: WordsRow) => onChange(rows.map((r, k) => (k === i ? row : r)));
const remove = (i: number) => onChange(rows.filter((_, k) => k !== i));
const move = (from: number, to: number) => {
if (from === to || to < 0 || to >= rows.length) return;
const next = [...rows];
const [r] = next.splice(from, 1);
next.splice(to, 0, r!);
onChange(next);
};
const add = () => onChange([...rows, ruleRow(newId(), newRuleText(scope))]);
const onPointerMove = (e: React.PointerEvent) => {
if (!drag) return;
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-row-index]");
const over = el ? Number(el.dataset.rowIndex) : drag.over;
if (over !== drag.over) setDrag({ ...drag, over });
};
const endDrag = () => {
if (drag) move(drag.from, drag.over);
setDrag(null);
};
const visible = rows.map((r, i) => ({ r, i })).filter(({ r }) => r.kind !== "blank");
return (
<div className="space-y-2" ref={list} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
{visible.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-caption text-muted-foreground">
{scope === "entity"
? "还没有作品识别词。发布者写的季集对不上季表时,在这里写换算。"
: "还没有全局识别词。可以直接粘贴 MoviePilot 的自定义识别词。"}
</p>
) : null}
{visible.map(({ r, i }) => (
<div
key={r.id}
data-row-index={i}
className={cn(
"rounded-xl transition-[box-shadow,opacity] duration-base",
drag?.from === i && "opacity-60",
drag && drag.over === i && drag.from !== i && "ring-2 ring-ring/50",
)}
>
{r.kind === "rule" ? (
<RuleCard
row={r}
scope={scope}
entityId={entityId ?? null}
knownGroups={knownGroups}
onChange={(row) => replace(i, row)}
onRemove={() => remove(i)}
handle={
<DragHandle
onStart={() => setDrag({ from: i, over: i })}
onKey={(d) => move(i, i + d)}
dragging={drag?.from === i}
/>
}
/>
) : r.kind === "comment" ? (
<CommentCard
row={r}
onEnable={() => replace(i, ruleRow(r.id, commentToRule(r.lines)))}
onRemove={() => remove(i)}
handle={
<DragHandle
onStart={() => setDrag({ from: i, over: i })}
onKey={(d) => move(i, i + d)}
dragging={drag?.from === i}
/>
}
/>
) : r.kind === "invalid" ? (
<InvalidCard row={r} line={starts[i]!} onEditText={onEditText} onRemove={() => remove(i)} />
) : null}
</div>
))}
<Button variant="outline" size="sm" onClick={add}>
<Plus />
添加一行
</Button>
</div>
);
}
function DragHandle({
onStart,
onKey,
dragging,
}: {
onStart: () => void;
onKey: (delta: -1 | 1) => void;
dragging: boolean;
}) {
return (
<button
type="button"
aria-label="拖动排序(也可以用上下方向键)"
title="拖动排序"
className={cn(
"inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50",
dragging && "cursor-grabbing",
)}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
onStart();
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
onKey(e.key === "ArrowUp" ? -1 : 1);
}
}}
>
<GripVertical className="size-4" />
</button>
);
}
function CardShell({ children, muted }: { children: ReactNode; muted?: boolean }) {
return (
<div className={cn("rounded-xl border border-border/80 px-3 py-2.5 sm:px-4", muted && "bg-foreground/[0.02]")}>
{children}
</div>
);
}
function RuleCard({
row,
scope,
entityId,
knownGroups,
onChange,
onRemove,
handle,
}: {
row: RuleRowT;
scope: WordsScope;
entityId: string | null;
knownGroups: readonly string[];
onChange: (row: RuleRowT) => void;
onRemove: () => void;
handle: ReactNode;
}) {
const id = useId();
const setRule = (rule: string) => onChange({ ...row, rule, lines: linesOf(row, { rule }) });
const setEnabled = (enabled: boolean) => onChange({ ...row, enabled, lines: linesOf(row, { enabled }) });
const setNote = (text: string) => {
const note = text.trim() ? text.split("\n") : [];
onChange({ ...row, note, lines: linesOf(row, { note }) });
};
const error = lineError(row.rule, scope);
return (
<CardShell muted={!row.enabled}>
<div className="flex flex-wrap items-start gap-x-2 gap-y-2">
<div className="flex items-center gap-1.5 pt-0.5">
{handle}
<Switch
checked={row.enabled}
onCheckedChange={setEnabled}
aria-label={row.enabled ? "停用这一行(注释掉)" : "启用这一行"}
title={row.enabled ? "启用中,关掉 = 注释掉这一行" : "已停用(注释掉了)"}
/>
</div>
<div
className={cn(
"order-last min-w-0 flex-1 basis-full space-y-2 sm:order-none sm:basis-0",
!row.enabled && "opacity-70",
)}
>
{scope === "entity" ? (
<EntityRuleFields rule={row.rule} onChange={setRule} knownGroups={knownGroups} idPrefix={id} />
) : (
<GlobalRuleFields rule={row.rule} onChange={setRule} idPrefix={id} />
)}
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-meta text-muted-foreground">备注</span>
<Input
aria-label="备注(写成这一行上面的注释)"
className="h-8 text-meta"
placeholder="写给自己看的说明,保存为上面一行注释"
value={row.note.join(" ")}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<code className="block min-w-0 font-mono text-meta break-all text-muted-foreground">
{row.enabled ? "" : "# "}
{row.rule}
</code>
{error ? (
<p className="text-meta text-destructive" role="alert">
{error}
</p>
) : row.enabled ? (
<RowExample scope={scope} entityId={entityId} line={row.rule} />
) : null}
</div>
<Button variant="ghost" size="icon-sm" className="ml-auto sm:ml-0" aria-label="删除这一行" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</CardShell>
);
}
/** 规则行的原文:备注只在改备注时重写,其余时候原样保留 */
function linesOf(row: RuleRowT, patch: Partial<Pick<RuleRowT, "rule" | "enabled" | "note">>): string[] {
const note = patch.note ?? row.note;
const noteLines = patch.note ? note.map((n) => `# ${n}`.trimEnd()) : row.lines.slice(0, row.note.length);
const enabled = patch.enabled ?? row.enabled;
const rule = patch.rule ?? row.rule;
return [...noteLines, enabled ? rule : `# ${rule}`];
}
function CommentCard({
row,
onEnable,
onRemove,
handle,
}: {
row: Extract<WordsRow, { kind: "comment" }>;
onEnable: () => void;
onRemove: () => void;
handle: ReactNode;
}) {
return (
<CardShell muted>
<div className="flex items-start gap-2">
<div className="flex items-center gap-1.5 pt-0.5">
{handle}
<Switch checked={false} onCheckedChange={onEnable} aria-label="把这条注释改成识别词启用" />
</div>
<div className="min-w-0 flex-1">
<div className="text-meta text-muted-foreground">注释</div>
<pre className="m-0 font-mono text-meta whitespace-pre-wrap break-all text-foreground">
{row.lines.join("\n")}
</pre>
</div>
<Button variant="ghost" size="icon-sm" aria-label="删除这条注释" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</CardShell>
);
}
function InvalidCard({
row,
line,
onEditText,
onRemove,
}: {
row: Extract<WordsRow, { kind: "invalid" }>;
line: number;
onEditText: (line: number) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-xl border border-destructive/40 px-3 py-2.5 sm:px-4">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-meta">
<StatusBadge tone="destructive">原文</StatusBadge>
<span className="text-muted-foreground">第 {line} 行,可视化里改不了</span>
</div>
<code className="mt-1 block font-mono text-meta break-all text-foreground">{row.rule}</code>
<p className="mt-1 text-meta text-destructive">{row.error}</p>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onEditText(line)}>
<FileText />
在文本里改
</Button>
<Button variant="ghost" size="icon-sm" aria-label="删除这一行" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</div>
</div>
);
}
// ── 字段 ──
function Field({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={cn("grid min-w-0 gap-1", className)}>
<span className="text-meta text-muted-foreground">{label}</span>
{children}
</div>
);
}
function TypeSelect<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: Record<T, string>;
onChange: (v: T) => void;
label: string;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as T)}>
<SelectTrigger aria-label={label} className="h-8 w-auto min-w-32 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(options) as T[]).map((k) => (
<SelectItem key={k} value={k}>
{options[k]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const mono = "h-8 font-mono text-meta";
function GlobalRuleFields({
rule,
onChange,
idPrefix,
}: {
rule: string;
onChange: (rule: string) => void;
idPrefix: string;
}) {
const f = useMemo(() => globalFields(rule), [rule]);
const set = (patch: Partial<GlobalFields>) => onChange(printGlobalFields({ ...f, ...patch }));
const input = (k: "pattern" | "replacement" | "front" | "back" | "offset", label: string, placeholder = "") => (
<Field label={label}>
<Input
id={`${idPrefix}-${k}`}
className={mono}
value={f[k]}
placeholder={placeholder}
onChange={(e) => set({ [k]: e.target.value })}
/>
</Field>
);
return (
<div className="space-y-2">
<TypeSelect<GlobalRowType>
value={f.type}
options={GLOBAL_TYPE_LABEL}
label="识别词类型"
onChange={(t) => onChange(printGlobalFields(switchGlobalType(f, t)))}
/>
<div className="grid gap-2 sm:grid-cols-2">
{f.type === "block" ? input("pattern", "屏蔽词(正则)", "如 国语中字") : null}
{f.type === "replace" || f.type === "replace_offset" ? (
<>
{input("pattern", "被替换词(正则)")}
{input("replacement", "替换为", "留空 = 删掉")}
</>
) : null}
{f.type === "offset" || f.type === "replace_offset" ? (
<>
{input("front", "前定位词(正则)", "如 第")}
{input("back", "后定位词(正则)", "如 集")}
<Field label="偏移" className="sm:col-span-2">
<OffsetInput value={f.offset} onChange={(offset) => set({ offset })} strictEp />
</Field>
</>
) : null}
{f.type === "identify" ? <IdentifyFields f={f} set={set} idPrefix={idPrefix} /> : null}
</div>
</div>
);
}
function IdentifyFields({
f,
set,
idPrefix,
}: {
f: GlobalFields;
set: (patch: Partial<GlobalFields>) => void;
idPrefix: string;
}) {
const [picked, setPicked] = useState<PickedEntity | null>(null);
const detail = useEntityDetail(picked?.id);
// 选了本地作品:用它的 TMDB 身份填 tmdbid 与类型
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在拿到所选作品详情时填一次
useEffect(() => {
if (!picked || !detail.data) return;
const tmdb = detail.data.identities.find((x) => x.provider === "tmdb" && x.status === "accepted");
set({
tag: {
...f.tag,
...(tmdb ? { tmdbId: tmdb.externalId } : {}),
type: picked.kind === "movie" ? "movie" : "tv",
},
replacement: f.replacement || picked.title,
});
setPicked(null);
}, [detail.data, picked]);
const tag = (patch: Partial<GlobalFields["tag"]>) => set({ tag: { ...f.tag, ...patch } });
return (
<>
<Field label="匹配词(正则)">
<Input className={mono} value={f.pattern} onChange={(e) => set({ pattern: e.target.value })} />
</Field>
<Field label="改成的标题">
<Input
className="h-8 text-meta"
value={f.replacement}
placeholder="可留空"
onChange={(e) => set({ replacement: e.target.value })}
/>
</Field>
<Field label="从本地作品填" className="sm:col-span-2">
<EntityPicker
id={`${idPrefix}-entity`}
value={null}
onChange={setPicked}
placeholder="搜索本地作品,填入 TMDB ID"
/>
</Field>
<div className="grid grid-cols-2 gap-2 sm:col-span-2 sm:grid-cols-4">
<Field label="TMDB ID">
<Input
className={mono}
inputMode="numeric"
value={f.tag.tmdbId ?? ""}
onChange={(e) => tag({ tmdbId: e.target.value.replace(/\D/g, "") })}
/>
</Field>
<Field label="类型">
<Select value={f.tag.type ?? "tv"} onValueChange={(v) => tag({ type: v as "tv" | "movie" })}>
<SelectTrigger aria-label="类型" className="h-8 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tv">剧集</SelectItem>
<SelectItem value="movie">电影</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="季(可选)">
<Input
className={mono}
value={f.tag.season ?? ""}
placeholder="1 或 1-2"
onChange={(e) => tag({ season: e.target.value.replace(/[^\d-]/g, "") || undefined })}
/>
</Field>
<Field label="集(可选)">
<Input
className={mono}
value={f.tag.episode ?? ""}
placeholder="3 或 3-4"
onChange={(e) => tag({ episode: e.target.value.replace(/[^\d-]/g, "") || undefined })}
/>
</Field>
</div>
</>
);
}
function EntityRuleFields({
rule,
onChange,
knownGroups,
idPrefix,
}: {
rule: string;
onChange: (rule: string) => void;
knownGroups: readonly string[];
idPrefix: string;
}) {
const f = useMemo(() => entityFields(rule), [rule]);
const set = (patch: Partial<EntityFields>) => onChange(printEntityFields({ ...f, ...patch }));
const seasonInput = (k: "season" | "targetSeason" | "toSeason", label: string, placeholder: string) => (
<Field label={label}>
<Input
id={`${idPrefix}-${k}`}
className={mono}
inputMode="numeric"
value={f[k]}
placeholder={placeholder}
onChange={(e) => set({ [k]: e.target.value.replace(/\D/g, "") })}
/>
</Field>
);
return (
<div className="space-y-2">
<TypeSelect<EntityRowType>
value={f.type}
options={ENTITY_TYPE_LABEL}
label="规则类型"
onChange={(type) => set({ type })}
/>
<div className="grid gap-2 sm:grid-cols-4">
{f.type === "table" ? (
<>
{seasonInput("season", "来源季", "空 = 任意")}
<Field label="来源集号">
<div className="flex items-center gap-1">
<Input
className={mono}
inputMode="numeric"
value={f.fromStart}
onChange={(e) => set({ fromStart: e.target.value.replace(/\D/g, "") })}
/>
<span className="text-muted-foreground">–</span>
<Input
className={mono}
inputMode="numeric"
value={f.fromEnd}
placeholder="单集"
aria-label="来源集号结束"
onChange={(e) => set({ fromEnd: e.target.value.replace(/\D/g, "") })}
/>
</div>
</Field>
{seasonInput("toSeason", "目标季", "0 = 特别篇")}
<Field label="目标集">
<Input
className={mono}
inputMode="numeric"
value={f.toEpisode}
onChange={(e) => set({ toEpisode: e.target.value.replace(/\D/g, "") })}
/>
</Field>
</>
) : (
seasonInput("season", "来源季", "空 = 任意")
)}
<Field label="发布组" className={f.type === "table" ? "sm:col-span-4" : "sm:col-span-3"}>
<GroupsInput value={f.groups} onChange={(groups) => set({ groups })} suggestions={knownGroups} />
</Field>
{f.type === "map" ? (
<>
{seasonInput("targetSeason", "目标季", "空 = 来源季")}
<Field label="集号换算" className="sm:col-span-3">
<OffsetInput value={f.expr} onChange={(expr) => set({ expr })} />
</Field>
</>
) : null}
{f.type === "special" ? (
<Field label="处理为">
<Select value={f.special} onValueChange={(v) => set({ special: v as EntityWordSpecial })}>
<SelectTrigger aria-label="处理为" className="h-8 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(SPECIAL_LABEL) as EntityWordSpecial[]).map((k) => (
<SelectItem key={k} value={k}>
{SPECIAL_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
</div>
</div>
);
}
/** 集号换算:不变 / +N / −N / 自定义表达式;自定义按同一套安全算术校验 */
function OffsetInput({
value,
onChange,
strictEp = false,
}: {
value: string;
onChange: (v: string) => void;
/** 全局识别词的偏移按 MP 原样(EP 大写),作品识别词不分大小写 */
strictEp?: boolean;
}) {
const c = exprChoice(value);
const [custom, setCustom] = useState(c.mode === "custom" ? c.expr : value);
let error: string | null = null;
if (c.mode === "custom") {
try {
if (strictEp && !value.includes("EP")) throw new Error("偏移里要有 EP,如 EP+12");
evalEpisodeExpr(value, 1);
} catch (e) {
error = (e as Error).message;
}
}
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="overflow-x-auto scroll-bleed">
<SegmentedControl
size="xs"
label="集号换算方式"
value={c.mode}
onChange={(mode) => {
if (mode === "same") onChange("EP");
else if (mode === "custom") onChange(custom || value || "EP");
else onChange(exprOf({ mode, n: c.mode === "plus" || c.mode === "minus" ? c.n : "1" }));
}}
options={[
{ value: "same", label: "不变" },
{ value: "plus", label: "+N" },
{ value: "minus", label: "−N" },
{ value: "custom", label: "自定义表达式" },
]}
/>
</div>
{c.mode === "plus" || c.mode === "minus" ? (
<Input
aria-label="集号加减多少"
className="h-8 w-20 font-mono text-meta"
inputMode="numeric"
value={c.n}
onChange={(e) => onChange(exprOf({ mode: c.mode, n: e.target.value.replace(/\D/g, "") }))}
/>
) : null}
{c.mode === "custom" ? (
<Input
aria-label="集号表达式"
aria-invalid={!!error}
className="h-8 w-40 font-mono text-meta"
value={value}
placeholder="如 EP*2-1"
onChange={(e) => {
setCustom(e.target.value);
onChange(e.target.value);
}}
/>
) : null}
</div>
{error ? <p className="text-meta text-destructive">{error}</p> : null}
</div>
);
}
/** 发布组:回车或逗号加一个,点 × 去掉;提示这部作品资源里出现过的发布组 */
function GroupsInput({
value,
onChange,
suggestions,
}: {
value: string[];
onChange: (v: string[]) => void;
suggestions: readonly string[];
}) {
const [draft, setDraft] = useState("");
const listId = useId();
const commit = (raw: string) => {
const g = raw.trim().replace(/^@/, "");
if (g && !value.some((x) => x.toLowerCase() === g.toLowerCase())) onChange([...value, g]);
setDraft("");
};
return (
<div className="field-glass flex min-h-8 flex-wrap items-center gap-1 rounded-xl px-2 py-1">
{value.map((g) => (
<span key={g} className="inline-flex items-center gap-0.5 rounded-full border border-border px-2 text-meta">
@{g}
<button
type="button"
aria-label={`去掉发布组 ${g}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => onChange(value.filter((x) => x !== g))}
>
<X className="size-3" />
</button>
</span>
))}
<input
aria-label="添加发布组"
list={listId}
className="h-6 min-w-24 flex-1 bg-transparent text-meta outline-none placeholder:text-muted-foreground"
placeholder={value.length ? "" : "空 = 任意发布组"}
value={draft}
onChange={(e) => {
const v = e.target.value;
// 从提示列表里点选时直接加入
if (suggestions.includes(v)) commit(v);
else setDraft(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
commit(draft);
} else if (e.key === "Backspace" && !draft && value.length) onChange(value.slice(0, -1));
}}
onBlur={() => draft && commit(draft)}
/>
<datalist id={listId}>
{suggestions
.filter((s) => !value.some((x) => x.toLowerCase() === s.toLowerCase()))
.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
);
}
// ── 实时示例 ──
/** 卡片进入视口才去试(长列表不一次发几百个请求) */
function useInView<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || seen) return;
if (typeof IntersectionObserver === "undefined") {
setSeen(true);
return;
}
const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setSeen(true));
io.observe(el);
return () => io.disconnect();
}, [seen]);
return { ref, seen };
}
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export function RowExample({ scope, entityId, line }: { scope: WordsScope; entityId: string | null; line: string }) {
const { ref, seen } = useInView<HTMLDivElement>();
const [own, setOwn] = useState<string | null>(null);
const debLine = useDebounced(line, 400);
const debOwn = useDebounced(own, 400);
const input = seen
? {
scope,
line: debLine,
...(scope === "entity" && entityId ? { mediaEntityId: entityId } : {}),
...(debOwn?.trim() ? { title: debOwn.trim() } : {}),
}
: null;
const q = useWordsTry(scope === "entity" && !entityId ? null : input);
const r = q.data;
return (
<div ref={ref} className="rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-meta">
{own === null ? null : (
<div className="mb-1.5 flex items-center gap-1.5">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input
aria-label="示例标题"
className="h-6 min-w-0 flex-1 bg-transparent font-mono outline-none placeholder:text-muted-foreground"
placeholder="粘贴一个资源标题试试"
value={own}
onChange={(e) => setOwn(e.target.value)}
/>
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => setOwn(null)}
>
用近期资源
</button>
</div>
)}
{!seen || q.isPending ? (
<span className="text-muted-foreground">示例加载中…</span>
) : q.isError ? (
<span className="text-destructive">{(q.error as Error).message}</span>
) : r?.error ? (
<span className="text-destructive">{r.error}</span>
) : !r?.sample ? (
<span className="text-muted-foreground">
近期资源里没有这一行能命中的。
{own === null ? (
<button
type="button"
className="ml-1 cursor-pointer underline underline-offset-2"
onClick={() => setOwn("")}
>
自己给个标题
</button>
) : null}
</span>
) : (
<div className="space-y-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<span className="shrink-0 text-muted-foreground">示例</span>
<span className="min-w-0 font-mono break-all text-foreground">{r.sample.title}</span>
{own === null ? (
<button
type="button"
className="shrink-0 cursor-pointer text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setOwn("")}
>
换个标题
</button>
) : null}
</div>
{!r.matched ? (
<div className="text-muted-foreground">这一行没有命中这个标题</div>
) : scope === "global" ? (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<ArrowRight className="size-3 shrink-0 self-center text-muted-foreground" />
<span className="min-w-0 font-mono break-all text-foreground">{r.titleAfter}</span>
{r.parsed ? <span className="text-muted-foreground">· {parsedText(r.parsed)}</span> : null}
</div>
) : r.episode ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="font-mono tabular-nums">{sourceText(r.episode)}</span>
<ArrowRight className="size-3 text-muted-foreground" />
<span className="font-mono font-semibold tabular-nums">{canonicalText(r.episode)}</span>
{r.validation ? (
<StatusBadge tone={VALIDATION[r.validation].tone}>{VALIDATION[r.validation].label}</StatusBadge>
) : null}
</div>
) : null}
</div>
)}
</div>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
function parsedText(p: NonNullable<ReturnType<typeof useWordsTry>["data"]>["parsed"]): string {
if (!p) return "";
const parts: string[] = [];
if (p.title) parts.push(`${p.title}${p.year ? ` (${p.year})` : ""}`);
if (p.season !== null || p.episodes) {
const s = p.season !== null ? `S${pad(p.season)}` : "";
const e = p.episodes
? p.episodes.start === p.episodes.end
? `E${pad(p.episodes.start)}`
: `E${pad(p.episodes.start)}-E${pad(p.episodes.end)}`
: "";
parts.push(`${s}${e}`);
}
const tmdb = p.explicitIds.find((x) => x.provider === "tmdb");
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
return parts.join(" · ");
}
/** 文本 → 行模型(给外层用,行 id 稳定性只在一次编辑会话内) */
export const toRows = (text: string, scope: WordsScope) => wordsToRows(text, scope);
@@ -0,0 +1,378 @@
import {
compileEntityWords,
type EntityWordsDryRun,
type EntityWordsSaveResult,
type GlobalWordsDryRun,
type IdentifyWordsBlock,
parseGlobalWords,
rowsToText,
type WordsLineError,
type WordsRow,
type WordsScope,
wordsToRows,
} from "@umt/contracts";
import { ArrowRight, Code2, LayoutList, RotateCcw, Save } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useSaveWords, useWordsDryRun } from "~/api/episode-mapping.ts";
import { ErrorState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { reresolveToast } from "~/components/episode-mapping/model.ts";
import { PreviewTable } from "~/components/episode-mapping/preview-table.tsx";
import { Button } from "~/components/ui/button.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { type EditorMode, endsWithNewline, loadMode, saveMode } from "./model.ts";
import { SyntaxHelp } from "./syntax-help.tsx";
import { type TextEditorHandle, WordsTextEditor } from "./text-editor.tsx";
import { WordsVisualEditor } from "./visual-editor.tsx";
// 识别词编辑器(全局与作品共用,规则页「识别词」与作品页「集数映射」同一个组件):
// 可视化 / 文本两种模式改同一份原文,随时切换不丢内容;改动后自动试算(纯读),确认无误再保存。
const DRY_RUN_DEBOUNCE_MS = 600;
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
/** 逐行错误(本地就能算,不等试算) */
export function localErrors(text: string, scope: WordsScope): WordsLineError[] {
if (scope === "entity") return compileEntityWords(text).errors;
return parseGlobalWords(text)
.filter((w) => w.error)
.map((w) => ({ line: w.line, message: w.error! }));
}
export function WordsEditor({
scope,
entityId = null,
block,
knownGroups,
draft: controlledDraft,
onDraftChange,
showPreview = true,
}: {
scope: WordsScope;
entityId?: string | null;
block: IdentifyWordsBlock;
knownGroups?: readonly string[];
/** 受控草稿(作品页要从外面往里追加一行);不给时组件自己管 */
draft?: string;
onDraftChange?: (text: string) => void;
/** 作品识别词的试算结果表格(作品页另有近期资源表时可以关掉) */
showPreview?: boolean;
}) {
const [own, setOwn] = useState(block.text);
const text = controlledDraft ?? own;
const setText = (t: string) => (onDraftChange ? onDraftChange(t) : setOwn(t));
const [mode, setModeState] = useState<EditorMode>("visual");
// 本机记住上次用的模式(SSR 时没有 localStorage,挂载后再读)
useEffect(() => setModeState(loadMode()), []);
const setMode = (m: EditorMode) => {
if (m === "visual") setRows(wordsToRows(text, scope));
setModeState(m);
saveMode(m);
};
const [rows, setRows] = useState<WordsRow[]>(() => wordsToRows(text, scope));
const textRef = useRef<TextEditorHandle>(null);
const [pendingLine, setPendingLine] = useState<number | null>(null);
// 别处保存了新版本、本地没改动时跟上
const base = useRef(block);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟版本变化
useEffect(() => {
if (block.version === base.current.version) return;
const clean = text === base.current.text;
base.current = block;
if (clean) {
setText(block.text);
setRows(wordsToRows(block.text, scope));
}
}, [block.version]);
// 外面改了受控草稿(追加一行)时,可视化行跟着重建
const lastText = useRef(text);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只看文本变化
useEffect(() => {
if (text !== lastText.current && text !== rowsToText(rows, endsWithNewline(text)))
setRows(wordsToRows(text, scope));
lastText.current = text;
}, [text]);
useEffect(() => {
if (mode === "text" && pendingLine) {
textRef.current?.focusLine(pendingLine);
setPendingLine(null);
}
}, [mode, pendingLine]);
const onRows = (next: WordsRow[]) => {
setRows(next);
const t = rowsToText(next, endsWithNewline(text) || !text);
lastText.current = t;
setText(t);
};
const dirty = text !== block.text;
const errors = useMemo(() => localErrors(text, scope), [text, scope]);
const debounced = useDebounced(text, DRY_RUN_DEBOUNCE_MS);
const dry = useWordsDryRun(scope === "entity" ? entityId : null, dirty ? debounced : null);
const save = useSaveWords(scope === "entity" ? entityId : null);
const blocked = scope === "entity" && errors.length > 0;
const doSave = () =>
save.mutateAsync({ text, expectedVersion: block.version }).then(
(r) => {
base.current = r.block;
if (scope === "entity") toast.success(reresolveToast((r as EntityWordsSaveResult).reresolve));
else toast.success(r.block.reresolve?.status === "queued" ? "已保存,后台按改动的行重新识别资源" : "已保存");
},
(e) => toast.error(errorMessage(e, "保存失败")),
);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<SegmentedControl
label="编辑方式"
value={mode}
onChange={setMode}
options={[
{ value: "visual", label: "可视化", icon: <LayoutList /> },
{ value: "text", label: "文本", icon: <Code2 /> },
]}
/>
<span className="text-meta text-muted-foreground">
{block.updatedAt
? `第 ${block.version} 版 · ${formatDateTime(block.updatedAt)}${block.updatedBy ? ` · ${actorText(block.updatedBy)}` : ""}`
: "还没保存过"}
</span>
</div>
{mode === "text" ? (
<WordsTextEditor
value={text}
onChange={setText}
scope={scope}
errors={errors}
label={scope === "entity" ? "作品识别词" : "全局识别词"}
handle={textRef}
/>
) : (
<WordsVisualEditor
rows={rows}
onChange={onRows}
scope={scope}
entityId={entityId}
knownGroups={knownGroups ?? []}
onEditText={(line) => {
setPendingLine(line);
setMode("text");
}}
/>
)}
{errors.length ? (
<ul className="space-y-0.5 text-meta" aria-label="有问题的行">
{errors.map((e) => (
<li key={`${e.line}-${e.message}`}>
<button
type="button"
className="cursor-pointer text-destructive underline-offset-2 hover:underline"
onClick={() => {
setPendingLine(e.line);
if (mode === "text") textRef.current?.focusLine(e.line);
else setMode("text");
}}
>
第 {e.line} 行:{e.message}
</button>
</li>
))}
{scope === "global" ? (
<li className="text-muted-foreground">写错的行可以保存,套用时会跳过(MoviePilot 也是这样)。</li>
) : null}
</ul>
) : null}
<SyntaxHelp scope={scope} />
{dirty ? (
<div className="space-y-3 rounded-xl border border-border/80 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-caption font-medium text-foreground">保存前试算</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setText(block.text);
setRows(wordsToRows(block.text, scope));
}}
>
<RotateCcw />
撤销修改
</Button>
<Button size="sm" disabled={blocked || save.isPending} onClick={() => void doSave()}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
{blocked ? <p className="text-meta text-destructive">先改好有问题的行再保存。</p> : null}
{dry.isError ? (
<ErrorState plain error={dry.error} onRetry={() => dry.refetch()} />
) : !dry.data || dry.isFetching ? (
<p className="text-meta text-muted-foreground">试算中…</p>
) : scope === "entity" ? (
<EntityDryRunView r={dry.data as EntityWordsDryRun} showTable={showPreview} />
) : (
<GlobalDryRunView r={dry.data as GlobalWordsDryRun} />
)}
</div>
) : null}
{scope === "global" && block.reresolve ? <ReresolveProgress p={block.reresolve} /> : null}
</div>
);
}
function EntityDryRunView({ r, showTable }: { r: EntityWordsDryRun; showTable: boolean }) {
const items = r.preview.items.filter((i) => i.changed || i.preview.method === "unresolved");
return (
<div className="space-y-2">
<p className="text-caption text-foreground">
{r.changed ? `近期资源里 ${r.changed} 条的季集会变` : "近期资源的季集都不变"}
{r.mismatched ? (
<>
{","}
<span className="text-destructive">{r.mismatched} 条仍对不上季表</span>(保持集数待确认)
</>
) : null}
</p>
{showTable && items.length ? (
<div className="-mx-3 border-t border-border/60">
<PreviewTable items={items} hasDraft />
</div>
) : null}
</div>
);
}
function GlobalDryRunView({ r }: { r: GlobalWordsDryRun }) {
return (
<div className="space-y-2 text-caption">
{r.added.length || r.removed.length ? (
<div className="space-y-0.5 font-mono text-meta">
{r.added.map((l) => (
<div key={`+${l}`} className="break-all text-foreground">
+ {l}
</div>
))}
{r.removed.map((l) => (
<div key={`-${l}`} className="break-all text-muted-foreground line-through">
− {l}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">生效的行没有变化(只改了注释或空行),保存后不用重新识别。</p>
)}
{r.added.length || r.removed.length ? (
<p className="text-foreground">
最近 {r.scanned} 条资源里 {r.matched} 条命中改动的行
{r.items.length ? `,${r.items.length} 条识别结果会变` : ",识别结果都不变"}
。保存后在后台把全部命中的资源重新识别。
</p>
) : null}
{r.items.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] text-meta">
<thead>
<tr className="border-b border-border/60 text-left text-muted-foreground">
<th className="py-1.5 pr-3 font-medium">资源</th>
<th className="py-1.5 pr-3 font-medium">现在识别为</th>
<th className="py-1.5 font-medium">改后识别为</th>
</tr>
</thead>
<tbody>
{r.items.map((it) => (
<tr key={it.sourceReleaseId} className="border-b border-border/60 align-top last:border-b-0">
<td className="max-w-[20rem] py-1.5 pr-3">
<div className="font-mono break-all text-foreground">{it.title}</div>
{it.entityTitle ? <div className="text-muted-foreground">现在绑定:{it.entityTitle}</div> : null}
</td>
<td className="py-1.5 pr-3 text-muted-foreground">{briefText(it.before)}</td>
<td className="py-1.5">
<span className="inline-flex items-start gap-1 font-semibold text-foreground">
<ArrowRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
{briefText(it.after)}
</span>
{it.words.length ? (
<div className="mt-0.5 font-mono break-all text-muted-foreground">
命中识别词:{it.words.join(";")}
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
function briefText(b: GlobalWordsDryRun["items"][number]["before"]): string {
const parts: string[] = [b.title ? `${b.title}${b.year ? ` (${b.year})` : ""}` : "认不出片名"];
if (b.season !== null || b.episodes) {
const e = b.episodes
? b.episodes.start === b.episodes.end
? `E${pad(b.episodes.start)}`
: `E${pad(b.episodes.start)}-E${pad(b.episodes.end)}`
: "";
parts.push(`${b.season !== null ? `S${pad(b.season)}` : ""}${e}`);
}
const tmdb = b.explicitIds.find((x) => x.provider === "tmdb");
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
return parts.join(" · ");
}
function ReresolveProgress({ p }: { p: NonNullable<IdentifyWordsBlock["reresolve"]> }) {
const running = p.status === "queued" || p.status === "running";
const pct = p.total ? Math.min(100, Math.round((p.scanned / p.total) * 100)) : 0;
return (
<div className="space-y-1.5 text-meta">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={p.status === "failed" ? "destructive" : running ? "neutral" : "success"}>
{p.status === "queued"
? "排队中"
: p.status === "running"
? "重新识别中"
: p.status === "failed"
? "失败"
: "已完成"}
</StatusBadge>
<span className="text-muted-foreground">
上次保存后:扫描 {p.scanned}/{p.total} 条资源,{p.matched} 条命中改动的行,已重新识别 {p.queued} 条
{p.finishedAt ? ` · ${formatDateTime(p.finishedAt)}` : ""}
</span>
</div>
{running ? (
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
<div className={cn("h-full bg-brand transition-[width] duration-layout")} style={{ width: `${pct}%` }} />
</div>
) : null}
{p.error ? <p className="text-destructive">{p.error}</p> : null}
</div>
);
}
@@ -0,0 +1,208 @@
import type { EntityWordsSummary } from "@umt/contracts";
import { ExternalLink, Plus } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useEntityWords, useEntityWordsList, useGlobalWords } from "~/api/episode-mapping.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
import { MAPPING_ANCHOR } from "~/components/episode-mapping/model.ts";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { actorText } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { WordsEditor } from "./words-editor.tsx";
// 规则页「识别词」页签(docs/design/identify-words.md):上面是全局识别词,下面是写了作品识别词的作品;
// 点一部作品在右侧抽屉里改它的识别词(?open=<作品 id>),与作品页「集数映射」是同一个编辑器、同一份数据。
export function IdentifyWordsTab() {
const global = useGlobalWords();
const [q, setQ] = useState("");
const list = useEntityWordsList(q.trim());
const sheet = useRecordSheet();
const [adding, setAdding] = useState(false);
const [picked, setPicked] = useState<PickedEntity | null>(null);
const columns: DataColumn<EntityWordsSummary>[] = [
{
key: "title",
header: "作品",
cell: (r) => (
<span className="font-medium text-foreground">
{r.title}
{r.year ? <span className="ml-1 text-muted-foreground">({r.year})</span> : null}
</span>
),
},
{
key: "lines",
header: "规则",
cell: (r) => (
<span className="tabular-nums">
{r.rules} 行{r.disabled ? <span className="text-muted-foreground"> · 停用 {r.disabled}</span> : null}
{r.errors ? <span className="text-destructive"> · 有问题 {r.errors}</span> : null}
</span>
),
},
{
key: "updated",
header: "最后修改",
hideBelow: "sm",
cell: (r) => (
<span className="text-muted-foreground">
{r.updatedAt ? formatDateTime(r.updatedAt) : "—"}
{r.updatedBy ? ` · ${actorText(r.updatedBy)}` : ""}
</span>
),
},
{
key: "link",
header: "",
align: "right",
cell: (r) => (
<Button asChild variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
<Link to={`${consolePath("library", r.mediaEntityId)}#${MAPPING_ANCHOR}`}>
<ExternalLink />
作品页
</Link>
</Button>
),
},
];
return (
<div className="space-y-6">
<Panel
title="全局识别词"
description="所有资源标题、副标题和整理时的文件名在识别前先过一遍,写法与 MoviePilot 自定义识别词相同"
>
{global.isPending ? (
<LoadingState rows={4} />
) : global.isError ? (
<ErrorState plain error={global.error} onRetry={() => global.refetch()} />
) : (
<WordsEditor scope="global" block={global.data} />
)}
</Panel>
<Panel
title="作品识别词"
description="只对一部作品生效的季集换算;作品页「集数映射」里改的是同一份"
actions={
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus />
为作品添加
</Button>
}
bodyClassName="px-0 py-0 sm:px-0"
>
<div className="px-4 pt-4 sm:px-5">
<FilterBar search={{ value: q, onChange: setQ, placeholder: "搜索作品" }} className="mb-3" />
</div>
<DataTable
plain
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.mediaEntityId}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => sheet.open(r.mediaEntityId)}
selectedKey={sheet.id}
empty={{
title: q.trim() ? "没有匹配的作品" : "还没有作品写过识别词",
description: q.trim() ? undefined : "发布者写的季集和作品季表对不上时,为那部作品写一行换算。",
}}
/>
</Panel>
<EntityWordsSheet id={sheet.id} onClose={sheet.close} />
<Dialog
open={adding}
onOpenChange={(o) => {
setAdding(o);
if (!o) setPicked(null);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>为作品添加识别词</DialogTitle>
<DialogDescription>选一部本地作品,在右侧抽屉里写它的季集换算。</DialogDescription>
</DialogHeader>
<EntityPicker value={picked} onChange={setPicked} />
<DialogFooter>
<Button variant="outline" onClick={() => setAdding(false)}>
取消
</Button>
<Button
disabled={!picked}
onClick={() => {
if (!picked) return;
setAdding(false);
setPicked(null);
sheet.open(picked.id);
}}
>
写识别词
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EntityWordsSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
const q = useEntityWords(id);
return (
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="xl"
title={q.data?.title ?? "作品识别词"}
description={
q.data && !q.data.applicable ? "这部作品不是剧集或综艺,季集换算不会生效" : "只对这部作品生效的季集换算"
}
actions={
id ? (
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("library", id)}#${MAPPING_ANCHOR}`}>
<ExternalLink />
打开作品页
</Link>
</Button>
) : null
}
>
<div>
{q.isPending ? (
<LoadingState rows={4} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<WordsEditor
key={id ?? ""}
scope="entity"
entityId={id}
block={q.data.block}
knownGroups={q.data.knownGroups}
/>
)}
</div>
</DetailSheet>
);
}