// 识别词语法(docs/design/identify-words.md):全局识别词照搬 MoviePilot 的自定义识别词,作品识别词是本项目的季集换算行。 // 这里只有纯函数(解析、打印、可视化行模型、安全算术),server、domain、web 共用;套用到标题的引擎在 domain。 // ── Python 正则 → JS 正则(MP 的识别词按 Python regex 写) ── export class WordsSyntaxError extends Error { override name = "WordsSyntaxError"; } const SCRIPTS = new Set([ "han", "hiragana", "katakana", "hangul", "latin", "cyrillic", "greek", "thai", "arabic", "hebrew", ]); /** * 把 Python 正则改写成 JS 能编译的写法:`(?P…)` → `(?…)`、`(?P=n)` → `\k`、`\A` / `\Z` → `^` / `$`, * 开头的内联标志 `(?i)(?s)(?m)` 变成 RegExp 标志。先按 Unicode 模式编译(`\p{Han}` 需要),不行再退回普通模式。 */ export function pyRegex(pattern: string, flags = ""): RegExp { let src = pattern; let f = flags; for (let m = /^\(\?([aiLmsux]+)\)/.exec(src); m; m = /^\(\?([aiLmsux]+)\)/.exec(src)) { for (const c of m[1]!) { if (c === "x") throw new WordsSyntaxError("不支持 (?x) 写法"); if ("ims".includes(c) && !f.includes(c)) f += c; } src = src.slice(m[0].length); } let out = ""; let inClass = false; for (let i = 0; i < src.length; i++) { const c = src[i]!; if (c === "\\") { const n = src[i + 1] ?? ""; // Python regex 的 \p{Han} 等脚本名,JS 要写成 \p{Script=Han} const prop = /^[pP]\{([A-Za-z_]+)\}/.exec(src.slice(i + 1)); if (prop && SCRIPTS.has(prop[1]!.toLowerCase())) { out += `\\${prop[0][0]}{Script=${prop[1]}}`; i += prop[0].length; continue; } if (!inClass && n === "A") out += "^"; else if (!inClass && n === "Z") out += "$"; else out += c + n; i++; continue; } if (inClass) { if (c === "]") inClass = false; out += c; continue; } if (c === "[") { inClass = true; out += c; // `[]…]` 与 `[^]…]` 里开头的 ] 是字面量 if (src[i + 1] === "^") out += src[++i]; if (src[i + 1] === "]") out += `\\${src[++i]}`; continue; } if (src.startsWith("(?P<", i)) { out += "(?<"; i += 3; continue; } const back = /^\(\?P=(\w+)\)/.exec(src.slice(i)); if (back) { out += `\\k<${back[1]}>`; i += back[0].length - 1; continue; } out += c; } try { return new RegExp(out, `${f}u`); } catch { try { return new RegExp(out, f); } catch (e) { throw new WordsSyntaxError(`正则写法不对:${(e as Error).message.replace(/^Invalid regular expression: /, "")}`); } } } /** 正则里的捕获组数 */ const groupCount = (re: RegExp) => (new RegExp(`${re.source}|`, re.flags.replace("g", "")).exec("")?.length ?? 1) - 1; const PY_ESCAPES: Record = { n: "\n", t: "\t", r: "\r", f: "\f", v: "\v", a: "\x07", b: "\b" }; type ReplacePart = string | { group: number | string }; /** * Python `re.sub` 的替换模板:`\1`、`\g<1>`、`\g` 引用分组,`\\` 是反斜杠,`\n` 等是控制字符; * 其余反斜杠 + ASCII 字母报错(Python 同样报错),反斜杠 + 其他字符原样保留。返回按匹配拼替换串的函数。 */ export function pyReplacement( template: string, re?: RegExp, ): (match: string, groups: (string | undefined)[], named?: Record) => string { const parts: ReplacePart[] = []; let lit = ""; const max = re ? groupCount(re) : Number.POSITIVE_INFINITY; const push = (g: number | string) => { if (typeof g === "number" && g > max) throw new WordsSyntaxError(`替换词引用了不存在的分组 \\${g}`); if (lit) parts.push(lit); lit = ""; parts.push({ group: g }); }; for (let i = 0; i < template.length; i++) { const c = template[i]!; if (c !== "\\") { lit += c; continue; } const n = template[i + 1]; if (n === undefined) throw new WordsSyntaxError("替换词末尾多了一个反斜杠"); if (n === "g") { const m = /^<(\w+)>/.exec(template.slice(i + 2)); if (!m) throw new WordsSyntaxError("替换词里的 \\g 要写成 \\g<1> 或 \\g<名字>"); push(/^\d+$/.test(m[1]!) ? Number(m[1]) : m[1]!); i += 1 + m[0].length; continue; } const d = /^[1-9]\d?/.exec(template.slice(i + 1)); if (d) { push(Number(d[0])); i += d[0].length; continue; } if (n === "\\") lit += "\\"; else if (n in PY_ESCAPES) lit += PY_ESCAPES[n]; else if (/[A-Za-z]/.test(n)) throw new WordsSyntaxError(`替换词里的 \\${n} 不是合法的转义`); else lit += `\\${n}`; i++; } if (lit) parts.push(lit); return (match, groups, named) => parts .map((p) => { if (typeof p === "string") return p; if (p.group === 0) return match; if (typeof p.group === "number") return groups[p.group - 1] ?? ""; return named?.[p.group] ?? ""; }) .join(""); } /** Python `pattern.subn(template, text)`:全部替换,返回新串与次数 */ export function pySubn(re: RegExp, template: string, text: string): [string, number] { const g = re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`); const build = pyReplacement(template, g); let count = 0; const out = text.replace(g, (...args: unknown[]) => { count++; const hasNamed = typeof args[args.length - 1] === "object" && args[args.length - 1] !== null; const named = hasNamed ? (args[args.length - 1] as Record) : undefined; const groups = args.slice(1, hasNamed ? -3 : -2) as (string | undefined)[]; return build(args[0] as string, groups, named); }); return [out, count]; } // ── 集号算术(MP calculate_episode_offset 的白名单求值) ── const IMPLICIT_EP = /(?:\d|\))\s*EP|EP\s*(?:\d|\()/; const EP_TOKEN = /(? a - b * Math.floor(a / b); function evalArith(src: string): number { const toks = lexArith(src); let pos = 0; const peek = () => toks[pos]; const expr = (): number => { let v = term(); for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) { pos++; const r = term(); v = p.v === "+" ? v + r : v - r; } return v; }; const term = (): number => { let v = unary(); for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) { pos++; const r = unary(); if (p.v !== "*" && r === 0) throw new WordsSyntaxError("集号表达式除以 0"); if (p.v === "*") v *= r; else if (p.v === "/") v /= r; else if (p.v === "//") v = Math.floor(v / r); else v = pyMod(v, r); } return v; }; const unary = (): number => { const p = peek(); if (p?.t === "op" && (p.v === "-" || p.v === "+")) { pos++; const v = unary(); return p.v === "-" ? -v : v; } return atom(); }; const atom = (): number => { const p = toks[pos++]; if (!p) throw new WordsSyntaxError("集号表达式不完整"); if (p.t === "num") return p.v; if (p.t === "lp") { const v = expr(); if (toks[pos++]?.t !== "rp") throw new WordsSyntaxError("集号表达式括号不配对"); return v; } throw new WordsSyntaxError("集号表达式写法不对"); }; const v = expr(); if (pos !== toks.length) throw new WordsSyntaxError("集号表达式写法不对"); if (!Number.isFinite(v)) throw new WordsSyntaxError("集号表达式算不出结果"); return Math.trunc(v); } /** * MP calculate_episode_offset:`EP` 换成集号后按白名单算术求值(+ - * / // % 与括号,结果取整)。 * `2EP`、`EP(…)` 这类省略运算符的写法报错;不含 EP 的表达式直接算出常数(与 MP 一致)。 */ export function calculateEpisodeOffset(offset: string, episode: number): number { if (IMPLICIT_EP.test(offset)) throw new WordsSyntaxError("EP 前后要写运算符,如 2*EP"); let count = 0; const expr = offset.replace(EP_TOKEN, () => { count++; return String(episode); }); if (offset.includes("EP") && count === 0) throw new WordsSyntaxError("EP 占位符写法不对"); return evalArith(expr); } /** * 作品识别词的集号换算:同一套算术,EP 不分大小写,必须出现 EP(`EP` 单独写 = 集号不变)。 */ export function evalEpisodeExpr(expr: string, episode: number): number { const e = expr.replace(/(? expr.replace(/(?\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$/; /** 按 MP __parse_word 拆一行(不校验正则);注释与空行另算 */ export function parseGlobalWordLine(text: string, line = 1): GlobalWordLine { const base = { line, text }; if (!text.trim()) return { ...base, kind: "blank" }; if (text.trimStart().startsWith("#")) return { ...base, kind: "comment" }; let w: GlobalWordLine; if (text.includes(" => ") && text.includes(" && ") && text.includes(" >> ") && text.includes(" <> ")) { const m = COMBINED.exec(text); if (!m) return { ...base, kind: "replace_offset", error: "替换加偏移的写法是:被替换词 => 替换词 && 前定位词 <> 后定位词 >> 偏移", }; w = { ...base, kind: "replace_offset", pattern: m[1]!, replacement: m[2]!, front: m[3]!, back: m[4]!, offset: m[5]!, }; } else if (text.includes(" => ")) { const parts = text.split(" => "); w = { ...base, kind: "replace", pattern: parts[0]!, replacement: parts[1]! }; } else if (text.includes(" >> ") && text.includes(" <> ")) { const parts = text.split(" <> "); const rest = parts[1]!.split(" >> "); w = { ...base, kind: "offset", front: parts[0]!, back: rest[0]!, offset: rest[1]! }; } else { w = { ...base, kind: "block", pattern: text }; } const error = validateGlobalWord(w); return error ? { ...w, error } : w; } /** 校验正则、替换模板与偏移表达式能否用(MP 在套用时才发现,这里提前报) */ export function validateGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string | undefined { try { if (w.kind === "block" || w.kind === "replace" || w.kind === "replace_offset") { if (!w.pattern) return "被替换词不能为空"; const re = pyRegex(w.pattern, "g"); if (w.kind !== "block") pyReplacement(w.replacement ?? "", re); } if (w.kind === "offset" || w.kind === "replace_offset") { if (!w.front && !w.back) return "前定位词和后定位词至少写一个"; if (w.front) pyRegex(w.front); if (w.back) pyRegex(w.back); // copy-allow: 正则,不是文案 pyRegex(`(?<=${w.front ?? ""}.*?)[0-9一二三四五六七八九十]+(?=.*?${w.back ?? ""})`, "g"); if (!w.offset?.trim()) return "偏移不能为空,如 EP+12"; calculateEpisodeOffset(w.offset, 1); } } catch (e) { return (e as Error).message; } return undefined; } export function printGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string { switch (w.kind) { case "block": return w.pattern ?? ""; case "replace": return `${w.pattern ?? ""} => ${w.replacement ?? ""}`; case "offset": return `${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`; case "replace_offset": return `${w.pattern ?? ""} => ${w.replacement ?? ""} && ${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`; default: return ""; } } export const splitLines = (text: string) => text.replace(/\r\n?/g, "\n").split("\n"); export function parseGlobalWords(text: string): GlobalWordLine[] { return splitLines(text).map((t, i) => parseGlobalWordLine(t, i + 1)); } /** 生效的识别词行(非空、非注释、没有错误),顺序同原文 */ export const activeGlobalWords = (text: string) => parseGlobalWords(text).filter((w) => w.kind !== "blank" && w.kind !== "comment" && !w.error); // ── `{[tmdbid=…;type=…;s=…;e=…]}` 指定作品 ── export interface IdentityTag { tmdbId?: string; doubanId?: string; type?: "movie" | "tv"; season?: string; episode?: string; /** 其余键(g=剧集组等)原样保留 */ extra: string[]; } const TAG_RE = /\{\[([^\]]*)\]\}/; /** 替换词里的 `{[…]}`;没有返回 null */ export function parseIdentityTag(replacement: string): { tag: IdentityTag; before: string; after: string } | null { const m = TAG_RE.exec(replacement); if (!m) return null; const tag: IdentityTag = { extra: [] }; for (const kv of m[1]!.split(";")) { const [k, ...rest] = kv.split("="); const key = k?.trim().toLowerCase() ?? ""; const v = rest.join("=").trim(); if (!key) continue; if (key === "tmdbid") tag.tmdbId = v; else if (key === "doubanid") tag.doubanId = v; else if (key === "type") tag.type = v === "tv" ? "tv" : v === "movie" || v === "movies" ? "movie" : undefined; else if (key === "s") tag.season = v; else if (key === "e") tag.episode = v; else tag.extra.push(kv.trim()); } return { tag, before: replacement.slice(0, m.index), after: replacement.slice(m.index + m[0].length) }; } export function printIdentityTag(t: IdentityTag): string { const kv = [ t.tmdbId ? `tmdbid=${t.tmdbId}` : null, t.doubanId ? `doubanid=${t.doubanId}` : null, t.type ? `type=${t.type}` : null, t.season ? `s=${t.season}` : null, t.episode ? `e=${t.episode}` : null, ...t.extra, ].filter(Boolean); return `{[${kv.join(";")}]}`; } // ── 作品识别词(季集换算,逐行) ── export type EntityWordKind = "blank" | "comment" | "map" | "table" | "special"; export type EntityWordSpecial = "special" | "extra" | "ignore"; export const ENTITY_SPECIAL_WORDS: Record = { special: "特别篇", extra: "花絮", ignore: "忽略", }; const SPECIAL_OF: Record = { 特别篇: "special", 特別篇: "special", special: "special", 花絮: "extra", extra: "extra", 忽略: "ignore", ignore: "ignore", }; export interface EntityWordFields { /** 来源季;null = 任意季(写 `*`) */ season: number | null; /** 发布组;空 = 任意发布组 */ groups: string[]; /** map:目标季(null = 来源季)与集号换算 */ targetSeason?: number | null; expr?: string; /** table:来源集号区间 → 目标季集 */ from?: { start: number; end: number }; to?: { season: number; episode: number }; /** special:特别篇 / 花絮 / 忽略 */ special?: EntityWordSpecial; } export interface EntityWordLine extends Partial { line: number; text: string; kind: EntityWordKind; error?: string; } /** 按空白切词,`@"带 空格"` 引号内不切 */ function tokens(s: string): string[] { const out: string[] = []; const re = /@"([^"]*)"|"([^"]*)"|(\S+)/g; for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? `@${m[1]}` : (m[2] ?? m[3]!)); return out; } const SEASON_RE = /^S(\d{1,3})$/i; const EPISODE_RE = /^(?:S(\d{1,3}))?E(\d{1,4})(?:-E?(\d{1,4}))?$/i; const TARGET_EP_RE = /^S(\d{1,3})E(\d{1,4})$/i; /** 解析作品识别词的一行(不含跨行检查) */ export function parseEntityWordLine(text: string, line = 1): EntityWordLine { const base = { line, text }; const t = text.trim(); if (!t) return { ...base, kind: "blank" }; if (t.startsWith("#")) return { ...base, kind: "comment" }; const fail = (kind: EntityWordKind, error: string): EntityWordLine => ({ ...base, kind, error }); const i = t.indexOf(">>"); if (i < 0) return fail("map", "缺少 >>,写成 S02 >> S01 EP+24"); const left = tokens(t.slice(0, i)); const right = t.slice(i + 2).trim(); const head = left[0]; if (!head) return fail("map", ">> 左边要写来源季,如 S02,任意季写 *"); const groups: string[] = []; for (const g of left.slice(1)) { if (!g.startsWith("@") || g.length < 2) return fail("map", `「${g}」前面要加 @ 才是发布组`); const name = g.slice(1).trim(); if (!groups.some((x) => x.toLowerCase() === name.toLowerCase())) groups.push(name); } // 单集对照:S01E13 >> S00E01 const ep = EPISODE_RE.exec(head); if (ep) { const start = Number(ep[2]); const end = ep[3] ? Number(ep[3]) : start; if (end < start) return fail("table", "集号区间写反了"); const to = TARGET_EP_RE.exec(right); if (!to) return fail("table", "单集对照的右边写目标季集,如 S00E01"); return { ...base, kind: "table", season: ep[1] !== undefined ? Number(ep[1]) : null, groups, from: { start, end }, to: { season: Number(to[1]), episode: Number(to[2]) }, ...(Number(to[2]) < 1 ? { error: "目标集号从 1 开始" } : {}), }; } let season: number | null; if (head === "*" || /^S\*$/i.test(head)) season = null; else { const s = SEASON_RE.exec(head); if (!s) return fail("map", `「${head}」不是季号,写成 S02,任意季写 *`); season = Number(s[1]); } const special = SPECIAL_OF[right.toLowerCase()]; if (special) return { ...base, kind: "special", season, groups, special }; if (!right) return fail("map", ">> 右边要写目标季和集号换算,如 S01 EP+24"); const parts = right.split(/\s+/); let targetSeason: number | null = null; let exprParts = parts; const ts = SEASON_RE.exec(parts[0]!); if (ts) { targetSeason = Number(ts[1]); exprParts = parts.slice(1); } const expr = exprParts.length ? normalizeEpisodeExpr(exprParts.join(" ")) : "EP"; try { evalEpisodeExpr(expr, 1); } catch (e) { return { ...base, kind: "map", season, groups, targetSeason, expr, error: (e as Error).message }; } return { ...base, kind: "map", season, groups, targetSeason, expr }; } const pad = (n: number) => String(n).padStart(2, "0"); const quoteGroup = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`); export function printEntityWord(w: EntityWordFields & { kind: EntityWordKind }): string { const groups = w.groups.map(quoteGroup).join(" "); const g = groups ? ` ${groups}` : ""; if (w.kind === "table") { const f = w.from ?? { start: 1, end: 1 }; const range = f.start === f.end ? `E${pad(f.start)}` : `E${pad(f.start)}-E${pad(f.end)}`; const s = w.season === null ? "" : `S${pad(w.season)}`; const to = w.to ?? { season: 0, episode: 1 }; return `${s}${range}${g} >> S${pad(to.season)}E${pad(to.episode)}`; } const scope = `${w.season === null ? "*" : `S${pad(w.season)}`}${g}`; if (w.kind === "special") return `${scope} >> ${ENTITY_SPECIAL_WORDS[w.special ?? "ignore"]}`; const target = w.targetSeason == null ? "" : `S${pad(w.targetSeason)} `; return `${scope} >> ${target}${normalizeEpisodeExpr(w.expr?.trim() || "EP")}`; } /** 编译后的规则:与 domain EpisodeMappingRule 同形,多了出处行 */ export interface CompiledEntityRule { id: string; season: number | null; releaseGroup: string | null; kind: "expression" | "table" | "special"; expression?: string; targetSeason?: number; table?: { from: { start: number; end: number }; to: { season: number; episode: number } }[]; special?: EntityWordSpecial; /** 出处:行号与原文(单集对照合并多行时取第一行) */ line: number; text: string; lines?: number[]; } export interface EntityWordsCompiled { lines: EntityWordLine[]; rules: CompiledEntityRule[]; errors: { line: number; message: string }[]; } const scopeKey = (season: number | null, group: string | null) => `${season ?? "*"}|${group?.toLowerCase() ?? ""}`; const scopeLabel = (season: number | null, group: string | null) => `${season === null ? "任意季" : `S${pad(season)}`}${group ? ` @${group}` : ""}`; /** * 解析整块作品识别词并编译成规则:一行多个发布组拆成多条;同一范围(季 + 发布组)只能有一条换算或特别篇, * 单集对照可以多行(合并成一张对照表),但不能与换算 / 特别篇同范围,区间不能重叠。 */ export function compileEntityWords(text: string): EntityWordsCompiled { const lines = splitLines(text).map((t, i) => parseEntityWordLine(t, i + 1)); const errors: { line: number; message: string }[] = []; const byScope = new Map(); const rules: CompiledEntityRule[] = []; for (const l of lines) { if (l.error) errors.push({ line: l.line, message: l.error }); if (l.error || l.kind === "blank" || l.kind === "comment") continue; const groups = l.groups?.length ? l.groups : [null]; const season = l.season ?? null; for (const [gi, group] of groups.entries()) { const key = scopeKey(season, group); const prev = byScope.get(key); if (l.kind === "table") { const entry = { from: l.from!, to: l.to! }; if (prev && prev.kind !== "table") { errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` }); continue; } if (prev) { const hit = prev.table!.find((e) => entry.from.start <= e.from.end && entry.from.end >= e.from.start); if (hit) { errors.push({ line: l.line, message: `集号与第 ${prev.lines?.find(Boolean) ?? prev.line} 行的对照重叠` }); continue; } prev.table!.push(entry); prev.lines!.push(l.line); continue; } const r: CompiledEntityRule = { id: ruleId(l.line, gi), season, releaseGroup: group, kind: "table", table: [entry], line: l.line, text: l.text.trim(), lines: [l.line], }; byScope.set(key, r); rules.push(r); continue; } if (prev) { errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` }); continue; } const r: CompiledEntityRule = { id: ruleId(l.line, gi), season, releaseGroup: group, kind: l.kind === "special" ? "special" : "expression", ...(l.kind === "special" ? { special: l.special! } : { expression: l.expr! }), ...(l.kind === "map" && l.targetSeason != null ? { targetSeason: l.targetSeason } : {}), line: l.line, text: l.text.trim(), }; byScope.set(key, r); rules.push(r); } } return { lines, rules, errors }; } const ruleId = (line: number, gi: number) => `L${String(line).padStart(4, "0")}${gi ? `.${gi}` : ""}`; // ── 可视化行模型:文本 ⇄ 行,原文逐字保留 ── export type WordsScope = "global" | "entity"; /** * 一行一个规则行;紧贴在规则上方(中间没有空行)的普通注释是它的备注。 * 注释掉的规则(`# S02 >> S01 EP+24`)是停用的规则行;解析不了的行是原文行(可视化里只读)。 * lines 是这一行在文本里占的全部原文(备注 + 规则),没改过就原样拼回,保证往返不丢字。 */ export type WordsRow = | { kind: "rule"; id: string; lines: string[]; note: string[]; enabled: boolean; rule: string } | { kind: "comment"; id: string; lines: string[] } | { kind: "blank"; id: string; lines: string[] } | { kind: "invalid"; id: string; lines: string[]; rule: string; error: string }; /** 去掉注释号:`# xxx` / `#xxx` → `xxx` */ export const uncomment = (line: string) => line.trimStart().replace(/^#\s?/, ""); /** 注释行的内容是不是一条规则(停用的规则) */ function commentedRule(line: string, scope: WordsScope): string | null { const body = uncomment(line); if (!body.trim()) return null; if (scope === "entity") { const p = parseEntityWordLine(body); return p.kind !== "comment" && p.kind !== "blank" && !p.error ? body : null; } // 全局:任何文字都是合法的屏蔽词,只把带运算符的当成停用规则 const p = parseGlobalWordLine(body); return (p.kind === "replace" || p.kind === "offset" || p.kind === "replace_offset") && !p.error ? body : null; } export function lineError(line: string, scope: WordsScope): string | undefined { return scope === "entity" ? parseEntityWordLine(line).error : parseGlobalWordLine(line).error; } /** 规则文本 → 可视化行模型 */ export function wordsToRows(text: string, scope: WordsScope): WordsRow[] { const src = splitLines(text); const rows: WordsRow[] = []; let pendingNote: string[] = []; let n = 0; const id = () => `r${n++}`; const flushNote = () => { if (pendingNote.length) rows.push({ kind: "comment", id: id(), lines: pendingNote }); pendingNote = []; }; // 文本以换行结尾时 split 多出一个空串,不算一行 const body = src.length > 1 && src[src.length - 1] === "" ? src.slice(0, -1) : src; for (const line of body) { const trimmed = line.trim(); if (!trimmed) { flushNote(); const last = rows[rows.length - 1]; if (last?.kind === "blank") last.lines.push(line); else rows.push({ kind: "blank", id: id(), lines: [line] }); continue; } if (trimmed.startsWith("#")) { const rule = commentedRule(line, scope); if (rule !== null) { rows.push({ kind: "rule", id: id(), lines: [...pendingNote, line], note: pendingNote.map(uncomment), enabled: false, rule, }); pendingNote = []; } else pendingNote.push(line); continue; } const error = lineError(line, scope); if (error) { flushNote(); rows.push({ kind: "invalid", id: id(), lines: [line], rule: line, error }); continue; } rows.push({ kind: "rule", id: id(), lines: [...pendingNote, line], note: pendingNote.map(uncomment), enabled: true, rule: line, }); pendingNote = []; } flushNote(); return rows; } /** 可视化行模型 → 文本;结尾带换行与否跟原文一致由调用方决定(trailingNewline) */ export function rowsToText(rows: readonly WordsRow[], trailingNewline = false): string { const text = rows.flatMap((r) => r.lines).join("\n"); return trailingNewline && text ? `${text}\n` : text; } /** 按备注、启用状态、规则文本重新生成规则行的原文 */ export function ruleRowLines(note: readonly string[], enabled: boolean, rule: string): string[] { return [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`]; } /** 改规则行的某一部分,返回新行(lines 随之重写) */ export function updateRuleRow( row: Extract, patch: Partial, "note" | "enabled" | "rule">>, ): Extract { const next = { ...row, ...patch }; // 只改启用状态时备注原文不动 const noteLines = patch.note ? next.note.map((x) => `# ${x}`.trimEnd()) : row.lines.slice(0, row.note.length); return { ...next, lines: [...noteLines, next.enabled ? next.rule : `# ${next.rule}`] }; }