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,51 +1,85 @@
|
||||
import { IdParam } from "../common.ts";
|
||||
import * as em from "../episode-mapping.ts";
|
||||
import * as iw from "../identify-words.ts";
|
||||
import { ep } from "./shared.ts";
|
||||
|
||||
// 集数映射(docs/plan/episode-resolution.md 批次 4):作品的映射规则增删改、模拟预览。
|
||||
// 写入后立即按作品重算已绑定资源(返回受影响数量);待处理 episode_mapping 的确认 / 忽略走 /decisions/{id}/resolve 的选项。
|
||||
// 识别词(docs/design/identify-words.md):全局一块、每部作品一块,原文整块读写;保存前试算,保存后重算。
|
||||
// 集数映射预览(纯读);集数待确认事项的确认 / 忽略走 /decisions/{id}/resolve 的选项(把一行写进作品识别词)。
|
||||
|
||||
export const episodeMappingApi = {
|
||||
episodeMapping: {
|
||||
listRules: ep({
|
||||
identifyWords: {
|
||||
getGlobal: ep({
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/episode-mapping-rules",
|
||||
path: "/identify-words/global",
|
||||
scope: "read",
|
||||
summary: "作品的集数映射规则(含停用的与停用原因)",
|
||||
params: IdParam,
|
||||
response: em.EpisodeMappingRuleList,
|
||||
summary: "全局识别词原文、版本与保存后的重算进度",
|
||||
response: iw.IdentifyWordsBlock,
|
||||
}),
|
||||
createRule: ep({
|
||||
dryRunGlobal: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/episode-mapping-rules",
|
||||
scope: "organize.execute",
|
||||
summary: "新建集数映射规则并按作品重算(同作品 + 季 + 发布组只能有一条启用的规则)",
|
||||
params: IdParam,
|
||||
body: em.EpisodeMappingRuleCreate,
|
||||
response: em.EpisodeMappingRuleWriteResult,
|
||||
path: "/identify-words/global/dry-run",
|
||||
scope: "read",
|
||||
summary: "试算全局识别词:逐行错误、改动的行、近期资源里识别结果会变的条目(纯读)",
|
||||
body: iw.IdentifyWordsDryRunInput,
|
||||
response: iw.GlobalWordsDryRun,
|
||||
}),
|
||||
updateRule: ep({
|
||||
method: "patch",
|
||||
path: "/episode-mapping-rules/{id}",
|
||||
scope: "organize.execute",
|
||||
summary: "修改 / 停用 / 启用集数映射规则并按作品重算",
|
||||
params: IdParam,
|
||||
body: em.EpisodeMappingRuleUpdate,
|
||||
response: em.EpisodeMappingRuleWriteResult,
|
||||
saveGlobal: ep({
|
||||
method: "put",
|
||||
path: "/identify-words/global",
|
||||
scope: "admin",
|
||||
summary: "保存全局识别词(版本不符返回 409),后台重新识别标题命中改动行的资源",
|
||||
body: iw.IdentifyWordsSave,
|
||||
response: iw.GlobalWordsSaveResult,
|
||||
}),
|
||||
deleteRule: ep({
|
||||
method: "delete",
|
||||
path: "/episode-mapping-rules/{id}",
|
||||
scope: "organize.execute",
|
||||
summary: "删除集数映射规则并按作品重算",
|
||||
params: IdParam,
|
||||
response: em.EpisodeMappingRuleWriteResult,
|
||||
listEntities: ep({
|
||||
method: "get",
|
||||
path: "/identify-words/entities",
|
||||
scope: "read",
|
||||
summary: "有作品识别词的作品:行数、停用行数、错误行数、最后修改",
|
||||
query: iw.EntityWordsListQuery,
|
||||
response: iw.EntityWordsList,
|
||||
}),
|
||||
try: ep({
|
||||
method: "post",
|
||||
path: "/identify-words/try",
|
||||
scope: "read",
|
||||
summary: "拿一个标题试一行识别词:替换后的标题、识别出的季集、作品识别词换算后的季集与校验(纯读)",
|
||||
body: iw.WordsTryInput,
|
||||
response: iw.WordsTryResult,
|
||||
}),
|
||||
getEntity: ep({
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/identify-words",
|
||||
scope: "read",
|
||||
summary: "作品识别词原文与版本,附这部作品资源里出现过的发布组",
|
||||
params: IdParam,
|
||||
response: iw.EntityWordsView,
|
||||
}),
|
||||
dryRunEntity: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/identify-words/dry-run",
|
||||
scope: "read",
|
||||
summary: "试算作品识别词:逐行错误、近期资源换算前后与仍对不上季表的条目(纯读)",
|
||||
params: IdParam,
|
||||
body: iw.IdentifyWordsDryRunInput,
|
||||
response: iw.EntityWordsDryRun,
|
||||
}),
|
||||
saveEntity: ep({
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/identify-words",
|
||||
scope: "organize.execute",
|
||||
summary: "保存作品识别词(版本不符返回 409),按作品重算已绑定资源的集数",
|
||||
params: IdParam,
|
||||
body: iw.IdentifyWordsSave,
|
||||
response: iw.EntityWordsSaveResult,
|
||||
}),
|
||||
},
|
||||
episodeMapping: {
|
||||
preview: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/episode-mapping/preview",
|
||||
scope: "read",
|
||||
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿规则后的结果(纯读)",
|
||||
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿识别词后的结果(纯读)",
|
||||
params: IdParam,
|
||||
body: em.EpisodeMappingPreviewInput,
|
||||
response: em.EpisodeMappingPreview,
|
||||
|
||||
@@ -7,9 +7,6 @@ import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from
|
||||
import { Id, JsonObject, NonNegInt } from "./common.ts";
|
||||
import { MediaEntity, MediaKind } from "./content.ts";
|
||||
import {
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeMappingRuleStatus,
|
||||
EpisodeMappingTableEntry,
|
||||
EpisodeReresolveResult,
|
||||
EpisodeResolutionMethod,
|
||||
EpisodeSpecialKind,
|
||||
@@ -17,14 +14,7 @@ import {
|
||||
EpisodeValidationState,
|
||||
} from "./episode-mapping.ts";
|
||||
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
|
||||
import {
|
||||
EnrichmentStatus,
|
||||
EnrichSuggestion,
|
||||
EnrichUsage,
|
||||
EvidenceLevel,
|
||||
ExternalEntityType,
|
||||
ExternalIdentity,
|
||||
} from "./identity.ts";
|
||||
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
|
||||
import { OrganizeMetadataInput, OrganizeMetadataOutput } from "./local-metadata.ts";
|
||||
import { ServerMapping } from "./ops.ts";
|
||||
import { OrganizationAction, OrganizationRecord } from "./organization.ts";
|
||||
@@ -927,7 +917,7 @@ export const WebSearchTestResult = z.object({
|
||||
export type WebSearchTestResult = z.infer<typeof WebSearchTestResult>;
|
||||
|
||||
// ── episode_mapping.*:集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5) ──
|
||||
// inspect 给上下文包(有上限),simulate 试草稿规则,apply_rule 服务端强制先模拟、通过才写规则并重算。
|
||||
// inspect 给上下文包(有上限),simulate 试一行作品识别词,apply_rule 服务端强制先模拟、通过才把这一行写进作品识别词并重算。
|
||||
|
||||
const EpisodeRange = z.object({ start: z.number().int(), end: z.number().int() });
|
||||
const EpisodeSourceView = z.object({
|
||||
@@ -974,8 +964,6 @@ export const EpisodeMappingInspectOutput = z.object({
|
||||
status: DecisionStatus,
|
||||
title: z.string(),
|
||||
summary: z.string().nullable(),
|
||||
/** 算出越界被停用的规则 */
|
||||
failedRules: z.array(z.string()),
|
||||
})
|
||||
.nullable(),
|
||||
/** 事项内资源(没有事项时 = 这个范围里当前待确认的资源),最多 50 条 */
|
||||
@@ -994,25 +982,18 @@ export const EpisodeMappingInspectOutput = z.object({
|
||||
),
|
||||
latestSeason: z.number().int().nullable(),
|
||||
ended: z.boolean(),
|
||||
/** 这部作品的映射规则(含停用及原因);byAgent = 智能体写的 */
|
||||
rules: z.array(
|
||||
z.object({
|
||||
id: Id,
|
||||
season: z.number().int().nullable(),
|
||||
releaseGroup: z.string().nullable(),
|
||||
kind: z.enum(["expression", "table", "special"]),
|
||||
expression: z.string().nullable(),
|
||||
targetSeason: z.number().int().nullable(),
|
||||
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
|
||||
special: EpisodeSpecialKind.nullable(),
|
||||
status: EpisodeMappingRuleStatus,
|
||||
disabledReason: z.string().nullable(),
|
||||
evidenceLevel: EvidenceLevel,
|
||||
byAgent: z.boolean(),
|
||||
/** 智能体写规则时的理由 */
|
||||
reasons: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
/** 这部作品的作品识别词原文与逐行解析(注释掉的行是用户停用的,不要再写同范围的行) */
|
||||
words: z.object({
|
||||
text: z.string(),
|
||||
lines: z.array(
|
||||
z.object({
|
||||
line: z.number().int(),
|
||||
text: z.string(),
|
||||
kind: z.enum(["blank", "comment", "map", "table", "special"]),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
/** 确定性建议(按发布时间对播出日期,全部样本成立且唯一才有) */
|
||||
suggestion: EpisodeSuggestion.nullable(),
|
||||
truncated: z.object({ releases: z.boolean(), neighbors: z.boolean() }),
|
||||
@@ -1023,8 +1004,8 @@ export const EpisodeMappingSimulateInput = z.object({
|
||||
decisionId: Id.optional(),
|
||||
/** 不给 decisionId 时必填 */
|
||||
mediaEntityId: Id.optional(),
|
||||
/** 草稿规则(expression / table / special + 作用域) */
|
||||
rule: EpisodeMappingRuleSpec,
|
||||
/** 一行作品识别词,如 `S02 @ANi >> S01 EP+12`、`S01E13 >> S00E01`、`S03 @Grp >> 忽略` */
|
||||
line: z.string().trim().min(1).max(500),
|
||||
/** 理由:模拟通过时作为智能体建议留在事项上(后台调查没有写规则授权时) */
|
||||
reasons: z.array(z.string().trim().min(1).max(300)).max(10).optional(),
|
||||
});
|
||||
@@ -1061,7 +1042,8 @@ export const EpisodeSimulationSummary = z.object({
|
||||
});
|
||||
export type EpisodeSimulationSummary = z.infer<typeof EpisodeSimulationSummary>;
|
||||
export const EpisodeMappingSimulateOutput = z.object({
|
||||
rule: EpisodeMappingRuleSpec,
|
||||
/** 规范写法的那一行 */
|
||||
line: z.string(),
|
||||
summary: EpisodeSimulationSummary,
|
||||
/** 没通过的原因(通过时为空) */
|
||||
failures: z.array(z.string()),
|
||||
@@ -1074,14 +1056,16 @@ export const EpisodeMappingSimulateOutput = z.object({
|
||||
export const EpisodeMappingApplyRuleInput = z.object({
|
||||
/** 集数待确认事项 ID */
|
||||
decisionId: Id,
|
||||
/** 草稿规则;季 / 发布组不填时用事项的 */
|
||||
rule: EpisodeMappingRuleSpec,
|
||||
/** 为什么这样映射(依据),至少一条 */
|
||||
/** 一行作品识别词(写法同 simulate) */
|
||||
line: z.string().trim().min(1).max(500),
|
||||
/** 为什么这样映射(依据),至少一条;写进识别词时作为这一行上方的注释 */
|
||||
reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10),
|
||||
});
|
||||
export const EpisodeMappingApplyRuleOutput = z.object({
|
||||
ruleId: Id,
|
||||
evidenceLevel: EvidenceLevel,
|
||||
/** 追加进作品识别词的原文(注释 + 规则行) */
|
||||
appended: z.string(),
|
||||
/** 规则行在识别词里的行号 */
|
||||
lineNumber: z.number().int(),
|
||||
summary: EpisodeSimulationSummary,
|
||||
reresolve: EpisodeReresolveResult,
|
||||
/** 写入后事项的状态(资源都解决时已自动关闭) */
|
||||
|
||||
@@ -639,7 +639,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"episode_mapping.inspect": d(
|
||||
"episode_mapping.inspect",
|
||||
"看集数映射上下文",
|
||||
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),现有规则(含停用原因),确定性建议规则。写规则前先看这个",
|
||||
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),这部作品的作品识别词原文(注释掉的行是用户停用的),确定性建议规则。写规则前先看这个",
|
||||
"read",
|
||||
"EpisodeMappingInspect",
|
||||
"read",
|
||||
@@ -650,7 +650,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"episode_mapping.simulate": d(
|
||||
"episode_mapping.simulate",
|
||||
"模拟映射规则",
|
||||
"试一条草稿映射规则(expression 偏移表达式 / table 对照表 / special 特别篇·花絮·忽略),逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,规则和理由会作为智能体建议留在事项上,由用户确认",
|
||||
"试一行作品识别词(季集换算 `S02 @发布组 >> S01 EP+12`、单集对照 `S01E13 >> S00E01`、特别篇 / 花絮 / 忽略 `S03 @发布组 >> 忽略`),同范围的已有行被它替换,逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,这一行和理由会作为智能体建议留在事项上,由用户确认",
|
||||
"preview",
|
||||
"EpisodeMappingSimulate",
|
||||
"read",
|
||||
@@ -661,7 +661,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"episode_mapping.apply_rule": d(
|
||||
"episode_mapping.apply_rule",
|
||||
"写映射规则",
|
||||
"把草稿映射规则写进作品并按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
|
||||
"把一行作品识别词追加到作品识别词末尾(上方带一行写明理由的注释),再按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写;同范围的行被用户注释掉过也不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
|
||||
"execute",
|
||||
"EpisodeMappingApplyRule",
|
||||
"organize.execute",
|
||||
|
||||
@@ -1,48 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { ActorType, EntityBase, Id, JsonObject } from "./common.ts";
|
||||
import { EvidenceLevel } from "./identity.ts";
|
||||
import { Id } from "./common.ts";
|
||||
|
||||
// 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域,
|
||||
// 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。
|
||||
// 集数映射(docs/plan/episode-resolution.md):规则改由作品识别词写(docs/design/identify-words.md),
|
||||
// 这里是解析结果、预览与剧集组的结构。
|
||||
|
||||
export const EpisodeMappingRuleKind = z.enum(["expression", "table", "special"]);
|
||||
export type EpisodeMappingRuleKind = z.infer<typeof EpisodeMappingRuleKind>;
|
||||
export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]);
|
||||
export type EpisodeSpecialKind = z.infer<typeof EpisodeSpecialKind>;
|
||||
export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]);
|
||||
export type EpisodeMappingRuleStatus = z.infer<typeof EpisodeMappingRuleStatus>;
|
||||
|
||||
const EpisodeRangeSchema = z.object({ start: z.number().int(), end: z.number().int() });
|
||||
|
||||
/** 表格规则的一条:来源集号区间 → 目标的一集 */
|
||||
export const EpisodeMappingTableEntry = z.object({
|
||||
from: EpisodeRangeSchema,
|
||||
to: z.object({ season: z.number().int(), episode: z.number().int() }),
|
||||
});
|
||||
export type EpisodeMappingTableEntry = z.infer<typeof EpisodeMappingTableEntry>;
|
||||
|
||||
export const EpisodeMappingRuleRow = EntityBase.extend({
|
||||
mediaEntityId: Id,
|
||||
/** 来源季;null = 任意季 */
|
||||
season: z.number().int().nullable(),
|
||||
/** 发布组;null = 任意发布组,比较忽略大小写 */
|
||||
releaseGroup: z.string().nullable(),
|
||||
kind: EpisodeMappingRuleKind,
|
||||
/** expression 规则的偏移表达式(如 `EP-12`) */
|
||||
expression: z.string().nullable(),
|
||||
targetSeason: z.number().int().nullable(),
|
||||
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
|
||||
special: EpisodeSpecialKind.nullable(),
|
||||
status: EpisodeMappingRuleStatus,
|
||||
evidenceLevel: EvidenceLevel,
|
||||
evidence: JsonObject,
|
||||
/** 规则失效被停用时的证据 */
|
||||
disabledReason: z.string().nullable(),
|
||||
createdByType: ActorType,
|
||||
createdById: z.string().nullable(),
|
||||
});
|
||||
export type EpisodeMappingRuleRow = z.infer<typeof EpisodeMappingRuleRow>;
|
||||
|
||||
// ── 接口(批次 4):规则增删改、模拟预览、应用后的重算结果 ──
|
||||
|
||||
export const EpisodeResolutionMethod = z.enum([
|
||||
@@ -67,44 +33,15 @@ export const EpisodeResolutionView = z.object({
|
||||
airDate: z.string().optional(),
|
||||
}),
|
||||
canonical: z.object({ season: z.number().int(), episodes: EpisodeRangeSchema }).nullable(),
|
||||
/** 命中的作品识别词:编译后的规则 id、行号与原文 */
|
||||
ruleId: z.string().optional(),
|
||||
ruleFailed: z.string().optional(),
|
||||
ruleLine: z.number().int().optional(),
|
||||
ruleText: z.string().optional(),
|
||||
special: EpisodeSpecialKind.optional(),
|
||||
evidence: z.array(z.string()),
|
||||
});
|
||||
export type EpisodeResolutionView = z.infer<typeof EpisodeResolutionView>;
|
||||
|
||||
/** 规则内容(新建 / 草稿共用):按 kind 只看对应字段,其余忽略 */
|
||||
export const EpisodeMappingRuleSpec = z.object({
|
||||
/** 来源季;null / 不填 = 任意季 */
|
||||
season: z.number().int().min(0).max(999).nullable().optional(),
|
||||
/** 发布组;null / 空 = 任意发布组 */
|
||||
releaseGroup: z.string().trim().max(200).nullable().optional(),
|
||||
kind: EpisodeMappingRuleKind,
|
||||
/** expression:偏移表达式,如 `EP-12`、`EP*2-1`(省略 EP 按 MP 约定补全) */
|
||||
expression: z.string().trim().max(64).nullable().optional(),
|
||||
/** expression:目标季,缺省为来源季 */
|
||||
targetSeason: z.number().int().min(0).max(999).nullable().optional(),
|
||||
tableEntries: z.array(EpisodeMappingTableEntry).max(500).nullable().optional(),
|
||||
special: EpisodeSpecialKind.nullable().optional(),
|
||||
});
|
||||
export type EpisodeMappingRuleSpec = z.infer<typeof EpisodeMappingRuleSpec>;
|
||||
|
||||
export const EpisodeMappingRuleCreate = EpisodeMappingRuleSpec.extend({
|
||||
/** 规则依据(如来自待处理建议的样本),原样存证 */
|
||||
evidence: JsonObject.optional(),
|
||||
});
|
||||
export type EpisodeMappingRuleCreate = z.infer<typeof EpisodeMappingRuleCreate>;
|
||||
|
||||
/** 修改:内容字段整体按 kind 重新校验;status 切换启用 / 停用 */
|
||||
export const EpisodeMappingRuleUpdate = EpisodeMappingRuleSpec.partial().extend({
|
||||
status: EpisodeMappingRuleStatus.optional(),
|
||||
});
|
||||
export type EpisodeMappingRuleUpdate = z.infer<typeof EpisodeMappingRuleUpdate>;
|
||||
|
||||
export const EpisodeMappingRuleList = z.object({ items: z.array(EpisodeMappingRuleRow) });
|
||||
export type EpisodeMappingRuleList = z.infer<typeof EpisodeMappingRuleList>;
|
||||
|
||||
/** 按作品重算已绑定资源的结果 */
|
||||
export const EpisodeReresolveResult = z.object({
|
||||
/** 重算的资源数 */
|
||||
@@ -116,22 +53,13 @@ export const EpisodeReresolveResult = z.object({
|
||||
});
|
||||
export type EpisodeReresolveResult = z.infer<typeof EpisodeReresolveResult>;
|
||||
|
||||
/** 规则写入(新建 / 修改 / 停用 / 删除)后立即按作品重算 */
|
||||
export const EpisodeMappingRuleWriteResult = z.object({
|
||||
rule: EpisodeMappingRuleRow,
|
||||
reresolve: EpisodeReresolveResult,
|
||||
});
|
||||
export type EpisodeMappingRuleWriteResult = z.infer<typeof EpisodeMappingRuleWriteResult>;
|
||||
|
||||
export const EpisodeMappingPreviewInput = z.object({
|
||||
/** 只看这个季(来源季或规范季任一匹配);不填 = 全部 */
|
||||
season: z.number().int().min(0).max(999).nullable().optional(),
|
||||
/** 只看这个发布组(忽略大小写);不填 = 全部 */
|
||||
releaseGroup: z.string().trim().max(200).nullable().optional(),
|
||||
/** 草稿规则:替换同作用域的已有规则后试算 */
|
||||
draft: EpisodeMappingRuleSpec.nullable().optional(),
|
||||
/** 正在修改的规则:试算时用草稿替换它 */
|
||||
replaceRuleId: Id.optional(),
|
||||
/** 草稿:整块作品识别词原文,按它试算(不填 = 当前已保存的) */
|
||||
words: z.string().max(200_000).optional(),
|
||||
/** 最多看多少条近期资源(按发布时间倒序) */
|
||||
limit: z.number().int().min(1).max(300).optional(),
|
||||
});
|
||||
@@ -147,8 +75,10 @@ export const EpisodeMappingPreviewItem = z.object({
|
||||
releaseGroup: z.string().nullable(),
|
||||
/** 库里的解析结果;没解析过为 null */
|
||||
current: EpisodeResolutionView.nullable(),
|
||||
/** 按原标题重解析、套用草稿规则后的结果(没有草稿时 = 当前规则重算) */
|
||||
/** 按原标题重解析、套用草稿后的结果(没有草稿时 = 当前识别词重算) */
|
||||
preview: EpisodeResolutionView,
|
||||
/** 命中的全局识别词 */
|
||||
words: z.array(z.string()),
|
||||
validation: EpisodeValidationState,
|
||||
/** 预览结果与库里的不同(方式、规范季集或特别篇) */
|
||||
changed: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
// 识别词语法(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<n>…)` → `(?<n>…)`、`(?P=n)` → `\k<n>`、`\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<string, string> = { 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<name>` 引用分组,`\\` 是反斜杠,`\n` 等是控制字符;
|
||||
* 其余反斜杠 + ASCII 字母报错(Python 同样报错),反斜杠 + 其他字符原样保留。返回按匹配拼替换串的函数。
|
||||
*/
|
||||
export function pyReplacement(
|
||||
template: string,
|
||||
re?: RegExp,
|
||||
): (match: string, groups: (string | undefined)[], named?: Record<string, string | undefined>) => 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<string, string | undefined>) : 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-Za-z0-9_])EP(?![A-Za-z0-9_])/g;
|
||||
|
||||
type Tok = { t: "num"; v: number } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
|
||||
|
||||
function lexArith(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
for (let i = 0; i < src.length; ) {
|
||||
const c = src[i]!;
|
||||
if (c === " " || c === "\t") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/\d/.test(c)) {
|
||||
let j = i;
|
||||
while (j < src.length && /\d/.test(src[j]!)) j++;
|
||||
out.push({ t: "num", v: Number(src.slice(i, j)) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("//", i)) {
|
||||
out.push({ t: "op", v: "//" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("**", i)) throw new WordsSyntaxError("集号表达式不支持 **");
|
||||
if ("+-*/%".includes(c)) out.push({ t: "op", v: c });
|
||||
else if (c === "(") out.push({ t: "lp" });
|
||||
else if (c === ")") out.push({ t: "rp" });
|
||||
else throw new WordsSyntaxError(`集号表达式只能有数字、EP、括号和 + - * / // %,多了「${c}」`);
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Python 的 % 与 //:结果符号跟除数 */
|
||||
const pyMod = (a: number, b: number) => 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(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
|
||||
if (!e.includes("EP")) throw new WordsSyntaxError("集号换算里要有 EP,如 EP+12");
|
||||
return calculateEpisodeOffset(e, episode);
|
||||
}
|
||||
|
||||
/** 规范写法:EP 大写、运算符两边不留空格(`ep + 12` → `EP+12`) */
|
||||
export const normalizeEpisodeExpr = (expr: string) =>
|
||||
expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP").replace(/\s+/g, "");
|
||||
|
||||
// ── 全局识别词(MP 自定义识别词,逐行) ──
|
||||
|
||||
export type GlobalWordKind = "blank" | "comment" | "block" | "replace" | "offset" | "replace_offset";
|
||||
|
||||
export interface GlobalWordFields {
|
||||
/** 屏蔽词 / 被替换词(正则) */
|
||||
pattern?: string;
|
||||
/** 替换词 */
|
||||
replacement?: string;
|
||||
/** 集数偏移的前定位词 / 后定位词(正则)与偏移表达式 */
|
||||
front?: string;
|
||||
back?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
export interface GlobalWordLine extends GlobalWordFields {
|
||||
/** 行号(从 1 起) */
|
||||
line: number;
|
||||
/** 原文 */
|
||||
text: string;
|
||||
kind: GlobalWordKind;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const COMBINED = /^\s*(.*?)\s*=>\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<EntityWordSpecial, string> = {
|
||||
special: "特别篇",
|
||||
extra: "花絮",
|
||||
ignore: "忽略",
|
||||
};
|
||||
const SPECIAL_OF: Record<string, EntityWordSpecial> = {
|
||||
特别篇: "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<EntityWordFields> {
|
||||
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<string, CompiledEntityRule>();
|
||||
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<WordsRow, { kind: "rule" }>,
|
||||
patch: Partial<Pick<Extract<WordsRow, { kind: "rule" }>, "note" | "enabled" | "rule">>,
|
||||
): Extract<WordsRow, { kind: "rule" }> {
|
||||
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}`] };
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { z } from "zod";
|
||||
import { ActorType, EntityBase, Id } from "./common.ts";
|
||||
import { EpisodeMappingPreview, EpisodeReresolveResult, EpisodeResolutionView } from "./episode-mapping.ts";
|
||||
|
||||
export * from "./identify-words-syntax.ts";
|
||||
|
||||
// 识别词(docs/design/identify-words.md):全局一块(MP 自定义识别词原样)+ 每部作品一块(季集换算)。
|
||||
// 原文整块存储(注释原样保留),保存前先试算;保存后按影响范围重算。
|
||||
|
||||
const NonNeg = z.number().int().min(0);
|
||||
|
||||
export const WordsLineError = z.object({ line: z.number().int().min(1), message: z.string() });
|
||||
export type WordsLineError = z.infer<typeof WordsLineError>;
|
||||
|
||||
/** 全局识别词保存后的后台重算进度 */
|
||||
export const WordsReresolveProgress = z.object({
|
||||
status: z.enum(["queued", "running", "done", "failed"]),
|
||||
/** 要扫描的资源数 */
|
||||
total: NonNeg,
|
||||
scanned: NonNeg,
|
||||
/** 标题 / 副标题命中改动行的资源数 */
|
||||
matched: NonNeg,
|
||||
/** 已重新识别的资源数 */
|
||||
queued: NonNeg,
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string().nullable(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
export type WordsReresolveProgress = z.infer<typeof WordsReresolveProgress>;
|
||||
|
||||
/** identify_words 表的行(审计 / 排查用;接口用下面的 IdentifyWordsBlock) */
|
||||
export const IdentifyWordsRow = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
text: z.string(),
|
||||
version: NonNeg,
|
||||
updatedByType: ActorType.nullable(),
|
||||
updatedById: z.string().nullable(),
|
||||
reresolve: WordsReresolveProgress.nullable(),
|
||||
});
|
||||
export type IdentifyWordsRow = z.infer<typeof IdentifyWordsRow>;
|
||||
|
||||
export const IdentifyWordsBlock = z.object({
|
||||
/** null = 全局 */
|
||||
mediaEntityId: Id.nullable(),
|
||||
text: z.string(),
|
||||
/** 每次保存 +1;保存时带上读到的版本,别人先改过返回 409 */
|
||||
version: NonNeg,
|
||||
updatedAt: z.string().nullable(),
|
||||
/** `<actor_type>:<actor_id>` */
|
||||
updatedBy: z.string().nullable(),
|
||||
reresolve: WordsReresolveProgress.nullable(),
|
||||
});
|
||||
export type IdentifyWordsBlock = z.infer<typeof IdentifyWordsBlock>;
|
||||
|
||||
/** 识别词原文上限:MP 的列表通常几百行,留足余量 */
|
||||
const WordsText = z.string().max(200_000);
|
||||
|
||||
export const IdentifyWordsSave = z.object({
|
||||
text: WordsText,
|
||||
/** 读到的版本;新建时为 0 */
|
||||
expectedVersion: NonNeg,
|
||||
});
|
||||
export type IdentifyWordsSave = z.infer<typeof IdentifyWordsSave>;
|
||||
|
||||
export const IdentifyWordsDryRunInput = z.object({ text: WordsText });
|
||||
export type IdentifyWordsDryRunInput = z.infer<typeof IdentifyWordsDryRunInput>;
|
||||
|
||||
/** 识别结果摘要(试算前后对照用) */
|
||||
export const WordsParsedBrief = z.object({
|
||||
title: z.string().nullable(),
|
||||
year: z.number().int().nullable(),
|
||||
season: z.number().int().nullable(),
|
||||
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
|
||||
releaseGroup: z.string().nullable(),
|
||||
/** `{[tmdbid=…]}` 等指定的身份 */
|
||||
explicitIds: z.array(z.object({ provider: z.string(), id: z.string() })),
|
||||
});
|
||||
export type WordsParsedBrief = z.infer<typeof WordsParsedBrief>;
|
||||
|
||||
export const GlobalWordsDryRunItem = z.object({
|
||||
sourceReleaseId: Id,
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
/** 当前绑定的作品 */
|
||||
mediaEntityId: Id.nullable(),
|
||||
entityTitle: z.string().nullable(),
|
||||
/** 用新识别词命中的行 */
|
||||
words: z.array(z.string()),
|
||||
before: WordsParsedBrief,
|
||||
after: WordsParsedBrief,
|
||||
});
|
||||
export type GlobalWordsDryRunItem = z.infer<typeof GlobalWordsDryRunItem>;
|
||||
|
||||
export const GlobalWordsDryRun = z.object({
|
||||
errors: z.array(WordsLineError),
|
||||
/** 生效行里新增 / 去掉的(改一行 = 去掉旧的 + 新增新的) */
|
||||
added: z.array(z.string()),
|
||||
removed: z.array(z.string()),
|
||||
/** 扫描的近期资源数与其中命中改动行的条数 */
|
||||
scanned: NonNeg,
|
||||
matched: NonNeg,
|
||||
/** 识别结果会变的资源(最多 100 条);保存后后台按全库重新识别命中改动行的资源 */
|
||||
items: z.array(GlobalWordsDryRunItem),
|
||||
});
|
||||
export type GlobalWordsDryRun = z.infer<typeof GlobalWordsDryRun>;
|
||||
|
||||
export const GlobalWordsSaveResult = z.object({ block: IdentifyWordsBlock });
|
||||
export type GlobalWordsSaveResult = z.infer<typeof GlobalWordsSaveResult>;
|
||||
|
||||
export const EntityWordsSummary = z.object({
|
||||
mediaEntityId: Id,
|
||||
title: z.string(),
|
||||
year: z.number().int().nullable(),
|
||||
/** 生效的规则行数 */
|
||||
rules: NonNeg,
|
||||
/** 注释掉(停用)的规则行数 */
|
||||
disabled: NonNeg,
|
||||
/** 有错误的行数 */
|
||||
errors: NonNeg,
|
||||
updatedAt: z.string().nullable(),
|
||||
updatedBy: z.string().nullable(),
|
||||
});
|
||||
export type EntityWordsSummary = z.infer<typeof EntityWordsSummary>;
|
||||
|
||||
export const EntityWordsListQuery = z.object({ q: z.string().trim().max(200).optional() });
|
||||
export const EntityWordsList = z.object({ items: z.array(EntityWordsSummary) });
|
||||
export type EntityWordsList = z.infer<typeof EntityWordsList>;
|
||||
|
||||
export const EntityWordsView = z.object({
|
||||
block: IdentifyWordsBlock,
|
||||
title: z.string(),
|
||||
/** 剧集 / 综艺才参与集数换算 */
|
||||
applicable: z.boolean(),
|
||||
/** 这部作品资源里出现过的发布组(填发布组时提示) */
|
||||
knownGroups: z.array(z.string()),
|
||||
});
|
||||
export type EntityWordsView = z.infer<typeof EntityWordsView>;
|
||||
|
||||
export const EntityWordsDryRun = z.object({
|
||||
errors: z.array(WordsLineError),
|
||||
/** 生效的规则数(一行多个发布组算多条) */
|
||||
rules: NonNeg,
|
||||
preview: EpisodeMappingPreview,
|
||||
/** 结果会变的条数;套用后仍对不上季表(待确认)的条数 */
|
||||
changed: NonNeg,
|
||||
mismatched: NonNeg,
|
||||
});
|
||||
export type EntityWordsDryRun = z.infer<typeof EntityWordsDryRun>;
|
||||
|
||||
export const EntityWordsSaveResult = z.object({ block: IdentifyWordsBlock, reresolve: EpisodeReresolveResult });
|
||||
export type EntityWordsSaveResult = z.infer<typeof EntityWordsSaveResult>;
|
||||
|
||||
/** 可视化编辑里一行的实时示例:拿一个标题试这一行 */
|
||||
export const WordsTryInput = z.object({
|
||||
scope: z.enum(["global", "entity"]),
|
||||
/** entity 时必填 */
|
||||
mediaEntityId: Id.optional(),
|
||||
/** 要试的那一行 */
|
||||
line: z.string().max(2000),
|
||||
/** 示例标题;不填就从近期资源里找一条这一行能命中的 */
|
||||
title: z.string().max(1000).optional(),
|
||||
subtitle: z.string().max(1000).nullable().optional(),
|
||||
});
|
||||
export type WordsTryInput = z.infer<typeof WordsTryInput>;
|
||||
|
||||
export const WordsTryResult = z.object({
|
||||
/** 实际用的示例;找不到命中的近期资源时为 null */
|
||||
sample: z.object({ sourceReleaseId: Id.nullable(), title: z.string(), subtitle: z.string().nullable() }).nullable(),
|
||||
/** 这一行有没有命中示例 */
|
||||
matched: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
titleAfter: z.string().nullable(),
|
||||
subtitleAfter: z.string().nullable(),
|
||||
parsed: WordsParsedBrief.nullable(),
|
||||
/** entity:套用这一行后的季集与是否对得上季表 */
|
||||
episode: EpisodeResolutionView.nullable(),
|
||||
validation: z.enum(["pass", "unverified", "fail", "special", "skipped"]).nullable(),
|
||||
});
|
||||
export type WordsTryResult = z.infer<typeof WordsTryResult>;
|
||||
@@ -16,6 +16,7 @@ export * from "./events.ts";
|
||||
export * from "./execution.ts";
|
||||
export * from "./features.ts";
|
||||
export * from "./hooks.ts";
|
||||
export * from "./identify-words.ts";
|
||||
export * from "./identity.ts";
|
||||
export * from "./instances.ts";
|
||||
export * from "./library-versions.ts";
|
||||
|
||||
@@ -57,7 +57,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
Decision: { row: C.Decision },
|
||||
DownloadTask: { row: C.DownloadTask },
|
||||
DownloaderInstance: { row: C.DownloaderInstance },
|
||||
EpisodeMappingRule: { row: C.EpisodeMappingRuleRow },
|
||||
IdentifyWords: { row: C.IdentifyWordsRow },
|
||||
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
|
||||
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
|
||||
IdentityRevision: { row: C.IdentityRevision },
|
||||
@@ -200,11 +200,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
DownloadTaskPauseReason: C.PauseReason,
|
||||
DownloaderInstanceKind: C.DownloaderKind,
|
||||
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
||||
EpisodeMappingRuleKind: C.EpisodeMappingRuleKind,
|
||||
EpisodeMappingRuleSpecial: C.EpisodeSpecialKind,
|
||||
EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus,
|
||||
EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel,
|
||||
EpisodeMappingRuleCreatedByType: C.ActorType,
|
||||
IdentifyWordsUpdatedByType: C.ActorType,
|
||||
ExternalIdentityEntityType: C.ExternalEntityType,
|
||||
ExternalIdentityStatus: C.IdentityStatus,
|
||||
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
|
||||
|
||||
@@ -3,60 +3,37 @@ import {
|
||||
Api,
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingPreviewInput,
|
||||
EpisodeMappingRuleCreate,
|
||||
EpisodeMappingRuleUpdate,
|
||||
EpisodeResolutionView,
|
||||
listEndpoints,
|
||||
} from "../src/index.ts";
|
||||
|
||||
// 集数映射接口(批次 4)的契约:端点登记、写入与预览的入参、解析结果结构。
|
||||
// 集数映射与识别词接口的契约:端点登记、预览入参、解析结果结构。
|
||||
|
||||
describe("集数映射契约", () => {
|
||||
it("端点登记在 Api 注册表:读用 read,写用 organize.execute", () => {
|
||||
it("端点登记在 Api 注册表:读和试算用 read,作品识别词写 organize.execute,全局写 admin", () => {
|
||||
const keys = listEndpoints()
|
||||
.filter((e) => e.path.includes("episode-mapping"))
|
||||
.filter((e) => e.path.includes("episode-mapping") || e.path.includes("identify-words"))
|
||||
.map((e) => `${e.method.toUpperCase()} ${e.path} ${e.scope}`)
|
||||
.sort();
|
||||
expect(keys).toEqual([
|
||||
"DELETE /episode-mapping-rules/{id} organize.execute",
|
||||
"GET /media-entities/{id}/episode-mapping-rules read",
|
||||
"PATCH /episode-mapping-rules/{id} organize.execute",
|
||||
"POST /media-entities/{id}/episode-mapping-rules organize.execute",
|
||||
"GET /identify-words/entities read",
|
||||
"GET /identify-words/global read",
|
||||
"GET /media-entities/{id}/identify-words read",
|
||||
"POST /identify-words/global/dry-run read",
|
||||
"POST /identify-words/try read",
|
||||
"POST /media-entities/{id}/episode-mapping/preview read",
|
||||
"POST /media-entities/{id}/identify-words/dry-run read",
|
||||
"PUT /identify-words/global admin",
|
||||
"PUT /media-entities/{id}/identify-words organize.execute",
|
||||
]);
|
||||
expect(Api.episodeMapping.preview.response).toBe(EpisodeMappingPreview);
|
||||
});
|
||||
|
||||
it("新建:三种规则都接受;季号不能为负,发布组去首尾空白", () => {
|
||||
expect(EpisodeMappingRuleCreate.parse({ kind: "expression", expression: "EP-12", targetSeason: 2 })).toEqual({
|
||||
kind: "expression",
|
||||
expression: "EP-12",
|
||||
targetSeason: 2,
|
||||
});
|
||||
expect(
|
||||
EpisodeMappingRuleCreate.safeParse({
|
||||
season: 1,
|
||||
releaseGroup: "ANi",
|
||||
kind: "table",
|
||||
tableEntries: [{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
EpisodeMappingRuleCreate.parse({ kind: "special", special: "ignore", releaseGroup: " ANi " }).releaseGroup,
|
||||
).toBe("ANi");
|
||||
expect(EpisodeMappingRuleCreate.safeParse({ kind: "special", special: "skip" }).success).toBe(false);
|
||||
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", season: -1 }).success).toBe(false);
|
||||
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", expression: "E".repeat(65) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("修改:全部可选,可只改状态", () => {
|
||||
expect(EpisodeMappingRuleUpdate.parse({ status: "disabled" })).toEqual({ status: "disabled" });
|
||||
expect(EpisodeMappingRuleUpdate.safeParse({ status: "deleted" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("预览入参:草稿可空,条数上限 300", () => {
|
||||
it("预览入参:草稿是整块识别词原文,条数上限 300", () => {
|
||||
expect(EpisodeMappingPreviewInput.parse({})).toEqual({});
|
||||
expect(EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", draft: null }).success).toBe(true);
|
||||
expect(
|
||||
EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", words: "S02 >> S01 EP+24" }).success,
|
||||
).toBe(true);
|
||||
expect(EpisodeMappingPreviewInput.safeParse({ limit: 301 }).success).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compileEntityWords,
|
||||
evalEpisodeExpr,
|
||||
parseEntityWordLine,
|
||||
parseGlobalWordLine,
|
||||
parseGlobalWords,
|
||||
parseIdentityTag,
|
||||
printEntityWord,
|
||||
printGlobalWord,
|
||||
printIdentityTag,
|
||||
pyRegex,
|
||||
pySubn,
|
||||
rowsToText,
|
||||
ruleRowLines,
|
||||
updateRuleRow,
|
||||
type WordsRow,
|
||||
wordsToRows,
|
||||
} from "../src/index.ts";
|
||||
|
||||
// 识别词语法(docs/design/identify-words.md):全局行按 MP __parse_word 拆;作品识别词的解析 / 打印往返、
|
||||
// 编译(范围、优先级、冲突)、可视化行模型与文本往返(注释、停用、顺序、写错的行都不丢)。
|
||||
|
||||
describe("全局识别词(MP 语法)", () => {
|
||||
it("四种写法与注释、空行", () => {
|
||||
expect(parseGlobalWordLine("国语中字")).toMatchObject({ kind: "block", pattern: "国语中字" });
|
||||
expect(parseGlobalWordLine("A => B")).toMatchObject({ kind: "replace", pattern: "A", replacement: "B" });
|
||||
expect(parseGlobalWordLine("测试替换 => ")).toMatchObject({
|
||||
kind: "replace",
|
||||
pattern: "测试替换",
|
||||
replacement: "",
|
||||
});
|
||||
expect(parseGlobalWordLine("第 <> 集 >> EP+1")).toMatchObject({
|
||||
kind: "offset",
|
||||
front: "第",
|
||||
back: "集",
|
||||
offset: "EP+1",
|
||||
});
|
||||
expect(parseGlobalWordLine("旧名 => 新名 && 第 <> 集 >> EP+1")).toMatchObject({
|
||||
kind: "replace_offset",
|
||||
pattern: "旧名",
|
||||
replacement: "新名",
|
||||
front: "第",
|
||||
back: "集",
|
||||
offset: "EP+1",
|
||||
});
|
||||
expect(parseGlobalWordLine("# 动漫").kind).toBe("comment");
|
||||
expect(parseGlobalWordLine(" ").kind).toBe("blank");
|
||||
});
|
||||
|
||||
it("打印与解析往返", () => {
|
||||
for (const t of ["国语中字", "A => B", "第 <> 集 >> EP+1", "旧名 => 新名 && 第 <> 集 >> 2*EP-1"]) {
|
||||
const p = parseGlobalWordLine(t);
|
||||
expect(printGlobalWord(p)).toBe(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("提前报错:正则写错、替换引用了不存在的分组、偏移表达式写错、两个定位词都空", () => {
|
||||
expect(parseGlobalWordLine("([ => X").error).toContain("正则");
|
||||
expect(parseGlobalWordLine("(a) => \\2").error).toContain("分组");
|
||||
expect(parseGlobalWordLine("第 <> 集 >> 2EP").error).toContain("运算符");
|
||||
expect(parseGlobalWordLine(" <> >> EP+1").error).toBeDefined();
|
||||
expect(parseGlobalWords("A => B\n\n# x\nC").map((w) => [w.line, w.kind])).toEqual([
|
||||
[1, "replace"],
|
||||
[2, "blank"],
|
||||
[3, "comment"],
|
||||
[4, "block"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("Python 正则与替换模板", () => {
|
||||
expect(pySubn(pyRegex("(?P<n>\\d+)集"), "第\\g<n>话", "12集")).toEqual(["第12话", 1]);
|
||||
expect(pySubn(pyRegex("(a)(b)"), "\\2\\1", "abab")).toEqual(["baba", 2]);
|
||||
expect(pySubn(pyRegex("(?i)foo"), "bar", "FOO foo")).toEqual(["bar bar", 2]);
|
||||
expect(pySubn(pyRegex("x"), "$1\\\\", "x")).toEqual(["$1\\", 1]);
|
||||
expect(pySubn(pyRegex("\\p{Han}+"), "中", "ab汉字cd")).toEqual(["ab中cd", 1]);
|
||||
});
|
||||
|
||||
it("指定作品标签", () => {
|
||||
const t = parseIdentityTag("一念永恒{[tmdbid=107371;type=tv;s=1;g=abc]}S01");
|
||||
expect(t).toMatchObject({
|
||||
before: "一念永恒",
|
||||
after: "S01",
|
||||
tag: { tmdbId: "107371", type: "tv", season: "1", extra: ["g=abc"] },
|
||||
});
|
||||
expect(printIdentityTag(t!.tag)).toBe("{[tmdbid=107371;type=tv;s=1;g=abc]}");
|
||||
expect(parseIdentityTag("没有标签")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("作品识别词", () => {
|
||||
it("季集换算:目标季可省、EP 单独写 = 集号不变、发布组可多个、任意季写 *", () => {
|
||||
expect(parseEntityWordLine("S02 >> S01 EP+24")).toMatchObject({
|
||||
kind: "map",
|
||||
season: 2,
|
||||
groups: [],
|
||||
targetSeason: 1,
|
||||
expr: "EP+24",
|
||||
});
|
||||
expect(parseEntityWordLine("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
|
||||
kind: "map",
|
||||
season: 3,
|
||||
groups: ["ADWeb", "MWeb"],
|
||||
targetSeason: 1,
|
||||
expr: "EP",
|
||||
});
|
||||
expect(parseEntityWordLine("S01 @Lilith-Raws >> ep - 12")).toMatchObject({ targetSeason: null, expr: "EP-12" });
|
||||
expect(parseEntityWordLine("S02 >> S01")).toMatchObject({ targetSeason: 1, expr: "EP" });
|
||||
expect(parseEntityWordLine("* @Judas >> EP*2-1")).toMatchObject({ season: null, groups: ["Judas"] });
|
||||
expect(parseEntityWordLine('S01 @"Some Group" >> EP')).toMatchObject({ groups: ["Some Group"] });
|
||||
});
|
||||
|
||||
it("单集对照与特别篇", () => {
|
||||
expect(parseEntityWordLine("S01E13 >> S00E01")).toMatchObject({
|
||||
kind: "table",
|
||||
season: 1,
|
||||
from: { start: 13, end: 13 },
|
||||
to: { season: 0, episode: 1 },
|
||||
});
|
||||
expect(parseEntityWordLine("S01E27-E28 @Baha >> S02E02")).toMatchObject({
|
||||
kind: "table",
|
||||
groups: ["Baha"],
|
||||
from: { start: 27, end: 28 },
|
||||
});
|
||||
expect(parseEntityWordLine("E05 >> S00E02")).toMatchObject({ kind: "table", season: null });
|
||||
expect(parseEntityWordLine("S00 @SweetSub >> 特别篇")).toMatchObject({ kind: "special", special: "special" });
|
||||
expect(parseEntityWordLine("S01 >> 花絮")).toMatchObject({ kind: "special", special: "extra" });
|
||||
expect(parseEntityWordLine("S01 >> 忽略")).toMatchObject({ kind: "special", special: "ignore" });
|
||||
});
|
||||
|
||||
it("写错的行给出原因", () => {
|
||||
expect(parseEntityWordLine("S02 S01 EP+1").error).toContain(">>");
|
||||
expect(parseEntityWordLine("Season2 >> EP").error).toContain("不是季号");
|
||||
expect(parseEntityWordLine("S02 ADWeb >> EP").error).toContain("@");
|
||||
expect(parseEntityWordLine("S02 >> S01 2EP").error).toContain("运算符");
|
||||
expect(parseEntityWordLine("S02 >> S01 12").error).toContain("EP");
|
||||
expect(parseEntityWordLine("S01E13 >> 第一集").error).toContain("目标季集");
|
||||
expect(parseEntityWordLine("S01E13-E10 >> S00E01").error).toContain("写反");
|
||||
});
|
||||
|
||||
it("打印与解析往返(规范写法)", () => {
|
||||
for (const t of [
|
||||
"S02 >> S01 EP+24",
|
||||
"S03 @ADWeb @MWeb @黒ネズミたち >> S01 EP",
|
||||
"* @Judas >> EP*2-1",
|
||||
"S01E13 >> S00E01",
|
||||
"S01E27-E28 @Baha >> S02E02",
|
||||
"E05 >> S00E02",
|
||||
"S01 @SweetSub >> 花絮",
|
||||
'S01 @"Some Group" >> EP-1',
|
||||
]) {
|
||||
const p = parseEntityWordLine(t);
|
||||
expect(p.error).toBeUndefined();
|
||||
expect(printEntityWord({ ...p, kind: p.kind, season: p.season ?? null, groups: p.groups ?? [] })).toBe(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("编译:一行多个发布组拆成多条;单集对照多行合成一张表;同范围冲突报错带行号", () => {
|
||||
const c = compileEntityWords(
|
||||
[
|
||||
"# 药屋",
|
||||
"S02 >> S01 EP+24",
|
||||
"S03 >> S01 EP+48",
|
||||
"S03 @ADWeb @MWeb >> S01 EP",
|
||||
"# S03 @Judas >> S01 EP",
|
||||
"S01E13 @Baha >> S00E01",
|
||||
"S01E14 @Baha >> S00E02",
|
||||
"S01E14 @Baha >> S00E03",
|
||||
"S01 @Baha >> 忽略",
|
||||
"s02 >> EP",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(c.rules.map((r) => [r.id, r.season, r.releaseGroup, r.kind, r.line])).toEqual([
|
||||
["L0002", 2, null, "expression", 2],
|
||||
["L0003", 3, null, "expression", 3],
|
||||
["L0004", 3, "ADWeb", "expression", 4],
|
||||
["L0004.1", 3, "MWeb", "expression", 4],
|
||||
["L0006", 1, "Baha", "table", 6],
|
||||
]);
|
||||
expect(c.rules[4]!.table).toEqual([
|
||||
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
|
||||
{ from: { start: 14, end: 14 }, to: { season: 0, episode: 2 } },
|
||||
]);
|
||||
expect(c.errors).toEqual([
|
||||
{ line: 8, message: expect.stringContaining("重叠") },
|
||||
{ line: 9, message: expect.stringContaining("第 6 行已有规则") },
|
||||
{ line: 10, message: expect.stringContaining("第 2 行已有规则") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("集号算术与 MP 一致,EP 必须出现", () => {
|
||||
expect(evalEpisodeExpr("EP+24", 1)).toBe(25);
|
||||
expect(evalEpisodeExpr("ep*2-1", 3)).toBe(5);
|
||||
expect(() => evalEpisodeExpr("5", 1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("可视化行模型 ⇄ 文本", () => {
|
||||
const ENTITY = [
|
||||
"# 药屋少女的呢喃",
|
||||
"",
|
||||
"# 第二季接着第一季编号",
|
||||
"S02 >> S01 EP+24",
|
||||
"S03 >> S01 EP+48",
|
||||
"# S03 @Judas >> S01 EP",
|
||||
"S03 @ADWeb @MWeb >> S01 EP",
|
||||
"这一行写错了",
|
||||
"",
|
||||
"",
|
||||
"# 结尾注释",
|
||||
].join("\n");
|
||||
|
||||
it("原文逐字往返(含结尾换行、连续空行、注释、写错的行)", () => {
|
||||
for (const text of [ENTITY, `${ENTITY}\n`, "", "S02 >> S01 EP+24", "#x\n#y\n"]) {
|
||||
const rows = wordsToRows(text, "entity");
|
||||
expect(rowsToText(rows, text.endsWith("\n"))).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("备注贴在规则上方;注释掉的规则是停用行;写错的行是原文行", () => {
|
||||
const rows = wordsToRows(ENTITY, "entity");
|
||||
expect(rows.map((r) => [r.kind, r.kind === "rule" ? [r.enabled, r.note, r.rule] : r.lines])).toEqual([
|
||||
["comment", ["# 药屋少女的呢喃"]],
|
||||
["blank", [""]],
|
||||
["rule", [true, ["第二季接着第一季编号"], "S02 >> S01 EP+24"]],
|
||||
["rule", [true, [], "S03 >> S01 EP+48"]],
|
||||
["rule", [false, [], "S03 @Judas >> S01 EP"]],
|
||||
["rule", [true, [], "S03 @ADWeb @MWeb >> S01 EP"]],
|
||||
["invalid", ["这一行写错了"]],
|
||||
["blank", ["", ""]],
|
||||
["comment", ["# 结尾注释"]],
|
||||
]);
|
||||
expect((rows[6] as Extract<WordsRow, { kind: "invalid" }>).error).toContain(">>");
|
||||
});
|
||||
|
||||
it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => {
|
||||
const rows = wordsToRows(ENTITY, "entity");
|
||||
const r2 = rows[3] as Extract<WordsRow, { kind: "rule" }>;
|
||||
const r4 = rows[4] as Extract<WordsRow, { kind: "rule" }>;
|
||||
const next = [...rows];
|
||||
next[3] = updateRuleRow(r2, { enabled: false });
|
||||
next[4] = updateRuleRow(r4, { enabled: true, note: ["Judas 接着第一季"] });
|
||||
// 交换两行
|
||||
[next[3], next[4]] = [next[4]!, next[3]!];
|
||||
next.splice(6, 1);
|
||||
expect(rowsToText(next)).toBe(
|
||||
[
|
||||
"# 药屋少女的呢喃",
|
||||
"",
|
||||
"# 第二季接着第一季编号",
|
||||
"S02 >> S01 EP+24",
|
||||
"# Judas 接着第一季",
|
||||
"S03 @Judas >> S01 EP",
|
||||
"# S03 >> S01 EP+48",
|
||||
"S03 @ADWeb @MWeb >> S01 EP",
|
||||
"",
|
||||
"",
|
||||
"# 结尾注释",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(ruleRowLines(["备注"], false, "S01 >> EP")).toEqual(["# 备注", "# S01 >> EP"]);
|
||||
});
|
||||
|
||||
it("全局:带运算符的注释是停用的识别词,其余注释是备注;MP 列表原样往返", () => {
|
||||
const text = ["#动漫", "Fake => 真", "# 旧名 => 新名", "#屏蔽词也行", "国语中字", "([ => 写错"].join("\n");
|
||||
const rows = wordsToRows(text, "global");
|
||||
expect(rows.map((r) => [r.kind, r.kind === "rule" ? r.enabled : null])).toEqual([
|
||||
["rule", true],
|
||||
["rule", false],
|
||||
["rule", true],
|
||||
["invalid", null],
|
||||
]);
|
||||
expect((rows[0] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["动漫"]);
|
||||
expect((rows[2] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["屏蔽词也行"]);
|
||||
expect(rowsToText(rows)).toBe(text);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user