From 7360939308ef2d352e9f7aaa90ce134a5fde8a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:51:37 +0000 Subject: [PATCH 01/46] docs: add local file organization architecture v2 integrated with existing modules Revise the uploaded MediaOrganizer standalone design into a Bakabase-integrated architecture: reuse ThirdParty clients as metadata providers, Enhancer conversion chain for property writes, ResourceSourceLink for identity persistence, BTask hosting, and Comparison/Alias/SpecialText for matching. Adds a concept glossary, a user-defined organize-rule system with a path template DSL, StandardValue-backed type-safe domain fields, franchise/IP grouping for mixed libraries, plan/execute/rollback with resource-consistency coupling, and an M0-M4 roadmap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../local-file-organization-architecture.html | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/local-file-organization-architecture.html diff --git a/docs/local-file-organization-architecture.html b/docs/local-file-organization-architecture.html new file mode 100644 index 000000000..23553ad6e --- /dev/null +++ b/docs/local-file-organization-architecture.html @@ -0,0 +1,273 @@ + + +Bakabase · 自动整理本地文件 · 架构 v2(融合版) +
+

Bakabase · 自动整理本地文件 · 架构 v2

+

在初版「五轴可插拔」骨架之上,与 Bakabase 现有模块体系(ThirdParty / Enhancer / Property / BTask / PathMark / Comparison…)深度融合的落地版设计

+

本页替代初版 architecture_1.html 成为单一来源。初版验证了「同一骨架 + 五个可插拔轴」的核心洞见,本版解决三件事:① 落进 Bakabase 的真实模块与数据模型(不再是独立 sln);② 命名/目录结构从"插件内置"升级为"用户完全自定义的规则系统"③ 域特定字段获得基于 StandardValue 的类型安全。所有引用的类型与路径均已在当前代码库中核实。

+ + +

§0Review 结论:初版 → v2 调整总览

+

初版的骨架(扫描 → 采集证据 → 解析 → 匹配 → 命名/定位 → 计划 → 执行/回滚,五轴插件化)整体成立,予以保留。以下是逐项调整及理由:

+ + + + + + + + + + +
初版设计v2 调整理由
独立 MediaOrganizer.sln,零依赖纯 BCL 内核落地为 Bakabase.Modules.Organizer(+ 域插件子目录),注册进现有 DI / Controller / SignalR / EF 体系Bakabase 已有完整的模块化骨架(Abstractions/Components/Services/Models/Extensions 五段式 + tests/ 每模块一测试工程);独立 sln 会导致双份 HTTP 栈、双份存储、双份任务系统
Provider 装饰器栈:Caching → RateLimit → Retry删除。复用 ThirdParty 模块的 AddBakabaseHttpClient + 各站点 HttpMessageHandler(已带限流/Cookie/代理),响应缓存落在 OrganizeItem.MetadataJsonResourceSourceLink.MetadataJson33 个第三方客户端已按同一套 HTTP 基建实现;再造装饰器是重复建设
轴④ 结构模型 + 轴⑤ 命名模板内置于插件IStructureModel / INamingPolicy移出插件,改为用户完全自定义的「整理规则系统」(§7):规则 = 作用域过滤 + 路径模板 DSL + 冲突策略。插件只提供字段 schema + 默认规则预设用户要求「按 IP>类型>文件」或「平铺 <IP>-<类型>-<文件>」等任意布局;结构写死在插件里做不到完全自由,也无法表达跨域混合合集
ResolvedWork.ExtraDictionary<string,string> 字符串袋类型安全的 DomainFieldBag:字段定义携带 StandardValueType + 建议 PropertyType,typed 读写 + 与资源属性双向转换(§8)Bakabase 已有 9 种 StandardValue + 16 种 PropertyType 的成熟转换体系;IEnhancerTargetDescriptor 已示范同一模式(ValueType + PropertyType 并列)
存储层 IStateStore/IMatchCache/… 内存 ↔ SQLite ↔ Redis 可换直接 BakabaseDbContext 新表(EF 迁移生成),文件类产物入 IAppService.AppDataDirectory单机单用户桌面应用,无 Redis 场景;repo 规则也要求用户数据一律走 AppData
执行 = 纯文件系统 move/rename + journal执行与资源一致性联动:移动时同步更新 Resource.Path、PathMark、媒体库归属;匹配到的元数据经 Enhancer 转换链写入属性并登记 ResourceSourceLink(§10)初版最大盲区:被整理的文件很多已是 Bakabase 资源,裸移动会丢失属性值 / 播放历史 / 标记;这是融合版最重要的增量
触发形态「以后再定」M0 起即定:手动发起 + BTask 一次性任务承载(进度/暂停/取消/冲突键),后期接 FileMover 监听与 Workflow 模块触发器BTask 已提供全部任务基建(含 SignalR 推送到 background-task 页)
复核队列 / override 为独立 Store 接口OrganizeItem.State = NeedsReview + 专页 UI;人工决定持久化为 OrganizeOverride 表,跨轮生效与状态机同表,断点续跑与复核天然一致;BulkModification 的 diff 预览 UI 模式可直接借鉴
去重/归并「放在内核聚类能力里」(未展开)复用 Bakabase.Modules.Comparison(加权规则 + 11 种策略,含文本相似度/归一化对比)作为整理前的去重闸门该模块正是为「识别逻辑重复资源」而建,无需重写
模糊匹配自带 StringSimilarity(Dice 二元组)保留算法,但归一化预处理走 ISpecialTextService(去括号/日期/语言词表),别名走 Alias 模块CJK 场景的清洗词表已在 SpecialText 中沉淀多年,社区词表可继续外挂
+ +

§1概念表初版 HTML 未解释的概念 + v2 新概念,一次说清

+ + + + + + + + + + + +
概念含义对应 Bakabase 概念 / 落点
Domain(域)一类整理场景的封装单位:动画、音乐、同人音声、漫画…。一个域 = 一份「如何识别这类文件、去哪些数据源查、有哪些专属字段」的打包。域 ≠ 媒体库:同一个媒体库(甚至同一个目录)里可以混着多个域的条目,由路由按置信度分流新接口 IOrganizerDomain(§5)。与 MediaType(扩展名分组)相关但不等同:MediaType 是文件形态,Domain 是内容语义
Signal / Evidence(信号/证据)能帮助确定「这是哪部作品」的一切线索:文件名、目录名、RJ/tt 等强编码、内嵌标签(ID3/Vorbis)、伴随文件(ComicInfo.xml / .nfo)、声学指纹、图像 pHash。证据有强弱:强编码 > 指纹 > 内嵌标签 > 文件名新接口 ISignalExtractor;文件名清洗复用 ISpecialTextService,压缩包内嗅探复用 CompressedFileService
状态机各状态Discovered 已扫描发现;Routed 已确定所属域(或确定无域→Skipped);Signaled 证据采集完毕(例如"从文件名里提出了 RJ01017217");Parsed 证据解析为结构化线索(标题/年份/话数/编码);Resolved 已匹配到规范作品(高置信);NeedsReview 低置信/歧义,等人工;Planned 已生成目标路径与操作序列(干跑终点);Applied 已落盘执行;Skipped/Failed 终态OrganizeItem.State 列(§4)。每次状态推进即 checkpoint 落库 → 断点续跑
Provider(数据源)一个外部元数据库的查询能力:按关键词搜、按精确码取、(可选)按指纹取。声明自己的能力(Capabilities),由 Resolver 编排薄适配器 IMetadataProviderAdapter 包装 ThirdParty 模块现有 33 个客户端(§3)
Resolver(匹配器)「怎么确定是哪部作品」的策略:精确码短路 → 指纹 → 跨 Provider 模糊打分 → 跨库合并 → 按置信档分流。绝大多数域直接用默认实现IMatchResolver + DefaultMatchResolver(§6)
Plan / Operation(计划)干跑产物:每个条目的「现路径 → 目标路径」+ 有序原子操作(建目录/改名/移动/合并),含冲突检测结果。只读,不动文件;用户确认后才进入执行OrganizePlan / OrganizeOperation(§10),预览 UI 借鉴 BulkModification 的 diff 页
Journal(日志/账本)执行期逐操作写「意图 → 完成」两条记录,崩溃后可判断执行到哪;回滚 = 逆序回放OrganizeJournalEntry 表(§10)
Override(人工裁定)复核时人做出的决定:钉死某个作品 id(ForcedIdentity)、改标题、跳过。持久化、跨轮生效——同一个文件下次重跑不再问第二遍。这是"越跑越收敛"的核心机制之一OrganizeOverride 表,键 = 条目稳定指纹(路径+大小+内容哈希摘要)
Convergence(收敛)整理是循环而非一次性:每轮自动解决一批(强证据直通)、人工裁定一批(写入 Override)、缓存一批(Provider 响应与匹配结果)。三者叠加使下一轮的待处理集合单调缩小状态机 + Override + ResourceSourceLink.MetadataJson 缓存 + BTask 重跑
Franchise / IP(系列)跨域的作品归属:「物语系列」的动画/漫画/OST/画集属于同一 IP。用于混合合集布局(§9)规范化为一个 Multilevel/SingleChoice 自定义属性 + FranchiseResolver 解析链
Rule / RuleSet(整理规则)v2 新概念:用户定义的「哪些条目(作用域)→ 放到哪、叫什么(路径模板)→ 撞了怎么办(冲突策略)」。规则有序,首个命中生效OrganizeRule / OrganizeRuleSet(§7)
+ +

§2分层架构(Bakabase 融合版)

+
+
触发层 · M0 手动 / M4 自动
整理向导 UI(选根目录 + 规则集 → 预览 → 确认)BTask 一次性任务(fluent builder)FileMover 监听目录(后期接入)Workflow 模块触发器/活动(后期)
+
+
整理内核 · Bakabase.Modules.Organizer(新)
DomainRouter 路由OrganizePipeline 状态机循环Route→Signal→Parse→Match→Plan规则引擎 + 路径模板渲染PlanExecutor 执行/回滚(journal)复核队列 / Override
+
▼ 按 Detector 置信度选中域
+
域插件层 · IOrganizerDomain(每场景一个,同模块内子目录,非独立程序集)
同人音声(M0)动画(M2)漫画(M3)音乐(M3)电影/剧集游戏画集/图集
+
▼ 适配
+
Provider 层 · 复用 Bakabase.Modules.ThirdParty(33 个客户端 + 统一 HTTP 基建)
DLsiteBangumiTmdbExHentaiSteamPixiv…新增:AniList · MusicBrainz/AcoustID · MangaDex(按里程碑)
+
▲▼ 一致性联动(v2 新增的关键层)
+
资源域联动 · 整理不是孤立的文件操作
Resource.Path 同步更新PathMark 同步MediaLibraryV2 归属ResourceSourceLink 身份登记属性写入(走 Enhancer 转换链)Comparison 去重闸门
+
+
存储层 · BakabaseDbContext(EF/SQLite)+ AppData
OrganizeRuleSet / OrganizeRuleOrganizeJob / OrganizeItemOrganizeOverrideOrganizeJournalEntry
+
+

内核域无关、稳定;域插件封装差异;Provider 与存储全部复用现有基建。与初版的分层相比,多出「资源域联动」一层——它是把"文件整理工具"变成"Bakabase 的整理能力"的分水岭。

+ +

§3现有资产复用映射初版概念 ↔ 代码库中已存在的实现

+ + + + + + + + + + + +
初版概念状态Bakabase 现有资产(已核实)
IMetadataProvider(轴③)直接复用src/modules/Bakabase.Modules.ThirdParty/ThirdParties/ 下 33 个客户端(DLsite、Bangumi、Tmdb、ExHentai、Pixiv、Steam、Fanbox、Fantia、各 AV 站…),统一经 AddBakabaseHttpClient + 站点专属 HttpMessageHandler(限流/Cookie/代理已内置)。只需加一层能力声明适配器
匹配→写属性 的转换链直接复用Enhancer 模块:AbstractEnhancer<TTarget,TContext,TOptions> 的 context→target→StandardValue→属性 转换全链路,含选项自动建属性、动态 target。整理器匹配得到的元数据走同一条链写属性,不另造
WorkIdentity 身份持久化扩展复用ResourceSourceLink(Source + SourceKey + MetadataJson + 封面缓存 + 抓取时间)就是「资源 ↔ 外部作品」的既有绑定表;扩展 ResourceSource 枚举(现有 PathMark/Steam/DLsite/ExHentai/Aigc,补 Bangumi/Tmdb/AniList/MusicBrainz…)
引擎宿主(循环/并发/进度)直接复用BTask:一次性 fluent builder(BTaskBuilder.Create(...).ConflictsWith(...).Run(...))、进度/暂停/取消(args.YieldAsync())、冲突键、SignalR 推送、重试策略
模糊匹配预处理直接复用ISpecialTextService(包装符/日期/语言词表/标准化)+ Alias 模块(别名解析)+ ComparisonTextSimilarityStrategy/NormalizedText
去重/归并直接复用Bakabase.Modules.Comparison:加权评分 + 一票否决 + 空值策略 + 11 种策略,本就为"识别逻辑重复的媒体资源"设计
条目过滤(规则作用域)直接复用ResourceSearchFilterGroup(ResourceProfile 的 Search 同款)+ PathFilter(Layer/Regex/FsType/扩展名组);倒排索引已就绪
命名模板引擎泛化复用显示名模板已有 {属性名} 占位 + 包装符空值丢弃语义(ResourceUtils.SplitDisplayNameTemplateIntoSegments,有测试覆盖)。§7 将其泛化为多级路径模板
文件操作原语部分复用FileMover(监听+延迟移动)、FileNameModifier(批量改名操作集)、file-processor 页(手动操作/解压 CompressedFileService)。执行器吸收其原语,补 journal/undo
AI 辅助消歧后期接入Bakabase.Modules.AI(多 Provider、缓存、已有 Enhancer 桥接),M4 用于低置信条目的辅助判定与封面识别
五阶段流水线内核新建状态机、规则引擎、计划/执行/回滚、复核/Override —— Bakabase.Modules.Organizer 的本体,Bakabase 目前没有等价物
+ +

§4内核:状态机、数据表、BTask 宿主

+

4.1 状态机

+
DiscoveredRoutedSignaledParsedResolved/NeedsReviewPlannedAppliedSkipped/Failed
+
  • 每次状态推进即写库(checkpoint)→ 断点续跑:重跑时跳过已 Applied/Skipped,NeedsReview 保留待裁定。
  • 强证据(精确码/指纹)从 Signaled 可直达 Resolved;NeedsReview 经人工裁定(写 Override)后重入队。
  • 干跑(默认)终点是 Planned;Applied 只能由用户确认计划后触发,两者是不同的 BTask。
  • 循环体内逐条目 await args.YieldAsync(),暂停/取消语义与 BTask 规范一致。
+

4.2 数据表(EF 迁移生成,禁手写)

+
OrganizeRuleSet   规则集:Name, TargetRoot(整理目标根), Rules(有序), DefaultCollision, Enabled
+OrganizeRule      规则:RuleSetId, Priority, Scope(过滤 JSON, 复用 ResourceSearchFilterGroup),
+                  DomainId?, PathTemplate, CollisionPolicy
+OrganizeJob       一轮运行:RuleSetId, SourceRoots, Mode(DryRun/Apply), Stats, CreatedAt
+OrganizeItem      条目状态机:JobId, Path, Fingerprint(路径+大小+mtime 摘要), State, DomainId?,
+                  SignalsJson, ParsedJson, MatchJson(候选+得分), MetadataJson(命中作品原始数据),
+                  PlannedPath?, Error?, Attempts
+OrganizeOverride  人工裁定:Fingerprint(键), Kind(ForcedIdentity/ForcedTitle/ForcedDomain/Skip),
+                  PayloadJson, CreatedAt          // 跨 Job 生效 → 收敛
+OrganizeJournalEntry  执行账本:JobId, Seq, Op(Move/Rename/Mkdir/MergeDir), From, To,
+                  Phase(Intent/Done/Undone), At   // 逆序回放 = 回滚
+

Provider 原始响应不单独建缓存表:匹配命中即存 OrganizeItem.MetadataJson;条目落盘登记后转存 ResourceSourceLink.MetadataJson(含 MetadataFetchedAt 时效字段,已有 DownloadExternalCovers 任务消费同表)。同一作品跨条目复用由 Resolver 内存缓存承担(进程内、按 Job 生命周期)。

+

4.3 BTask 宿主

+
await taskManager.Enqueue(BTaskBuilder.Create($"Organize:{job.Id}")
+    .Named(() => localizer.Organize_TaskName(ruleSet.Name))
+    .ConflictsWith("MoveFiles", $"OrganizeRoot:{ruleSet.TargetRoot}")  // 不与 FileMover / 同根任务并跑
+    .Persistent().StartImmediately()
+    .Run(async args => { /* 流水线循环,逐条目 YieldAsync + UpdateTask 进度 */ }));
+ +

§5域插件契约 v2五轴保留,但轴④⑤退位给用户规则

+

初版五轴中,①信号 ②主键 ③数据源仍由插件提供;④结构 ⑤命名改由用户规则系统承载(§7),插件降级为提供字段 schema + 默认规则预设。这样「布局完全自由」与「开箱即用」两者兼得:

+
public interface IOrganizerDomain
+{
+    string DomainId { get; }                                   // "doujin-audio"
+    IDomainDetector Detector { get; }                          // 路由:Detect(item) → 0~100 置信度
+    IReadOnlyList<ISignalExtractor> SignalExtractors { get; }  // 轴① 证据采集
+    ISignalParser Parser { get; }                              // 证据 → 结构化线索(标题/码/话数…)
+    IReadOnlyList<IMetadataProviderAdapter> Providers { get; } // 轴③ ThirdParty 客户端适配
+    IMatchResolver Resolver { get; }                           // 轴② 默认 DefaultMatchResolver
+    DomainFieldDefinition[] Fields { get; }                    // 域特定字段 schema(§8,类型安全)
+    OrganizeRulePreset[] DefaultRulePresets { get; }           // 旧轴④⑤:仅作为用户规则的出厂默认值
+}
+
+public interface IMetadataProviderAdapter
+{
+    string Name { get; }                                       // "DLsite",与 ResourceSource 对齐
+    ProviderCapabilities Capabilities { get; }                 // SupportsExactCode/Fingerprint/Languages/搜索
+    Task<IReadOnlyList<MatchCandidate>> SearchAsync(ProviderQuery q, CancellationToken ct);
+    Task<MatchCandidate?> GetByCodeAsync(string code, CancellationToken ct);
+}
+
  • 插件形态Bakabase.Modules.Organizer/Domains/{DoujinAudio,Anime,…}/ 子目录 + DI 枚举注册(同 Enhancer 的多实现注册模式),不做独立程序集/热插拔——桌面应用无此需求,编译期全量即可。
  • Detector 与混合库:一个目录树里动漫/音声/音乐混放时,路由对每个条目取各域 Detector 最高置信;两域得分接近(差 < 阈值)→ NeedsReview 而非硬选,避免错域整理。
  • Adapter 很薄:例如 DLsite 适配器内部就是现有 DLsiteClient.ParseWorkDetailById;Bangumi 适配器包一层 subject 搜索。新数据源(AniList/MusicBrainz)按 ThirdParty 现有约定新增客户端,Organizer 只见 Adapter。
+ +

§6匹配层与初版一致的骨架 + Bakabase 化的细节

+
  1. 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
  2. 精确码短路:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → 置信 High,跳过模糊与复核。DLsite 增强器现有 [BVR]J\d{6,10} 正则即第一个信号提取器。
  3. 模糊打分:清洗(SpecialText)→ 别名展开(Alias)→ 跨 Provider 搜索 → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。
  4. 跨库合并:不同 Provider 命中同一作品(外链 id 互指或标题+年份强一致)→ 合并补齐语言与字段(AniList 补产地、Bangumi 补中文名)。
  5. 分档:High 自动 Resolved;Medium 可配置(自动过 / 抽样复核);Low/None → NeedsReview。阈值全局默认 + 每规则集可覆盖。
  6. Override 优先:进入匹配前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。
  7. 身份落库:Applied 之后写 ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 调整点 2),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。
+ +

§7用户自定义命名与目录规则回答「完全自由的布局」

+

7.1 模型:规则集 → 规则 → 路径模板

+
OrganizeRuleSet                       // 绑定一个整理目标根目录,如 Z:/Library
+ ├─ OrganizeRule (Priority=1)         // Scope: 域=动画 且 属性[IP]非空
+ │    PathTemplate = "{franchise}/动画/{title:lang(zh-Hans)|title:lang(ja)} [{year}]"
+ ├─ OrganizeRule (Priority=2)         // Scope: 域=同人音声
+ │    PathTemplate = "音声/[{code}] {dlsite.circle} - {title:sanitize:truncate(80)}"
+ └─ OrganizeRule (默认兜底)            // Scope: 空 = 全部;平铺示例 ↓
+      PathTemplate = "{franchise|title}-{domainLabel}-{fileName}"
+
  • Scope(作用域):复用 ResourceSearchFilterGroup(与资源搜索、ResourceProfile.Search 同一模型同一 UI 组件)+ 域限定 + PathFilter(扩展名组/层级/正则)。有序匹配,首个命中生效——与防火墙规则同心智。
  • PathTemplate/ 即目录层级。想要「IP>类型>文件」就写三段;想要平铺就写一段。层级深度、分隔风格完全由模板表达,系统不预设任何结构
  • CollisionPolicy(冲突策略):目标已存在时 → AutoSuffix(追加 [2] / 追加码)/ Merge(并入同目录,适合多版本/多碟)/ Skip / Review。每规则可覆盖规则集默认。
+

7.2 模板 DSL(泛化现有显示名模板引擎)

+
segment      := (literal | placeholder | group)*
+placeholder  := "{" selector (":" formatter)* ("|" alternative)* "}"
+selector     := title | year | code | franchise | domainLabel | fileName
+              | {域字段}  如 dlsite.circle · music.albumArtist · anime.season
+              | prop("属性名")            // 任意 Bakabase 资源属性,含自定义属性
+formatter    := lang(zh-Hans|ja|en|romaji) · pad(n) · date(yyyy) · case(upper|lower)
+              · sanitize · truncate(n)    // sanitize: 非法字符/保留名/结尾点空格清理
+alternative  := 依序回退:{title:lang(zh-Hans)|title:lang(ja)|fileName}
+group        := "[" ... "]"               // 组内任一占位符为空 → 整组丢弃(沿用显示名
+                                             模板"包装符空值丢弃"的既有语义与实现)
+

示例——同一批文件,两种用户规则,两种世界:

+
# 规则 A:按 IP 分层合集
+
Z:/Library/物语系列/动画/化物語 [2009]/…
+
Z:/Library/物语系列/音乐/君の知らない物語/…
+
Z:/Library/物语系列/漫画/化物語 (vol.01-08)/…
+
# 规则 B:平铺 <IP>-<类型>-<文件>
+
Z:/Flat/物语系列-动画-化物語 [2009].mkv
+
Z:/Flat/物语系列-音乐-君の知らない物語.flac
+
  • 渲染管线:占位符求值 → 空值组丢弃 → 逐段 sanitize(Windows 非法字符、保留名、260 长度预算自顶向下分配)→ 拼接目标路径。求值器对域字段走 DomainFieldBag(§8),对 prop() 走现有 StandardValue 显示值处理器——与显示名模板同一取值路径。
  • 实时预览:规则编辑页右侧用抽样条目即时渲染目标树(媒体库模板编辑器的 SamplePaths 模式),保存前所见即所得。
  • 与 ResourceProfile.NameTemplate 的关系:刻意分离。NameTemplate 决定显示名(虚拟层),OrganizeRule 决定物理路径;共享同一模板引擎与语法,但互不影响——用户可以物理平铺 + 显示名分层,反之亦然。
+ +

§8域特定字段的类型安全设计回答「Extra 是否要 type-safe」——要,且不发明第二套类型系统

+

结论:需要。字符串袋在三处会出问题:模板格式化({music.trackNo:pad(2)} 需要知道是数字)、写回资源属性(需要 StandardValue 类型对齐,类型错配会损坏数据)、跨库合并(同字段不同 Provider 的值要能比较)。但类型骨架复用 StandardValueType/PropertyType,与 IEnhancerTargetDescriptor(ValueType + PropertyType 并列)同构:

+
public sealed record DomainFieldDefinition(
+    string Key,                          // "circle";模板中经 "{域id.Key}" 引用
+    StandardValueType ValueType,         // String / ListString / Decimal / DateTime…(9 种之一)
+    PropertyType SuggestedPropertyType,  // 写回属性时的建议类型:SingleLineText / Multilevel / Tags…
+    bool Multilingual = false);          // true → 值为 LocalizedText,模板可 :lang() 选取
+
+public sealed class DomainField<T>(string key, StandardValueType type) { … }
+
+public static class DoujinAudioFields    // 每个域一个静态字段表 → 编译期检查
+{
+    public static readonly DomainField<string>       Circle = new("circle", StandardValueType.String);
+    public static readonly DomainField<List<string>> Cv     = new("cv",     StandardValueType.ListString);
+    public static readonly DomainField<DateTime>     ReleaseDate = new("releaseDate", StandardValueType.DateTime);
+}
+
+public sealed class DomainFieldBag       // ResolvedWork.Extra 的替代者
+{
+    public T? Get<T>(DomainField<T> field);
+    public void Set<T>(DomainField<T> field, T? value);  // 写入校验 ValueType,错配即抛
+    public string SerializeToJson();                     // 持久化进 MetadataJson
+}
+
  • 与属性的双向转换:用户在域设置页把字段映射到资源属性(如 circle → 自定义属性「社团」,缺省按 SuggestedPropertyType 自动建属性——Enhancer target 的 options 自动建属性已是同一模式)。写入走 PropertyValueFactory;类型不同时走 StandardValueSystem.GetConversionRule 的既有转换规则并提示损失。
  • 通用规范字段(title/year/code/franchise/workType)不进袋子,是 MatchedWork 的强类型属性;袋子只装真正域特定的部分。这保持了初版"不污染通用模型"的意图,同时把类型安全做满。
  • 不做的事:不为每个域生成专属 C# record + 反射映射(收益低、迁移成本高);不引入运行时 schema 语言(字段 schema 就是 C# 声明,随插件编译)。
+ +

§9混合库与 IP 合集按 IP 聚合动画/漫画/音乐/画集

+
  1. IP 是规范化属性,不是新实体:整理器把解析出的 franchise 写入一个内置约定的属性(Multilevel「系列/IP」,支持层级如 物语系列/伤物语)。它同时服务于:路径模板 {franchise}、资源搜索/筛选、显示名模板。
  2. FranchiseResolver 解析链(可各域覆盖):
    1. Provider 关系图:Bangumi subject 关联(动画↔漫画↔音乐同 IP)、AniList relations、DLsite 系列字段 —— 强证据;
    2. 别名聚类:Alias 模块 + SpecialText 清洗后的标题聚类 —— 中证据;
    3. 用户裁定:复核页手动归属,写 Override —— 最高优先级。
  3. 跨域一致性:同一 Job 内,不同域的条目解析出的 franchise 经别名表归一(「Monogatari Series」=「物语系列」),确保动画和 OST 落进同一个合集目录。
  4. 混合目录路由:§5 的 Detector 置信度分流保证一个乱堆目录里各条目各归各域;franchise 属性再把它们在目标布局上重新聚拢——分流与聚合解耦,这正是轴④⑤退位给规则系统后才可能的表达。
+ +

§10计划 / 执行 / 回滚与资源一致性v2 的关键增量层

+

10.1 计划(干跑,默认形态)

+
  • 逐条目产出 OrganizeOperation 序列(Mkdir/Rename/Move/MergeDir),含冲突检测(目标存在、同 Job 内目标互撞、源在目标子树内等)与每条目的 NeedsReview 标记。
  • 预览 UI:目标树视图 + 逐条目 diff(BulkModification 预览页同款交互),可单条目排除/改判。
+

10.2 执行(用户确认后的独立 BTask)

+
  1. 逐操作:journal 写 Intent → 执行文件系统操作 → 同事务更新 Resource.Path(含子路径级联)与 PathMark → journal 写 Done。崩溃恢复时按 Intent/Done 差集续跑或回退。
  2. 同卷 rename 与跨卷 copy+delete 区分记录(回滚语义不同);执行前做剩余空间与路径长度预检。
  3. 目标根在媒体库内 → 触发既有同步管线,未入库文件成为新资源;随后写 ResourceSourceLink + 按域字段映射写属性(走 Enhancer 转换链)。整理完成的资源立即带全元数据。
  4. 被覆盖/被合并的旧文件一律走回收站(系统 trash),不做物理删除。
+

10.3 回滚

+
  • UndoAsync(jobId):逆序回放 journal(Done 条目 → 反向操作 → 标记 Undone),同步回滚 Resource.Path。Journal 保留期限可配置(默认 30 天),过期由既有清理任务收割。
+ +

§11对现有模块的调整建议用户允许的架构再优化

+ + + + + + + +
模块调整收益
Enhancer增加 SourceLink 快路径AbstractKeywordEnhancer 构建 context 前先查资源的 ResourceSourceLink(同 Source 且 MetadataJson 未过期)→ 直接用缓存元数据,跳过文件名猜关键词与网络请求整理与增强收敛为一套匹配;被整理器改过名的文件不会因文件名变化而增强失败;省网络配额
FileMover定位为「进料口」:监听目录的目标可指向某个 OrganizeRuleSet(先移入暂存 → 自动触发整理 Job)。长期由 Organizer 的 watcher 模式吸收,FileMover 转纯兼容入口「下载完成 → 自动归位」的完整自动化闭环;避免两套移动逻辑长期并存
FileNameModifier保留为手动工具,不合并。其操作枚举(Case/增删替换)可被模板 formatter 引用复用职责清晰:确定性批量改名 vs 语义化整理
ResourceSource 枚举扩展为与 Provider 名对齐的完整清单(Bangumi/Tmdb/AniList/MusicBrainz…)ResourceSourceLink 成为全域身份登记表
Comparison暴露一个服务级入口供 Organizer 在 Plan 阶段调用(当前面向 UI 会话)移动前发现「目标库里已有同作」→ 提示合并/跳过,而非制造重复
Workflow后期(M4)注册 Organizer 的触发器("整理完成")与活动("对 item 运行规则集"),item 类型走 IWorkflowItemTypeDescriptor订阅→下载→整理→通知 的全链自动化由用户在 Workflow 编辑器里自由编排
ResourceProfile不改。NameTemplate 继续只管显示名;文档与 UI 明确两套模板的分工(§7.2)避免"改显示名模板导致文件被移动"的惊吓
+ +

§12场景差异矩阵标注 Provider 现状:已有 = ThirdParty 已存在客户端,需新增

+ + + + + + + +
场景① 主信号② 主键③ 数据源④⑤ 默认规则预设(用户可改)
同人音声 RJ/VJ/BJ 码DLsite 产品码(精确)DLsite音声/[{code}] {dlsite.circle} - {title}
动画 模糊目录/文件名模糊标题→idBangumi Tmdb AniList{franchise|title}/动画/{title} [{year}]
漫画/本子 模糊目录名+ComicInfoid / 画廊哈希ExHentai Bangumi MangaDex{franchise|title}/漫画/{title} (vol.{vol:pad(2)})
音乐 指纹内嵌标签(+指纹)标签/MBIDMusicBrainz AcoustID(可选){music.albumArtist}/{music.album}/{music.trackNo:pad(2)} {title}
电影/剧集 /模糊文件名(tt/SxxEyy)TMDB/IMDb idTmdb{title} ({year})/{title} SxxEyy
游戏 文件名+appidSteam appid / 序列号Steam IGDB游戏/{platform}/{title}
AV 番号番号(精确)Javbus/Javdb/…(10+ 站){av.code} {title}
+

三种主键原型(码/指纹/模糊)与初版一致;Bakabase 的既有 Provider 覆盖面已足够先做 4 个域而无需任何新客户端。

+ +

§13实施路线图垂直切片、每步可收敛可交付

+ + + + + +
里程碑内容验收 / 收敛指标
M0内核表 + 状态机流水线 + 同人音声域(RJ 码短路,复用 DLsiteClient)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
M1规则集/模板 DSL + 规则编辑器(实时预览目标树)+ 冲突策略 + Resource.Path/PathMark 联动 + ResourceSourceLink 登记与属性写入用户可自由表达分层/平铺布局;已入库资源整理后属性与播放记录零丢失
M2动画域(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
M3漫画 + 音乐域(TagLib 标签信号;MusicBrainz 客户端)+ franchise 解析链 + IP 合集布局 + Comparison 去重闸门动画/漫画/OST/画集按 IP 聚拢的演示库跑通;重复作品在 Plan 期被拦截
M4watcher 自动整理(FileMover 进料口)+ Workflow 触发器/活动 + AI 辅助低置信消歧 + 规则预设分享(Sharable 体系)下载→整理→通知全自动链路;低置信条目 AI 建议采纳率可观测
+

每个里程碑都是可发布的完整功能,不存在「基建全铺完才见效」的阶段。M0 选同人音声是因为:精确码型近零歧义、Provider 已存在、单域即可验证内核全部机制(状态机/计划/journal/复核/续跑)。

+ +

§14风险与未决点

+
    +
  • 高置信错配:精确码也会撞上改名党(文件名里嵌错码)。缓解:Applied 全量 journal 可回滚 + 复核页支持"事后改判 → 自动重整理"。
  • +
  • Windows 路径限制:260 字符与非法字符在深层模板下易触雷。模板渲染期自顶向下做长度预算 + sanitize,超限自动截断并在预览中高亮。
  • +
  • 执行期外部变更:Plan 与 Apply 之间文件可能被移走。Apply 前逐条目校验指纹(大小+mtime),失配 → 该条目回退 NeedsReview,不整批失败。
  • +
  • Provider 风控:批量整理的请求量远超增强器单资源模式。依赖现有各站点 Handler 限流 + Job 级并发上限(每 Provider 独立信号量)+ MetadataJson 缓存优先。
  • +
  • 压缩包语义:音声/漫画大量以压缩包为条目单位。M0 将压缩包视为叶子条目(不解包);解包整理(借 CompressedFileService)列为 M3 未决项。
  • +
  • franchise 权威性:跨库 IP 归属没有全局权威源,别名聚类会有错并。默认仅强证据(Provider 关系图)自动归并,聚类结果一律进复核。
  • +
  • 词表外挂:各域 Parser 的正则/词表是否如初版建议外挂为数据文件并走 Sharable 分享体系——倾向于是,M4 决策。
  • +
+
Bakabase · 自动整理本地文件 · 架构 v2(融合版)· 本页为单一来源,替代初版 architecture_1.html。引用的既有类型/路径均已在当前代码库核实。
+
From 46a517efe0077330b04510ff4bd1b9f19c31cd8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 05:53:56 +0000 Subject: [PATCH 02/46] docs(organizer): decompose domain plugins into component palette + data-driven pipelines Per-layer components (signal extractors, providers, resolver policies) become user-selectable; pipelines are DB-stored configurations with read-only built-in presets that users can fork (copy-on-write). The Domain concept shrinks to a field vocabulary plus a routing/display label. Clarifies the Workflow module relationship: reuse its registry/descriptor/config-form patterns but not its runner; Workflow orchestrates around the organizer (triggers/activities) while the organizer keeps its own slot-based state machine. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../local-file-organization-architecture.html | 103 +++++++++++------- 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/docs/local-file-organization-architecture.html b/docs/local-file-organization-architecture.html index 23553ad6e..b60d76dbf 100644 --- a/docs/local-file-organization-architecture.html +++ b/docs/local-file-organization-architecture.html @@ -51,8 +51,8 @@

Bakabase · 自动整理本地文件 · 架构 v2

在初版「五轴可插拔」骨架之上,与 Bakabase 现有模块体系(ThirdParty / Enhancer / Property / BTask / PathMark / Comparison…)深度融合的落地版设计

-

本页替代初版 architecture_1.html 成为单一来源。初版验证了「同一骨架 + 五个可插拔轴」的核心洞见,本版解决三件事:① 落进 Bakabase 的真实模块与数据模型(不再是独立 sln);② 命名/目录结构从"插件内置"升级为"用户完全自定义的规则系统"③ 域特定字段获得基于 StandardValue 的类型安全。所有引用的类型与路径均已在当前代码库中核实。

- +

本页替代初版 architecture_1.html 成为单一来源。初版验证了「同一骨架 + 五个可插拔轴」的核心洞见,本版解决四件事:① 落进 Bakabase 的真实模块与数据模型(不再是独立 sln);② 命名/目录结构从"插件内置"升级为"用户完全自定义的规则系统"③ 域特定字段获得基于 StandardValue 的类型安全④ 整理能力全面组件化——分层中每层的组件用户可自由选换,内置 Pipeline 覆盖主流场景、可 fork 自定义(§5)。所有引用的类型与路径均已在当前代码库中核实。

+

§0Review 结论:初版 → v2 调整总览

初版的骨架(扫描 → 采集证据 → 解析 → 匹配 → 命名/定位 → 计划 → 执行/回滚,五轴插件化)整体成立,予以保留。以下是逐项调整及理由:

@@ -60,6 +60,7 @@

§0Review 结论:初版 → v2 调整总览 独立 MediaOrganizer.sln,零依赖纯 BCL 内核落地为 Bakabase.Modules.Organizer(+ 域插件子目录),注册进现有 DI / Controller / SignalR / EF 体系Bakabase 已有完整的模块化骨架(Abstractions/Components/Services/Models/Extensions 五段式 + tests/ 每模块一测试工程);独立 sln 会导致双份 HTTP 栈、双份存储、双份任务系统 Provider 装饰器栈:Caching → RateLimit → Retry删除。复用 ThirdParty 模块的 AddBakabaseHttpClient + 各站点 HttpMessageHandler(已带限流/Cookie/代理),响应缓存落在 OrganizeItem.MetadataJsonResourceSourceLink.MetadataJson33 个第三方客户端已按同一套 HTTP 基建实现;再造装饰器是重复建设 轴④ 结构模型 + 轴⑤ 命名模板内置于插件IStructureModel / INamingPolicy移出插件,改为用户完全自定义的「整理规则系统」(§7):规则 = 作用域过滤 + 路径模板 DSL + 冲突策略。插件只提供字段 schema + 默认规则预设用户要求「按 IP>类型>文件」或「平铺 <IP>-<类型>-<文件>」等任意布局;结构写死在插件里做不到完全自由,也无法表达跨域混合合集 +域插件 IDomainPlugin 是密封的五轴打包进一步拆为「组件调色板 + Pipeline 数据」(§5):信号提取器 / Provider / Resolver 策略皆为独立组件,Pipeline 存 DB;内置 Pipeline 只读可 fork,"域"退化为字段词汇表 + 标签密封包摘不掉对个别用户无效甚至有害的能力(如非 RJ 规范的音声库里,RJ 正则误命中 + 精确码短路 = 高置信错配);组件化让"摘掉/换掉某层能力"零代码 ResolvedWork.ExtraDictionary<string,string> 字符串袋类型安全的 DomainFieldBag:字段定义携带 StandardValueType + 建议 PropertyType,typed 读写 + 与资源属性双向转换(§8)Bakabase 已有 9 种 StandardValue + 16 种 PropertyType 的成熟转换体系;IEnhancerTargetDescriptor 已示范同一模式(ValueType + PropertyType 并列) 存储层 IStateStore/IMatchCache/… 内存 ↔ SQLite ↔ Redis 可换直接 BakabaseDbContext 新表(EF 迁移生成),文件类产物入 IAppService.AppDataDirectory单机单用户桌面应用,无 Redis 场景;repo 规则也要求用户数据一律走 AppData 执行 = 纯文件系统 move/rename + journal执行与资源一致性联动:移动时同步更新 Resource.Path、PathMark、媒体库归属;匹配到的元数据经 Enhancer 转换链写入属性并登记 ResourceSourceLink(§10)初版最大盲区:被整理的文件很多已是 Bakabase 资源,裸移动会丢失属性值 / 播放历史 / 标记;这是融合版最重要的增量 @@ -70,7 +71,9 @@

§0Review 结论:初版 → v2 调整总览

§1概念表初版 HTML 未解释的概念 + v2 新概念,一次说清

- + + + @@ -87,8 +90,8 @@

§2分层架构(Bakabase 融合版)

触发层 · M0 手动 / M4 自动
整理向导 UI(选根目录 + 规则集 → 预览 → 确认)BTask 一次性任务(fluent builder)FileMover 监听目录(后期接入)Workflow 模块触发器/活动(后期)
整理内核 · Bakabase.Modules.Organizer(新)
DomainRouter 路由OrganizePipeline 状态机循环Route→Signal→Parse→Match→Plan规则引擎 + 路径模板渲染PlanExecutor 执行/回滚(journal)复核队列 / Override
-
▼ 按 Detector 置信度选中域
-
域插件层 · IOrganizerDomain(每场景一个,同模块内子目录,非独立程序集)
同人音声(M0)动画(M2)漫画(M3)音乐(M3)电影/剧集游戏画集/图集
+
▼ 按作用域绑定与 Detector 置信度选中 Pipeline
+
Pipeline 层 · 组件组合的数据化配置(内置只读可 fork;组件代码注册于 OrganizerComponentRegistry)
内置:同人音声/DLsite(M0)内置:通用音频(M1)内置:动画(M2)内置:漫画 · 音乐(M3)内置:电影/剧集 · 游戏 · AV用户 fork 的自定义 Pipeline
▼ 适配
Provider 层 · 复用 Bakabase.Modules.ThirdParty(33 个客户端 + 统一 HTTP 基建)
DLsiteBangumiTmdbExHentaiSteamPixiv…新增:AniList · MusicBrainz/AcoustID · MangaDex(按里程碑)
▲▼ 一致性联动(v2 新增的关键层)
@@ -96,7 +99,7 @@

§2分层架构(Bakabase 融合版)

存储层 · BakabaseDbContext(EF/SQLite)+ AppData
OrganizeRuleSet / OrganizeRuleOrganizeJob / OrganizeItemOrganizeOverrideOrganizeJournalEntry
-

内核域无关、稳定;域插件封装差异;Provider 与存储全部复用现有基建。与初版的分层相比,多出「资源域联动」一层——它是把"文件整理工具"变成"Bakabase 的整理能力"的分水岭。

+

内核域无关、稳定;场景差异由组件与 Pipeline 配置承载;Provider 与存储全部复用现有基建。与初版的分层相比,多出「资源域联动」一层——它是把"文件整理工具"变成"Bakabase 的整理能力"的分水岭。

§3现有资产复用映射初版概念 ↔ 代码库中已存在的实现

概念含义对应 Bakabase 概念 / 落点
Domain(域)一类整理场景的封装单位:动画、音乐、同人音声、漫画…。一个域 = 一份「如何识别这类文件、去哪些数据源查、有哪些专属字段」的打包。域 ≠ 媒体库:同一个媒体库(甚至同一个目录)里可以混着多个域的条目,由路由按置信度分流新接口 IOrganizerDomain(§5)。与 MediaType(扩展名分组)相关但不等同:MediaType 是文件形态,Domain 是内容语义
Domain(域)一类整理场景的语义标签:动画、音乐、同人音声、漫画…。域 ≠ 媒体库:同一目录可混多个域的条目。v2 中域不再是密封的行为单元——「如何识别、去哪查」由 Pipeline 的组件组合承载,域只保留字段词汇表(§8)与路由/展示标签两个职责字段词汇表 = 随组件的代码声明(§5/§8)。与 MediaType(扩展名分组)相关但不等同:MediaType 是文件形态,Domain 是内容语义
Component(组件)某个槽位契约的一个实现:信号提取器、Provider 适配器、Resolver 策略、Detector…。代码注册,带配置 schema、适用性元数据、产出字段声明;同一组件可在多条 Pipeline 中以不同配置实例化IOrganizerComponentDescriptor + OrganizerComponentRegistry(§5,借鉴 Workflow 模块 ActivityRegistry 模式)
Pipeline(流水线)一条可运行的整理配置:作用域/Detector + 有序信号组件 + Parser + 有序 Provider + Resolver 策略参数 + 字段映射 + 默认规则预设。是数据不是代码;内置 Pipeline 为只读种子,用户可 fork 后增删组件、调参OrganizePipeline 表(§4/§5)
Signal / Evidence(信号/证据)能帮助确定「这是哪部作品」的一切线索:文件名、目录名、RJ/tt 等强编码、内嵌标签(ID3/Vorbis)、伴随文件(ComicInfo.xml / .nfo)、声学指纹、图像 pHash。证据有强弱:强编码 > 指纹 > 内嵌标签 > 文件名新接口 ISignalExtractor;文件名清洗复用 ISpecialTextService,压缩包内嗅探复用 CompressedFileService
状态机各状态Discovered 已扫描发现;Routed 已确定所属域(或确定无域→Skipped);Signaled 证据采集完毕(例如"从文件名里提出了 RJ01017217");Parsed 证据解析为结构化线索(标题/年份/话数/编码);Resolved 已匹配到规范作品(高置信);NeedsReview 低置信/歧义,等人工;Planned 已生成目标路径与操作序列(干跑终点);Applied 已落盘执行;Skipped/Failed 终态OrganizeItem.State 列(§4)。每次状态推进即 checkpoint 落库 → 断点续跑
Provider(数据源)一个外部元数据库的查询能力:按关键词搜、按精确码取、(可选)按指纹取。声明自己的能力(Capabilities),由 Resolver 编排薄适配器 IMetadataProviderAdapter 包装 ThirdParty 模块现有 33 个客户端(§3)
@@ -117,11 +120,14 @@

4.1 状态机

DiscoveredRoutedSignaledParsedResolved/NeedsReviewPlannedAppliedSkipped/Failed
  • 每次状态推进即写库(checkpoint)→ 断点续跑:重跑时跳过已 Applied/Skipped,NeedsReview 保留待裁定。
  • 强证据(精确码/指纹)从 Signaled 可直达 Resolved;NeedsReview 经人工裁定(写 Override)后重入队。
  • 干跑(默认)终点是 Planned;Applied 只能由用户确认计划后触发,两者是不同的 BTask。
  • 循环体内逐条目 await args.YieldAsync(),暂停/取消语义与 BTask 规范一致。

4.2 数据表(EF 迁移生成,禁手写)

-
OrganizeRuleSet   规则集:Name, TargetRoot(整理目标根), Rules(有序), DefaultCollision, Enabled
+
OrganizePipeline  流水线:Name, ForkedFromId?(内置预设来源), ScopeJson(作用域绑定),
+                  ComponentsJson(有序组件实例+各自配置), ResolverConfigJson(阈值/分档),
+                  FieldMappingJson(域字段→属性), Enabled   // 内置 Pipeline 为只读种子数据(§5)
+OrganizeRuleSet   规则集:Name, TargetRoot(整理目标根), Rules(有序), DefaultCollision, Enabled
 OrganizeRule      规则:RuleSetId, Priority, Scope(过滤 JSON, 复用 ResourceSearchFilterGroup),
-                  DomainId?, PathTemplate, CollisionPolicy
+                  PipelineId?, PathTemplate, CollisionPolicy
 OrganizeJob       一轮运行:RuleSetId, SourceRoots, Mode(DryRun/Apply), Stats, CreatedAt
-OrganizeItem      条目状态机:JobId, Path, Fingerprint(路径+大小+mtime 摘要), State, DomainId?,
+OrganizeItem      条目状态机:JobId, Path, Fingerprint(路径+大小+mtime 摘要), State, PipelineId?,
                   SignalsJson, ParsedJson, MatchJson(候选+得分), MetadataJson(命中作品原始数据),
                   PlannedPath?, Error?, Attempts
 OrganizeOverride  人工裁定:Fingerprint(键), Kind(ForcedIdentity/ForcedTitle/ForcedDomain/Skip),
@@ -136,31 +142,52 @@ 

4.3 BTask 宿主

.Persistent().StartImmediately() .Run(async args => { /* 流水线循环,逐条目 YieldAsync + UpdateTask 进度 */ }));
-

§5域插件契约 v2五轴保留,但轴④⑤退位给用户规则

-

初版五轴中,①信号 ②主键 ③数据源仍由插件提供;④结构 ⑤命名改由用户规则系统承载(§7),插件降级为提供字段 schema + 默认规则预设。这样「布局完全自由」与「开箱即用」两者兼得:

-
public interface IOrganizerDomain
+

§5组件与 Pipeline域插件契约的终局形态:骨架固定,槽内自由

+

初版把五轴密封在 IDomainPlugin 里;v2 初稿先把轴④⑤移交用户规则(§7);本节走完最后一步——轴①②③也组件化,用户可自由选择分层中每一层用到的组件。驱动用例:并非所有同人音声库都遵循 RJ/DLsite 规范,对这类用户,RJ 提取器与 DLsite Provider 不仅无效,还会在随机数字上误命中、并借"精确码短路"生成高置信错配——密封的域插件摘不掉这块能力,组件化可以。

+

5.1 模型:组件(代码)+ Pipeline(数据)

+
// 组件:每个槽位契约的一个实现,注册进 OrganizerComponentRegistry
+//(注册表 / 描述符 / 配置表单三件套借鉴 Workflow 模块的 ActivityRegistry 模式)
+public interface IOrganizerComponentDescriptor
 {
-    string DomainId { get; }                                   // "doujin-audio"
-    IDomainDetector Detector { get; }                          // 路由:Detect(item) → 0~100 置信度
-    IReadOnlyList<ISignalExtractor> SignalExtractors { get; }  // 轴① 证据采集
-    ISignalParser Parser { get; }                              // 证据 → 结构化线索(标题/码/话数…)
-    IReadOnlyList<IMetadataProviderAdapter> Providers { get; } // 轴③ ThirdParty 客户端适配
-    IMatchResolver Resolver { get; }                           // 轴② 默认 DefaultMatchResolver
-    DomainFieldDefinition[] Fields { get; }                    // 域特定字段 schema(§8,类型安全)
-    OrganizeRulePreset[] DefaultRulePresets { get; }           // 旧轴④⑤:仅作为用户规则的出厂默认值
+    string Kind { get; }                          // "signal.rj-code" / "provider.dlsite" / "resolver.fuzzy"
+    OrganizerSlot Slot { get; }                   // Detector / Signal / Parser / Provider / ResolverPolicy
+    string DisplayName { get; }
+    Type? ConfigType { get; }                     // 配置 schema,编辑器据此渲染表单
+    ComponentApplicability Applicability { get; } // 适用形态:音频/压缩包/任意…(编辑器校验)
+    DomainFieldDefinition[] Fields { get; }       // 该组件产出的域字段(§8)→ 字段随组件走
 }
 
-public interface IMetadataProviderAdapter
+public interface IMetadataProviderAdapter         // Provider 槽的组件契约(包装 ThirdParty 客户端)
 {
-    string Name { get; }                                       // "DLsite",与 ResourceSource 对齐
-    ProviderCapabilities Capabilities { get; }                 // SupportsExactCode/Fingerprint/Languages/搜索
+    string Name { get; }                          // "DLsite",与 ResourceSource 对齐
+    ProviderCapabilities Capabilities { get; }    // SupportsExactCode/Fingerprint/Languages/搜索
     Task<IReadOnlyList<MatchCandidate>> SearchAsync(ProviderQuery q, CancellationToken ct);
     Task<MatchCandidate?> GetByCodeAsync(string code, CancellationToken ct);
-}
-
  • 插件形态Bakabase.Modules.Organizer/Domains/{DoujinAudio,Anime,…}/ 子目录 + DI 枚举注册(同 Enhancer 的多实现注册模式),不做独立程序集/热插拔——桌面应用无此需求,编译期全量即可。
  • Detector 与混合库:一个目录树里动漫/音声/音乐混放时,路由对每个条目取各域 Detector 最高置信;两域得分接近(差 < 阈值)→ NeedsReview 而非硬选,避免错域整理。
  • Adapter 很薄:例如 DLsite 适配器内部就是现有 DLsiteClient.ParseWorkDetailById;Bangumi 适配器包一层 subject 搜索。新数据源(AniList/MusicBrainz)按 ThirdParty 现有约定新增客户端,Organizer 只见 Adapter。
+} + +// Pipeline 是数据(OrganizePipeline 表,§4),不是代码: +// 作用域/Detector + 有序 Signal 组件实例(各带配置) + Parser + 有序 Provider 实例 +// + Resolver 策略参数(阈值/分档) + 域字段→属性映射 + 默认规则预设绑定
+
    +
  • 骨架固定,槽内自由:Route→Signal→Parse→Match→Plan 的阶段顺序不可重排,用户的自由是"每个槽用哪些组件、什么顺序、什么参数"。非法组合(如先 Match 后 Signal)从模型上无法表达,编辑器因此是「勾选 + 排序 + 调参」,不是连线画布。
  • +
  • 内置 Pipeline 可 fork:内置 Pipeline 为只读种子数据,覆盖主流场景(§12 每行一条);不满足时 fork 成用户 Pipeline(copy-on-write)再增删组件、调参。内置预设升级后,fork 收到"上游已更新"提示,不被强制同步。
  • +
  • 路由:显式作用域绑定("这个目录用这条 Pipeline")优先于 Detector 置信度竞争;多条 Pipeline 得分接近(差 < 阈值)→ NeedsReview 而非硬选,避免错管线整理。
  • +
  • 字段随组件走:Pipeline 可用的域字段 = 其组件 Fields 的并集。模板编辑器据此提示占位符——{dlsite.circle} 只在含 DLsite Provider 的 Pipeline 里可用,与 §8 的类型安全形成闭环。
  • +
  • "域"的残余:不再有密封的 IOrganizerDomain 接口。域退化为:字段词汇表(随组件的代码声明,§8)+ 路由/展示/统计用的标签。
  • +
  • 代码形态:组件实现位于 Bakabase.Modules.Organizer/Components/{Detectors,Signals,Parsers,Providers,Resolvers}/,DI 枚举注册(同 Enhancer 多实现模式);不做独立程序集/热插拔——桌面应用无此需求。Adapter 很薄:DLsite 适配器内部就是现有 DLsiteClient.ParseWorkDetailById,新数据源按 ThirdParty 现有约定新增客户端。
  • +
  • 驱动用例的解法:非 RJ 规范的音声库 → 把目录作用域绑到内置「通用音频」Pipeline(文件名清洗 + 内嵌标签信号,无 RJ/DLsite 组件),或 fork 音声 Pipeline 摘掉这两个组件——零代码,误命中的反效果彻底消失。
  • +
+

5.2 与 Workflow 模块的关系:复用模式,不复用引擎

+

Pipeline 高度动态,自然的问题是要不要直接跑在现有 Workflow 模块(typed-item 线性链 + Runner + Rehydrator)上。结论:不作为执行引擎,三个结构性错配:

+
    +
  • 一次性流过 vs 跨轮状态机:Workflow 的 item 在单次 run 内单向流过链条,结局是 Keep/Drop/Replace;整理条目需要跨 run 的持久状态——NeedsReview 停车等人(可能数日)、裁定后重入队、Override 跨轮生效、断点续跑。收敛闭环恰恰长在这些机制上。
  • +
  • 逐条独立 vs 聚合阶段:Plan 期必须跨条目——同 Job 目标撞车检测、Comparison 去重闸门、franchise 聚拢;per-item 链没有聚合语义。
  • +
  • 直通执行 vs 两阶段确认:干跑→人工确认→执行→journal/回滚 的安全模型在 Workflow 中不存在。另外,把 Evidence/ParsedClues/MatchCandidate 等强类型阶段契约压进 item-type 字符串标签 + configJson,会拆掉 §8 刚建立的类型安全。
  • +
+

正确的分工:Workflow 管「什么时候整理、整理完干什么」——注册"运行某 Pipeline / 规则集"活动与"整理完成"触发器(§11),供用户在 Workflow 编辑器里编排 订阅→下载→整理→通知 的跨功能链路;Organizer Pipeline 管「怎么整理」,内部仍是 §4 的槽式状态机。复用的是模式:组件注册表、descriptor 驱动的配置表单、运行统计展示等借鉴或直接共用 Workflow 的前端实现。descriptor/配置 schema 是否抽成两模块共享的抽象——暂不抽,待 Organizer 第一版落地、看到真实重复再决定。

§6匹配层与初版一致的骨架 + Bakabase 化的细节

-
  1. 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
  2. 精确码短路:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → 置信 High,跳过模糊与复核。DLsite 增强器现有 [BVR]J\d{6,10} 正则即第一个信号提取器。
  3. 模糊打分:清洗(SpecialText)→ 别名展开(Alias)→ 跨 Provider 搜索 → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。
  4. 跨库合并:不同 Provider 命中同一作品(外链 id 互指或标题+年份强一致)→ 合并补齐语言与字段(AniList 补产地、Bangumi 补中文名)。
  5. 分档:High 自动 Resolved;Medium 可配置(自动过 / 抽样复核);Low/None → NeedsReview。阈值全局默认 + 每规则集可覆盖。
  6. Override 优先:进入匹配前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。
  7. 身份落库:Applied 之后写 ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 调整点 2),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。
+
  1. 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
  2. 精确码短路:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → 置信 High,跳过模糊与复核。DLsite 增强器现有 [BVR]J\d{6,10} 正则即第一个信号提取器。
  3. 模糊打分:清洗(SpecialText)→ 别名展开(Alias)→ 跨 Provider 搜索 → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。
  4. 跨库合并:不同 Provider 命中同一作品(外链 id 互指或标题+年份强一致)→ 合并补齐语言与字段(AniList 补产地、Bangumi 补中文名)。
  5. 分档:High 自动 Resolved;Medium 可配置(自动过 / 抽样复核);Low/None → NeedsReview。阈值属于 Pipeline 的 Resolver 策略配置(内置预设给默认值,fork 可调)。
  6. Override 优先:进入匹配前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。
  7. 身份落库:Applied 之后写 ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 调整点 2),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。

§7用户自定义命名与目录规则回答「完全自由的布局」

7.1 模型:规则集 → 规则 → 路径模板

@@ -191,7 +218,7 @@

7.2 模板 DSL(泛化现有显示名模板引擎)

# 规则 B:平铺 <IP>-<类型>-<文件>
Z:/Flat/物语系列-动画-化物語 [2009].mkv
Z:/Flat/物语系列-音乐-君の知らない物語.flac
-
  • 渲染管线:占位符求值 → 空值组丢弃 → 逐段 sanitize(Windows 非法字符、保留名、260 长度预算自顶向下分配)→ 拼接目标路径。求值器对域字段走 DomainFieldBag(§8),对 prop() 走现有 StandardValue 显示值处理器——与显示名模板同一取值路径。
  • 实时预览:规则编辑页右侧用抽样条目即时渲染目标树(媒体库模板编辑器的 SamplePaths 模式),保存前所见即所得。
  • 与 ResourceProfile.NameTemplate 的关系:刻意分离。NameTemplate 决定显示名(虚拟层),OrganizeRule 决定物理路径;共享同一模板引擎与语法,但互不影响——用户可以物理平铺 + 显示名分层,反之亦然。
+
  • 渲染管线:占位符求值 → 空值组丢弃 → 逐段 sanitize(Windows 非法字符、保留名、260 长度预算自顶向下分配)→ 拼接目标路径。求值器对域字段走 DomainFieldBag(§8),对 prop() 走现有 StandardValue 显示值处理器——与显示名模板同一取值路径。
  • 实时预览:规则编辑页右侧用抽样条目即时渲染目标树(媒体库模板编辑器的 SamplePaths 模式),保存前所见即所得。
  • 占位符可用性随 Pipeline:域字段占位符按作用域内可能命中的 Pipeline 组件集合提示与校验(§5"字段随组件走");引用了不可用字段的模板在预览中高亮,运行时按 | 回退链降级。
  • 与 ResourceProfile.NameTemplate 的关系:刻意分离。NameTemplate 决定显示名(虚拟层),OrganizeRule 决定物理路径;共享同一模板引擎与语法,但互不影响——用户可以物理平铺 + 显示名分层,反之亦然。

§8域特定字段的类型安全设计回答「Extra 是否要 type-safe」——要,且不发明第二套类型系统

结论:需要。字符串袋在三处会出问题:模板格式化({music.trackNo:pad(2)} 需要知道是数字)、写回资源属性(需要 StandardValue 类型对齐,类型错配会损坏数据)、跨库合并(同字段不同 Provider 的值要能比较)。但类型骨架复用 StandardValueType/PropertyType,与 IEnhancerTargetDescriptor(ValueType + PropertyType 并列)同构:

@@ -219,7 +246,7 @@

§8域特定字段的类型安全设计
  • 与属性的双向转换:用户在域设置页把字段映射到资源属性(如 circle → 自定义属性「社团」,缺省按 SuggestedPropertyType 自动建属性——Enhancer target 的 options 自动建属性已是同一模式)。写入走 PropertyValueFactory;类型不同时走 StandardValueSystem.GetConversionRule 的既有转换规则并提示损失。
  • 通用规范字段(title/year/code/franchise/workType)不进袋子,是 MatchedWork 的强类型属性;袋子只装真正域特定的部分。这保持了初版"不污染通用模型"的意图,同时把类型安全做满。
  • 不做的事:不为每个域生成专属 C# record + 反射映射(收益低、迁移成本高);不引入运行时 schema 语言(字段 schema 就是 C# 声明,随插件编译)。
  • §9混合库与 IP 合集按 IP 聚合动画/漫画/音乐/画集

    -
    1. IP 是规范化属性,不是新实体:整理器把解析出的 franchise 写入一个内置约定的属性(Multilevel「系列/IP」,支持层级如 物语系列/伤物语)。它同时服务于:路径模板 {franchise}、资源搜索/筛选、显示名模板。
    2. FranchiseResolver 解析链(可各域覆盖):
      1. Provider 关系图:Bangumi subject 关联(动画↔漫画↔音乐同 IP)、AniList relations、DLsite 系列字段 —— 强证据;
      2. 别名聚类:Alias 模块 + SpecialText 清洗后的标题聚类 —— 中证据;
      3. 用户裁定:复核页手动归属,写 Override —— 最高优先级。
    3. 跨域一致性:同一 Job 内,不同域的条目解析出的 franchise 经别名表归一(「Monogatari Series」=「物语系列」),确保动画和 OST 落进同一个合集目录。
    4. 混合目录路由:§5 的 Detector 置信度分流保证一个乱堆目录里各条目各归各域;franchise 属性再把它们在目标布局上重新聚拢——分流与聚合解耦,这正是轴④⑤退位给规则系统后才可能的表达。
    +
    1. IP 是规范化属性,不是新实体:整理器把解析出的 franchise 写入一个内置约定的属性(Multilevel「系列/IP」,支持层级如 物语系列/伤物语)。它同时服务于:路径模板 {franchise}、资源搜索/筛选、显示名模板。
    2. FranchiseResolver 解析链(可各域覆盖):
      1. Provider 关系图:Bangumi subject 关联(动画↔漫画↔音乐同 IP)、AniList relations、DLsite 系列字段 —— 强证据;
      2. 别名聚类:Alias 模块 + SpecialText 清洗后的标题聚类 —— 中证据;
      3. 用户裁定:复核页手动归属,写 Override —— 最高优先级。
    3. 跨域一致性:同一 Job 内,不同域的条目解析出的 franchise 经别名表归一(「Monogatari Series」=「物语系列」),确保动画和 OST 落进同一个合集目录。
    4. 混合目录路由:§5 的作用域绑定 + Detector 置信度分流保证一个乱堆目录里各条目各归其 Pipeline;franchise 属性再把它们在目标布局上重新聚拢——分流与聚合解耦,这正是轴④⑤退位给规则系统后才可能的表达。

    §10计划 / 执行 / 回滚与资源一致性v2 的关键增量层

    10.1 计划(干跑,默认形态)

    @@ -236,26 +263,27 @@

    §11对现有模块的调整建议

    - +
    初版概念状态Bakabase 现有资产(已核实)
    FileNameModifier保留为手动工具,不合并。其操作枚举(Case/增删替换)可被模板 formatter 引用复用职责清晰:确定性批量改名 vs 语义化整理
    ResourceSource 枚举扩展为与 Provider 名对齐的完整清单(Bangumi/Tmdb/AniList/MusicBrainz…)ResourceSourceLink 成为全域身份登记表
    Comparison暴露一个服务级入口供 Organizer 在 Plan 阶段调用(当前面向 UI 会话)移动前发现「目标库里已有同作」→ 提示合并/跳过,而非制造重复
    Workflow后期(M4)注册 Organizer 的触发器("整理完成")与活动("对 item 运行规则集"),item 类型走 IWorkflowItemTypeDescriptor订阅→下载→整理→通知 的全链自动化由用户在 Workflow 编辑器里自由编排
    Workflow复用模式,不复用引擎(§5.2):Organizer 不跑在 WorkflowRunner 上;组件注册表、descriptor 配置表单、运行统计等机制借鉴或共用其前端实现。M4 注册触发器("整理完成")与活动("运行某 Pipeline / 规则集"),item 类型走 IWorkflowItemTypeDescriptor外层自动化(订阅→下载→整理→通知)由用户在 Workflow 编辑器编排;整理内核保持强类型状态机与两阶段安全模型
    ResourceProfile不改。NameTemplate 继续只管显示名;文档与 UI 明确两套模板的分工(§7.2)避免"改显示名模板导致文件被移动"的惊吓

    §12场景差异矩阵标注 Provider 现状:已有 = ThirdParty 已存在客户端,需新增

    - - +
    场景① 主信号② 主键③ 数据源④⑤ 默认规则预设(用户可改)
    同人音声 RJ/VJ/BJ 码DLsite 产品码(精确)DLsite音声/[{code}] {dlsite.circle} - {title}
    + +
    内置 Pipeline① 主信号② 主键③ 数据源④⑤ 默认规则预设(用户可改)
    同人音声/DLsite RJ/VJ/BJ 码DLsite 产品码(精确)DLsite音声/[{code}] {dlsite.circle} - {title}
    通用音频(非 RJ 规范音声等)模糊内嵌标签+文件名标签/模糊(可无外部源)(可空)MusicBrainz 可选音声/{music.albumArtist|title}/{title}
    动画 模糊目录/文件名模糊标题→idBangumi Tmdb AniList{franchise|title}/动画/{title} [{year}]
    漫画/本子 模糊目录名+ComicInfoid / 画廊哈希ExHentai Bangumi MangaDex{franchise|title}/漫画/{title} (vol.{vol:pad(2)})
    音乐 指纹内嵌标签(+指纹)标签/MBIDMusicBrainz AcoustID(可选){music.albumArtist}/{music.album}/{music.trackNo:pad(2)} {title}
    电影/剧集 /模糊文件名(tt/SxxEyy)TMDB/IMDb idTmdb{title} ({year})/{title} SxxEyy
    游戏 文件名+appidSteam appid / 序列号Steam IGDB游戏/{platform}/{title}
    AV 番号番号(精确)Javbus/Javdb/…(10+ 站){av.code} {title}
    -

    三种主键原型(码/指纹/模糊)与初版一致;Bakabase 的既有 Provider 覆盖面已足够先做 4 个域而无需任何新客户端。

    +

    三种主键原型(码/指纹/模糊)与初版一致;每行即一条内置 Pipeline(只读种子,可 fork 调整组件,§5)。Bakabase 的既有 Provider 覆盖面已足够先做 4 条内置 Pipeline 而无需任何新客户端。

    §13实施路线图垂直切片、每步可收敛可交付

    - - - - + + + +
    里程碑内容验收 / 收敛指标
    M0内核表 + 状态机流水线 + 同人音声域(RJ 码短路,复用 DLsiteClient)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
    M1规则集/模板 DSL + 规则编辑器(实时预览目标树)+ 冲突策略 + Resource.Path/PathMark 联动 + ResourceSourceLink 登记与属性写入用户可自由表达分层/平铺布局;已入库资源整理后属性与播放记录零丢失
    M2动画域(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
    M3漫画 + 音乐域(TagLib 标签信号;MusicBrainz 客户端)+ franchise 解析链 + IP 合集布局 + Comparison 去重闸门动画/漫画/OST/画集按 IP 聚拢的演示库跑通;重复作品在 Plan 期被拦截
    M0内核表(含 OrganizePipeline)+ 状态机流水线 + 组件注册表 + 内置「同人音声/DLsite」Pipeline(RJ 码短路,复用 DLsiteClient;纯种子数据,暂无编辑器)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
    M1规则集/模板 DSL + 规则编辑器(实时预览目标树)+ 冲突策略 + Resource.Path/PathMark 联动 + ResourceSourceLink 登记与属性写入 + Pipeline fork 与最小编辑器(组件开关/排序/阈值)+ 内置「通用音频」预设用户可自由表达分层/平铺布局;已入库资源整理后属性与播放记录零丢失;非 RJ 规范用户可零代码摘除 DLsite 能力
    M2内置动画 Pipeline(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径 + Pipeline 编辑器完整版(适用性校验/上游更新提示)复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
    M3内置漫画 / 音乐 Pipeline(TagLib 标签信号;MusicBrainz 客户端)+ franchise 解析链 + IP 合集布局 + Comparison 去重闸门动画/漫画/OST/画集按 IP 聚拢的演示库跑通;重复作品在 Plan 期被拦截
    M4watcher 自动整理(FileMover 进料口)+ Workflow 触发器/活动 + AI 辅助低置信消歧 + 规则预设分享(Sharable 体系)下载→整理→通知全自动链路;低置信条目 AI 建议采纳率可观测

    每个里程碑都是可发布的完整功能,不存在「基建全铺完才见效」的阶段。M0 选同人音声是因为:精确码型近零歧义、Provider 已存在、单域即可验证内核全部机制(状态机/计划/journal/复核/续跑)。

    @@ -267,7 +295,8 @@

    §14风险与未决点

  • Provider 风控:批量整理的请求量远超增强器单资源模式。依赖现有各站点 Handler 限流 + Job 级并发上限(每 Provider 独立信号量)+ MetadataJson 缓存优先。
  • 压缩包语义:音声/漫画大量以压缩包为条目单位。M0 将压缩包视为叶子条目(不解包);解包整理(借 CompressedFileService)列为 M3 未决项。
  • franchise 权威性:跨库 IP 归属没有全局权威源,别名聚类会有错并。默认仅强证据(Provider 关系图)自动归并,聚类结果一律进复核。
  • -
  • 词表外挂:各域 Parser 的正则/词表是否如初版建议外挂为数据文件并走 Sharable 分享体系——倾向于是,M4 决策。
  • +
  • 内置预设与 fork 漂移:内置 Pipeline 迭代后用户 fork 不自动跟进,可能长期落后甚至引用已废弃组件。缓解:fork 记录 ForkedFromId,上游变更时提示 diff;组件实例支持"重置为上游默认";废弃组件保留兼容运行并在编辑器中标记。
  • +
  • 词表外挂:各 Parser 组件的正则/词表是否如初版建议外挂为数据文件并走 Sharable 分享体系(Pipeline 本身也可分享)——倾向于是,M4 决策。
  • Bakabase · 自动整理本地文件 · 架构 v2(融合版)· 本页为单一来源,替代初版 architecture_1.html。引用的既有类型/路径均已在当前代码库核实。
    From e939e4035d50bb0cd5982dec2d040f546c49b552 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 07:23:01 +0000 Subject: [PATCH 03/46] docs(organizer): add built-in pipeline composition appendix (12.1) Slot-by-slot comparison of the 8 built-in pipelines (signal chain, provider chain, resolver chain, field vocabulary), the thin detector slot, the four axes that actually differentiate them, and the component-reuse payoff. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../local-file-organization-architecture.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/local-file-organization-architecture.html b/docs/local-file-organization-architecture.html index b60d76dbf..3af7fc61f 100644 --- a/docs/local-file-organization-architecture.html +++ b/docs/local-file-organization-architecture.html @@ -277,6 +277,26 @@

    §12场景差异矩阵标 游戏 文件名+appidSteam appid / 序列号Steam IGDB游戏/{platform}/{title} AV 番号番号(精确)Javbus/Javdb/…(10+ 站){av.code} {title}

    三种主键原型(码/指纹/模糊)与初版一致;每行即一条内置 Pipeline(只读种子,可 fork 调整组件,§5)。Bakabase 的既有 Provider 覆盖面已足够先做 4 条内置 Pipeline 而无需任何新客户端。

    +

    12.1 内置 Pipeline 组件构成 统一骨架下的槽位填充对照(Detector → Signal 链 → Parser → Provider 链 → Resolver 链)

    +

    组件以 kind 标注;同名组件 = 同一份代码、不同配置实例。上表给出布局预设,本表给出各槽位的具体填充:

    + + + + + + + + +
    内置 PipelineSignal 链(有序 = 证据强度序)Provider 链(有序)Resolver 链特有字段(决定模板占位符)
    同人音声/DLsitesignal.rj-code → signal.filenameprovider.dlsiteexact-code → fuzzy高阈值,Medium 全复核dlsite.circle/cv/releaseDate
    通用音频signal.embedded-tag → signal.filename(空),可选挂 MusicBrainztag-first不联网:标签→清洗文件名定名;缺标签→复核music.albumArtist/album/trackNo
    动画signal.filename字幕组词表清洗 → signal.nfoprovider.bangumi → provider.tmdb(→ anilist)fuzzyAlias 展开+Dice+年份加权 → merge跨库补语言anime.season/episode/subGroup/origin
    漫画/本子signal.comicinfo → signal.gallery-token[作者] 题名 [汉化组] 语法 → signal.filenameprovider.exhentai → provider.bangumi(→ mangadex)fuzzy括号语法定制权重manga.author/volume/translator/parody
    音乐signal.embedded-tag → signal.filename(→ 可选 signal.acoustid)provider.musicbrainzexact-code标签内 MBID → fingerprint → fuzzy通用音频字段 + music.mbid/discNo
    电影/剧集signal.imdb-codett → signal.sxxeyy → signal.filenameprovider.tmdbexact-code → fuzzyvideo.season/episode/edition
    游戏signal.steam-appid → signal.filenameprovider.steam(→ igdb)exact-code → fuzzygame.platform/developer
    AVsignal.av-code番号provider.javbus → provider.javdb → …现有 10+ 客户端依序 fallback仅 exact-code无码→复核,不做模糊av.code/actress/studio
    +

    Detector 槽未列入表中,因为都很薄:detector.extension(扩展名组)是共同底座,个别管线叠加特征加分项(音声叠"路径含 RJ 码"、动画叠"[字幕组] 括号风格"、漫画叠"图片序列目录/压缩包")。同人音声/DLsite 与通用音频对同一个音频文件竞争时:有码前者胜出,无码后者接住——非 RJ 规范用户的默认行为开箱即正确,连 fork 都不需要。

    +

    真正把八条管线区分开的是四条线:

    +
      +
    • Resolver 姿态是个光谱:AV 是"仅精确码,宁可复核不可猜"(该域模糊匹配风险过高);音声/电影/游戏/音乐是"精确码短路 → 模糊兜底";动画/漫画是"纯模糊 + 打分分档";通用音频干脆不做外部匹配——它证明 Provider 槽可以为空,整理不必然等于刮削。
    • +
    • 主信号来源不同:码(音声/AV/电影/游戏)、内嵌标签(音频/音乐)、伴随文件(漫画 ComicInfo、影视 nfo)、纯文件名(动画)。Signal 链的顺序就是证据强度的顺序。
    • +
    • 字段词汇表随组件走:模板里能写 {dlsite.circle} 还是 {manga.translator},完全由该 Pipeline 装了哪些组件决定(§5"字段随组件走")。
    • +
    • 复核阈值不同:音声标题短、模糊不可靠 → Medium 档全复核;动画标题信息量大 → Medium 可配置为抽查。
    • +
    +

    组件复用收益signal.filename(SpecialText 清洗)出现在全部 8 条中;resolver.fuzzy 一份代码五处配置;signal.embedded-tag 同时服务通用音频与音乐——通用音频本质上就是"音乐 Pipeline fork 掉 Provider"的形态,反向验证了 fork 模型的合理性。

    §13实施路线图垂直切片、每步可收敛可交付

    From ac97c6603f47a88001922f711eb780a5f9684518 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 08:40:58 +0000 Subject: [PATCH 04/46] docs(organizer): user-defined signal rules, two-phase matching, resolver chain semantics Adds 5.2: signal.pattern engine with SignalRuleSet (typed named-group bindings) so users can define extraction rules; built-in plain-text signal components become seeded rule sets. Rewrites 6 into a two-phase matching model: search phase as a resolver chain of policies (Final/Augment/Pass, FirstHit vs FanOutUnion strategies, worked examples) and an enrichment phase with parallel EnrichAsync, identity sets, per-field merge policies and provenance. Extends the provider adapter contract accordingly and updates glossary, roadmap, and open questions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../local-file-organization-architecture.html | 75 +++++++++++++++---- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/local-file-organization-architecture.html b/docs/local-file-organization-architecture.html index 3af7fc61f..8d01519ac 100644 --- a/docs/local-file-organization-architecture.html +++ b/docs/local-file-organization-architecture.html @@ -61,6 +61,7 @@

    §0Review 结论:初版 → v2 调整总览

    + @@ -74,10 +75,11 @@

    §1概念表初版 HTML 未

    + - - + + @@ -160,9 +162,11 @@

    5.1 模型:组件(代码)+ Pipeline(数据)

    public interface IMetadataProviderAdapter // Provider 槽的组件契约(包装 ThirdParty 客户端) { string Name { get; } // "DLsite",与 ResourceSource 对齐 - ProviderCapabilities Capabilities { get; } // SupportsExactCode/Fingerprint/Languages/搜索 - Task<IReadOnlyList<MatchCandidate>> SearchAsync(ProviderQuery q, CancellationToken ct); - Task<MatchCandidate?> GetByCodeAsync(string code, CancellationToken ct); + ProviderCapabilities Capabilities { get; } // SupportsExactCode/Fingerprint/Languages + // + ExternalIdKinds:payload 携带哪些外站 id(§6.3 身份对齐) + Task<IReadOnlyList<MatchCandidate>> SearchAsync(ProviderQuery q, CancellationToken ct); // 检索相 + Task<MatchCandidate?> GetByCodeAsync(string code, CancellationToken ct); // 精确码 + Task<MatchCandidate?> EnrichAsync(WorkIdentity identity, CancellationToken ct); // 富集相(§6.3) } // Pipeline 是数据(OrganizePipeline 表,§4),不是代码: @@ -177,7 +181,19 @@

    5.1 模型:组件(代码)+ Pipeline(数据)

  • 代码形态:组件实现位于 Bakabase.Modules.Organizer/Components/{Detectors,Signals,Parsers,Providers,Resolvers}/,DI 枚举注册(同 Enhancer 多实现模式);不做独立程序集/热插拔——桌面应用无此需求。Adapter 很薄:DLsite 适配器内部就是现有 DLsiteClient.ParseWorkDetailById,新数据源按 ThirdParty 现有约定新增客户端。
  • 驱动用例的解法:非 RJ 规范的音声库 → 把目录作用域绑到内置「通用音频」Pipeline(文件名清洗 + 内嵌标签信号,无 RJ/DLsite 组件),或 fork 音声 Pipeline 摘掉这两个组件——零代码,误命中的反效果彻底消失。
  • -

    5.2 与 Workflow 模块的关系:复用模式,不复用引擎

    +

    5.2 用户自定义信号:signal.pattern 引擎与 SignalRuleSet

    +

    Signal 槽同样开放给用户。现状对齐:SpecialText 是固定枚举的清洗词汇表Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language 七种,值为字符串对,语义硬编码在各消费点)——它是"词汇",不是"提取规则"。因此不动 SpecialText 的表与枚举(legacy、消费点多),新建概念上泛化它的数据模型:SpecialText 的每个枚举值是硬编码的 token 类型,SignalRule 的具名捕获组就是用户定义的动态 token 类型

    +
    SignalRuleSet   名称 + AppliesTo(FileName/DirName/RelativePath/SidecarText) + 有序规则列表
    +SignalRule      Pattern:带具名捕获组的正则(强制 MatchTimeout 防 ReDoS)
    +                Bindings:捕获组 → 绑定目标 ;Cleanup:命中后是否从待解析文本中剥离
    +Binding         目标二选一:规范线索槽(title/year/code/episode/volume…)
    +                或域字段(key + StandardValueType,附解析器如日期格式)
    +
      +
    • 内置信号组件大规模退位signal.rj-code / signal.sxxeyy / signal.imdb-code / signal.av-code 等纯文本模式组件全部改为 signal.pattern 引擎的内置 SignalRuleSet 种子数据——与 Pipeline 同一套"只读内置 + fork 自定义"语义。保留为代码组件的只剩结构化读取器:embedded-tag(TagLib)、comicinfo(XML)、nfoacoustid(指纹)。
    • +
    • 类型安全闭环不破:Binding 声明 StandardValueType,捕获组产出进 ParsedClues / DomainFieldBag 时校验;绑定到 code 槽的输出可触发 exact-code 短路——例如用户为私有编号体系(GB\d{4})自定义提取规则,并自行决定挂不挂 Provider。
    • +
    • 编辑与分享:规则编辑器带样例试跑(贴几个文件名实时看提取结果,SamplePaths 模式);SignalRuleSet 走 Sharable 体系分享——顺带解决 §14 原"词表外挂"未决点中提取规则的部分;清洗环节仍引用 SpecialText 词汇表。
    • +
    +

    5.3 与 Workflow 模块的关系:复用模式,不复用引擎

    Pipeline 高度动态,自然的问题是要不要直接跑在现有 Workflow 模块(typed-item 线性链 + Runner + Rehydrator)上。结论:不作为执行引擎,三个结构性错配:

    • 一次性流过 vs 跨轮状态机:Workflow 的 item 在单次 run 内单向流过链条,结局是 Keep/Drop/Replace;整理条目需要跨 run 的持久状态——NeedsReview 停车等人(可能数日)、裁定后重入队、Override 跨轮生效、断点续跑。收敛闭环恰恰长在这些机制上。
    • @@ -186,8 +202,41 @@

      5.2 与 Workflow 模块的关系:复用模式,不复用引擎

    正确的分工:Workflow 管「什么时候整理、整理完干什么」——注册"运行某 Pipeline / 规则集"活动与"整理完成"触发器(§11),供用户在 Workflow 编辑器里编排 订阅→下载→整理→通知 的跨功能链路;Organizer Pipeline 管「怎么整理」,内部仍是 §4 的槽式状态机。复用的是模式:组件注册表、descriptor 驱动的配置表单、运行统计展示等借鉴或直接共用 Workflow 的前端实现。descriptor/配置 schema 是否抽成两模块共享的抽象——暂不抽,待 Organizer 第一版落地、看到真实重复再决定。

    -

    §6匹配层与初版一致的骨架 + Bakabase 化的细节

    -
    1. 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
    2. 精确码短路:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → 置信 High,跳过模糊与复核。DLsite 增强器现有 [BVR]J\d{6,10} 正则即第一个信号提取器。
    3. 模糊打分:清洗(SpecialText)→ 别名展开(Alias)→ 跨 Provider 搜索 → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。
    4. 跨库合并:不同 Provider 命中同一作品(外链 id 互指或标题+年份强一致)→ 合并补齐语言与字段(AniList 补产地、Bangumi 补中文名)。
    5. 分档:High 自动 Resolved;Medium 可配置(自动过 / 抽样复核);Low/None → NeedsReview。阈值属于 Pipeline 的 Resolver 策略配置(内置预设给默认值,fork 可调)。
    6. Override 优先:进入匹配前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。
    7. 身份落库:Applied 之后写 ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 调整点 2),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。
    +

    §6匹配层两相分离:检索相定身份,富集相填字段

    +

    串行的 Provider 优先级链混淆了两件事,v2 把它们拆开:候选检索(这是谁?——Resolver 责任链)与字段富集(关于它我们知道什么?——并行 union)。边界清晰:富集发生在 Resolver 链 Final 之后、Plan 之前。

    +

    6.1 检索相:Resolver 责任链

    +

    Resolver 链是 Match 阶段的策略链。链前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。输入 ParsedClues,输出 MatchDecision(身份集合 + 置信档 + 可解释打分):

    +
    public interface IResolverPolicy
    +{
    +    Task<ResolverOutcome> ResolveAsync(ResolverContext ctx, CancellationToken ct);
    +}
    +// ResolverOutcome 三选一:
    +//   Final(identity, band, explanation)   定案,链终止
    +//   Augment(enrichedContext)             补充了上下文,继续下一环
    +//   Pass                                 本环无话可说,交给下一环
    +
      +
    • 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
    • +
    • 精确码短路 = 首环直接 Final 的特例:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → Final(High),跳过模糊与复核;查无此码(如已下架)→ Pass 而非失败。
    • +
    • 模糊环:清洗(SpecialText)→ 别名展开(Alias)→ 按检索策略搜 Provider → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。检索策略按管线可配FirstHit(串行短路,省配额,精确码域默认)/ FanOutUnion(并行扇出、候选取并集,模糊域默认,提高召回)。
    • +
    • merge 环:不同 Provider 的高分候选经外链 id 互指或标题+年份强一致判定为同一作品 → 合并为身份集合(Augment/Final);判定失败(同名不同作)→ 按配置保最高分单身份或降档复核。
    • +
    • 收口规则:分数→档位映射(banding)是管线配置而非环内硬编码——High 自动 Resolved;Medium 可配置(自动过/抽样/全复核);整条链无人 Final → NeedsReview。
    • +
    +

    6.2 两个走查示例

    +
    # 音声管线 exact-code → fuzzy :[CANDYVOICE] RJ01017217 耳かきボイス.zip
    +
    exact-code 环:code=RJ01017217 → DLsite GetByCode 命中 → Final(High, 100, ExactCode),链终止,不进复核
    +
    若该码已下架(404) → Pass → fuzzy 环:清洗出「耳かきボイス」→ 最高分 82 vs 次高 79,差距小于歧义阈值
    +
    → Medium → 音声管线配置"Medium 全复核" → NeedsReview(附前 5 候选与逐项打分解释)
    +
    # 动画管线 fuzzy → merge :[LoliHouse] 進撃の巨人 S03
    +
    fuzzy 环:Bangumi 候选 96 分、TMDB 候选 95 分 —— 产出两个身份 → Augment(都进上下文)
    +
    merge 环:外链 id / 标题+年份强一致 → 判定同一作品 → Final(High, {bangumi:X, tmdb:Y})
    +

    6.3 富集相:并行 union 与合并策略

    +
      +
    • 并行富集:身份定案后,对身份集合内所有 Provider 并行 EnrichAsync,字段 union 进 DomainFieldBag——这是"跨库合并补语言"(AniList 补产地、Bangumi 补中文名)的一般化。策略可配:PrimaryOnly / UnionAll
    • +
    • 身份对齐:并行 union 的前提是知道 AniList#123 == Bangumi#456。WorkIdentity 从单个 (source, key) 变为集合;对齐证据 = payload 外链 id(Capabilities.ExternalIdKinds 声明)> 匹配期等价(同轮高分共现)。存储侧无需改动——ResourceSourceLink 本就是一资源多行、每源一行。
    • +
    • per-field 合并策略:同字段冲突(两库发行日期不同)→ 默认 fill-if-empty 按 Provider 顺序;列表字段(tags)做集合并;多语言字段按语言键合并;允许 per-field 覆盖优先序。DomainFieldBag 每个值带 provenance(哪个 Provider 给的):复核 UI 展示依据,且"换合并策略重算"零网络请求(各 Provider 的 MetadataJson 分开缓存)。
    • +
    • 配额:扇出使请求量倍增——每 Provider 独立信号量限流;富集默认只对 High 定案执行。
    • +
    • 身份落库:Applied 之后按身份集合逐源ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 Enhancer 行),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。
    • +

    §7用户自定义命名与目录规则回答「完全自由的布局」

    7.1 模型:规则集 → 规则 → 路径模板

    @@ -263,7 +312,7 @@

    §11对现有模块的调整建议

    - +
    里程碑内容验收 / 收敛指标
    Provider 装饰器栈:Caching → RateLimit → Retry删除。复用 ThirdParty 模块的 AddBakabaseHttpClient + 各站点 HttpMessageHandler(已带限流/Cookie/代理),响应缓存落在 OrganizeItem.MetadataJsonResourceSourceLink.MetadataJson33 个第三方客户端已按同一套 HTTP 基建实现;再造装饰器是重复建设
    轴④ 结构模型 + 轴⑤ 命名模板内置于插件IStructureModel / INamingPolicy移出插件,改为用户完全自定义的「整理规则系统」(§7):规则 = 作用域过滤 + 路径模板 DSL + 冲突策略。插件只提供字段 schema + 默认规则预设用户要求「按 IP>类型>文件」或「平铺 <IP>-<类型>-<文件>」等任意布局;结构写死在插件里做不到完全自由,也无法表达跨域混合合集
    域插件 IDomainPlugin 是密封的五轴打包进一步拆为「组件调色板 + Pipeline 数据」(§5):信号提取器 / Provider / Resolver 策略皆为独立组件,Pipeline 存 DB;内置 Pipeline 只读可 fork,"域"退化为字段词汇表 + 标签密封包摘不掉对个别用户无效甚至有害的能力(如非 RJ 规范的音声库里,RJ 正则误命中 + 精确码短路 = 高置信错配);组件化让"摘掉/换掉某层能力"零代码
    信号提取器由插件代码内置;Provider 仅串行优先级编排纯文本信号退位为 signal.pattern 引擎的内置规则集,用户可自定义/分享(§5.2);匹配层拆为检索相(Resolver 责任链,FirstHit/FanOutUnion 可配)+ 富集相(并行 EnrichAsync + per-field 合并 + provenance,§6)非规范命名体系需要用户自建提取规则;多库字段互补(语言/产地/标签)要求身份对齐后的并行 union,而非单库胜者通吃
    ResolvedWork.ExtraDictionary<string,string> 字符串袋类型安全的 DomainFieldBag:字段定义携带 StandardValueType + 建议 PropertyType,typed 读写 + 与资源属性双向转换(§8)Bakabase 已有 9 种 StandardValue + 16 种 PropertyType 的成熟转换体系;IEnhancerTargetDescriptor 已示范同一模式(ValueType + PropertyType 并列)
    存储层 IStateStore/IMatchCache/… 内存 ↔ SQLite ↔ Redis 可换直接 BakabaseDbContext 新表(EF 迁移生成),文件类产物入 IAppService.AppDataDirectory单机单用户桌面应用,无 Redis 场景;repo 规则也要求用户数据一律走 AppData
    执行 = 纯文件系统 move/rename + journal执行与资源一致性联动:移动时同步更新 Resource.Path、PathMark、媒体库归属;匹配到的元数据经 Enhancer 转换链写入属性并登记 ResourceSourceLink(§10)初版最大盲区:被整理的文件很多已是 Bakabase 资源,裸移动会丢失属性值 / 播放历史 / 标记;这是融合版最重要的增量
    Domain(域)一类整理场景的语义标签:动画、音乐、同人音声、漫画…。域 ≠ 媒体库:同一目录可混多个域的条目。v2 中域不再是密封的行为单元——「如何识别、去哪查」由 Pipeline 的组件组合承载,域只保留字段词汇表(§8)与路由/展示标签两个职责字段词汇表 = 随组件的代码声明(§5/§8)。与 MediaType(扩展名分组)相关但不等同:MediaType 是文件形态,Domain 是内容语义
    Component(组件)某个槽位契约的一个实现:信号提取器、Provider 适配器、Resolver 策略、Detector…。代码注册,带配置 schema、适用性元数据、产出字段声明;同一组件可在多条 Pipeline 中以不同配置实例化IOrganizerComponentDescriptor + OrganizerComponentRegistry(§5,借鉴 Workflow 模块 ActivityRegistry 模式)
    Pipeline(流水线)一条可运行的整理配置:作用域/Detector + 有序信号组件 + Parser + 有序 Provider + Resolver 策略参数 + 字段映射 + 默认规则预设。是数据不是代码;内置 Pipeline 为只读种子,用户可 fork 后增删组件、调参OrganizePipeline 表(§4/§5)
    SignalRuleSet(信号规则集)数据驱动的提取规则:带具名捕获组的正则 + 捕获组→线索槽/域字段的类型化绑定。概念上是 SpecialText(固定清洗词汇表)的泛化——枚举 token 类型变为用户可定义;内置纯文本信号组件即内置规则集,可 fork、可分享signal.pattern 引擎(§5.2);清洗仍引用 ISpecialTextService 词汇
    Signal / Evidence(信号/证据)能帮助确定「这是哪部作品」的一切线索:文件名、目录名、RJ/tt 等强编码、内嵌标签(ID3/Vorbis)、伴随文件(ComicInfo.xml / .nfo)、声学指纹、图像 pHash。证据有强弱:强编码 > 指纹 > 内嵌标签 > 文件名新接口 ISignalExtractor;文件名清洗复用 ISpecialTextService,压缩包内嗅探复用 CompressedFileService
    状态机各状态Discovered 已扫描发现;Routed 已确定所属域(或确定无域→Skipped);Signaled 证据采集完毕(例如"从文件名里提出了 RJ01017217");Parsed 证据解析为结构化线索(标题/年份/话数/编码);Resolved 已匹配到规范作品(高置信);NeedsReview 低置信/歧义,等人工;Planned 已生成目标路径与操作序列(干跑终点);Applied 已落盘执行;Skipped/Failed 终态OrganizeItem.State 列(§4)。每次状态推进即 checkpoint 落库 → 断点续跑
    Provider(数据源)一个外部元数据库的查询能力:按关键词搜、按精确码取、(可选)按指纹取。声明自己的能力(Capabilities),由 Resolver 编排薄适配器 IMetadataProviderAdapter 包装 ThirdParty 模块现有 33 个客户端(§3)
    Resolver(匹配器)「怎么确定是哪部作品」的策略:精确码短路 → 指纹 → 跨 Provider 模糊打分 → 跨库合并 → 按置信档分流。绝大多数域直接用默认实现IMatchResolver + DefaultMatchResolver(§6)
    Provider(数据源)一个外部元数据库的查询能力:按关键词搜(检索相)、按精确码取、按已知身份富集(富集相 EnrichAsync)。Capabilities 声明能力与 payload 携带的外站 id 种类(供身份对齐),由 Resolver 链与富集器编排薄适配器 IMetadataProviderAdapter 包装 ThirdParty 模块现有 33 个客户端(§3/§6.3)
    Resolver(匹配器)「怎么确定是哪部作品」的策略责任链:每环产出 Final(定案,链终止)/ Augment(补充上下文继续)/ Pass(下一环)。精确码短路是首环 Final 的特例,merge 是 Augment 环;分数→档位(banding)收口为管线配置IResolverPolicy 链(§6.1,走查示例见 §6.2)
    Plan / Operation(计划)干跑产物:每个条目的「现路径 → 目标路径」+ 有序原子操作(建目录/改名/移动/合并),含冲突检测结果。只读,不动文件;用户确认后才进入执行OrganizePlan / OrganizeOperation(§10),预览 UI 借鉴 BulkModification 的 diff 页
    Journal(日志/账本)执行期逐操作写「意图 → 完成」两条记录,崩溃后可判断执行到哪;回滚 = 逆序回放OrganizeJournalEntry 表(§10)
    Override(人工裁定)复核时人做出的决定:钉死某个作品 id(ForcedIdentity)、改标题、跳过。持久化、跨轮生效——同一个文件下次重跑不再问第二遍。这是"越跑越收敛"的核心机制之一OrganizeOverride 表,键 = 条目稳定指纹(路径+大小+内容哈希摘要)
    FileNameModifier保留为手动工具,不合并。其操作枚举(Case/增删替换)可被模板 formatter 引用复用职责清晰:确定性批量改名 vs 语义化整理
    ResourceSource 枚举扩展为与 Provider 名对齐的完整清单(Bangumi/Tmdb/AniList/MusicBrainz…)ResourceSourceLink 成为全域身份登记表
    Comparison暴露一个服务级入口供 Organizer 在 Plan 阶段调用(当前面向 UI 会话)移动前发现「目标库里已有同作」→ 提示合并/跳过,而非制造重复
    Workflow复用模式,不复用引擎(§5.2):Organizer 不跑在 WorkflowRunner 上;组件注册表、descriptor 配置表单、运行统计等机制借鉴或共用其前端实现。M4 注册触发器("整理完成")与活动("运行某 Pipeline / 规则集"),item 类型走 IWorkflowItemTypeDescriptor外层自动化(订阅→下载→整理→通知)由用户在 Workflow 编辑器编排;整理内核保持强类型状态机与两阶段安全模型
    Workflow复用模式,不复用引擎(§5.3):Organizer 不跑在 WorkflowRunner 上;组件注册表、descriptor 配置表单、运行统计等机制借鉴或共用其前端实现。M4 注册触发器("整理完成")与活动("运行某 Pipeline / 规则集"),item 类型走 IWorkflowItemTypeDescriptor外层自动化(订阅→下载→整理→通知)由用户在 Workflow 编辑器编排;整理内核保持强类型状态机与两阶段安全模型
    ResourceProfile不改。NameTemplate 继续只管显示名;文档与 UI 明确两套模板的分工(§7.2)避免"改显示名模板导致文件被移动"的惊吓

    §12场景差异矩阵标注 Provider 现状:已有 = ThirdParty 已存在客户端,需新增

    @@ -278,7 +327,7 @@

    §12场景差异矩阵标 AV 番号番号(精确)Javbus/Javdb/…(10+ 站){av.code} {title}

    三种主键原型(码/指纹/模糊)与初版一致;每行即一条内置 Pipeline(只读种子,可 fork 调整组件,§5)。Bakabase 的既有 Provider 覆盖面已足够先做 4 条内置 Pipeline 而无需任何新客户端。

    12.1 内置 Pipeline 组件构成 统一骨架下的槽位填充对照(Detector → Signal 链 → Parser → Provider 链 → Resolver 链)

    -

    组件以 kind 标注;同名组件 = 同一份代码、不同配置实例。上表给出布局预设,本表给出各槽位的具体填充:

    +

    组件以 kind 标注;同名组件 = 同一份代码、不同配置实例。上表给出布局预设,本表给出各槽位的具体填充。其中 signal.rj-code / sxxeyy / imdb-code / av-code 均为 signal.pattern 引擎的内置规则集实例(§5.2),可 fork 自定义提取规则:

    @@ -300,9 +349,9 @@

    12.1 内置 Pipeline 组件构成 统一骨架下的槽位

    §13实施路线图垂直切片、每步可收敛可交付

    内置 PipelineSignal 链(有序 = 证据强度序)Provider 链(有序)Resolver 链特有字段(决定模板占位符)
    同人音声/DLsitesignal.rj-code → signal.filenameprovider.dlsiteexact-code → fuzzy高阈值,Medium 全复核dlsite.circle/cv/releaseDate
    通用音频signal.embedded-tag → signal.filename(空),可选挂 MusicBrainztag-first不联网:标签→清洗文件名定名;缺标签→复核music.albumArtist/album/trackNo
    - + - +
    里程碑内容验收 / 收敛指标
    M0内核表(含 OrganizePipeline)+ 状态机流水线 + 组件注册表 + 内置「同人音声/DLsite」Pipeline(RJ 码短路,复用 DLsiteClient;纯种子数据,暂无编辑器)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
    M0内核表(含 OrganizePipeline)+ 状态机流水线 + 组件注册表 + signal.pattern 规则引擎(RJ 码即内置规则集)+ 内置「同人音声/DLsite」Pipeline(RJ 码短路,复用 DLsiteClient;纯种子数据,暂无编辑器)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
    M1规则集/模板 DSL + 规则编辑器(实时预览目标树)+ 冲突策略 + Resource.Path/PathMark 联动 + ResourceSourceLink 登记与属性写入 + Pipeline fork 与最小编辑器(组件开关/排序/阈值)+ 内置「通用音频」预设用户可自由表达分层/平铺布局;已入库资源整理后属性与播放记录零丢失;非 RJ 规范用户可零代码摘除 DLsite 能力
    M2内置动画 Pipeline(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径 + Pipeline 编辑器完整版(适用性校验/上游更新提示)复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
    M2内置动画 Pipeline(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径 + Pipeline 编辑器完整版(适用性校验/上游更新提示)+ SignalRuleSet 自定义编辑器(样例试跑)+ 检索 FanOutUnion / merge 环 / 富集 union(provenance + per-field 合并策略)复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
    M3内置漫画 / 音乐 Pipeline(TagLib 标签信号;MusicBrainz 客户端)+ franchise 解析链 + IP 合集布局 + Comparison 去重闸门动画/漫画/OST/画集按 IP 聚拢的演示库跑通;重复作品在 Plan 期被拦截
    M4watcher 自动整理(FileMover 进料口)+ Workflow 触发器/活动 + AI 辅助低置信消歧 + 规则预设分享(Sharable 体系)下载→整理→通知全自动链路;低置信条目 AI 建议采纳率可观测

    每个里程碑都是可发布的完整功能,不存在「基建全铺完才见效」的阶段。M0 选同人音声是因为:精确码型近零歧义、Provider 已存在、单域即可验证内核全部机制(状态机/计划/journal/复核/续跑)。

    @@ -316,7 +365,7 @@

    §14风险与未决点

  • 压缩包语义:音声/漫画大量以压缩包为条目单位。M0 将压缩包视为叶子条目(不解包);解包整理(借 CompressedFileService)列为 M3 未决项。
  • franchise 权威性:跨库 IP 归属没有全局权威源,别名聚类会有错并。默认仅强证据(Provider 关系图)自动归并,聚类结果一律进复核。
  • 内置预设与 fork 漂移:内置 Pipeline 迭代后用户 fork 不自动跟进,可能长期落后甚至引用已废弃组件。缓解:fork 记录 ForkedFromId,上游变更时提示 diff;组件实例支持"重置为上游默认";废弃组件保留兼容运行并在编辑器中标记。
  • -
  • 词表外挂:各 Parser 组件的正则/词表是否如初版建议外挂为数据文件并走 Sharable 分享体系(Pipeline 本身也可分享)——倾向于是,M4 决策。
  • +
  • 词表外挂:提取规则部分已由 SignalRuleSet 落地为"数据 + Sharable 分享"(§5.2);剩余未决为清洗词汇(SpecialText)与 Pipeline 整体的分享打包格式——M4 决策。
  • Bakabase · 自动整理本地文件 · 架构 v2(融合版)· 本页为单一来源,替代初版 architecture_1.html。引用的既有类型/路径均已在当前代码库核实。
    From d9f758abcc7401d08106e0c42581d18f73507416 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 09:14:29 +0000 Subject: [PATCH 05/46] docs(organizer): split architecture into linked per-module design docs Replace the monolithic HTML with docs/local-file-organization/: an overview (execution plan + progress tracker), a foundations doc (concepts, component model, type system, reuse map), and nine implementation modules ordered bottom-up from atomic capabilities (text rules, extractors, providers) through decision layers (resolution, enrichment) and disk-level capabilities (layout, execution) to top-level orchestration (pipelines, collections). Docs cross-reference by links instead of copying content; v1 comparison material is dropped. Renames the Signal concept to TextRule/Extractor/Clue (state Signaled -> Extracted) and adds a 'capabilities gained on completion' section with examples to every module. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../local-file-organization-architecture.html | 371 ------------------ docs/local-file-organization/README.md | 69 ++++ docs/local-file-organization/foundations.md | 105 +++++ .../modules/01-text-processing.md | 59 +++ .../modules/02-extractors.md | 48 +++ .../modules/03-providers.md | 50 +++ .../modules/04-resolution.md | 69 ++++ .../modules/05-enrichment.md | 39 ++ .../modules/06-layout.md | 66 ++++ .../modules/07-execution.md | 55 +++ .../modules/08-orchestration.md | 84 ++++ .../modules/09-collections.md | 35 ++ 12 files changed, 679 insertions(+), 371 deletions(-) delete mode 100644 docs/local-file-organization-architecture.html create mode 100644 docs/local-file-organization/README.md create mode 100644 docs/local-file-organization/foundations.md create mode 100644 docs/local-file-organization/modules/01-text-processing.md create mode 100644 docs/local-file-organization/modules/02-extractors.md create mode 100644 docs/local-file-organization/modules/03-providers.md create mode 100644 docs/local-file-organization/modules/04-resolution.md create mode 100644 docs/local-file-organization/modules/05-enrichment.md create mode 100644 docs/local-file-organization/modules/06-layout.md create mode 100644 docs/local-file-organization/modules/07-execution.md create mode 100644 docs/local-file-organization/modules/08-orchestration.md create mode 100644 docs/local-file-organization/modules/09-collections.md diff --git a/docs/local-file-organization-architecture.html b/docs/local-file-organization-architecture.html deleted file mode 100644 index 8d01519ac..000000000 --- a/docs/local-file-organization-architecture.html +++ /dev/null @@ -1,371 +0,0 @@ - - -Bakabase · 自动整理本地文件 · 架构 v2(融合版) -
    -

    Bakabase · 自动整理本地文件 · 架构 v2

    -

    在初版「五轴可插拔」骨架之上,与 Bakabase 现有模块体系(ThirdParty / Enhancer / Property / BTask / PathMark / Comparison…)深度融合的落地版设计

    -

    本页替代初版 architecture_1.html 成为单一来源。初版验证了「同一骨架 + 五个可插拔轴」的核心洞见,本版解决四件事:① 落进 Bakabase 的真实模块与数据模型(不再是独立 sln);② 命名/目录结构从"插件内置"升级为"用户完全自定义的规则系统"③ 域特定字段获得基于 StandardValue 的类型安全④ 整理能力全面组件化——分层中每层的组件用户可自由选换,内置 Pipeline 覆盖主流场景、可 fork 自定义(§5)。所有引用的类型与路径均已在当前代码库中核实。

    - - -

    §0Review 结论:初版 → v2 调整总览

    -

    初版的骨架(扫描 → 采集证据 → 解析 → 匹配 → 命名/定位 → 计划 → 执行/回滚,五轴插件化)整体成立,予以保留。以下是逐项调整及理由:

    - - - - - - - - - - - - -
    初版设计v2 调整理由
    独立 MediaOrganizer.sln,零依赖纯 BCL 内核落地为 Bakabase.Modules.Organizer(+ 域插件子目录),注册进现有 DI / Controller / SignalR / EF 体系Bakabase 已有完整的模块化骨架(Abstractions/Components/Services/Models/Extensions 五段式 + tests/ 每模块一测试工程);独立 sln 会导致双份 HTTP 栈、双份存储、双份任务系统
    Provider 装饰器栈:Caching → RateLimit → Retry删除。复用 ThirdParty 模块的 AddBakabaseHttpClient + 各站点 HttpMessageHandler(已带限流/Cookie/代理),响应缓存落在 OrganizeItem.MetadataJsonResourceSourceLink.MetadataJson33 个第三方客户端已按同一套 HTTP 基建实现;再造装饰器是重复建设
    轴④ 结构模型 + 轴⑤ 命名模板内置于插件IStructureModel / INamingPolicy移出插件,改为用户完全自定义的「整理规则系统」(§7):规则 = 作用域过滤 + 路径模板 DSL + 冲突策略。插件只提供字段 schema + 默认规则预设用户要求「按 IP>类型>文件」或「平铺 <IP>-<类型>-<文件>」等任意布局;结构写死在插件里做不到完全自由,也无法表达跨域混合合集
    域插件 IDomainPlugin 是密封的五轴打包进一步拆为「组件调色板 + Pipeline 数据」(§5):信号提取器 / Provider / Resolver 策略皆为独立组件,Pipeline 存 DB;内置 Pipeline 只读可 fork,"域"退化为字段词汇表 + 标签密封包摘不掉对个别用户无效甚至有害的能力(如非 RJ 规范的音声库里,RJ 正则误命中 + 精确码短路 = 高置信错配);组件化让"摘掉/换掉某层能力"零代码
    信号提取器由插件代码内置;Provider 仅串行优先级编排纯文本信号退位为 signal.pattern 引擎的内置规则集,用户可自定义/分享(§5.2);匹配层拆为检索相(Resolver 责任链,FirstHit/FanOutUnion 可配)+ 富集相(并行 EnrichAsync + per-field 合并 + provenance,§6)非规范命名体系需要用户自建提取规则;多库字段互补(语言/产地/标签)要求身份对齐后的并行 union,而非单库胜者通吃
    ResolvedWork.ExtraDictionary<string,string> 字符串袋类型安全的 DomainFieldBag:字段定义携带 StandardValueType + 建议 PropertyType,typed 读写 + 与资源属性双向转换(§8)Bakabase 已有 9 种 StandardValue + 16 种 PropertyType 的成熟转换体系;IEnhancerTargetDescriptor 已示范同一模式(ValueType + PropertyType 并列)
    存储层 IStateStore/IMatchCache/… 内存 ↔ SQLite ↔ Redis 可换直接 BakabaseDbContext 新表(EF 迁移生成),文件类产物入 IAppService.AppDataDirectory单机单用户桌面应用,无 Redis 场景;repo 规则也要求用户数据一律走 AppData
    执行 = 纯文件系统 move/rename + journal执行与资源一致性联动:移动时同步更新 Resource.Path、PathMark、媒体库归属;匹配到的元数据经 Enhancer 转换链写入属性并登记 ResourceSourceLink(§10)初版最大盲区:被整理的文件很多已是 Bakabase 资源,裸移动会丢失属性值 / 播放历史 / 标记;这是融合版最重要的增量
    触发形态「以后再定」M0 起即定:手动发起 + BTask 一次性任务承载(进度/暂停/取消/冲突键),后期接 FileMover 监听与 Workflow 模块触发器BTask 已提供全部任务基建(含 SignalR 推送到 background-task 页)
    复核队列 / override 为独立 Store 接口OrganizeItem.State = NeedsReview + 专页 UI;人工决定持久化为 OrganizeOverride 表,跨轮生效与状态机同表,断点续跑与复核天然一致;BulkModification 的 diff 预览 UI 模式可直接借鉴
    去重/归并「放在内核聚类能力里」(未展开)复用 Bakabase.Modules.Comparison(加权规则 + 11 种策略,含文本相似度/归一化对比)作为整理前的去重闸门该模块正是为「识别逻辑重复资源」而建,无需重写
    模糊匹配自带 StringSimilarity(Dice 二元组)保留算法,但归一化预处理走 ISpecialTextService(去括号/日期/语言词表),别名走 Alias 模块CJK 场景的清洗词表已在 SpecialText 中沉淀多年,社区词表可继续外挂
    - -

    §1概念表初版 HTML 未解释的概念 + v2 新概念,一次说清

    - - - - - - - - - - - - - - -
    概念含义对应 Bakabase 概念 / 落点
    Domain(域)一类整理场景的语义标签:动画、音乐、同人音声、漫画…。域 ≠ 媒体库:同一目录可混多个域的条目。v2 中域不再是密封的行为单元——「如何识别、去哪查」由 Pipeline 的组件组合承载,域只保留字段词汇表(§8)与路由/展示标签两个职责字段词汇表 = 随组件的代码声明(§5/§8)。与 MediaType(扩展名分组)相关但不等同:MediaType 是文件形态,Domain 是内容语义
    Component(组件)某个槽位契约的一个实现:信号提取器、Provider 适配器、Resolver 策略、Detector…。代码注册,带配置 schema、适用性元数据、产出字段声明;同一组件可在多条 Pipeline 中以不同配置实例化IOrganizerComponentDescriptor + OrganizerComponentRegistry(§5,借鉴 Workflow 模块 ActivityRegistry 模式)
    Pipeline(流水线)一条可运行的整理配置:作用域/Detector + 有序信号组件 + Parser + 有序 Provider + Resolver 策略参数 + 字段映射 + 默认规则预设。是数据不是代码;内置 Pipeline 为只读种子,用户可 fork 后增删组件、调参OrganizePipeline 表(§4/§5)
    SignalRuleSet(信号规则集)数据驱动的提取规则:带具名捕获组的正则 + 捕获组→线索槽/域字段的类型化绑定。概念上是 SpecialText(固定清洗词汇表)的泛化——枚举 token 类型变为用户可定义;内置纯文本信号组件即内置规则集,可 fork、可分享signal.pattern 引擎(§5.2);清洗仍引用 ISpecialTextService 词汇
    Signal / Evidence(信号/证据)能帮助确定「这是哪部作品」的一切线索:文件名、目录名、RJ/tt 等强编码、内嵌标签(ID3/Vorbis)、伴随文件(ComicInfo.xml / .nfo)、声学指纹、图像 pHash。证据有强弱:强编码 > 指纹 > 内嵌标签 > 文件名新接口 ISignalExtractor;文件名清洗复用 ISpecialTextService,压缩包内嗅探复用 CompressedFileService
    状态机各状态Discovered 已扫描发现;Routed 已确定所属域(或确定无域→Skipped);Signaled 证据采集完毕(例如"从文件名里提出了 RJ01017217");Parsed 证据解析为结构化线索(标题/年份/话数/编码);Resolved 已匹配到规范作品(高置信);NeedsReview 低置信/歧义,等人工;Planned 已生成目标路径与操作序列(干跑终点);Applied 已落盘执行;Skipped/Failed 终态OrganizeItem.State 列(§4)。每次状态推进即 checkpoint 落库 → 断点续跑
    Provider(数据源)一个外部元数据库的查询能力:按关键词搜(检索相)、按精确码取、按已知身份富集(富集相 EnrichAsync)。Capabilities 声明能力与 payload 携带的外站 id 种类(供身份对齐),由 Resolver 链与富集器编排薄适配器 IMetadataProviderAdapter 包装 ThirdParty 模块现有 33 个客户端(§3/§6.3)
    Resolver(匹配器)「怎么确定是哪部作品」的策略责任链:每环产出 Final(定案,链终止)/ Augment(补充上下文继续)/ Pass(下一环)。精确码短路是首环 Final 的特例,merge 是 Augment 环;分数→档位(banding)收口为管线配置IResolverPolicy 链(§6.1,走查示例见 §6.2)
    Plan / Operation(计划)干跑产物:每个条目的「现路径 → 目标路径」+ 有序原子操作(建目录/改名/移动/合并),含冲突检测结果。只读,不动文件;用户确认后才进入执行OrganizePlan / OrganizeOperation(§10),预览 UI 借鉴 BulkModification 的 diff 页
    Journal(日志/账本)执行期逐操作写「意图 → 完成」两条记录,崩溃后可判断执行到哪;回滚 = 逆序回放OrganizeJournalEntry 表(§10)
    Override(人工裁定)复核时人做出的决定:钉死某个作品 id(ForcedIdentity)、改标题、跳过。持久化、跨轮生效——同一个文件下次重跑不再问第二遍。这是"越跑越收敛"的核心机制之一OrganizeOverride 表,键 = 条目稳定指纹(路径+大小+内容哈希摘要)
    Convergence(收敛)整理是循环而非一次性:每轮自动解决一批(强证据直通)、人工裁定一批(写入 Override)、缓存一批(Provider 响应与匹配结果)。三者叠加使下一轮的待处理集合单调缩小状态机 + Override + ResourceSourceLink.MetadataJson 缓存 + BTask 重跑
    Franchise / IP(系列)跨域的作品归属:「物语系列」的动画/漫画/OST/画集属于同一 IP。用于混合合集布局(§9)规范化为一个 Multilevel/SingleChoice 自定义属性 + FranchiseResolver 解析链
    Rule / RuleSet(整理规则)v2 新概念:用户定义的「哪些条目(作用域)→ 放到哪、叫什么(路径模板)→ 撞了怎么办(冲突策略)」。规则有序,首个命中生效OrganizeRule / OrganizeRuleSet(§7)
    - -

    §2分层架构(Bakabase 融合版)

    -
    -
    触发层 · M0 手动 / M4 自动
    整理向导 UI(选根目录 + 规则集 → 预览 → 确认)BTask 一次性任务(fluent builder)FileMover 监听目录(后期接入)Workflow 模块触发器/活动(后期)
    -
    -
    整理内核 · Bakabase.Modules.Organizer(新)
    DomainRouter 路由OrganizePipeline 状态机循环Route→Signal→Parse→Match→Plan规则引擎 + 路径模板渲染PlanExecutor 执行/回滚(journal)复核队列 / Override
    -
    ▼ 按作用域绑定与 Detector 置信度选中 Pipeline
    -
    Pipeline 层 · 组件组合的数据化配置(内置只读可 fork;组件代码注册于 OrganizerComponentRegistry)
    内置:同人音声/DLsite(M0)内置:通用音频(M1)内置:动画(M2)内置:漫画 · 音乐(M3)内置:电影/剧集 · 游戏 · AV用户 fork 的自定义 Pipeline
    -
    ▼ 适配
    -
    Provider 层 · 复用 Bakabase.Modules.ThirdParty(33 个客户端 + 统一 HTTP 基建)
    DLsiteBangumiTmdbExHentaiSteamPixiv…新增:AniList · MusicBrainz/AcoustID · MangaDex(按里程碑)
    -
    ▲▼ 一致性联动(v2 新增的关键层)
    -
    资源域联动 · 整理不是孤立的文件操作
    Resource.Path 同步更新PathMark 同步MediaLibraryV2 归属ResourceSourceLink 身份登记属性写入(走 Enhancer 转换链)Comparison 去重闸门
    -
    -
    存储层 · BakabaseDbContext(EF/SQLite)+ AppData
    OrganizeRuleSet / OrganizeRuleOrganizeJob / OrganizeItemOrganizeOverrideOrganizeJournalEntry
    -
    -

    内核域无关、稳定;场景差异由组件与 Pipeline 配置承载;Provider 与存储全部复用现有基建。与初版的分层相比,多出「资源域联动」一层——它是把"文件整理工具"变成"Bakabase 的整理能力"的分水岭。

    - -

    §3现有资产复用映射初版概念 ↔ 代码库中已存在的实现

    - - - - - - - - - - - -
    初版概念状态Bakabase 现有资产(已核实)
    IMetadataProvider(轴③)直接复用src/modules/Bakabase.Modules.ThirdParty/ThirdParties/ 下 33 个客户端(DLsite、Bangumi、Tmdb、ExHentai、Pixiv、Steam、Fanbox、Fantia、各 AV 站…),统一经 AddBakabaseHttpClient + 站点专属 HttpMessageHandler(限流/Cookie/代理已内置)。只需加一层能力声明适配器
    匹配→写属性 的转换链直接复用Enhancer 模块:AbstractEnhancer<TTarget,TContext,TOptions> 的 context→target→StandardValue→属性 转换全链路,含选项自动建属性、动态 target。整理器匹配得到的元数据走同一条链写属性,不另造
    WorkIdentity 身份持久化扩展复用ResourceSourceLink(Source + SourceKey + MetadataJson + 封面缓存 + 抓取时间)就是「资源 ↔ 外部作品」的既有绑定表;扩展 ResourceSource 枚举(现有 PathMark/Steam/DLsite/ExHentai/Aigc,补 Bangumi/Tmdb/AniList/MusicBrainz…)
    引擎宿主(循环/并发/进度)直接复用BTask:一次性 fluent builder(BTaskBuilder.Create(...).ConflictsWith(...).Run(...))、进度/暂停/取消(args.YieldAsync())、冲突键、SignalR 推送、重试策略
    模糊匹配预处理直接复用ISpecialTextService(包装符/日期/语言词表/标准化)+ Alias 模块(别名解析)+ ComparisonTextSimilarityStrategy/NormalizedText
    去重/归并直接复用Bakabase.Modules.Comparison:加权评分 + 一票否决 + 空值策略 + 11 种策略,本就为"识别逻辑重复的媒体资源"设计
    条目过滤(规则作用域)直接复用ResourceSearchFilterGroup(ResourceProfile 的 Search 同款)+ PathFilter(Layer/Regex/FsType/扩展名组);倒排索引已就绪
    命名模板引擎泛化复用显示名模板已有 {属性名} 占位 + 包装符空值丢弃语义(ResourceUtils.SplitDisplayNameTemplateIntoSegments,有测试覆盖)。§7 将其泛化为多级路径模板
    文件操作原语部分复用FileMover(监听+延迟移动)、FileNameModifier(批量改名操作集)、file-processor 页(手动操作/解压 CompressedFileService)。执行器吸收其原语,补 journal/undo
    AI 辅助消歧后期接入Bakabase.Modules.AI(多 Provider、缓存、已有 Enhancer 桥接),M4 用于低置信条目的辅助判定与封面识别
    五阶段流水线内核新建状态机、规则引擎、计划/执行/回滚、复核/Override —— Bakabase.Modules.Organizer 的本体,Bakabase 目前没有等价物
    - -

    §4内核:状态机、数据表、BTask 宿主

    -

    4.1 状态机

    -
    DiscoveredRoutedSignaledParsedResolved/NeedsReviewPlannedAppliedSkipped/Failed
    -
    • 每次状态推进即写库(checkpoint)→ 断点续跑:重跑时跳过已 Applied/Skipped,NeedsReview 保留待裁定。
    • 强证据(精确码/指纹)从 Signaled 可直达 Resolved;NeedsReview 经人工裁定(写 Override)后重入队。
    • 干跑(默认)终点是 Planned;Applied 只能由用户确认计划后触发,两者是不同的 BTask。
    • 循环体内逐条目 await args.YieldAsync(),暂停/取消语义与 BTask 规范一致。
    -

    4.2 数据表(EF 迁移生成,禁手写)

    -
    OrganizePipeline  流水线:Name, ForkedFromId?(内置预设来源), ScopeJson(作用域绑定),
    -                  ComponentsJson(有序组件实例+各自配置), ResolverConfigJson(阈值/分档),
    -                  FieldMappingJson(域字段→属性), Enabled   // 内置 Pipeline 为只读种子数据(§5)
    -OrganizeRuleSet   规则集:Name, TargetRoot(整理目标根), Rules(有序), DefaultCollision, Enabled
    -OrganizeRule      规则:RuleSetId, Priority, Scope(过滤 JSON, 复用 ResourceSearchFilterGroup),
    -                  PipelineId?, PathTemplate, CollisionPolicy
    -OrganizeJob       一轮运行:RuleSetId, SourceRoots, Mode(DryRun/Apply), Stats, CreatedAt
    -OrganizeItem      条目状态机:JobId, Path, Fingerprint(路径+大小+mtime 摘要), State, PipelineId?,
    -                  SignalsJson, ParsedJson, MatchJson(候选+得分), MetadataJson(命中作品原始数据),
    -                  PlannedPath?, Error?, Attempts
    -OrganizeOverride  人工裁定:Fingerprint(键), Kind(ForcedIdentity/ForcedTitle/ForcedDomain/Skip),
    -                  PayloadJson, CreatedAt          // 跨 Job 生效 → 收敛
    -OrganizeJournalEntry  执行账本:JobId, Seq, Op(Move/Rename/Mkdir/MergeDir), From, To,
    -                  Phase(Intent/Done/Undone), At   // 逆序回放 = 回滚
    -

    Provider 原始响应不单独建缓存表:匹配命中即存 OrganizeItem.MetadataJson;条目落盘登记后转存 ResourceSourceLink.MetadataJson(含 MetadataFetchedAt 时效字段,已有 DownloadExternalCovers 任务消费同表)。同一作品跨条目复用由 Resolver 内存缓存承担(进程内、按 Job 生命周期)。

    -

    4.3 BTask 宿主

    -
    await taskManager.Enqueue(BTaskBuilder.Create($"Organize:{job.Id}")
    -    .Named(() => localizer.Organize_TaskName(ruleSet.Name))
    -    .ConflictsWith("MoveFiles", $"OrganizeRoot:{ruleSet.TargetRoot}")  // 不与 FileMover / 同根任务并跑
    -    .Persistent().StartImmediately()
    -    .Run(async args => { /* 流水线循环,逐条目 YieldAsync + UpdateTask 进度 */ }));
    - -

    §5组件与 Pipeline域插件契约的终局形态:骨架固定,槽内自由

    -

    初版把五轴密封在 IDomainPlugin 里;v2 初稿先把轴④⑤移交用户规则(§7);本节走完最后一步——轴①②③也组件化,用户可自由选择分层中每一层用到的组件。驱动用例:并非所有同人音声库都遵循 RJ/DLsite 规范,对这类用户,RJ 提取器与 DLsite Provider 不仅无效,还会在随机数字上误命中、并借"精确码短路"生成高置信错配——密封的域插件摘不掉这块能力,组件化可以。

    -

    5.1 模型:组件(代码)+ Pipeline(数据)

    -
    // 组件:每个槽位契约的一个实现,注册进 OrganizerComponentRegistry
    -//(注册表 / 描述符 / 配置表单三件套借鉴 Workflow 模块的 ActivityRegistry 模式)
    -public interface IOrganizerComponentDescriptor
    -{
    -    string Kind { get; }                          // "signal.rj-code" / "provider.dlsite" / "resolver.fuzzy"
    -    OrganizerSlot Slot { get; }                   // Detector / Signal / Parser / Provider / ResolverPolicy
    -    string DisplayName { get; }
    -    Type? ConfigType { get; }                     // 配置 schema,编辑器据此渲染表单
    -    ComponentApplicability Applicability { get; } // 适用形态:音频/压缩包/任意…(编辑器校验)
    -    DomainFieldDefinition[] Fields { get; }       // 该组件产出的域字段(§8)→ 字段随组件走
    -}
    -
    -public interface IMetadataProviderAdapter         // Provider 槽的组件契约(包装 ThirdParty 客户端)
    -{
    -    string Name { get; }                          // "DLsite",与 ResourceSource 对齐
    -    ProviderCapabilities Capabilities { get; }    // SupportsExactCode/Fingerprint/Languages
    -                                                  //  + ExternalIdKinds:payload 携带哪些外站 id(§6.3 身份对齐)
    -    Task<IReadOnlyList<MatchCandidate>> SearchAsync(ProviderQuery q, CancellationToken ct); // 检索相
    -    Task<MatchCandidate?> GetByCodeAsync(string code, CancellationToken ct);                // 精确码
    -    Task<MatchCandidate?> EnrichAsync(WorkIdentity identity, CancellationToken ct);         // 富集相(§6.3)
    -}
    -
    -// Pipeline 是数据(OrganizePipeline 表,§4),不是代码:
    -//   作用域/Detector + 有序 Signal 组件实例(各带配置) + Parser + 有序 Provider 实例
    -// + Resolver 策略参数(阈值/分档) + 域字段→属性映射 + 默认规则预设绑定
    -
      -
    • 骨架固定,槽内自由:Route→Signal→Parse→Match→Plan 的阶段顺序不可重排,用户的自由是"每个槽用哪些组件、什么顺序、什么参数"。非法组合(如先 Match 后 Signal)从模型上无法表达,编辑器因此是「勾选 + 排序 + 调参」,不是连线画布。
    • -
    • 内置 Pipeline 可 fork:内置 Pipeline 为只读种子数据,覆盖主流场景(§12 每行一条);不满足时 fork 成用户 Pipeline(copy-on-write)再增删组件、调参。内置预设升级后,fork 收到"上游已更新"提示,不被强制同步。
    • -
    • 路由:显式作用域绑定("这个目录用这条 Pipeline")优先于 Detector 置信度竞争;多条 Pipeline 得分接近(差 < 阈值)→ NeedsReview 而非硬选,避免错管线整理。
    • -
    • 字段随组件走:Pipeline 可用的域字段 = 其组件 Fields 的并集。模板编辑器据此提示占位符——{dlsite.circle} 只在含 DLsite Provider 的 Pipeline 里可用,与 §8 的类型安全形成闭环。
    • -
    • "域"的残余:不再有密封的 IOrganizerDomain 接口。域退化为:字段词汇表(随组件的代码声明,§8)+ 路由/展示/统计用的标签。
    • -
    • 代码形态:组件实现位于 Bakabase.Modules.Organizer/Components/{Detectors,Signals,Parsers,Providers,Resolvers}/,DI 枚举注册(同 Enhancer 多实现模式);不做独立程序集/热插拔——桌面应用无此需求。Adapter 很薄:DLsite 适配器内部就是现有 DLsiteClient.ParseWorkDetailById,新数据源按 ThirdParty 现有约定新增客户端。
    • -
    • 驱动用例的解法:非 RJ 规范的音声库 → 把目录作用域绑到内置「通用音频」Pipeline(文件名清洗 + 内嵌标签信号,无 RJ/DLsite 组件),或 fork 音声 Pipeline 摘掉这两个组件——零代码,误命中的反效果彻底消失。
    • -
    -

    5.2 用户自定义信号:signal.pattern 引擎与 SignalRuleSet

    -

    Signal 槽同样开放给用户。现状对齐:SpecialText 是固定枚举的清洗词汇表Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language 七种,值为字符串对,语义硬编码在各消费点)——它是"词汇",不是"提取规则"。因此不动 SpecialText 的表与枚举(legacy、消费点多),新建概念上泛化它的数据模型:SpecialText 的每个枚举值是硬编码的 token 类型,SignalRule 的具名捕获组就是用户定义的动态 token 类型

    -
    SignalRuleSet   名称 + AppliesTo(FileName/DirName/RelativePath/SidecarText) + 有序规则列表
    -SignalRule      Pattern:带具名捕获组的正则(强制 MatchTimeout 防 ReDoS)
    -                Bindings:捕获组 → 绑定目标 ;Cleanup:命中后是否从待解析文本中剥离
    -Binding         目标二选一:规范线索槽(title/year/code/episode/volume…)
    -                或域字段(key + StandardValueType,附解析器如日期格式)
    -
      -
    • 内置信号组件大规模退位signal.rj-code / signal.sxxeyy / signal.imdb-code / signal.av-code 等纯文本模式组件全部改为 signal.pattern 引擎的内置 SignalRuleSet 种子数据——与 Pipeline 同一套"只读内置 + fork 自定义"语义。保留为代码组件的只剩结构化读取器:embedded-tag(TagLib)、comicinfo(XML)、nfoacoustid(指纹)。
    • -
    • 类型安全闭环不破:Binding 声明 StandardValueType,捕获组产出进 ParsedClues / DomainFieldBag 时校验;绑定到 code 槽的输出可触发 exact-code 短路——例如用户为私有编号体系(GB\d{4})自定义提取规则,并自行决定挂不挂 Provider。
    • -
    • 编辑与分享:规则编辑器带样例试跑(贴几个文件名实时看提取结果,SamplePaths 模式);SignalRuleSet 走 Sharable 体系分享——顺带解决 §14 原"词表外挂"未决点中提取规则的部分;清洗环节仍引用 SpecialText 词汇表。
    • -
    -

    5.3 与 Workflow 模块的关系:复用模式,不复用引擎

    -

    Pipeline 高度动态,自然的问题是要不要直接跑在现有 Workflow 模块(typed-item 线性链 + Runner + Rehydrator)上。结论:不作为执行引擎,三个结构性错配:

    -
      -
    • 一次性流过 vs 跨轮状态机:Workflow 的 item 在单次 run 内单向流过链条,结局是 Keep/Drop/Replace;整理条目需要跨 run 的持久状态——NeedsReview 停车等人(可能数日)、裁定后重入队、Override 跨轮生效、断点续跑。收敛闭环恰恰长在这些机制上。
    • -
    • 逐条独立 vs 聚合阶段:Plan 期必须跨条目——同 Job 目标撞车检测、Comparison 去重闸门、franchise 聚拢;per-item 链没有聚合语义。
    • -
    • 直通执行 vs 两阶段确认:干跑→人工确认→执行→journal/回滚 的安全模型在 Workflow 中不存在。另外,把 Evidence/ParsedClues/MatchCandidate 等强类型阶段契约压进 item-type 字符串标签 + configJson,会拆掉 §8 刚建立的类型安全。
    • -
    -

    正确的分工:Workflow 管「什么时候整理、整理完干什么」——注册"运行某 Pipeline / 规则集"活动与"整理完成"触发器(§11),供用户在 Workflow 编辑器里编排 订阅→下载→整理→通知 的跨功能链路;Organizer Pipeline 管「怎么整理」,内部仍是 §4 的槽式状态机。复用的是模式:组件注册表、descriptor 驱动的配置表单、运行统计展示等借鉴或直接共用 Workflow 的前端实现。descriptor/配置 schema 是否抽成两模块共享的抽象——暂不抽,待 Organizer 第一版落地、看到真实重复再决定。

    - -

    §6匹配层两相分离:检索相定身份,富集相填字段

    -

    串行的 Provider 优先级链混淆了两件事,v2 把它们拆开:候选检索(这是谁?——Resolver 责任链)与字段富集(关于它我们知道什么?——并行 union)。边界清晰:富集发生在 Resolver 链 Final 之后、Plan 之前。

    -

    6.1 检索相:Resolver 责任链

    -

    Resolver 链是 Match 阶段的策略链。链前先查 OrganizeOverride(按条目指纹)——人工裁定过的直接采用,这是收敛闭环的另一半。输入 ParsedClues,输出 MatchDecision(身份集合 + 置信档 + 可解释打分):

    -
    public interface IResolverPolicy
    -{
    -    Task<ResolverOutcome> ResolveAsync(ResolverContext ctx, CancellationToken ct);
    -}
    -// ResolverOutcome 三选一:
    -//   Final(identity, band, explanation)   定案,链终止
    -//   Augment(enrichedContext)             补充了上下文,继续下一环
    -//   Pass                                 本环无话可说,交给下一环
    -
      -
    • 证据强度排序:精确码(RJ/tt/序列号/包名)> 指纹(AcoustID/pHash,后期)> 内嵌标签/伴随文件 > 文件名。
    • -
    • 精确码短路 = 首环直接 Final 的特例:命中 SupportsExactCode 的 Provider 直接 GetByCodeAsync → Final(High),跳过模糊与复核;查无此码(如已下架)→ Pass 而非失败。
    • -
    • 模糊环:清洗(SpecialText)→ 别名展开(Alias)→ 按检索策略搜 Provider → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权。检索策略按管线可配FirstHit(串行短路,省配额,精确码域默认)/ FanOutUnion(并行扇出、候选取并集,模糊域默认,提高召回)。
    • -
    • merge 环:不同 Provider 的高分候选经外链 id 互指或标题+年份强一致判定为同一作品 → 合并为身份集合(Augment/Final);判定失败(同名不同作)→ 按配置保最高分单身份或降档复核。
    • -
    • 收口规则:分数→档位映射(banding)是管线配置而非环内硬编码——High 自动 Resolved;Medium 可配置(自动过/抽样/全复核);整条链无人 Final → NeedsReview。
    • -
    -

    6.2 两个走查示例

    -
    # 音声管线 exact-code → fuzzy :[CANDYVOICE] RJ01017217 耳かきボイス.zip
    -
    exact-code 环:code=RJ01017217 → DLsite GetByCode 命中 → Final(High, 100, ExactCode),链终止,不进复核
    -
    若该码已下架(404) → Pass → fuzzy 环:清洗出「耳かきボイス」→ 最高分 82 vs 次高 79,差距小于歧义阈值
    -
    → Medium → 音声管线配置"Medium 全复核" → NeedsReview(附前 5 候选与逐项打分解释)
    -
    # 动画管线 fuzzy → merge :[LoliHouse] 進撃の巨人 S03
    -
    fuzzy 环:Bangumi 候选 96 分、TMDB 候选 95 分 —— 产出两个身份 → Augment(都进上下文)
    -
    merge 环:外链 id / 标题+年份强一致 → 判定同一作品 → Final(High, {bangumi:X, tmdb:Y})
    -

    6.3 富集相:并行 union 与合并策略

    -
      -
    • 并行富集:身份定案后,对身份集合内所有 Provider 并行 EnrichAsync,字段 union 进 DomainFieldBag——这是"跨库合并补语言"(AniList 补产地、Bangumi 补中文名)的一般化。策略可配:PrimaryOnly / UnionAll
    • -
    • 身份对齐:并行 union 的前提是知道 AniList#123 == Bangumi#456。WorkIdentity 从单个 (source, key) 变为集合;对齐证据 = payload 外链 id(Capabilities.ExternalIdKinds 声明)> 匹配期等价(同轮高分共现)。存储侧无需改动——ResourceSourceLink 本就是一资源多行、每源一行。
    • -
    • per-field 合并策略:同字段冲突(两库发行日期不同)→ 默认 fill-if-empty 按 Provider 顺序;列表字段(tags)做集合并;多语言字段按语言键合并;允许 per-field 覆盖优先序。DomainFieldBag 每个值带 provenance(哪个 Provider 给的):复核 UI 展示依据,且"换合并策略重算"零网络请求(各 Provider 的 MetadataJson 分开缓存)。
    • -
    • 配额:扇出使请求量倍增——每 Provider 独立信号量限流;富集默认只对 High 定案执行。
    • -
    • 身份落库:Applied 之后按身份集合逐源ResourceSourceLink(Source + SourceKey + MetadataJson)。Enhancer 后续增强同一资源时优先读该链接(§11 Enhancer 行),不再从文件名瞎猜关键词——整理与增强从两套匹配收敛为一套。
    • -
    - -

    §7用户自定义命名与目录规则回答「完全自由的布局」

    -

    7.1 模型:规则集 → 规则 → 路径模板

    -
    OrganizeRuleSet                       // 绑定一个整理目标根目录,如 Z:/Library
    - ├─ OrganizeRule (Priority=1)         // Scope: 域=动画 且 属性[IP]非空
    - │    PathTemplate = "{franchise}/动画/{title:lang(zh-Hans)|title:lang(ja)} [{year}]"
    - ├─ OrganizeRule (Priority=2)         // Scope: 域=同人音声
    - │    PathTemplate = "音声/[{code}] {dlsite.circle} - {title:sanitize:truncate(80)}"
    - └─ OrganizeRule (默认兜底)            // Scope: 空 = 全部;平铺示例 ↓
    -      PathTemplate = "{franchise|title}-{domainLabel}-{fileName}"
    -
    • Scope(作用域):复用 ResourceSearchFilterGroup(与资源搜索、ResourceProfile.Search 同一模型同一 UI 组件)+ 域限定 + PathFilter(扩展名组/层级/正则)。有序匹配,首个命中生效——与防火墙规则同心智。
    • PathTemplate/ 即目录层级。想要「IP>类型>文件」就写三段;想要平铺就写一段。层级深度、分隔风格完全由模板表达,系统不预设任何结构
    • CollisionPolicy(冲突策略):目标已存在时 → AutoSuffix(追加 [2] / 追加码)/ Merge(并入同目录,适合多版本/多碟)/ Skip / Review。每规则可覆盖规则集默认。
    -

    7.2 模板 DSL(泛化现有显示名模板引擎)

    -
    segment      := (literal | placeholder | group)*
    -placeholder  := "{" selector (":" formatter)* ("|" alternative)* "}"
    -selector     := title | year | code | franchise | domainLabel | fileName
    -              | {域字段}  如 dlsite.circle · music.albumArtist · anime.season
    -              | prop("属性名")            // 任意 Bakabase 资源属性,含自定义属性
    -formatter    := lang(zh-Hans|ja|en|romaji) · pad(n) · date(yyyy) · case(upper|lower)
    -              · sanitize · truncate(n)    // sanitize: 非法字符/保留名/结尾点空格清理
    -alternative  := 依序回退:{title:lang(zh-Hans)|title:lang(ja)|fileName}
    -group        := "[" ... "]"               // 组内任一占位符为空 → 整组丢弃(沿用显示名
    -                                             模板"包装符空值丢弃"的既有语义与实现)
    -

    示例——同一批文件,两种用户规则,两种世界:

    -
    # 规则 A:按 IP 分层合集
    -
    Z:/Library/物语系列/动画/化物語 [2009]/…
    -
    Z:/Library/物语系列/音乐/君の知らない物語/…
    -
    Z:/Library/物语系列/漫画/化物語 (vol.01-08)/…
    -
    # 规则 B:平铺 <IP>-<类型>-<文件>
    -
    Z:/Flat/物语系列-动画-化物語 [2009].mkv
    -
    Z:/Flat/物语系列-音乐-君の知らない物語.flac
    -
    • 渲染管线:占位符求值 → 空值组丢弃 → 逐段 sanitize(Windows 非法字符、保留名、260 长度预算自顶向下分配)→ 拼接目标路径。求值器对域字段走 DomainFieldBag(§8),对 prop() 走现有 StandardValue 显示值处理器——与显示名模板同一取值路径。
    • 实时预览:规则编辑页右侧用抽样条目即时渲染目标树(媒体库模板编辑器的 SamplePaths 模式),保存前所见即所得。
    • 占位符可用性随 Pipeline:域字段占位符按作用域内可能命中的 Pipeline 组件集合提示与校验(§5"字段随组件走");引用了不可用字段的模板在预览中高亮,运行时按 | 回退链降级。
    • 与 ResourceProfile.NameTemplate 的关系:刻意分离。NameTemplate 决定显示名(虚拟层),OrganizeRule 决定物理路径;共享同一模板引擎与语法,但互不影响——用户可以物理平铺 + 显示名分层,反之亦然。
    - -

    §8域特定字段的类型安全设计回答「Extra 是否要 type-safe」——要,且不发明第二套类型系统

    -

    结论:需要。字符串袋在三处会出问题:模板格式化({music.trackNo:pad(2)} 需要知道是数字)、写回资源属性(需要 StandardValue 类型对齐,类型错配会损坏数据)、跨库合并(同字段不同 Provider 的值要能比较)。但类型骨架复用 StandardValueType/PropertyType,与 IEnhancerTargetDescriptor(ValueType + PropertyType 并列)同构:

    -
    public sealed record DomainFieldDefinition(
    -    string Key,                          // "circle";模板中经 "{域id.Key}" 引用
    -    StandardValueType ValueType,         // String / ListString / Decimal / DateTime…(9 种之一)
    -    PropertyType SuggestedPropertyType,  // 写回属性时的建议类型:SingleLineText / Multilevel / Tags…
    -    bool Multilingual = false);          // true → 值为 LocalizedText,模板可 :lang() 选取
    -
    -public sealed class DomainField<T>(string key, StandardValueType type) { … }
    -
    -public static class DoujinAudioFields    // 每个域一个静态字段表 → 编译期检查
    -{
    -    public static readonly DomainField<string>       Circle = new("circle", StandardValueType.String);
    -    public static readonly DomainField<List<string>> Cv     = new("cv",     StandardValueType.ListString);
    -    public static readonly DomainField<DateTime>     ReleaseDate = new("releaseDate", StandardValueType.DateTime);
    -}
    -
    -public sealed class DomainFieldBag       // ResolvedWork.Extra 的替代者
    -{
    -    public T? Get<T>(DomainField<T> field);
    -    public void Set<T>(DomainField<T> field, T? value);  // 写入校验 ValueType,错配即抛
    -    public string SerializeToJson();                     // 持久化进 MetadataJson
    -}
    -
    • 与属性的双向转换:用户在域设置页把字段映射到资源属性(如 circle → 自定义属性「社团」,缺省按 SuggestedPropertyType 自动建属性——Enhancer target 的 options 自动建属性已是同一模式)。写入走 PropertyValueFactory;类型不同时走 StandardValueSystem.GetConversionRule 的既有转换规则并提示损失。
    • 通用规范字段(title/year/code/franchise/workType)不进袋子,是 MatchedWork 的强类型属性;袋子只装真正域特定的部分。这保持了初版"不污染通用模型"的意图,同时把类型安全做满。
    • 不做的事:不为每个域生成专属 C# record + 反射映射(收益低、迁移成本高);不引入运行时 schema 语言(字段 schema 就是 C# 声明,随插件编译)。
    - -

    §9混合库与 IP 合集按 IP 聚合动画/漫画/音乐/画集

    -
    1. IP 是规范化属性,不是新实体:整理器把解析出的 franchise 写入一个内置约定的属性(Multilevel「系列/IP」,支持层级如 物语系列/伤物语)。它同时服务于:路径模板 {franchise}、资源搜索/筛选、显示名模板。
    2. FranchiseResolver 解析链(可各域覆盖):
      1. Provider 关系图:Bangumi subject 关联(动画↔漫画↔音乐同 IP)、AniList relations、DLsite 系列字段 —— 强证据;
      2. 别名聚类:Alias 模块 + SpecialText 清洗后的标题聚类 —— 中证据;
      3. 用户裁定:复核页手动归属,写 Override —— 最高优先级。
    3. 跨域一致性:同一 Job 内,不同域的条目解析出的 franchise 经别名表归一(「Monogatari Series」=「物语系列」),确保动画和 OST 落进同一个合集目录。
    4. 混合目录路由:§5 的作用域绑定 + Detector 置信度分流保证一个乱堆目录里各条目各归其 Pipeline;franchise 属性再把它们在目标布局上重新聚拢——分流与聚合解耦,这正是轴④⑤退位给规则系统后才可能的表达。
    - -

    §10计划 / 执行 / 回滚与资源一致性v2 的关键增量层

    -

    10.1 计划(干跑,默认形态)

    -
    • 逐条目产出 OrganizeOperation 序列(Mkdir/Rename/Move/MergeDir),含冲突检测(目标存在、同 Job 内目标互撞、源在目标子树内等)与每条目的 NeedsReview 标记。
    • 预览 UI:目标树视图 + 逐条目 diff(BulkModification 预览页同款交互),可单条目排除/改判。
    -

    10.2 执行(用户确认后的独立 BTask)

    -
    1. 逐操作:journal 写 Intent → 执行文件系统操作 → 同事务更新 Resource.Path(含子路径级联)与 PathMark → journal 写 Done。崩溃恢复时按 Intent/Done 差集续跑或回退。
    2. 同卷 rename 与跨卷 copy+delete 区分记录(回滚语义不同);执行前做剩余空间与路径长度预检。
    3. 目标根在媒体库内 → 触发既有同步管线,未入库文件成为新资源;随后写 ResourceSourceLink + 按域字段映射写属性(走 Enhancer 转换链)。整理完成的资源立即带全元数据。
    4. 被覆盖/被合并的旧文件一律走回收站(系统 trash),不做物理删除。
    -

    10.3 回滚

    -
    • UndoAsync(jobId):逆序回放 journal(Done 条目 → 反向操作 → 标记 Undone),同步回滚 Resource.Path。Journal 保留期限可配置(默认 30 天),过期由既有清理任务收割。
    - -

    §11对现有模块的调整建议用户允许的架构再优化

    - - - - - - - -
    模块调整收益
    Enhancer增加 SourceLink 快路径AbstractKeywordEnhancer 构建 context 前先查资源的 ResourceSourceLink(同 Source 且 MetadataJson 未过期)→ 直接用缓存元数据,跳过文件名猜关键词与网络请求整理与增强收敛为一套匹配;被整理器改过名的文件不会因文件名变化而增强失败;省网络配额
    FileMover定位为「进料口」:监听目录的目标可指向某个 OrganizeRuleSet(先移入暂存 → 自动触发整理 Job)。长期由 Organizer 的 watcher 模式吸收,FileMover 转纯兼容入口「下载完成 → 自动归位」的完整自动化闭环;避免两套移动逻辑长期并存
    FileNameModifier保留为手动工具,不合并。其操作枚举(Case/增删替换)可被模板 formatter 引用复用职责清晰:确定性批量改名 vs 语义化整理
    ResourceSource 枚举扩展为与 Provider 名对齐的完整清单(Bangumi/Tmdb/AniList/MusicBrainz…)ResourceSourceLink 成为全域身份登记表
    Comparison暴露一个服务级入口供 Organizer 在 Plan 阶段调用(当前面向 UI 会话)移动前发现「目标库里已有同作」→ 提示合并/跳过,而非制造重复
    Workflow复用模式,不复用引擎(§5.3):Organizer 不跑在 WorkflowRunner 上;组件注册表、descriptor 配置表单、运行统计等机制借鉴或共用其前端实现。M4 注册触发器("整理完成")与活动("运行某 Pipeline / 规则集"),item 类型走 IWorkflowItemTypeDescriptor外层自动化(订阅→下载→整理→通知)由用户在 Workflow 编辑器编排;整理内核保持强类型状态机与两阶段安全模型
    ResourceProfile不改。NameTemplate 继续只管显示名;文档与 UI 明确两套模板的分工(§7.2)避免"改显示名模板导致文件被移动"的惊吓
    - -

    §12场景差异矩阵标注 Provider 现状:已有 = ThirdParty 已存在客户端,需新增

    - - - - - - - - -
    内置 Pipeline① 主信号② 主键③ 数据源④⑤ 默认规则预设(用户可改)
    同人音声/DLsite RJ/VJ/BJ 码DLsite 产品码(精确)DLsite音声/[{code}] {dlsite.circle} - {title}
    通用音频(非 RJ 规范音声等)模糊内嵌标签+文件名标签/模糊(可无外部源)(可空)MusicBrainz 可选音声/{music.albumArtist|title}/{title}
    动画 模糊目录/文件名模糊标题→idBangumi Tmdb AniList{franchise|title}/动画/{title} [{year}]
    漫画/本子 模糊目录名+ComicInfoid / 画廊哈希ExHentai Bangumi MangaDex{franchise|title}/漫画/{title} (vol.{vol:pad(2)})
    音乐 指纹内嵌标签(+指纹)标签/MBIDMusicBrainz AcoustID(可选){music.albumArtist}/{music.album}/{music.trackNo:pad(2)} {title}
    电影/剧集 /模糊文件名(tt/SxxEyy)TMDB/IMDb idTmdb{title} ({year})/{title} SxxEyy
    游戏 文件名+appidSteam appid / 序列号Steam IGDB游戏/{platform}/{title}
    AV 番号番号(精确)Javbus/Javdb/…(10+ 站){av.code} {title}
    -

    三种主键原型(码/指纹/模糊)与初版一致;每行即一条内置 Pipeline(只读种子,可 fork 调整组件,§5)。Bakabase 的既有 Provider 覆盖面已足够先做 4 条内置 Pipeline 而无需任何新客户端。

    -

    12.1 内置 Pipeline 组件构成 统一骨架下的槽位填充对照(Detector → Signal 链 → Parser → Provider 链 → Resolver 链)

    -

    组件以 kind 标注;同名组件 = 同一份代码、不同配置实例。上表给出布局预设,本表给出各槽位的具体填充。其中 signal.rj-code / sxxeyy / imdb-code / av-code 均为 signal.pattern 引擎的内置规则集实例(§5.2),可 fork 自定义提取规则:

    - - - - - - - - -
    内置 PipelineSignal 链(有序 = 证据强度序)Provider 链(有序)Resolver 链特有字段(决定模板占位符)
    同人音声/DLsitesignal.rj-code → signal.filenameprovider.dlsiteexact-code → fuzzy高阈值,Medium 全复核dlsite.circle/cv/releaseDate
    通用音频signal.embedded-tag → signal.filename(空),可选挂 MusicBrainztag-first不联网:标签→清洗文件名定名;缺标签→复核music.albumArtist/album/trackNo
    动画signal.filename字幕组词表清洗 → signal.nfoprovider.bangumi → provider.tmdb(→ anilist)fuzzyAlias 展开+Dice+年份加权 → merge跨库补语言anime.season/episode/subGroup/origin
    漫画/本子signal.comicinfo → signal.gallery-token[作者] 题名 [汉化组] 语法 → signal.filenameprovider.exhentai → provider.bangumi(→ mangadex)fuzzy括号语法定制权重manga.author/volume/translator/parody
    音乐signal.embedded-tag → signal.filename(→ 可选 signal.acoustid)provider.musicbrainzexact-code标签内 MBID → fingerprint → fuzzy通用音频字段 + music.mbid/discNo
    电影/剧集signal.imdb-codett → signal.sxxeyy → signal.filenameprovider.tmdbexact-code → fuzzyvideo.season/episode/edition
    游戏signal.steam-appid → signal.filenameprovider.steam(→ igdb)exact-code → fuzzygame.platform/developer
    AVsignal.av-code番号provider.javbus → provider.javdb → …现有 10+ 客户端依序 fallback仅 exact-code无码→复核,不做模糊av.code/actress/studio
    -

    Detector 槽未列入表中,因为都很薄:detector.extension(扩展名组)是共同底座,个别管线叠加特征加分项(音声叠"路径含 RJ 码"、动画叠"[字幕组] 括号风格"、漫画叠"图片序列目录/压缩包")。同人音声/DLsite 与通用音频对同一个音频文件竞争时:有码前者胜出,无码后者接住——非 RJ 规范用户的默认行为开箱即正确,连 fork 都不需要。

    -

    真正把八条管线区分开的是四条线:

    -
      -
    • Resolver 姿态是个光谱:AV 是"仅精确码,宁可复核不可猜"(该域模糊匹配风险过高);音声/电影/游戏/音乐是"精确码短路 → 模糊兜底";动画/漫画是"纯模糊 + 打分分档";通用音频干脆不做外部匹配——它证明 Provider 槽可以为空,整理不必然等于刮削。
    • -
    • 主信号来源不同:码(音声/AV/电影/游戏)、内嵌标签(音频/音乐)、伴随文件(漫画 ComicInfo、影视 nfo)、纯文件名(动画)。Signal 链的顺序就是证据强度的顺序。
    • -
    • 字段词汇表随组件走:模板里能写 {dlsite.circle} 还是 {manga.translator},完全由该 Pipeline 装了哪些组件决定(§5"字段随组件走")。
    • -
    • 复核阈值不同:音声标题短、模糊不可靠 → Medium 档全复核;动画标题信息量大 → Medium 可配置为抽查。
    • -
    -

    组件复用收益signal.filename(SpecialText 清洗)出现在全部 8 条中;resolver.fuzzy 一份代码五处配置;signal.embedded-tag 同时服务通用音频与音乐——通用音频本质上就是"音乐 Pipeline fork 掉 Provider"的形态,反向验证了 fork 模型的合理性。

    - -

    §13实施路线图垂直切片、每步可收敛可交付

    - - - - - -
    里程碑内容验收 / 收敛指标
    M0内核表(含 OrganizePipeline)+ 状态机流水线 + 组件注册表 + signal.pattern 规则引擎(RJ 码即内置规则集)+ 内置「同人音声/DLsite」Pipeline(RJ 码短路,复用 DLsiteClient;纯种子数据,暂无编辑器)+ 干跑计划 + journal 执行/回滚 + 最小复核列表页一个混乱音声目录端到端整理成功;kill 进程后续跑不重不漏;Undo 完整还原;RJ 码条目 0 人工
    M1规则集/模板 DSL + 规则编辑器(实时预览目标树)+ 冲突策略 + Resource.Path/PathMark 联动 + ResourceSourceLink 登记与属性写入 + Pipeline fork 与最小编辑器(组件开关/排序/阈值)+ 内置「通用音频」预设用户可自由表达分层/平铺布局;已入库资源整理后属性与播放记录零丢失;非 RJ 规范用户可零代码摘除 DLsite 能力
    M2内置动画 Pipeline(模糊打分:SpecialText 清洗 + Alias + 跨 Provider 合并)+ Override 全流程 + Enhancer SourceLink 快路径 + Pipeline 编辑器完整版(适用性校验/上游更新提示)+ SignalRuleSet 自定义编辑器(样例试跑)+ 检索 FanOutUnion / merge 环 / 富集 union(provenance + per-field 合并策略)复核率随轮次单调下降(Override 命中率可观测);同一文件不会被问第二遍
    M3内置漫画 / 音乐 Pipeline(TagLib 标签信号;MusicBrainz 客户端)+ franchise 解析链 + IP 合集布局 + Comparison 去重闸门动画/漫画/OST/画集按 IP 聚拢的演示库跑通;重复作品在 Plan 期被拦截
    M4watcher 自动整理(FileMover 进料口)+ Workflow 触发器/活动 + AI 辅助低置信消歧 + 规则预设分享(Sharable 体系)下载→整理→通知全自动链路;低置信条目 AI 建议采纳率可观测
    -

    每个里程碑都是可发布的完整功能,不存在「基建全铺完才见效」的阶段。M0 选同人音声是因为:精确码型近零歧义、Provider 已存在、单域即可验证内核全部机制(状态机/计划/journal/复核/续跑)。

    - -

    §14风险与未决点

    -
      -
    • 高置信错配:精确码也会撞上改名党(文件名里嵌错码)。缓解:Applied 全量 journal 可回滚 + 复核页支持"事后改判 → 自动重整理"。
    • -
    • Windows 路径限制:260 字符与非法字符在深层模板下易触雷。模板渲染期自顶向下做长度预算 + sanitize,超限自动截断并在预览中高亮。
    • -
    • 执行期外部变更:Plan 与 Apply 之间文件可能被移走。Apply 前逐条目校验指纹(大小+mtime),失配 → 该条目回退 NeedsReview,不整批失败。
    • -
    • Provider 风控:批量整理的请求量远超增强器单资源模式。依赖现有各站点 Handler 限流 + Job 级并发上限(每 Provider 独立信号量)+ MetadataJson 缓存优先。
    • -
    • 压缩包语义:音声/漫画大量以压缩包为条目单位。M0 将压缩包视为叶子条目(不解包);解包整理(借 CompressedFileService)列为 M3 未决项。
    • -
    • franchise 权威性:跨库 IP 归属没有全局权威源,别名聚类会有错并。默认仅强证据(Provider 关系图)自动归并,聚类结果一律进复核。
    • -
    • 内置预设与 fork 漂移:内置 Pipeline 迭代后用户 fork 不自动跟进,可能长期落后甚至引用已废弃组件。缓解:fork 记录 ForkedFromId,上游变更时提示 diff;组件实例支持"重置为上游默认";废弃组件保留兼容运行并在编辑器中标记。
    • -
    • 词表外挂:提取规则部分已由 SignalRuleSet 落地为"数据 + Sharable 分享"(§5.2);剩余未决为清洗词汇(SpecialText)与 Pipeline 整体的分享打包格式——M4 决策。
    • -
    -
    Bakabase · 自动整理本地文件 · 架构 v2(融合版)· 本页为单一来源,替代初版 architecture_1.html。引用的既有类型/路径均已在当前代码库核实。
    -
    diff --git a/docs/local-file-organization/README.md b/docs/local-file-organization/README.md new file mode 100644 index 000000000..d53e03ac0 --- /dev/null +++ b/docs/local-file-organization/README.md @@ -0,0 +1,69 @@ +# 本地文件自动整理(Organizer)· 设计文档 + +> 状态:**设计阶段**(尚未开工)。本目录是该功能的**唯一真相源**:每个概念只在其归属文档中定义一次,其余文档以链接引用,不做内容复制。 + +## 目标 + +把任意混乱的本地目录,按用户可定制的规则整理成结构化媒体库: + +``` +扫描 → 提取线索 → 匹配身份 → 富集字段 → 按模板计划目标路径 → 干跑确认 → 执行/回滚 +(循环处理,人工裁定持久化,每轮待处理集合单调缩小,直至收敛) +``` + +支持混合类型目录(动画/漫画/音乐/音声混放各归其所),支持按 IP 跨域聚合成合集。 + +## 设计原则 + +1. **干跑默认**:计划只读,执行需确认,一切已执行操作可回滚。 +2. **资源一致性**:整理不是裸文件操作。`Resource.Path`、PathMark、属性值、播放记录随移动保持;匹配到的元数据落库复用。 +3. **组件化,骨架固定、槽内自由**:流水线阶段顺序固定;每个槽位的组件用户可选换。内置能力一律做成"只读种子数据 + 可 fork"。 +4. **收敛**:Override(人工裁定持久化)+ 元数据缓存 + 条目状态机,使重跑不重复劳动。 +5. **复用优先**:ThirdParty(33 个站点客户端)、Enhancer 转换链、Property/StandardValue 类型系统、BTask、Comparison、Alias、SpecialText 直接使用,见[复用总表](foundations.md#现有资产复用总表)。 + +## 分层与文档地图 + +自底向上(构建顺序即阅读顺序): + +| 层 | 模块 | 一句话职责 | +|---|---|---| +| 基础定义 | [foundations](foundations.md) | 概念表、组件模型、类型系统、复用总表 | +| 原子能力 | [01 文本处理规则](modules/01-text-processing.md) | 用户定义规则,对"一段文本"清洗 + 类型化提取(来源无关) | +| 原子能力 | [02 条目提取器](modules/02-extractors.md) | 面向文件/目录采集线索:文本规则应用 + 结构化读取 | +| 原子能力 | [03 元数据 Provider](modules/03-providers.md) | 外部站点统一适配:搜候选 / 精确码取 / 按身份富集 | +| 决策能力 | [04 身份判定](modules/04-resolution.md) | Resolver 责任链:线索 → 身份 + 置信档 + 解释 | +| 决策能力 | [05 字段富集](modules/05-enrichment.md) | 多库并行取详情,per-field 合并,带来源追溯 | +| 落盘能力 | [06 布局](modules/06-layout.md) | 路径模板 DSL + 整理规则集:完全自由的目录/命名表达 | +| 落盘能力 | [07 执行](modules/07-execution.md) | 计划 / journal / 回滚 / 资源一致性联动 | +| 顶层编排 | [08 编排](modules/08-orchestration.md) | Pipeline 组合、路由、条目状态机、BTask 宿主、复核 | +| 顶层编排 | [09 合集](modules/09-collections.md) | franchise/IP 解析与跨域聚合布局 | + +依赖只允许**上层引用下层**;下层模块不感知上层(01 甚至不知道整理器的存在)。 + +## 执行计划 + +按底层 → 顶层构建。**每个阶段完成后都有独立可用、可验证的能力**(详见各模块文档的"完成后获得的能力"节);最终交付仍可垂直组合——例如 01+02+03(仅 DLsite)+04(仅 exact-code 环)+06(单条规则)+07 就构成"音声整理"最小闭环。 + +| 阶段 | 模块 | 完成后获得的能力(摘要) | +|---|---|---| +| P1 原子能力 | 01, 02, 03 | 独立的文本规则管理与试跑页;对任意条目产出类型化线索;对已适配站点统一查询(可做成调试页直接验证) | +| P2 决策能力 | 04, 05 | 给定线索集返回可解释的身份判定;多库字段合并含来源。纯逻辑层,可全量单测 | +| P3 落盘能力 | 06, 07 | 模板渲染纯函数 + 规则预览;安全执行与完整回滚,资源零丢失 | +| P4 顶层编排 | 08, 09 | 端到端 Pipeline(内置种子 + fork)、断点续跑、复核收敛、IP 合集 | + +## 当前进度 + +> 维护规则:进度**只在本表**更新,模块文档内不写进度。 + +| 模块 | 概要设计 | 详细设计 | 实现 | +|---|---|---|---| +| foundations | ✅ | 🚧 | ⬜ | +| 01 文本处理规则 | ✅ | 🚧(UI 交互细节待定) | ⬜ | +| 02 条目提取器 | ✅ | ⬜ | ⬜ | +| 03 元数据 Provider | ✅ | ⬜ | ⬜ | +| 04 身份判定 | ✅ | ⬜ | ⬜ | +| 05 字段富集 | ✅ | ⬜ | ⬜ | +| 06 布局 | ✅ | ⬜ | ⬜ | +| 07 执行 | ✅ | ⬜ | ⬜ | +| 08 编排 | ✅ | ⬜ | ⬜ | +| 09 合集 | ✅ | ⬜(依赖最多,最后细化) | ⬜ | diff --git a/docs/local-file-organization/foundations.md b/docs/local-file-organization/foundations.md new file mode 100644 index 000000000..e0e986e4a --- /dev/null +++ b/docs/local-file-organization/foundations.md @@ -0,0 +1,105 @@ +# 基础定义 + +> 被所有模块引用的共享概念、组件模型与类型系统。本文件不含任何流程编排——流程见[08 编排](modules/08-orchestration.md)。 + +## 核心概念 + +| 概念 | 定义 | 详见 | +|---|---|---| +| Item(条目) | 一次整理的处理单位:一个文件或目录(含压缩包),带稳定指纹(路径+大小+mtime 摘要),用于断点续跑与 Override 定位 | [08](modules/08-orchestration.md) | +| Clue(线索) | 从条目上提取出的**类型化**键值:`title/year/code/episode…` 或域字段,绑定 `StandardValueType` | [02](modules/02-extractors.md) | +| TextRuleSet(文本规则集) | 用户定义的清洗 + 提取规则,作用于"一段文本",来源无关 | [01](modules/01-text-processing.md) | +| Extractor(提取器) | 面向条目产出 Clue 的组件:文本规则应用器或结构化读取器 | [02](modules/02-extractors.md) | +| Provider(数据源) | 一个外部元数据库的统一适配:搜候选 / 精确码取 / 按身份富集 | [03](modules/03-providers.md) | +| Candidate / WorkIdentity | 匹配候选;定案后的作品身份是 **(source, key) 的集合**(同一作品在多库的 id) | [04](modules/04-resolution.md) | +| Band(置信档) | High / Medium / Low / None。分数→档位映射是 Pipeline 配置 | [04](modules/04-resolution.md) | +| FieldBag(字段袋) | 类型安全的域字段容器,每个值带 provenance(来源 Provider) | 本文件[类型系统](#类型系统) | +| Rule / RuleSet(整理规则) | 作用域 + 路径模板 + 冲突策略;有序首中生效 | [06](modules/06-layout.md) | +| Journal(执行账本) | 逐操作 Intent/Done 两段记录;回滚 = 逆序回放 | [07](modules/07-execution.md) | +| Pipeline(流水线) | 一条可运行的整理配置:各槽位组件实例的组合,**数据而非代码** | [08](modules/08-orchestration.md) | +| Override(人工裁定) | 复核时人的决定,按条目指纹持久化、跨轮生效 | [04](modules/04-resolution.md) | +| Franchise(IP/系列) | 跨域的作品归属,规范化为一个资源属性 | [09](modules/09-collections.md) | + +### 命名约定:为什么不叫 Signal + +早期草稿把线索采集层叫 "Signal"。弃用理由:signal 暗示"信号/触发/流",而这一层实际做的是**从原始材料中提取类型化线索**。现命名:通用文本层 = TextRule,条目层组件 = Extractor,产物 = Clue,对应的条目状态 = `Extracted`。 + +## 组件模型(所有槽位共享) + +Extractor、Provider、Resolver 策略、Detector 都遵循同一套组件机制(注册表/描述符/配置表单三件套借鉴 Workflow 模块的 `WorkflowActivityRegistry` 模式,但**不复用其执行引擎**,理由见 [08](modules/08-orchestration.md#与-workflow-模块的关系)): + +```csharp +public interface IOrganizerComponentDescriptor +{ + string Kind { get; } // "extractor.pattern" / "provider.dlsite" / "resolver.fuzzy" + OrganizerSlot Slot { get; } // Detector / Extractor / Provider / ResolverPolicy + string DisplayName { get; } + Type? ConfigType { get; } // 配置 schema,编辑器据此渲染表单 + ComponentApplicability Applicability { get; } // 适用形态:音频/压缩包/任意…(编辑器校验) + FieldDefinition[] Fields { get; } // 该组件产出的域字段 → "字段随组件走" +} +``` + +- **组件 = 代码,实例 = 数据**:同一组件可在多处以不同配置实例化(configJson)。 +- **内置皆种子**:内置组件实例、内置 TextRuleSet、内置 Pipeline 都是只读种子数据;用户 fork(copy-on-write)后自由修改。上游更新时 fork 收到提示,不强制同步。 +- **可分享**:种子数据形态的配置(TextRuleSet、Pipeline、规则集)走现有 Sharable 体系分享。 +- **DI 注册**:同 Enhancer 的多实现枚举注册模式;不做独立程序集/热插拔。 + +## 类型系统 + +**唯一类型骨架是 `StandardValueType`(9 种)**,不发明第二套类型系统。先例:`IEnhancerTargetDescriptor` 已同时携带 `StandardValueType ValueType` + `PropertyType PropertyType`。 + +```csharp +public sealed record FieldDefinition( + string Key, // "circle";模板中经 "{域标签.Key}" 引用 + StandardValueType ValueType, // String / ListString / Decimal / DateTime… + PropertyType SuggestedPropertyType, // 写回资源属性时的建议类型 + bool Multilingual = false); // true → 值为多语言文本,模板可 :lang() 选取 + +public sealed class TypedField(string key, StandardValueType type) { /* 编译期检查的字段句柄 */ } + +public sealed class FieldBag // 域字段容器 +{ + public T? Get(TypedField field); + public void Set(TypedField field, T? value, string providerName); // 写入校验 ValueType;记录 provenance + public string SerializeToJson(); // 持久化进 MetadataJson +} +``` + +- **通用规范字段**(title/year/code/workType/franchise)是 `MatchedWork` 的强类型属性;FieldBag 只装真正域特定的部分(circle/cv/albumArtist…)。 +- **provenance**:每个值记录来源 Provider——复核 UI 展示依据;"换合并策略重算"零网络请求(见 [05](modules/05-enrichment.md))。 +- **与资源属性双向转换**:字段映射到属性(缺省按 `SuggestedPropertyType` 自动建属性,Enhancer target options 已是同一模式);写入走 `PropertyValueFactory`,跨类型走 `StandardValueSystem.GetConversionRule` 既有转换规则。 +- **字段随组件走**:一条 Pipeline 可用的域字段 = 其组件 `Fields` 声明的并集;模板占位符按此提示与校验(见 [06](modules/06-layout.md))。 + +## 数据表索引 + +表定义写在归属模块内,此处仅索引。所有表经 EF 迁移生成(禁手写,见仓库根 `CLAUDE.md`);文件类产物一律入 `IAppService.AppDataDirectory`(见 `.claude/rules/appdata-paths.md`)。 + +| 表 | 归属 | +|---|---| +| TextRuleSet / TextRule | [01](modules/01-text-processing.md) | +| OrganizeOverride | [04](modules/04-resolution.md) | +| OrganizeRuleSet / OrganizeRule | [06](modules/06-layout.md) | +| OrganizeJournalEntry | [07](modules/07-execution.md) | +| OrganizePipeline / OrganizeJob / OrganizeItem | [08](modules/08-orchestration.md) | + +## 现有资产复用总表 + +以下路径与类型均已在代码库核实: + +| 现有资产 | 位置 | 被谁复用 | +|---|---|---| +| 33 个第三方客户端 + 统一 HTTP 基建(`AddBakabaseHttpClient`、站点专属 Handler:限流/Cookie/代理) | `src/modules/Bakabase.Modules.ThirdParty/ThirdParties/` | [03](modules/03-providers.md) | +| Enhancer 转换链(context→StandardValue→属性,含选项自动建属性);`AbstractEnhancer<,,>` | `src/modules/Bakabase.Modules.Enhancer/` | [05](modules/05-enrichment.md)、[07](modules/07-execution.md) | +| `ResourceSourceLink`(Source + SourceKey + MetadataJson + 封面缓存),一资源多行 | `src/abstractions/.../Models/Domain/ResourceSourceLink.cs` | [04](modules/04-resolution.md)、[07](modules/07-execution.md) | +| `ISpecialTextService`:固定枚举清洗词汇表(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language) | legacy | [01](modules/01-text-processing.md)、[04](modules/04-resolution.md) | +| Alias 模块(别名解析) | `src/modules/Bakabase.Modules.Alias/` | [04](modules/04-resolution.md)、[09](modules/09-collections.md) | +| Comparison 模块(加权规则去重,11 种策略含文本相似度) | `src/modules/Bakabase.Modules.Comparison/` | [04](modules/04-resolution.md)、[07](modules/07-execution.md) | +| `ResourceSearchFilterGroup` + `PathFilter`(过滤模型与 UI) | `src/abstractions/.../Models/Domain/` | [06](modules/06-layout.md)、[08](modules/08-orchestration.md) | +| 显示名模板引擎(`{属性名}` 占位 + 包装符空值丢弃,`ResourceUtils.SplitDisplayNameTemplateIntoSegments`,有测试) | legacy `ResourceService` | [06](modules/06-layout.md) | +| BTask(一次性 fluent builder、进度/暂停/取消、冲突键、SignalR) | `src/abstractions/.../Components/Tasks/` | [08](modules/08-orchestration.md) | +| Workflow 模块(注册表/描述符/配置表单模式;触发器与活动) | `src/modules/Bakabase.Modules.Workflow/` | 本文件组件模型、[08](modules/08-orchestration.md) | +| `CompressedFileService`(压缩包处理) | legacy | [02](modules/02-extractors.md)、[07](modules/07-execution.md) | +| FileMover / FileNameModifier / file-processor 页 | legacy + `src/web/src/pages/` | [08](modules/08-orchestration.md)(触发形态)、[07](modules/07-execution.md) | +| Sharable 体系(配置分享) | `src/abstractions/.../Models/Domain/Sharable/` | 组件模型(种子分享) | +| AI 模块(多 Provider、缓存、Enhancer 桥接) | `src/modules/Bakabase.Modules.AI/` | [04](modules/04-resolution.md)(后期辅助消歧) | diff --git a/docs/local-file-organization/modules/01-text-processing.md b/docs/local-file-organization/modules/01-text-processing.md new file mode 100644 index 000000000..45f2f622e --- /dev/null +++ b/docs/local-file-organization/modules/01-text-processing.md @@ -0,0 +1,59 @@ +# 01 · 文本处理规则(TextRule) + +> 层级:原子能力 | 依赖:无([SpecialText 词汇表](../foundations.md#现有资产复用总表)为可选引用) | 被依赖:[02 条目提取器](02-extractors.md) +> +> 职责:让用户创建规则,对**一段文本**执行清洗与类型化提取。目标抽象为 text,**来源无关**——可能是文件名、目录名、相对路径、伴随文本,或未来任何调用方传入的字符串。 + +## 与 SpecialText 的关系 + +现状:SpecialText 是**固定枚举的清洗词汇表**(`Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language` 七种,值为字符串对,语义硬编码在各消费点)。它是"词汇",不是"提取规则"。 + +决策:**不动 SpecialText 的表与枚举**(legacy、消费点多)。TextRule 是它的概念泛化——SpecialText 的每个枚举值是硬编码的 token 类型,TextRule 的具名捕获组就是**用户定义的动态 token 类型**。TextRule 的清洗步骤可引用 SpecialText 词汇(包装符剥离、无用词、标准化替换)。 + +## 模型 + +``` +TextRuleSet 名称 + 有序规则列表 + (可选)残余清洗开关(引用 SpecialText 词汇) +TextRule Pattern:带具名捕获组的正则(强制 MatchTimeout 防 ReDoS) + Bindings:捕获组 → 绑定目标 + Cleanup:命中后是否从待处理文本中剥离 +Binding 目标二选一: + a) 规范线索槽:title / year / code / episode / volume / … + b) 域字段:FieldDefinition(Key + StandardValueType,附解析器如日期格式) +``` + +处理语义:规则依序执行;`Cleanup=true` 的命中从文本中剥离;全部规则跑完后,剩余文本经 SpecialText 清洗成为"残余标题"候选。 + +输出: + +```csharp +public sealed record TextExtractionResult( + IReadOnlyList Clues, // 类型化线索(绑定校验过 StandardValueType) + string CleanedText); // 残余文本 +``` + +类型安全:Binding 声明了 `StandardValueType`,捕获值在产出时解析校验(如 `year` 必须能进 Decimal、日期按声明格式解析),失败的捕获丢弃并在试跑 UI 中标红——见 [foundations · 类型系统](../foundations.md#类型系统)。 + +## 数据表 + +``` +TextRuleSet Name, BuiltinKind?(内置种子来源), Rules(JSON), Enabled, CreatedAt/UpdatedAt +``` + +内置种子(只读,可 fork,语义见 [foundations · 组件模型](../foundations.md#组件模型所有槽位共享)):`rj-code`(`[BVR]J\d{6,10}` → code)、`imdb-code`(`tt\d+` → code)、`sxxeyy`(→ season/episode)、`av-code`(番号 → code)、`volume`(卷号)等。可经 Sharable 体系分享。 + +## UI + +独立管理页(不依赖整理器的任何其他部分):规则集列表 + 规则编辑器 + **试跑区**——粘贴若干样例文本,实时显示每条规则的命中高亮、提取出的类型化值、残余文本。 + +## 完成后获得的能力 + +- 用户可为私有编号体系建规则:新建规则集「我的编号」,Pattern `(?GB\d{4})` 绑定到 `code:String`;试跑 `[GB0421] 某标题 (2023)` → `code=GB0421, year=2023, cleaned="某标题"`。 +- 内置 `rj-code` 等规则集开箱可用、可 fork 改造(如放宽位数)。 +- 该能力**独立成立**:即使整理器一行未写,它已是一个可用的"文本规则工具",并可被未来其他功能(文件名批量清洗、对比归一预处理)复用。 +- 全量单测:规则引擎是纯函数(文本进、结果出)。 + +## 开放问题 + +- 多规则集组合时的优先级语义(02 中由提取器实例的顺序决定,是否够用)。 +- 正则之外是否需要第二种规则形式(分隔符切段/模板反推)——倾向于先不做,用数据说话。 diff --git a/docs/local-file-organization/modules/02-extractors.md b/docs/local-file-organization/modules/02-extractors.md new file mode 100644 index 000000000..7bf1b69ca --- /dev/null +++ b/docs/local-file-organization/modules/02-extractors.md @@ -0,0 +1,48 @@ +# 02 · 条目提取器(Extractor) + +> 层级:原子能力 | 依赖:[01 文本处理规则](01-text-processing.md)、[foundations 组件模型/类型系统](../foundations.md) | 被依赖:[04 身份判定](04-resolution.md)、[08 编排](08-orchestration.md) +> +> 职责:面向**条目**(文件/目录/压缩包)采集类型化线索(Clue)。命名说明见 [foundations · 为什么不叫 Signal](../foundations.md#命名约定为什么不叫-signal)。 + +## 两类组件 + +| 类型 | Kind | 说明 | +|---|---|---| +| 文本规则应用器 | `extractor.pattern` | 把某个 [TextRuleSet](01-text-processing.md) 应用到条目的一个**文本面**:FileName / DirName / RelativePath / SidecarText。配置 = (文本面, 规则集引用)。用户可自定义任意多个实例 | +| 结构化读取器(代码组件) | `extractor.embedded-tag`(TagLib,音频标签)、`extractor.comicinfo`(ComicInfo.xml)、`extractor.nfo`、`extractor.acoustid`(声学指纹,后期) | 读二进制/结构化内容,正则表达不了,保持为代码 | + +所有提取器实现统一契约(描述符携带 `Applicability` 适用性与 `Fields` 产出字段声明,见 [foundations · 组件模型](../foundations.md#组件模型所有槽位共享)): + +```csharp +public interface IExtractor +{ + Task> ExtractAsync(OrganizeItemContext item, CancellationToken ct); +} +``` + +## Clue 与归并(Parse) + +每条 Clue 携带:`目标槽或字段 + 类型化值 + 来源提取器 + 强度`。强度序(用于归并与后续判定): + +``` +精确码(rj/tt/序列号/包名) > 指纹 > 内嵌标签/伴随文件 > 文件名 +``` + +**Parse 归并**:多个提取器对同一槽给出冲突值(如标签里的 title 与文件名里的 title 不同)时,按提取器实例顺序 + 强度归并成 `ParsedClues`(每槽一个胜出值 + 保留候选列表供复核展示)。归并是纯函数,独立可测。 + +## 压缩包语义 + +音声/漫画大量以压缩包为条目单位。第一阶段把压缩包视为**叶子条目**(只读文件名与伴随文件,不解包);解包嗅探(借 `CompressedFileService` 读取包内条目名)作为可选提取器后续加入——它只是又一个结构化读取器,契约不变。 + +## 完成后获得的能力 + +- 对任意条目一键产出线索集,例: + - `[CANDYVOICE] RJ01017217 耳かきボイス.zip` → `{code=RJ01017217(精确码), title=耳かきボイス(文件名), circle=CANDYVOICE(文件名)}` + - `01 - Intro.flac` → `{trackNo=1, title=Intro, albumArtist=…(内嵌标签)}` +- 调试页:选一个文件/目录 + 一组提取器实例 → 展示逐提取器命中与归并结果。这个页面同时就是 [08](08-orchestration.md) Pipeline 编辑器里"提取预览"的雏形。 +- [04 身份判定](04-resolution.md)的全部输入(ParsedClues)自此就绪。 + +## 开放问题 + +- SidecarText 的发现规则(同名 .txt/.nfo/.xml?目录内唯一说明文件?)需要一个小型约定表。 +- 指纹提取(acoustid)的算力与依赖成本,放到音乐场景实装时再评估。 diff --git a/docs/local-file-organization/modules/03-providers.md b/docs/local-file-organization/modules/03-providers.md new file mode 100644 index 000000000..bef2f48fc --- /dev/null +++ b/docs/local-file-organization/modules/03-providers.md @@ -0,0 +1,50 @@ +# 03 · 元数据 Provider + +> 层级:原子能力 | 依赖:[foundations 组件模型/类型系统](../foundations.md)、ThirdParty 模块([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[04 身份判定](04-resolution.md)、[05 字段富集](05-enrichment.md) +> +> 职责:把外部元数据源适配成统一的三操作契约。适配器很薄——HTTP、限流、Cookie、代理全部由 ThirdParty 模块现有基建承担。 + +## 契约 + +```csharp +public interface IMetadataProviderAdapter +{ + string Name { get; } // "DLsite",与 ResourceSource 枚举对齐 + ProviderCapabilities Capabilities { get; } // 见下 + Task> SearchAsync(ProviderQuery q, CancellationToken ct); // 检索:关键词 → 候选 + Task GetByCodeAsync(string code, CancellationToken ct); // 精确码 → 详情 + Task EnrichAsync(WorkIdentity identity, CancellationToken ct); // 已知身份 → 详情(富集用) +} + +public sealed record ProviderCapabilities( + bool SupportsExactCode, // RJ 码 / tt / appid / 番号… + bool SupportsFingerprint, // AcoustID 等(后期) + string[] Languages, // 返回标题覆盖的语言 + string[] ExternalIdKinds); // payload 携带哪些外站 id(如 AniList 带 MAL id)→ 供身份对齐 +``` + +`MatchCandidate` 携带:多语言标题、年份、类型、本站 id、外站 id 列表、原始 payload、以及按组件 `Fields` 声明填充的 [FieldBag](../foundations.md#类型系统)。 + +## 与 ThirdParty 模块的关系 + +- 每个适配器内部就是现有客户端调用(如 DLsite 适配器 ≈ `DLsiteClient.ParseWorkDetailById`;Bangumi 适配器包一层 subject 搜索)。33 个客户端中按场景需要逐个适配,**不要求一次适配全部**。 +- 新数据源(AniList / MusicBrainz / MangaDex …)按 ThirdParty 现有约定先写客户端,再包适配器。 +- `ResourceSource` 枚举需扩全至与 Provider 名对齐(现有 PathMark/Steam/DLsite/ExHentai/Aigc 五值)。 + +## 配额与缓存 + +- 每 Provider 独立信号量限流(站点 Handler 限流之上再加 Job 级并发上限)。 +- 响应缓存:按 (Provider, key) 缓存原始 payload(进程内 + 落条目 `MetadataJson`,见 [08 数据表](08-orchestration.md#数据表));同一作品跨条目复用不重复请求。 + +## 完成后获得的能力 + +- 统一接口查询任意已适配站点,例: + - `dlsite.GetByCodeAsync("RJ01017217")` → 标题/社团/CV/发售日的结构化详情 + - `bangumi.SearchAsync("進撃の巨人")` → 候选列表(中日标题、年份、外站 id) +- 一个简单的 Provider 调试页(选 Provider + 输入关键词/码 → 看结构化结果)即可验证本模块,且顺手成为用户排查"为什么匹配不到"的工具。 +- [04](04-resolution.md)/[05](05-enrichment.md) 的全部外部数据入口自此就绪。 + +## 开放问题 + +- 需要登录态/风控严格的站点(ExHentai 等)在批量整理下的请求预算策略。 +- `ProviderQuery` 是否要携带域提示(搜"漫画"还是"动画")——倾向于要,作为可选过滤。 diff --git a/docs/local-file-organization/modules/04-resolution.md b/docs/local-file-organization/modules/04-resolution.md new file mode 100644 index 000000000..db6698908 --- /dev/null +++ b/docs/local-file-organization/modules/04-resolution.md @@ -0,0 +1,69 @@ +# 04 · 身份判定(Resolution) + +> 层级:决策能力 | 依赖:[02 条目提取器](02-extractors.md)(ParsedClues)、[03 Provider](03-providers.md)、Alias / SpecialText / Comparison([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[05 字段富集](05-enrichment.md)、[08 编排](08-orchestration.md) +> +> 职责:`ParsedClues → MatchDecision`([WorkIdentity 集合](../foundations.md#核心概念) + [置信档 Band](../foundations.md#核心概念) + 可解释打分)。**只定身份,不填字段**——字段富集在 [05](05-enrichment.md)。 + +## Resolver 责任链 + +判定逻辑组织为策略链;每环是一个组件(`resolver.*`,见 [foundations · 组件模型](../foundations.md#组件模型所有槽位共享)): + +```csharp +public interface IResolverPolicy +{ + Task ResolveAsync(ResolverContext ctx, CancellationToken ct); +} +// ResolverOutcome 三选一: +// Final(identity, band, explanation) 定案,链终止 +// Augment(enrichedContext) 补充了上下文,继续下一环 +// Pass 本环无话可说,交给下一环 +``` + +链前置:先查 **Override**(按条目指纹)——人工裁定过的直接采用。这是收敛闭环的另一半。 + +### 内置环 + +| 环 | 行为 | +|---|---| +| `resolver.exact-code` | 线索含精确码 → 对 `SupportsExactCode` 的 Provider `GetByCodeAsync` → 命中即 `Final(High)`;查无此码(下架等)→ `Pass` 而非失败 | +| `resolver.fingerprint`(后期) | 声学指纹 → 高置信命中 | +| `resolver.fuzzy` | 清洗(SpecialText)→ 别名展开(Alias)→ 按**检索策略**搜 Provider → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权 | +| `resolver.merge` | 多 Provider 高分候选经外站 id 互指(`Capabilities.ExternalIdKinds`)或标题+年份强一致判定为同一作品 → 合并为身份集合;判定失败(同名不同作)→ 按配置保最高分单身份或降档复核 | + +检索策略(Pipeline 可配):`FirstHit`(串行短路,省配额,精确码场景默认)/ `FanOutUnion`(并行扇出、候选取并集,模糊场景默认,提高召回)。 + +### 收口规则 + +- 分数→档位映射(banding)是 **Pipeline 配置**,不硬编码在环内。High 自动定案;Medium 可配置(自动过 / 抽样复核 / 全复核);整条链无人 Final → NeedsReview。 +- 歧义保护:最高分与次高分差距小于歧义阈值 → 降档。 + +## 走查示例 + +``` +# 链 exact-code → fuzzy :[CANDYVOICE] RJ01017217 耳かきボイス.zip +exact-code 环:code=RJ01017217 → DLsite 命中 → Final(High, 100, ExactCode),不进复核 +若该码已下架(404) → Pass → fuzzy 环:清洗出「耳かきボイス」→ 最高 82 vs 次高 79 + → 差距小于歧义阈值 → Medium →(该 Pipeline 配置 Medium 全复核)→ NeedsReview + 前 5 候选与打分解释 + +# 链 fuzzy → merge :[LoliHouse] 進撃の巨人 S03 +fuzzy 环:Bangumi 候选 96、TMDB 候选 95 —— 两个身份 → Augment +merge 环:外站 id / 标题+年份强一致 → 同一作品 → Final(High, {bangumi:X, tmdb:Y}) +``` + +## 数据表 + +``` +OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity/ForcedTitle/ForcedPipeline/Skip), + PayloadJson, CreatedAt // 跨 Job 生效 → 越跑越少人工 +``` + +## 完成后获得的能力 + +- 纯逻辑层:给定线索集 + Provider(可 mock)→ 可解释的身份判定,全量单测覆盖打分与歧义边界。 +- 配合 01–03 的调试页即可端到端验证"这个文件名能不能认出来、为什么认错"——不需要任何文件操作或 UI 编排。 +- Override 表落地后,人工纠错一次、永久生效。 + +## 开放问题 + +- 各场景歧义阈值与 banding 默认值(拿真实库跑分布后定)。 +- 低置信条目的 AI 辅助消歧(AI 模块已有基建)放在哪一环、何时引入。 diff --git a/docs/local-file-organization/modules/05-enrichment.md b/docs/local-file-organization/modules/05-enrichment.md new file mode 100644 index 000000000..d71cf01e9 --- /dev/null +++ b/docs/local-file-organization/modules/05-enrichment.md @@ -0,0 +1,39 @@ +# 05 · 字段富集(Enrichment) + +> 层级:决策能力 | 依赖:[03 Provider](03-providers.md)(`EnrichAsync`)、[04 身份判定](04-resolution.md)(身份集合)、[foundations 类型系统](../foundations.md#类型系统)(FieldBag/provenance) | 被依赖:[06 布局](06-layout.md)(模板取值)、[07 执行](07-execution.md)(属性写入) +> +> 职责:身份定案后,从多个 Provider 并行取详情,把字段 **union** 成一个带来源追溯的 [FieldBag](../foundations.md#类型系统) + 规范字段集。**[04](04-resolution.md) 定身份,本模块填字段**——边界在 Final 之后、Plan 之前。 + +## 并行 union + +- 对身份集合内每个 (source, key) 并行调用 `EnrichAsync`;策略可配:`PrimaryOnly`(只取主源)/ `UnionAll`(全并)。 +- 富集默认只对 High 定案执行(配额保护);Medium 进复核的条目在人工确认后补富集。 + +## per-field 合并策略 + +同一字段多库给值时: + +| 情形 | 默认策略 | +|---|---| +| 标量字段冲突(发行日期不同) | fill-if-empty:按 Provider 顺序,先到先得,后来者只填空 | +| 列表字段(tags/genre) | 集合并(去重 union) | +| 多语言字段(标题/简介) | 按语言键合并(AniList 补日文与产地、Bangumi 补中文名) | +| 用户覆盖 | 任意字段可 per-field 指定 Provider 优先序,覆盖默认 | + +**provenance 是硬要求**:FieldBag 每个值记录来源 Provider([类型系统](../foundations.md#类型系统)的 `Set(field, value, providerName)`)。用途:复核 UI 展示"这个值哪来的";各 Provider 原始 payload 分开缓存后,**换合并策略重算零网络请求**。 + +## 与资源属性的衔接 + +富集结果本身不写属性——写入发生在 [07 执行](07-execution.md)的落库阶段:字段按映射(`SuggestedPropertyType` 缺省自动建属性)经 `PropertyValueFactory` / StandardValue 转换规则写入,复用 Enhancer 的转换链路,不另造。 + +## 完成后获得的能力 + +- 例:`進撃の巨人` 定案为 `{bangumi:X, tmdb:Y}` 后,富集产出: + `title{ja:進撃の巨人, zh-Hans:进击的巨人(来源:Bangumi), en:Attack on Titan(来源:TMDB)}, origin=JP(来源:TMDB), tags=…(两库并集)`。 +- 复核页可以按字段展示来源与被覆盖的备选值;用户把"发行日期以 Bangumi 为准"设为 per-field 覆盖后,一键重算、不发请求。 +- [06 布局](06-layout.md)的模板占位符(`{title:lang(zh-Hans)}`、`{dlsite.circle}`)自此有了完整、类型化、可追溯的取值来源。 + +## 开放问题 + +- 冲突可视化的信息密度(全部展示 vs 仅冲突字段)。 +- 富集结果的过期与刷新策略(沿用 `ResourceSourceLink.MetadataFetchedAt` 的时效语义,见 [07](07-execution.md))。 diff --git a/docs/local-file-organization/modules/06-layout.md b/docs/local-file-organization/modules/06-layout.md new file mode 100644 index 000000000..4c2c26469 --- /dev/null +++ b/docs/local-file-organization/modules/06-layout.md @@ -0,0 +1,66 @@ +# 06 · 布局:路径模板与整理规则 + +> 层级:落盘能力 | 依赖:[05 字段富集](05-enrichment.md)(取值)、[foundations 类型系统](../foundations.md#类型系统)、`ResourceSearchFilterGroup`/`PathFilter`([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[07 执行](07-execution.md)、[08 编排](08-orchestration.md) +> +> 职责:用户以规则集表达"哪些条目 → 放到哪、叫什么、撞了怎么办"。渲染是**纯函数**(条目 + 字段 → 目标相对路径),因此天然可预览。系统不预设任何目录结构。 + +## 模型 + +``` +OrganizeRuleSet 规则集:Name, TargetRoot(整理目标根), Rules(有序), DefaultCollision, Enabled +OrganizeRule 规则:RuleSetId, Priority, Scope(过滤 JSON,复用 ResourceSearchFilterGroup + PathFilter), + PipelineId?(可选限定), PathTemplate, CollisionPolicy +``` + +- **有序首中生效**(防火墙规则心智);无规则命中 → 条目进复核或原地不动(规则集可配)。 +- **CollisionPolicy**:目标已存在时 → `AutoSuffix`(追加 [2]/码)/ `Merge`(并入同目录,多版本/多碟)/ `Skip` / `Review`。每规则可覆盖规则集默认。 + +## 路径模板 DSL + +`/` 即目录层级——"IP>类型>文件"写三段、平铺 `-<类型>-<文件>` 写一段: + +``` +segment := (literal | placeholder | group)* +placeholder := "{" selector (":" formatter)* ("|" alternative)* "}" +selector := title | year | code | franchise | domainLabel | fileName ← 规范字段 + | 域字段:dlsite.circle · music.albumArtist · manga.volume … ← FieldBag(05) + | prop("属性名") ← 任意资源属性 +formatter := lang(zh-Hans|ja|en|romaji) · pad(n) · date(yyyy) · case(upper|lower) + · sanitize · truncate(n) +alternative := 依序回退:{title:lang(zh-Hans)|title:lang(ja)|fileName} +group := "[" … "]" 组内任一占位符为空 → 整组丢弃 +``` + +示例——同一批文件,两种规则,两种世界: + +``` +# 规则 A:按 IP 分层合集 +{franchise}/动画/{title:lang(zh-Hans)|title:lang(ja)} [{year}] +→ Z:/Library/物语系列/动画/化物語 [2009]/… + +# 规则 B:平铺 +{franchise|title}-{domainLabel}-{fileName} +→ Z:/Flat/物语系列-动画-化物語 [2009].mkv +``` + +## 渲染管线 + +占位符求值 → 空值组丢弃 → 逐段 sanitize(Windows 非法字符、保留名、结尾点/空格)→ **长度预算自顶向下分配**(260 限制,超限自动截断并在预览中高亮)→ 拼接目标路径。 + +- 求值:规范字段/域字段走 [05](05-enrichment.md) 的产出;`prop()` 走现有 StandardValue 显示值处理器——与显示名模板同一取值路径("包装符空值丢弃"语义即复用其既有实现,[复用总表](../foundations.md#现有资产复用总表))。 +- **占位符可用性随 Pipeline**:域字段占位符按作用域内可能命中的 Pipeline 组件集合提示与校验(["字段随组件走"](../foundations.md#类型系统));引用不可用字段 → 预览标黄,运行时走 `|` 回退链。 + +## 与 ResourceProfile.NameTemplate 的分工 + +刻意分离:`NameTemplate` 决定**显示名**(虚拟层),本模块决定**物理路径**。共享模板引擎与语法,互不影响——用户可以物理平铺 + 显示名分层,反之亦然。 + +## 完成后获得的能力 + +- 规则编辑页:左侧规则、右侧抽样条目实时渲染目标树(SamplePaths 模式),所见即所得;渲染纯函数全量单测(非法字符/超长/空值回退边界)。 +- 用户可零代码表达任意布局:分层合集、平铺、按社团、按艺术家/专辑/曲目三层…… +- [07 执行](07-execution.md)的输入(每条目的目标路径 + 冲突策略)自此就绪。 + +## 开放问题 + +- formatter 集是否够用(roman 数字卷号、全半角转换?)——按需求追加,语法已预留。 +- `Merge` 策略下子文件命名冲突的次级策略。 diff --git a/docs/local-file-organization/modules/07-execution.md b/docs/local-file-organization/modules/07-execution.md new file mode 100644 index 000000000..eb52ac276 --- /dev/null +++ b/docs/local-file-organization/modules/07-execution.md @@ -0,0 +1,55 @@ +# 07 · 执行:计划 / journal / 回滚 / 资源一致性 + +> 层级:落盘能力 | 依赖:[06 布局](06-layout.md)(目标路径)、[05 字段富集](05-enrichment.md)(属性写入内容)、Comparison / Enhancer 转换链 / `ResourceSourceLink`([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[08 编排](08-orchestration.md) +> +> 职责:把"目标路径"变成"安全落盘"。三段式:计划(只读)→ 执行(journal 护航)→ 回滚(逆序回放)。**整理不是裸文件操作**——资源一致性联动是本模块与独立整理工具的分水岭。 + +## 计划(干跑,默认形态) + +- 逐条目产出有序原子操作:`Mkdir / Rename / Move / MergeDir`。 +- 冲突检测:目标已存在、同批目标互撞、源在目标子树内。 +- **去重闸门**:调用 Comparison 模块比对目标库存量——"库里已有同作" → 提示合并/跳过,而非制造重复。 +- 预览 UI:目标树视图 + 逐条目 diff,可单条排除/改判。计划是只读的,**不动任何文件**。 + +## 执行(用户确认后) + +逐操作流程: + +``` +journal 写 Intent → 文件系统操作 → 同事务更新 Resource.Path(级联子路径) + PathMark → journal 写 Done +``` + +- 崩溃恢复:按 Intent/Done 差集判断执行到哪,续跑或回退半途操作。 +- 同卷 rename 与跨卷 copy+delete 区分记录(回滚语义不同);执行前做剩余空间与路径长度预检。 +- 执行前逐条目校验指纹(大小+mtime)——计划与执行之间文件被外部改动 → 该条目回退复核,不整批失败。 +- 被覆盖/被合并的旧文件一律进系统回收站,不物理删除。 + +## 落库(执行成功后的资源侧收尾) + +1. 目标根在媒体库内 → 触发既有同步管线;未入库文件成为新资源。 +2. 按身份集合**逐源**写 `ResourceSourceLink`(Source + SourceKey + MetadataJson + MetadataFetchedAt)。 +3. [05](05-enrichment.md) 的字段按映射写入资源属性(经 `PropertyValueFactory` / StandardValue 转换,复用 Enhancer 转换链)。整理完成的资源立即带全元数据。 +4. **Enhancer 快路径**(对现有模块的调整):`AbstractKeywordEnhancer` 构建 context 前先查资源的 `ResourceSourceLink`(同源且未过期)→ 直接用缓存元数据。整理与增强收敛为一套匹配;文件被改名后增强不再因文件名变化而失败。 + +## 回滚 + +`UndoAsync(jobId)`:逆序回放 journal(Done → 反向操作 → 标 Undone),同步回滚 `Resource.Path`。journal 保留期可配(默认 30 天),过期由清理任务收割。 + +## 数据表 + +``` +OrganizeJournalEntry JobId, Seq, Op(Move/Rename/Mkdir/MergeDir), From, To, + Phase(Intent/Done/Undone), VolumeMode(SameVolume/CrossVolume), At +``` + +## 完成后获得的能力 + +- 干跑计划 + 预览随便看,不碰文件;确认执行后 kill 进程再启动,续跑不重不漏。 +- `Undo` 一键完整还原(含 Resource.Path)。 +- 已入库资源被整理后,属性值、播放记录、封面**零丢失**;新文件整理完即是带全元数据的资源。 +- 例:把 500 个音声压缩包从下载目录整理进库,中途断电——重启续跑;发现某条整错了——复核页改判后单条重整理;整批后悔——Undo 回到原状。 + +## 开放问题 + +- journal 与 `Resource.Path` 更新的事务边界(文件系统无事务,DB 侧补偿顺序需要精确定义)。 +- 空目录清理的白名单策略(源目录残壳何时删)。 diff --git a/docs/local-file-organization/modules/08-orchestration.md b/docs/local-file-organization/modules/08-orchestration.md new file mode 100644 index 000000000..881285343 --- /dev/null +++ b/docs/local-file-organization/modules/08-orchestration.md @@ -0,0 +1,84 @@ +# 08 · 编排:Pipeline / 状态机 / 宿主 + +> 层级:顶层编排 | 依赖:下层全部([01](01-text-processing.md)–[07](07-execution.md))、BTask / Workflow / FileMover([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[09 合集](09-collections.md) +> +> 职责:把下层原子能力组合成可运行的整体。本模块**只做组合与调度**,不含任何一层的业务逻辑——这是"先底层原子能力、后顶层编排"划分的收口。 + +## Pipeline:组件组合的数据化配置 + +一条 Pipeline = 各槽位组件实例的组合([组件模型](../foundations.md#组件模型所有槽位共享))。**骨架固定、槽内自由**:阶段顺序不可重排,用户的自由是"每槽用哪些组件、什么顺序、什么参数"——非法组合从模型上无法表达,编辑器是「勾选 + 排序 + 调参」而非连线画布。 + +``` +Pipeline = 作用域/Detector + + 有序 Extractor 实例(02) + + 有序 Provider 实例(03) + + Resolver 链与 banding/检索策略配置(04) + + 富集策略(05) + + 默认规则预设绑定(06) +``` + +内置种子(只读、可 fork)覆盖主流场景;构成一览: + +| 内置 Pipeline | Extractor 链 | Provider 链 | Resolver 链 | +|---|---|---|---| +| 同人音声/DLsite | pattern(rj-code) → pattern(filename) | dlsite | exact-code → fuzzy(Medium 全复核) | +| 通用音频 | embedded-tag → pattern(filename) | (空) | tag-first(不联网定名;缺标签→复核) | +| 动画 | pattern(filename,字幕组词表) → nfo | bangumi → tmdb | fuzzy → merge | +| 漫画/本子 | comicinfo → pattern(gallery-token) → pattern(filename) | exhentai → bangumi | fuzzy | +| 音乐 | embedded-tag → pattern(filename) | musicbrainz | exact-code(MBID) → fingerprint → fuzzy | +| 电影/剧集 | pattern(imdb-code) → pattern(sxxeyy) → pattern(filename) | tmdb | exact-code → fuzzy | +| 游戏 | pattern(steam-appid) → pattern(filename) | steam | exact-code → fuzzy | +| AV | pattern(av-code) | javbus → javdb → … | 仅 exact-code(无码→复核) | + +典型用例:非 RJ 规范的音声库 → 把目录作用域绑到「通用音频」,或 fork「同人音声/DLsite」摘掉 rj-code/dlsite 组件——零代码。 + +## 路由 + +显式作用域绑定("这个目录用这条 Pipeline")优先于 Detector 置信度竞争;多条得分接近(差 < 阈值)→ NeedsReview 而非硬选。Detector 组件很薄:`detector.extension`(扩展名组)为共同底座 + 各管线特征加分项。 + +## 条目状态机 + +``` +Discovered → Routed → Extracted → Parsed → Resolved / NeedsReview → Planned → Applied + ↘ Skipped / Failed(终态) +``` + +- 每次状态推进即落库(checkpoint)→ 断点续跑:重跑跳过已 Applied/Skipped,NeedsReview 保留待裁定,裁定(写 [Override](04-resolution.md#数据表))后重入队。 +- 干跑终点 Planned;Applied 只能由用户确认计划后的独立任务触发([07](07-execution.md))。 + +## 数据表 + +``` +OrganizePipeline Name, ForkedFromId?, ScopeJson, ComponentsJson(有序组件实例+配置), + ResolverConfigJson(banding/检索策略), FieldMappingJson, Enabled +OrganizeJob RuleSetId, SourceRoots, Mode(DryRun/Apply), Stats, CreatedAt +OrganizeItem JobId, Path, Fingerprint, State, PipelineId?, + CluesJson, MatchJson(候选+得分), MetadataJson(按 Provider 分开缓存), + PlannedPath?, Error?, Attempts +``` + +## 宿主与触发 + +- **BTask 承载**:`BTaskBuilder.Create($"Organize:{jobId}").ConflictsWith("MoveFiles", $"OrganizeRoot:{root}")…`——进度/暂停/取消(逐条目 `YieldAsync`)、SignalR 推送全部免费获得。 +- **触发形态**:手动向导(选根目录 + 规则集 → 预览 → 确认)为第一形态;FileMover 定位为"进料口"(监听目录目标指向某规则集,移入暂存后自动触发干跑);Workflow 注册触发器("整理完成")与活动("运行某 Pipeline/规则集")。 +- **复核页**:NeedsReview 条目列表 + 候选对比(含 [05](05-enrichment.md) 的 provenance)+ 裁定动作(选定身份/改标题/换 Pipeline/跳过)→ 写 Override → 重入队。 + +## 与 Workflow 模块的关系 + +**复用模式,不复用引擎。** 组件注册表、descriptor 驱动的配置表单、运行统计展示借鉴或共用 Workflow 前端实现;但 Organizer 不跑在 WorkflowRunner 上,三个结构性错配: + +1. 一次性流过 vs 跨轮状态机(NeedsReview 停车等人、Override 跨轮、断点续跑); +2. 逐条独立 vs 聚合阶段(计划期撞车检测/去重/合集聚拢需要跨条目视角); +3. 直通执行 vs 两阶段确认(干跑→确认→journal/回滚)。 + +分工:Workflow 管"什么时候整理、整理完干什么";Organizer 管"怎么整理"。 + +## 完成后获得的能力 + +- 端到端:一个混放动画/音声/漫画的乱堆目录,一键干跑 → 预览目标树 → 确认执行 → 各归其所;断电续跑;复核裁定后重跑,人工量逐轮下降(Override 命中率可观测)。 +- 内置 Pipeline 开箱即用;fork + 组件开关/排序/调参零代码适配非规范库。 + +## 开放问题 + +- fork 与内置种子的漂移管理(上游 diff 提示、组件级"重置为上游默认"、废弃组件兼容运行)。 +- Job 级并发与单条目内 FanOutUnion 并发的预算分配。 diff --git a/docs/local-file-organization/modules/09-collections.md b/docs/local-file-organization/modules/09-collections.md new file mode 100644 index 000000000..a07070c79 --- /dev/null +++ b/docs/local-file-organization/modules/09-collections.md @@ -0,0 +1,35 @@ +# 09 · 合集:franchise / 跨域聚合 + +> 层级:顶层编排 | 依赖:[04 身份判定](04-resolution.md)、[05 字段富集](05-enrichment.md)、[06 布局](06-layout.md)、Alias([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:无(最顶层) +> +> 职责:把不同 Pipeline 整理出的条目按 IP/系列在**目标布局**上重新聚拢——分流(路由)与聚合(布局)解耦。例:「物语系列」的动画、漫画、OST、画集落进同一个合集目录树。 + +## 设计要点 + +1. **IP 是规范化属性,不是新实体**:franchise 写入一个内置约定的资源属性(Multilevel「系列/IP」,支持层级如 `物语系列/伤物语`)。同时服务于:路径模板 `{franchise}`([06](06-layout.md))、资源搜索/筛选、显示名模板。 +2. **FranchiseResolver 解析链**(证据强度降序): + 1. Provider 关系图:Bangumi subject 关联(动画↔漫画↔音乐同 IP)、AniList relations、DLsite 系列字段——强证据,自动归并; + 2. 别名聚类:Alias 模块 + SpecialText 清洗后的标题聚类——中证据,**结果一律进复核**(错并风险); + 3. 用户裁定:复核页手动归属 → 写 [Override](04-resolution.md#数据表)——最高优先级。 +3. **跨域归一**:同一 Job 内不同 Pipeline 的条目解析出的 franchise 经别名表归一(「Monogatari Series」=「物语系列」),保证动画和 OST 落进同一合集。 +4. **布局表达**:合集只是规则的一种写法——`{franchise}/{domainLabel}/{title…}`;不想要合集就不用该占位符。系统无"合集模式"开关。 + +## 示例 + +``` +Z:/Library/物语系列/ + 动画/化物語 [2009]/… + 音乐/君の知らない物語/… + 漫画/化物語 (vol.01-08)/… + 画集/VOFAN 画集/… +``` + +## 完成后获得的能力 + +- 混合类型库按 IP 聚拢的完整演示:一次干跑,动画/漫画/OST/画集各经其 Pipeline 判定身份,再被同一条布局规则聚进合集树。 +- franchise 成为普通属性后,搜索"物语系列"直接命中全部跨域资源;显示名模板同样可用。 + +## 开放问题 + +- 跨库 IP 归属无全局权威源:聚类错并的复核成本 vs 覆盖率,需真实库数据标定。 +- franchise 层级深度约定(系列/子系列/作品?)与 Provider 关系图的映射规则。 From 8619fba11d491c241c8a05acc9eca3496a4ff330 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 10:25:12 +0000 Subject: [PATCH 06/46] docs(organizer): redesign resolution as candidate generators + fixed 3-phase runner Drops the Certainty/Generate/Transform node-role taxonomy and the merge-as-chain-node design. Resolution now has a single component concept (ICandidateGenerator, with CandidateBasis expressing certainty per candidate) and a kernel runner with three fixed phases: generate (ordered, early-exit on deterministic candidates), consolidate (deterministic key-join always on, heuristic join configurable, conflict flagging, thin-field semantics), and decide (basis-aware banding). Documents the canonical MatchCandidate contract and syncs the pipeline examples in orchestration, enrichment, foundations and the overview. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/local-file-organization/README.md | 2 +- docs/local-file-organization/foundations.md | 4 +- .../modules/04-resolution.md | 146 +++++++++++++----- .../modules/05-enrichment.md | 2 +- .../modules/08-orchestration.md | 22 +-- 5 files changed, 126 insertions(+), 50 deletions(-) diff --git a/docs/local-file-organization/README.md b/docs/local-file-organization/README.md index d53e03ac0..48ed12d75 100644 --- a/docs/local-file-organization/README.md +++ b/docs/local-file-organization/README.md @@ -31,7 +31,7 @@ | 原子能力 | [01 文本处理规则](modules/01-text-processing.md) | 用户定义规则,对"一段文本"清洗 + 类型化提取(来源无关) | | 原子能力 | [02 条目提取器](modules/02-extractors.md) | 面向文件/目录采集线索:文本规则应用 + 结构化读取 | | 原子能力 | [03 元数据 Provider](modules/03-providers.md) | 外部站点统一适配:搜候选 / 精确码取 / 按身份富集 | -| 决策能力 | [04 身份判定](modules/04-resolution.md) | Resolver 责任链:线索 → 身份 + 置信档 + 解释 | +| 决策能力 | [04 身份判定](modules/04-resolution.md) | 候选生成 → 归拢 → 裁决:线索 → 身份 + 置信档 + 解释 | | 决策能力 | [05 字段富集](modules/05-enrichment.md) | 多库并行取详情,per-field 合并,带来源追溯 | | 落盘能力 | [06 布局](modules/06-layout.md) | 路径模板 DSL + 整理规则集:完全自由的目录/命名表达 | | 落盘能力 | [07 执行](modules/07-execution.md) | 计划 / journal / 回滚 / 资源一致性联动 | diff --git a/docs/local-file-organization/foundations.md b/docs/local-file-organization/foundations.md index e0e986e4a..2b179280e 100644 --- a/docs/local-file-organization/foundations.md +++ b/docs/local-file-organization/foundations.md @@ -31,8 +31,8 @@ Extractor、Provider、Resolver 策略、Detector 都遵循同一套组件机制 ```csharp public interface IOrganizerComponentDescriptor { - string Kind { get; } // "extractor.pattern" / "provider.dlsite" / "resolver.fuzzy" - OrganizerSlot Slot { get; } // Detector / Extractor / Provider / ResolverPolicy + string Kind { get; } // "extractor.pattern" / "provider.dlsite" / "resolver.fuzzy-search" + OrganizerSlot Slot { get; } // Detector / Extractor / Provider / CandidateGenerator string DisplayName { get; } Type? ConfigType { get; } // 配置 schema,编辑器据此渲染表单 ComponentApplicability Applicability { get; } // 适用形态:音频/压缩包/任意…(编辑器校验) diff --git a/docs/local-file-organization/modules/04-resolution.md b/docs/local-file-organization/modules/04-resolution.md index db6698908..8c895207a 100644 --- a/docs/local-file-organization/modules/04-resolution.md +++ b/docs/local-file-organization/modules/04-resolution.md @@ -2,68 +2,144 @@ > 层级:决策能力 | 依赖:[02 条目提取器](02-extractors.md)(ParsedClues)、[03 Provider](03-providers.md)、Alias / SpecialText / Comparison([复用总表](../foundations.md#现有资产复用总表)) | 被依赖:[05 字段富集](05-enrichment.md)、[08 编排](08-orchestration.md) > -> 职责:`ParsedClues → MatchDecision`([WorkIdentity 集合](../foundations.md#核心概念) + [置信档 Band](../foundations.md#核心概念) + 可解释打分)。**只定身份,不填字段**——字段富集在 [05](05-enrichment.md)。 +> 职责:`ParsedClues → MatchDecision`([WorkIdentity 集合](../foundations.md#核心概念) + [置信档 Band](../foundations.md#核心概念) + 可解释依据)。**只定身份,不填字段**——字段富集在 [05](05-enrichment.md)。 -## Resolver 责任链 +## 模型:候选生成器 + 固定三阶段 Runner -判定逻辑组织为策略链;每环是一个组件(`resolver.*`,见 [foundations · 组件模型](../foundations.md#组件模型所有槽位共享)): +身份判定的本质是一个「发散假设 → 归拢 → 裁决」的过程,其中**只有"发散假设"存在值得用户插拔的自由度**(用什么线索、查什么源、用什么方法产生候选);归拢与裁决的算法是唯一的,用户能调的只是参数。因此: + +- 组件只有**一种**概念:**候选生成器 `ICandidateGenerator`**——从线索产出候选。用户自由组合其实例。 +- **Runner 是内核**(不是组件),固定三阶段,行为由 Pipeline 配置驱动: + +``` +ParsedClues ──(Override 前置:人工裁定过的条目直接采用)──▶ + ① Generate 依序运行生成器实例;出现确定性候选可提前终止(早停,可配) + ② Consolidate 候选池归拢:等价分组 → 合并(内核固定算法,配置驱动) + ③ Decide top1 + 歧义差 + banding 映射 → Resolved(band) 或 NeedsReview +``` + +> 演进备注:早期草稿把判定逻辑做成"责任链节点",并分 Certainty/Generate/Transform 三种角色、把归拢(merge)做成可编排节点——**已废弃**。原因:确定性不是节点的属性而是**候选的属性**(见 Basis);归拢必然发生在全部生成之后、算法唯一,没有编排自由度,做成节点只会让链变得不可理解。 + +## 候选契约(MatchCandidate) + +所有生成器产出、Runner 各阶段消费的统一结构。**薄字段清单是固定契约**,判据:身份判定所需(打分、等价判定、复核展示)才进契约;权威数据一律留在 `RawPayloads`,字段真相由 [05](05-enrichment.md) 决定。 + +```csharp +public sealed record MatchCandidate +{ + public required WorkIdentitySet Identity { get; init; } // (source, key) 集合;生成时通常单元素 + public required decimal Score { get; init; } // 与线索的匹配程度 0–100 + public required CandidateBasis Basis { get; init; } // 这个候选"怎么来的",见下 + public LocalizedText Titles { get; init; } // 语言 → 标题 + public int? Year { get; init; } // 可空:Provider 没给就是 null + public string? WorkType { get; init; } + public ExternalIdLink[] Links { get; init; } // payload 声明的外站 id + public MatchExplanation Explanation { get; init; } // 复核页展示的判定依据 + public IReadOnlyDictionary RawPayloads { get; init; } // 按源保留原始响应,不融合 +} + +public enum CandidateBasis +{ + KeyLookup = 1, // 键查得:精确码/包名/MBID 直查 → 确定性 + UserTable = 2, // 用户映射表命中 → 确定性 + Fingerprint = 3, // 指纹命中 → 高置信 + ClueDirect = 4, // 线索直取(不查外部源) + TextSimilarity = 5, // 文本相似度搜索 → 概率性 + Inference = 6, // AI 推断(后期)→ 概率性 +} +``` + +映射责任:Provider 适配器([03](03-providers.md))负责把各站 payload 映射进薄字段;缺失即 null,所有消费方按可空处理——"归拢怎么知道有没有 year"的答案就是读 `candidate.Year`,null 即没有(启发式连接会因此退化,见下)。 + +## ① Generate:候选生成器 ```csharp -public interface IResolverPolicy +public interface ICandidateGenerator { - Task ResolveAsync(ResolverContext ctx, CancellationToken ct); + Task> GenerateAsync(ResolutionContext ctx, CancellationToken ct); } -// ResolverOutcome 三选一: -// Final(identity, band, explanation) 定案,链终止 -// Augment(enrichedContext) 补充了上下文,继续下一环 -// Pass 本环无话可说,交给下一环 +// AbstractCandidateGenerator:configJson → TConfig,镜像 AbstractEnhancer 的类型化配置模式 ``` -链前置:先查 **Override**(按条目指纹)——人工裁定过的直接采用。这是收敛闭环的另一半。 +实现类是**无状态单例**(DI 枚举注册,同 Enhancer 模式);Pipeline 里的生成器链是**数据行**(kind + configJson,存于 [OrganizePipeline](08-orchestration.md#数据表));同一 kind 可多实例、各带配置(如两个 `code-lookup` 分别处理 RJ 码与私有码、限定不同 Provider)。 + +| kind | 类名 | Basis | 配置 | +|---|---|---|---| +| `resolver.code-lookup` | `CodeLookupCandidateGenerator` | KeyLookup | CodeSlots(哪些线索槽算码)、ProviderFilter | +| `resolver.fuzzy-search` | `FuzzySearchCandidateGenerator` | TextSimilarity | SearchStrategy(FirstHit/FanOutUnion)、MinScore、TitleSlots、UseAliasExpansion、Year/Type 权重 | +| `resolver.clue-direct` | `ClueDirectCandidateGenerator` | ClueDirect | 取哪些线索槽(通用音频"以标签定名"即它的一个实例) | +| `resolver.lookup-table` | `LookupTableCandidateGenerator` | UserTable | 用户映射表引用(线索值 → 身份/标题) | +| `resolver.fingerprint`(后期) | `FingerprintCandidateGenerator` | Fingerprint | 指纹源、命中阈值 | +| `resolver.ai`(后期) | `AiCandidateGenerator` | Inference | 模型 / 提示词模板 / 置信要求 | + +生成器排序即优先级:**先便宜先准**(确定性来源在前)。**早停**是 Runner 的配置行为:出现 KeyLookup/UserTable 候选即跳过后续生成器——"精确码短路"由此实现,不再是特殊节点。 + +**kind 清单不钉死**:内核只认识 `ICandidateGenerator` 契约;实现类注册即出现新 kind(同新增 Enhancer 的贡献模式)。终端用户不能凭空造 kind(刻意不做脚本引擎),但 `lookup-table` / `clue-direct` 的行为本身由用户数据定义,覆盖绝大多数"自有逻辑"。 -### 内置环 +## ② Consolidate:候选归拢(内核固定阶段,不是组件) -| 环 | 行为 | -|---|---| -| `resolver.exact-code` | 线索含精确码 → 对 `SupportsExactCode` 的 Provider `GetByCodeAsync` → 命中即 `Final(High)`;查无此码(下架等)→ `Pass` 而非失败 | -| `resolver.fingerprint`(后期) | 声学指纹 → 高置信命中 | -| `resolver.fuzzy` | 清洗(SpecialText)→ 别名展开(Alias)→ 按**检索策略**搜 Provider → 每候选 × 各语言标题取最高 Dice 相似度 → 年份/类型一致性加权 | -| `resolver.merge` | 多 Provider 高分候选经外站 id 互指(`Capabilities.ExternalIdKinds`)或标题+年份强一致判定为同一作品 → 合并为身份集合;判定失败(同名不同作)→ 按配置保最高分单身份或降档复核 | +跨 Provider 的同一作品会以多个候选出现(Bangumi 96 分、TMDB 95 分)。不归拢的后果:top1/top2 差 1 → **假歧义**被迫进复核;且定案只带单库身份,多源关联永久丢失。归拢没有值得用户重排/替换的自由度,因此固化在内核、仅暴露参数: -检索策略(Pipeline 可配):`FirstHit`(串行短路,省配额,精确码场景默认)/ `FanOutUnion`(并行扇出、候选取并集,模糊场景默认,提高召回)。 +1. **等价分组**(并查集;两类连接在算法上本质不同): + - **确定性连接**——身份键或外站链接相等:`A.Identity ∩ B.Identity ≠ ∅`,或 A 的 `Links` 指向 B 的 (source, key)。键相等是 join,不存在"判错"(除非源数据本身错)。**永远启用,无需配置。** + - **启发式连接**——SpecialText 归一化后标题相等 + 年份差 ≤ 容差(默认 ±1)+ 类型兼容。**显式开关 + 参数**(模糊场景默认开);`Year == null` 时年份条件不可用 → 该连接自动收紧(仅标题相等不足以并组)。 +2. **组内合并**,每组产出一个候选: + - `Identity` 取并集——多源身份在此固化,供 [05](05-enrichment.md) 并行富集、[07](07-execution.md) 逐源写 `ResourceSourceLink`; + - `Score = max(成员)`(可选小额"佐证加分",默认关——两库共现是佐证,不代表标题更匹配;绝不能相加,否则热门作品靠收录量碾压更匹配的冷门候选); + - 薄字段浅并:`Titles` 按语言键并、冲突取高分方;`Year`/`WorkType` 取高分方。**这些值仅供复核展示与重打分,不是权威值,不会落库**——写进属性的最终值由 [05](05-enrichment.md) 按 per-field 策略从各源 payload 选出; + - **冲突打旗**:确定性连接成立但薄字段冲突(如两库 year 不同)→ MergeFlag,复核页展示双值;可配"带旗降一档"(High→Medium 进复核),防御外链本身指错的罕见情况; + - `RawPayloads` 按源原样保留,绝不融合。 +3. 启发式连接判不上的候选保持独立——同名的原作与重制版靠这个不被错并。 -### 收口规则 +## ③ Decide:裁决 -- 分数→档位映射(banding)是 **Pipeline 配置**,不硬编码在环内。High 自动定案;Medium 可配置(自动过 / 抽样复核 / 全复核);整条链无人 Final → NeedsReview。 -- 歧义保护:最高分与次高分差距小于歧义阈值 → 降档。 +- top1 分数 + 与 top2 的歧义差 + banding 映射,全部是 Pipeline 配置:`{ high, medium, ambiguityGap, mediumAction(自动过/抽样/全复核) }`。 +- **Basis 感知**:KeyLookup / UserTable 候选 → High 直通;TextSimilarity / Inference 走分数映射。 +- 无候选或歧义 → NeedsReview,携带候选池与 `Explanation` 供复核页展示。 ## 走查示例 ``` -# 链 exact-code → fuzzy :[CANDYVOICE] RJ01017217 耳かきボイス.zip -exact-code 环:code=RJ01017217 → DLsite 命中 → Final(High, 100, ExactCode),不进复核 -若该码已下架(404) → Pass → fuzzy 环:清洗出「耳かきボイス」→ 最高 82 vs 次高 79 - → 差距小于歧义阈值 → Medium →(该 Pipeline 配置 Medium 全复核)→ NeedsReview + 前 5 候选与打分解释 - -# 链 fuzzy → merge :[LoliHouse] 進撃の巨人 S03 -fuzzy 环:Bangumi 候选 96、TMDB 候选 95 —— 两个身份 → Augment -merge 环:外站 id / 标题+年份强一致 → 同一作品 → Final(High, {bangumi:X, tmdb:Y}) +生成器链:code-lookup → fuzzy-search | consolidate{启发式:开} | banding{high:90, ambiguityGap:5} + +输入 1:[CANDYVOICE] RJ01017217 耳かきボイス.zip + ① code-lookup:code 线索 → DLsite 直查命中 → [{dlsite:RJ01017217, Basis=KeyLookup}] + → 早停,fuzzy-search 不执行 + ② consolidate:单候选,无事可做 + ③ decide:Basis=KeyLookup → Resolved(High)。不进复核。 + +输入 2:[LoliHouse] 進撃の巨人 S03 + ① code-lookup:无码线索 → 0 候选;fuzzy-search:池 = [A{bangumi, 96}, B{tmdb, 95}] + ② consolidate:A/B 经外站 id 互指(确定性连接)→ 池 = [AB{bangumi+tmdb, 96}] + ③ decide:96 ≥ high,次高候选 60、差 36 > ambiguityGap → Resolved(High) + + 反事实:若跳过 consolidate → 96 vs 95 差 1 < ambiguityGap → 假歧义进复核, + 且即便人工选了 A,身份也只剩 {bangumi}——这两点就是归拢阶段存在的全部理由。 ``` -## 数据表 +## Override(人工裁定,Runner 前置) ``` OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity/ForcedTitle/ForcedPipeline/Skip), PayloadJson, CreatedAt // 跨 Job 生效 → 越跑越少人工 ``` +## 用户如何自定义 + +三层阶梯,前两层零代码: + +1. **组链**:fork Pipeline 后对生成器列表增删/重排/调参——AV 库只留 `code-lookup`(宁复核不猜)、动画库调大 `ambiguityGap`、私有码库加一个限定自有 Provider 的 `code-lookup` 实例。 +2. **数据驱动生成器**:`lookup-table`(本地映射表:私有编号、自建元数据)、`clue-direct`(不联网,以标签/文件名直接定名)——行为即用户数据。 +3. **写代码**:实现 `AbstractCandidateGenerator` + 注册即出现在调色板,同 Enhancer 贡献模式。 + ## 完成后获得的能力 -- 纯逻辑层:给定线索集 + Provider(可 mock)→ 可解释的身份判定,全量单测覆盖打分与歧义边界。 -- 配合 01–03 的调试页即可端到端验证"这个文件名能不能认出来、为什么认错"——不需要任何文件操作或 UI 编排。 -- Override 表落地后,人工纠错一次、永久生效。 +- 纯逻辑层:线索集 + Provider(可 mock)→ 可解释的身份判定;打分、归拢、歧义边界全量单测。 +- 配合 01–03 的调试页端到端验证"这个文件名能不能认出来、为什么认错",无需任何文件操作。 +- Override 落地后,人工纠错一次、永久生效。 ## 开放问题 -- 各场景歧义阈值与 banding 默认值(拿真实库跑分布后定)。 -- 低置信条目的 AI 辅助消歧(AI 模块已有基建)放在哪一环、何时引入。 +- 佐证加分是否默认开、幅度多少(拿真实库标定)。 +- 启发式连接的年份容差 / 标题归一强度的各场景默认值。 +- `resolver.ai` 生成器的引入时机与置信要求。 diff --git a/docs/local-file-organization/modules/05-enrichment.md b/docs/local-file-organization/modules/05-enrichment.md index d71cf01e9..44f587a1e 100644 --- a/docs/local-file-organization/modules/05-enrichment.md +++ b/docs/local-file-organization/modules/05-enrichment.md @@ -2,7 +2,7 @@ > 层级:决策能力 | 依赖:[03 Provider](03-providers.md)(`EnrichAsync`)、[04 身份判定](04-resolution.md)(身份集合)、[foundations 类型系统](../foundations.md#类型系统)(FieldBag/provenance) | 被依赖:[06 布局](06-layout.md)(模板取值)、[07 执行](07-execution.md)(属性写入) > -> 职责:身份定案后,从多个 Provider 并行取详情,把字段 **union** 成一个带来源追溯的 [FieldBag](../foundations.md#类型系统) + 规范字段集。**[04](04-resolution.md) 定身份,本模块填字段**——边界在 Final 之后、Plan 之前。 +> 职责:身份定案后,从多个 Provider 并行取详情,把字段 **union** 成一个带来源追溯的 [FieldBag](../foundations.md#类型系统) + 规范字段集。**[04](04-resolution.md) 定身份,本模块填字段**——边界在裁决(Decide)之后、Plan 之前。 ## 并行 union diff --git a/docs/local-file-organization/modules/08-orchestration.md b/docs/local-file-organization/modules/08-orchestration.md index 881285343..f86b64963 100644 --- a/docs/local-file-organization/modules/08-orchestration.md +++ b/docs/local-file-organization/modules/08-orchestration.md @@ -12,23 +12,23 @@ Pipeline = 作用域/Detector + 有序 Extractor 实例(02) + 有序 Provider 实例(03) - + Resolver 链与 banding/检索策略配置(04) + + 候选生成器实例与判定配置(早停/consolidate/banding)(04) + 富集策略(05) + 默认规则预设绑定(06) ``` 内置种子(只读、可 fork)覆盖主流场景;构成一览: -| 内置 Pipeline | Extractor 链 | Provider 链 | Resolver 链 | +| 内置 Pipeline | Extractor 链 | Provider 链 | 候选生成器(有序)+ 判定配置 | |---|---|---|---| -| 同人音声/DLsite | pattern(rj-code) → pattern(filename) | dlsite | exact-code → fuzzy(Medium 全复核) | -| 通用音频 | embedded-tag → pattern(filename) | (空) | tag-first(不联网定名;缺标签→复核) | -| 动画 | pattern(filename,字幕组词表) → nfo | bangumi → tmdb | fuzzy → merge | -| 漫画/本子 | comicinfo → pattern(gallery-token) → pattern(filename) | exhentai → bangumi | fuzzy | -| 音乐 | embedded-tag → pattern(filename) | musicbrainz | exact-code(MBID) → fingerprint → fuzzy | -| 电影/剧集 | pattern(imdb-code) → pattern(sxxeyy) → pattern(filename) | tmdb | exact-code → fuzzy | -| 游戏 | pattern(steam-appid) → pattern(filename) | steam | exact-code → fuzzy | -| AV | pattern(av-code) | javbus → javdb → … | 仅 exact-code(无码→复核) | +| 同人音声/DLsite | pattern(rj-code) → pattern(filename) | dlsite | code-lookup → fuzzy-search(Medium 全复核) | +| 通用音频 | embedded-tag → pattern(filename) | (空) | clue-direct(标签)(不联网定名;缺标签→复核) | +| 动画 | pattern(filename,字幕组词表) → nfo | bangumi → tmdb | fuzzy-search(FanOutUnion;启发式归拢开) | +| 漫画/本子 | comicinfo → pattern(gallery-token) → pattern(filename) | exhentai → bangumi | fuzzy-search | +| 音乐 | embedded-tag → pattern(filename) | musicbrainz | code-lookup(MBID) → fingerprint → fuzzy-search | +| 电影/剧集 | pattern(imdb-code) → pattern(sxxeyy) → pattern(filename) | tmdb | code-lookup → fuzzy-search | +| 游戏 | pattern(steam-appid) → pattern(filename) | steam | code-lookup → fuzzy-search | +| AV | pattern(av-code) | javbus → javdb → … | 仅 code-lookup(无码→复核) | 典型用例:非 RJ 规范的音声库 → 把目录作用域绑到「通用音频」,或 fork「同人音声/DLsite」摘掉 rj-code/dlsite 组件——零代码。 @@ -50,7 +50,7 @@ Discovered → Routed → Extracted → Parsed → Resolved / NeedsReview → Pl ``` OrganizePipeline Name, ForkedFromId?, ScopeJson, ComponentsJson(有序组件实例+配置), - ResolverConfigJson(banding/检索策略), FieldMappingJson, Enabled + ResolverConfigJson(生成器链/早停/consolidate/banding), FieldMappingJson, Enabled OrganizeJob RuleSetId, SourceRoots, Mode(DryRun/Apply), Stats, CreatedAt OrganizeItem JobId, Path, Fingerprint, State, PipelineId?, CluesJson, MatchJson(候选+得分), MetadataJson(按 Provider 分开缓存), From ed40417c6908ca6c24d84327705c94e11a8ed1e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 10:58:45 +0000 Subject: [PATCH 07/46] docs(organizer): generalize candidate fields to typed features, add failure-mode defenses MatchCandidate loses hardcoded Year/WorkType: canonical members are only what any work has (identity, titles, links) plus runner bookkeeping; all domain data moves into the typed FieldBag populated per provider field declarations. Heuristic consolidation and fuzzy scoring reference features via FeaturePredicate config reusing Comparison-module strategies, so the kernel contains no domain field names. Adds concrete Consolidate/Decide implementations, a failure-mode analysis with defenses (conservative defaults, visibility flags, IsSameWork / NotSameWork override assertions, code-lookup title cross-validation), and syncs provider/orchestration docs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../modules/03-providers.md | 2 +- .../modules/04-resolution.md | 274 ++++++++++++++---- .../modules/08-orchestration.md | 2 +- 3 files changed, 213 insertions(+), 65 deletions(-) diff --git a/docs/local-file-organization/modules/03-providers.md b/docs/local-file-organization/modules/03-providers.md index bef2f48fc..830b8eed6 100644 --- a/docs/local-file-organization/modules/03-providers.md +++ b/docs/local-file-organization/modules/03-providers.md @@ -23,7 +23,7 @@ public sealed record ProviderCapabilities( string[] ExternalIdKinds); // payload 携带哪些外站 id(如 AniList 带 MAL id)→ 供身份对齐 ``` -`MatchCandidate` 携带:多语言标题、年份、类型、本站 id、外站 id 列表、原始 payload、以及按组件 `Fields` 声明填充的 [FieldBag](../foundations.md#类型系统)。 +`MatchCandidate`(契约定义见 [04](04-resolution.md#候选契约matchcandidate))中,适配器负责填充:作品身份/多语言标题/外站 id 链接(canonical 部分),以及按组件 `Fields` 声明填进 `Features`([FieldBag](../foundations.md#类型系统))的域数据(year/workType/albumArtist…——candidate 上**没有**这些专属属性,全部走类型化字段袋);原始 payload 按源保留。 ## 与 ThirdParty 模块的关系 diff --git a/docs/local-file-organization/modules/04-resolution.md b/docs/local-file-organization/modules/04-resolution.md index 8c895207a..c9bc87e5f 100644 --- a/docs/local-file-organization/modules/04-resolution.md +++ b/docs/local-file-organization/modules/04-resolution.md @@ -6,10 +6,7 @@ ## 模型:候选生成器 + 固定三阶段 Runner -身份判定的本质是一个「发散假设 → 归拢 → 裁决」的过程,其中**只有"发散假设"存在值得用户插拔的自由度**(用什么线索、查什么源、用什么方法产生候选);归拢与裁决的算法是唯一的,用户能调的只是参数。因此: - -- 组件只有**一种**概念:**候选生成器 `ICandidateGenerator`**——从线索产出候选。用户自由组合其实例。 -- **Runner 是内核**(不是组件),固定三阶段,行为由 Pipeline 配置驱动: +身份判定的本质是「发散假设 → 归拢 → 裁决」,其中**只有"发散假设"存在值得用户插拔的自由度**;归拢与裁决算法唯一、仅参数可调。因此组件只有一种概念——**候选生成器 `ICandidateGenerator`**;Runner 是内核(非组件),固定三阶段: ``` ParsedClues ──(Override 前置:人工裁定过的条目直接采用)──▶ @@ -18,24 +15,28 @@ ParsedClues ──(Override 前置:人工裁定过的条目直接采用) ③ Decide top1 + 歧义差 + banding 映射 → Resolved(band) 或 NeedsReview ``` -> 演进备注:早期草稿把判定逻辑做成"责任链节点",并分 Certainty/Generate/Transform 三种角色、把归拢(merge)做成可编排节点——**已废弃**。原因:确定性不是节点的属性而是**候选的属性**(见 Basis);归拢必然发生在全部生成之后、算法唯一,没有编排自由度,做成节点只会让链变得不可理解。 +> 演进备注:早期草稿的"责任链节点 + Certainty/Generate/Transform 三角色 + merge 节点"已废弃——确定性不是节点的属性而是**候选的属性**(Basis);归拢没有编排自由度。 ## 候选契约(MatchCandidate) -所有生成器产出、Runner 各阶段消费的统一结构。**薄字段清单是固定契约**,判据:身份判定所需(打分、等价判定、复核展示)才进契约;权威数据一律留在 `RawPayloads`,字段真相由 [05](05-enrichment.md) 决定。 +所有生成器产出、Runner 消费的统一结构。边界判据:**是不是"作品"这个概念本身必有的东西**——而不是"判定是否需要"(那个判据挡不住域字段蔓延:音乐会想加 albumArtist、漫画想加卷数,god record 迟早出现;`year` 本身就是影视偏见,单曲/软件/图集没有年份这个身份概念)。 ```csharp public sealed record MatchCandidate { - public required WorkIdentitySet Identity { get; init; } // (source, key) 集合;生成时通常单元素 - public required decimal Score { get; init; } // 与线索的匹配程度 0–100 - public required CandidateBasis Basis { get; init; } // 这个候选"怎么来的",见下 - public LocalizedText Titles { get; init; } // 语言 → 标题 - public int? Year { get; init; } // 可空:Provider 没给就是 null - public string? WorkType { get; init; } - public ExternalIdLink[] Links { get; init; } // payload 声明的外站 id - public MatchExplanation Explanation { get; init; } // 复核页展示的判定依据 - public IReadOnlyDictionary RawPayloads { get; init; } // 按源保留原始响应,不融合 + // 作品概念本身:任何域的作品必有身份与称谓 + public required WorkIdentitySet Identity { get; init; } // (source, key) 集合;生成时通常单元素 + public LocalizedText Titles { get; init; } // 语言 → 标题 + public ExternalIdLink[] Links { get; init; } // payload 声明的外站 id(等价 join 的输入) + + // Runner 簿记:与域无关 + public required decimal Score { get; init; } // 与线索的匹配程度 0–100 + public required CandidateBasis Basis { get; init; } // 候选"怎么来的",见下 + public MatchExplanation Explanation { get; init; } // 复核页展示的判定依据 + public IReadOnlyDictionary RawPayloads { get; init; } // 按源保留原始响应,不融合 + + // 其余一切域数据(year/workType/albumArtist/volume…):无专属属性,全部进类型化字段袋 + public FieldBag Features { get; init; } } public enum CandidateBasis @@ -49,7 +50,8 @@ public enum CandidateBasis } ``` -映射责任:Provider 适配器([03](03-providers.md))负责把各站 payload 映射进薄字段;缺失即 null,所有消费方按可空处理——"归拢怎么知道有没有 year"的答案就是读 `candidate.Year`,null 即没有(启发式连接会因此退化,见下)。 +- `Features` 就是 [foundations 类型系统](../foundations.md#类型系统)的 FieldBag:Provider 组件经 `FieldDefinition[]` 声明产出字段("字段随组件走"),适配器负责把 payload 填进去([03](03-providers.md))。**冗余与歧义的解药**:字段词汇按组件声明、按 key 隔离,不在中心 record 上累积属性。 +- 判定逻辑对 year 之类的使用退化为**配置里的 feature 引用**(见 FeaturePredicate)——内核代码里没有任何域字段名。 ## ① Generate:候选生成器 @@ -61,85 +63,231 @@ public interface ICandidateGenerator // AbstractCandidateGenerator:configJson → TConfig,镜像 AbstractEnhancer 的类型化配置模式 ``` -实现类是**无状态单例**(DI 枚举注册,同 Enhancer 模式);Pipeline 里的生成器链是**数据行**(kind + configJson,存于 [OrganizePipeline](08-orchestration.md#数据表));同一 kind 可多实例、各带配置(如两个 `code-lookup` 分别处理 RJ 码与私有码、限定不同 Provider)。 +实现类是**无状态单例**(DI 枚举注册);Pipeline 里的生成器链是**数据行**(kind + configJson,存 [OrganizePipeline](08-orchestration.md#数据表));同一 kind 可多实例、各带配置。 | kind | 类名 | Basis | 配置 | |---|---|---|---| -| `resolver.code-lookup` | `CodeLookupCandidateGenerator` | KeyLookup | CodeSlots(哪些线索槽算码)、ProviderFilter | -| `resolver.fuzzy-search` | `FuzzySearchCandidateGenerator` | TextSimilarity | SearchStrategy(FirstHit/FanOutUnion)、MinScore、TitleSlots、UseAliasExpansion、Year/Type 权重 | -| `resolver.clue-direct` | `ClueDirectCandidateGenerator` | ClueDirect | 取哪些线索槽(通用音频"以标签定名"即它的一个实例) | +| `resolver.code-lookup` | `CodeLookupCandidateGenerator` | KeyLookup | CodeSlots、ProviderFilter、**CrossValidateWithTitle**(命中后与标题线索交叉检验,见误判防线) | +| `resolver.fuzzy-search` | `FuzzySearchCandidateGenerator` | TextSimilarity | SearchStrategy(FirstHit/FanOutUnion)、MinScore、TitleSlots、UseAliasExpansion、**FeatureWeights**(feature 一致性加权,与 FeaturePredicate 同词汇) | +| `resolver.clue-direct` | `ClueDirectCandidateGenerator` | ClueDirect | 取哪些线索槽(通用音频"以标签定名"即它的实例) | | `resolver.lookup-table` | `LookupTableCandidateGenerator` | UserTable | 用户映射表引用(线索值 → 身份/标题) | | `resolver.fingerprint`(后期) | `FingerprintCandidateGenerator` | Fingerprint | 指纹源、命中阈值 | | `resolver.ai`(后期) | `AiCandidateGenerator` | Inference | 模型 / 提示词模板 / 置信要求 | -生成器排序即优先级:**先便宜先准**(确定性来源在前)。**早停**是 Runner 的配置行为:出现 KeyLookup/UserTable 候选即跳过后续生成器——"精确码短路"由此实现,不再是特殊节点。 +排序即优先级(先便宜先准);**早停**为 Runner 配置:出现 KeyLookup/UserTable 候选即跳过后续生成器。**kind 清单不钉死**:内核只认识契约,实现类注册即新增 kind(同 Enhancer 贡献模式);终端用户不造 kind(无脚本引擎),但 `lookup-table`/`clue-direct` 的行为由用户数据定义。 -**kind 清单不钉死**:内核只认识 `ICandidateGenerator` 契约;实现类注册即出现新 kind(同新增 Enhancer 的贡献模式)。终端用户不能凭空造 kind(刻意不做脚本引擎),但 `lookup-table` / `clue-direct` 的行为本身由用户数据定义,覆盖绝大多数"自有逻辑"。 +## ② Consolidate:候选归拢(内核固定阶段) -## ② Consolidate:候选归拢(内核固定阶段,不是组件) +跨 Provider 的同一作品会以多个候选出现;不归拢 → **假歧义**(96 vs 95 被迫进复核)+ 多源身份丢失。归拢固化在内核、仅暴露参数。比较语义复用 **Comparison 模块**的策略(StrictEqual/FixedTolerance/SameDay/SetIntersection…): -跨 Provider 的同一作品会以多个候选出现(Bangumi 96 分、TMDB 95 分)。不归拢的后果:top1/top2 差 1 → **假歧义**被迫进复核;且定案只带单库身份,多源关联永久丢失。归拢没有值得用户重排/替换的自由度,因此固化在内核、仅暴露参数: +```csharp +public sealed record ConsolidationConfig +{ + public bool EnableHeuristicJoin { get; init; } + // 启发式并组谓词表。动画内置 Pipeline 默认:[("year", FixedTolerance{1}), ("workType", StrictEqual)] + // —— "year" 只出现在这里(配置),不出现在内核代码里。 + public IReadOnlyList HeuristicPredicates { get; init; } = []; + public decimal CorroborationBonus { get; init; } = 0; // 佐证加分,默认 0 +} +public sealed record FeaturePredicate(string FeatureKey, ComparisonMode Mode, string? ArgJson); +``` -1. **等价分组**(并查集;两类连接在算法上本质不同): - - **确定性连接**——身份键或外站链接相等:`A.Identity ∩ B.Identity ≠ ∅`,或 A 的 `Links` 指向 B 的 (source, key)。键相等是 join,不存在"判错"(除非源数据本身错)。**永远启用,无需配置。** - - **启发式连接**——SpecialText 归一化后标题相等 + 年份差 ≤ 容差(默认 ±1)+ 类型兼容。**显式开关 + 参数**(模糊场景默认开);`Year == null` 时年份条件不可用 → 该连接自动收紧(仅标题相等不足以并组)。 -2. **组内合并**,每组产出一个候选: - - `Identity` 取并集——多源身份在此固化,供 [05](05-enrichment.md) 并行富集、[07](07-execution.md) 逐源写 `ResourceSourceLink`; - - `Score = max(成员)`(可选小额"佐证加分",默认关——两库共现是佐证,不代表标题更匹配;绝不能相加,否则热门作品靠收录量碾压更匹配的冷门候选); - - 薄字段浅并:`Titles` 按语言键并、冲突取高分方;`Year`/`WorkType` 取高分方。**这些值仅供复核展示与重打分,不是权威值,不会落库**——写进属性的最终值由 [05](05-enrichment.md) 按 per-field 策略从各源 payload 选出; - - **冲突打旗**:确定性连接成立但薄字段冲突(如两库 year 不同)→ MergeFlag,复核页展示双值;可配"带旗降一档"(High→Medium 进复核),防御外链本身指错的罕见情况; - - `RawPayloads` 按源原样保留,绝不融合。 -3. 启发式连接判不上的候选保持独立——同名的原作与重制版靠这个不被错并。 +```csharp +public sealed class CandidateConsolidator(ISpecialTextService specialText, IComparisonStrategyResolver strategies) +{ + public ConsolidationResult Consolidate(IReadOnlyList pool, ConsolidationConfig config) + { + if (pool.Count < 2) return new(pool.ToList(), []); + + var uf = new UnionFind(pool.Count); // 1) 并查集分组 + for (var i = 0; i < pool.Count; i++) + for (var j = i + 1; j < pool.Count; j++) + { + if (IsDeterministicallyLinked(pool[i], pool[j]) || // 永远启用 + (config.EnableHeuristicJoin && IsHeuristicallyLinked(pool[i], pool[j], config))) + uf.Union(i, j); + } + + var conflicts = new List(); // 2) 每组合并为一个候选 + var merged = uf.Groups() + .Select(g => g.Count == 1 ? pool[g[0]] : MergeGroup(g.Select(x => pool[x]).ToList(), config, conflicts)) + .ToList(); + return new(merged, conflicts); + } + + // 键相等 = join,不存在"判错"(除非源数据本身错) + private static bool IsDeterministicallyLinked(MatchCandidate a, MatchCandidate b) => + a.Identity.Overlaps(b.Identity) + || a.Links.Any(l => b.Identity.Contains(l.Source, l.Key)) + || b.Links.Any(l => a.Identity.Contains(l.Source, l.Key)); + + private bool IsHeuristicallyLinked(MatchCandidate a, MatchCandidate b, ConsolidationConfig config) + { + if (!HasNormalizedTitleMatch(a.Titles, b.Titles)) return false; // 门槛:归一化标题相等 + foreach (var p in config.HeuristicPredicates) + { + var va = a.Features.GetRaw(p.FeatureKey); + var vb = b.Features.GetRaw(p.FeatureKey); + if (va is null || vb is null) return false; // 缺值 → 谓词不可用 → 保守拒绝并组 + if (!strategies.Get(p.Mode).AreEquivalent(va, vb, p.ArgJson)) return false; + } + return true; + } + + private bool HasNormalizedTitleMatch(LocalizedText a, LocalizedText b) => + a.Values.Any(ta => b.Values.Any(tb => + specialText.Standardize(ta).Equals(specialText.Standardize(tb), StringComparison.OrdinalIgnoreCase))); + + private static MatchCandidate MergeGroup(List members, ConsolidationConfig config, + List conflicts) + { + var byScore = members.OrderByDescending(m => m.Score).ToList(); + var top = byScore[0]; + var identity = WorkIdentitySet.Union(members.Select(m => m.Identity)); + + // Titles 与 Features 同一条规则:高分方先写入、后来者只填空;值不同 → 记冲突旗 + var titles = new Dictionary(); + foreach (var m in byScore) + foreach (var (lang, title) in m.Titles) + { + if (titles.TryGetValue(lang, out var existed)) + { if (existed != title) conflicts.Add(new(identity, $"title[{lang}]", existed, title)); } + else titles[lang] = title; + } + + var features = FieldBag.Empty(); + foreach (var m in byScore) + foreach (var (key, value) in m.Features.Raw) + { + if (features.TryGetRaw(key, out var existed)) + { if (!Equals(existed, value)) conflicts.Add(new(identity, key, existed, value)); } + else features.SetRaw(key, value, provenance: m.Identity.Primary.Source); + } + + return top with + { + Identity = identity, + Score = Math.Min(100, top.Score + (members.Count > 1 ? config.CorroborationBonus : 0)), + Titles = new LocalizedText(titles), + Features = features, + Explanation = MatchExplanation.Concat(members.Select(m => m.Explanation)), + RawPayloads = members.SelectMany(m => m.RawPayloads).ToDictionary(kv => kv.Key, kv => kv.Value), + }; + } +} +``` + +合并出的薄值(Titles/Features 上的胜出值)**仅供复核展示与重打分,从不落库**——权威值由 [05](05-enrichment.md) 按 per-field 策略从各源 payload 选出。启发式并组的候选携带 `HeuristicallyMerged` 标记,供 Decide 与复核页消费。 ## ③ Decide:裁决 -- top1 分数 + 与 top2 的歧义差 + banding 映射,全部是 Pipeline 配置:`{ high, medium, ambiguityGap, mediumAction(自动过/抽样/全复核) }`。 -- **Basis 感知**:KeyLookup / UserTable 候选 → High 直通;TextSimilarity / Inference 走分数映射。 -- 无候选或歧义 → NeedsReview,携带候选池与 `Explanation` 供复核页展示。 +```csharp +public sealed record BandingConfig +{ + public decimal High { get; init; } = 90; + public decimal Medium { get; init; } = 70; + public decimal AmbiguityGap { get; init; } = 5; + public MediumAction MediumAction { get; init; } = MediumAction.ReviewAll; // AutoPass / Sample / ReviewAll + public bool DowngradeOnMergeConflict { get; init; } = false; +} + +public sealed class MatchDecider +{ + public MatchDecision Decide(ConsolidationResult consolidated, BandingConfig config) + { + var pool = consolidated.Candidates; + if (pool.Count == 0) return MatchDecision.NeedsReview(consolidated, ReviewReason.NoCandidates); + + var ranked = pool.OrderByDescending(c => c.Score).ToList(); + var top = ranked[0]; + + // 1) 确定性 Basis 直通(早停通常已在 Generate 发生,这里兜底;交叉检验旗除外) + if (top.Basis is CandidateBasis.KeyLookup or CandidateBasis.UserTable && !top.HasFlag(CandidateFlag.CrossValidationFailed)) + return MatchDecision.Resolved(top, MatchBand.High); + + // 2) 歧义保护:Consolidate 之后仍打架的才是真歧义 + if (ranked.Count > 1 && top.Score - ranked[1].Score < config.AmbiguityGap) + return MatchDecision.NeedsReview(consolidated, ReviewReason.Ambiguous(top.Score, ranked[1].Score)); + + // 3) 分数 → 档位 + var band = top.Score >= config.High ? MatchBand.High + : top.Score >= config.Medium ? MatchBand.Medium + : MatchBand.Low; + + // 4) 保守降档(可配):合并冲突旗 / 交叉检验失败旗 + if (band == MatchBand.High && + ((config.DowngradeOnMergeConflict && consolidated.Conflicts.Any(c => c.Identity.Overlaps(top.Identity))) + || top.HasFlag(CandidateFlag.CrossValidationFailed))) + band = MatchBand.Medium; + + return band switch + { + MatchBand.High => MatchDecision.Resolved(top, band), + MatchBand.Medium => config.MediumAction switch + { + MediumAction.AutoPass => MatchDecision.Resolved(top, band), + MediumAction.Sample => MatchDecision.ResolvedWithSampling(top, band), + _ => MatchDecision.NeedsReview(consolidated, ReviewReason.MediumBand), + }, + _ => MatchDecision.NeedsReview(consolidated, ReviewReason.LowScore), + }; + } +} +``` + +## 误判模式与防线 + +判定是不完美信息下的统计决策,**误判不可能为零**。架构目标是控制"错误的期望成本":把错误尽量推向"多一次人工"(便宜、可见),而不是"错数据落盘"(贵、隐蔽)。四条通用防线:**保守默认**(缺值拒并、歧义降档、Medium 默认复核)、**可见**(Explanation/冲突旗/启发式合并旗/provenance——每个结论能回答"为什么")、**可纠**(复核改判 → Override 断言永久生效;执行层可 [Undo](07-execution.md))、**可调**(谓词/阈值/gap/降档开关全是 Pipeline 配置,用真实库标定)。 + +| 误判模式 | 错误方向 | 防线 | +|---|---|---| +| **错并**:启发式连接把同名同年的不同作品并成一个(重制版/剧场版/同名漫改) | 错数据(身份集合被污染) | 谓词全过 + 标题归一相等才并;`HeuristicallyMerged` 旗复核可见;错并通常伴随字段冲突 → 冲突旗 + `DowngradeOnMergeConflict`;复核页"拆开合并" → 写 `NotSameWork` 断言,下轮遵守 | +| **漏并**:同一作品因标题写法差异没并上 → 假歧义 | 多人工(不错数据) | 进复核后人工确认 → 写 `IsSameWork` 断言 + 顺手沉淀 Alias;下轮自动并 | +| **薄值选错**:合并候选上 year/标题取了错误一方 | 展示偏差 | 薄值不落库,权威值在 [05](05-enrichment.md) 按 per-field 策略决定;冲突旗展示双值 | +| **高分错配**:线索本身误导(文件名嵌错码、蹭名作关键词) | 错数据(最危险类) | `code-lookup` 的 **CrossValidateWithTitle**:键命中后与标题线索做相似度交叉检验,不符 → 打旗降档进复核;全量 journal 可回滚;复核页事后改判 → 自动重整理 | +| **阈值边界**:89 分被拦(成本人工)/ 91 分错配(危险) | 双向 | 阈值 per-Pipeline 可调;`MediumAction.Sample` 抽样审计 High 边界;banding 分布可观测,指导标定 | +| **歧义差错判**:gap 太小放过真歧义 / 太大复核爆量 | 双向 | 同上,per-Pipeline 标定;复核量本身是收敛指标 | + +Override 断言因此扩展为: + +``` +OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity / ForcedTitle / ForcedPipeline / Skip + / IsSameWork / NotSameWork), PayloadJson, CreatedAt + // IsSameWork/NotSameWork 以 (source,key) 对为主体,Consolidate 优先遵守,跨 Job 生效 +``` ## 走查示例 ``` -生成器链:code-lookup → fuzzy-search | consolidate{启发式:开} | banding{high:90, ambiguityGap:5} +生成器链:code-lookup → fuzzy-search | consolidate{启发式:开, 谓词:[year±1, workType 相等]} | banding{high:90, gap:5} 输入 1:[CANDYVOICE] RJ01017217 耳かきボイス.zip - ① code-lookup:code 线索 → DLsite 直查命中 → [{dlsite:RJ01017217, Basis=KeyLookup}] - → 早停,fuzzy-search 不执行 + ① code-lookup:code 线索 → DLsite 直查命中,标题交叉检验通过 → [{dlsite:RJ01017217, Basis=KeyLookup}] + → 早停 ② consolidate:单候选,无事可做 - ③ decide:Basis=KeyLookup → Resolved(High)。不进复核。 + ③ decide:Basis=KeyLookup 且无旗 → Resolved(High) 输入 2:[LoliHouse] 進撃の巨人 S03 - ① code-lookup:无码线索 → 0 候选;fuzzy-search:池 = [A{bangumi, 96}, B{tmdb, 95}] - ② consolidate:A/B 经外站 id 互指(确定性连接)→ 池 = [AB{bangumi+tmdb, 96}] - ③ decide:96 ≥ high,次高候选 60、差 36 > ambiguityGap → Resolved(High) + ① code-lookup:无码 → 0 候选;fuzzy-search:池 = [A{bangumi, 96}, B{tmdb, 95}] + ② consolidate:A/B 外站 id 互指(确定性连接)→ 池 = [AB{bangumi+tmdb, 96}] + ③ decide:96 ≥ high,次高 60、差 36 > gap → Resolved(High) - 反事实:若跳过 consolidate → 96 vs 95 差 1 < ambiguityGap → 假歧义进复核, - 且即便人工选了 A,身份也只剩 {bangumi}——这两点就是归拢阶段存在的全部理由。 -``` - -## Override(人工裁定,Runner 前置) - -``` -OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity/ForcedTitle/ForcedPipeline/Skip), - PayloadJson, CreatedAt // 跨 Job 生效 → 越跑越少人工 + 反事实:跳过 consolidate → 96 vs 95 差 1 < gap → 假歧义进复核,且人工选 A 后身份只剩 {bangumi} + ——归拢阶段存在的全部理由。 ``` ## 用户如何自定义 -三层阶梯,前两层零代码: - -1. **组链**:fork Pipeline 后对生成器列表增删/重排/调参——AV 库只留 `code-lookup`(宁复核不猜)、动画库调大 `ambiguityGap`、私有码库加一个限定自有 Provider 的 `code-lookup` 实例。 -2. **数据驱动生成器**:`lookup-table`(本地映射表:私有编号、自建元数据)、`clue-direct`(不联网,以标签/文件名直接定名)——行为即用户数据。 -3. **写代码**:实现 `AbstractCandidateGenerator` + 注册即出现在调色板,同 Enhancer 贡献模式。 +三层阶梯,前两层零代码:① **组链**(fork 后增删/重排/调参生成器实例:AV 库只留 `code-lookup`;私有码加一个限定自有 Provider 的实例);② **数据驱动生成器**(`lookup-table`/`clue-direct`——行为即用户数据);③ **写 C#**(实现 `AbstractCandidateGenerator` + 注册,同 Enhancer 贡献模式)。 ## 完成后获得的能力 -- 纯逻辑层:线索集 + Provider(可 mock)→ 可解释的身份判定;打分、归拢、歧义边界全量单测。 -- 配合 01–03 的调试页端到端验证"这个文件名能不能认出来、为什么认错",无需任何文件操作。 -- Override 落地后,人工纠错一次、永久生效。 +- 纯逻辑层:线索集 + Provider(可 mock)→ 可解释的身份判定;打分、归拢、歧义、误判防线全量单测。 +- 配合 01–03 的调试页端到端验证"这个文件名能不能认出来、为什么认错"。 +- Override(含正/负同一性断言)落地后,人工纠错一次、永久生效。 ## 开放问题 -- 佐证加分是否默认开、幅度多少(拿真实库标定)。 -- 启发式连接的年份容差 / 标题归一强度的各场景默认值。 +- 佐证加分、启发式谓词、banding 的各场景默认值(拿真实库标定)。 +- `CrossValidateWithTitle` 的相似度阈值与多语言标题的检验策略。 - `resolver.ai` 生成器的引入时机与置信要求。 diff --git a/docs/local-file-organization/modules/08-orchestration.md b/docs/local-file-organization/modules/08-orchestration.md index f86b64963..620600266 100644 --- a/docs/local-file-organization/modules/08-orchestration.md +++ b/docs/local-file-organization/modules/08-orchestration.md @@ -23,7 +23,7 @@ Pipeline = 作用域/Detector |---|---|---|---| | 同人音声/DLsite | pattern(rj-code) → pattern(filename) | dlsite | code-lookup → fuzzy-search(Medium 全复核) | | 通用音频 | embedded-tag → pattern(filename) | (空) | clue-direct(标签)(不联网定名;缺标签→复核) | -| 动画 | pattern(filename,字幕组词表) → nfo | bangumi → tmdb | fuzzy-search(FanOutUnion;启发式归拢开) | +| 动画 | pattern(filename,字幕组词表) → nfo | bangumi → tmdb | fuzzy-search(FanOutUnion;启发式归拢:year±1 + workType 相等) | | 漫画/本子 | comicinfo → pattern(gallery-token) → pattern(filename) | exhentai → bangumi | fuzzy-search | | 音乐 | embedded-tag → pattern(filename) | musicbrainz | code-lookup(MBID) → fingerprint → fuzzy-search | | 电影/剧集 | pattern(imdb-code) → pattern(sxxeyy) → pattern(filename) | tmdb | code-lookup → fuzzy-search | From b5eeab0a7d1f5473013cb20104d4e8477a24a46e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 11:38:45 +0000 Subject: [PATCH 08/46] docs(organizer): consolidation closure semantics, identity assertions, title role Restructures the consolidation section around the three-question separation (equivalence vs display value vs authoritative data), an exhaustive logic inventory showing the consolidator does not grow with domains, and pure-function/non-destructive replay semantics. Identity assertions (IsSameWork/NotSameWork) become a consolidator input with highest priority, the heuristic title gate compares alias-expanded normalized title sets, and a dedicated section documents title's role in both join paths including the granularity-mismatch signal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../modules/04-resolution.md | 135 +++++++++++++----- 1 file changed, 103 insertions(+), 32 deletions(-) diff --git a/docs/local-file-organization/modules/04-resolution.md b/docs/local-file-organization/modules/04-resolution.md index c9bc87e5f..ca6cbdfd4 100644 --- a/docs/local-file-organization/modules/04-resolution.md +++ b/docs/local-file-organization/modules/04-resolution.md @@ -11,7 +11,7 @@ ``` ParsedClues ──(Override 前置:人工裁定过的条目直接采用)──▶ ① Generate 依序运行生成器实例;出现确定性候选可提前终止(早停,可配) - ② Consolidate 候选池归拢:等价分组 → 合并(内核固定算法,配置驱动) + ② Consolidate 候选池归拢(断言 > 键 join > 启发式;纯函数,可无损重放) ③ Decide top1 + 歧义差 + banding 映射 → Resolved(band) 或 NeedsReview ``` @@ -19,7 +19,7 @@ ParsedClues ──(Override 前置:人工裁定过的条目直接采用) ## 候选契约(MatchCandidate) -所有生成器产出、Runner 消费的统一结构。边界判据:**是不是"作品"这个概念本身必有的东西**——而不是"判定是否需要"(那个判据挡不住域字段蔓延:音乐会想加 albumArtist、漫画想加卷数,god record 迟早出现;`year` 本身就是影视偏见,单曲/软件/图集没有年份这个身份概念)。 +所有生成器产出、Runner 消费的统一结构。边界判据:**是不是"作品"这个概念本身必有的东西**——而不是"判定是否需要"(那个判据挡不住域字段蔓延;`year` 本身就是影视偏见,单曲/软件/图集没有年份这个身份概念)。 ```csharp public sealed record MatchCandidate @@ -50,7 +50,7 @@ public enum CandidateBasis } ``` -- `Features` 就是 [foundations 类型系统](../foundations.md#类型系统)的 FieldBag:Provider 组件经 `FieldDefinition[]` 声明产出字段("字段随组件走"),适配器负责把 payload 填进去([03](03-providers.md))。**冗余与歧义的解药**:字段词汇按组件声明、按 key 隔离,不在中心 record 上累积属性。 +- `Features` 就是 [foundations 类型系统](../foundations.md#类型系统)的 FieldBag:Provider 组件经 `FieldDefinition[]` 声明产出字段("字段随组件走"),适配器负责把 payload 填进去([03](03-providers.md))。字段词汇按组件声明、按 key 隔离,不在中心 record 上累积属性。 - 判定逻辑对 year 之类的使用退化为**配置里的 feature 引用**(见 FeaturePredicate)——内核代码里没有任何域字段名。 ## ① Generate:候选生成器 @@ -78,7 +78,43 @@ public interface ICandidateGenerator ## ② Consolidate:候选归拢(内核固定阶段) -跨 Provider 的同一作品会以多个候选出现;不归拢 → **假歧义**(96 vs 95 被迫进复核)+ 多源身份丢失。归拢固化在内核、仅暴露参数。比较语义复用 **Comparison 模块**的策略(StrictEqual/FixedTolerance/SameDay/SetIntersection…): +**它只回答一个封闭问题:这几个候选是不是同一作品。** 三个容易混淆的问题及其归属——理解本阶段的前提: + +| 问题 | 谁回答 | 规则 | 错了的后果 | +|---|---|---|---| +| ① 是不是同一作品 | Consolidate | 断言 > 键 join > 标题门槛+谓词表 | 错并/漏并——有旗、有断言、可重放 | +| ② 合并候选上展示哪个值 | Consolidate 的唯一合并规则 | 全字段同一条:fill-empty + 打旗 | 复核页显示偏差;**不落库** | +| ③ 字段的权威值到底是什么 | **[05 富集](05-enrichment.md)**(不是本阶段) | per-field 策略 + provenance | 可改策略零请求重算、[07](07-execution.md) 可重整理 | + +问题③是无限的(每个域有自己的字段与权威源)——所以被整个隔离出本阶段。**一旦让 Consolidate 顺便回答"哪个值对",它就会膨胀成无限逻辑的泥球;这条边界是本设计最重要的一条。** + +### 逻辑封闭性(穷举) + +``` +等价判定(问题①): + a. 人工断言:NotSameWork 一票否决;IsSameWork 直接并 ← 最高优先级(人 > 源数据) + b. 确定性:身份键相交 或 外站链接互指 ← 键 join,不存在"判错" + c. 启发式:归一化标题相等(门槛)且 谓词表全过 ← 谓词表是配置数据,不是代码分支 +合并(问题②): + d. 唯一规则:按分数序 fill-empty——先到先写、后来者只填空、 + 同键不同值 → 打旗。不存在"替代/覆盖"。全字段同构,零特判。 +产出: + e. 身份并集 + max 分数(+可选佐证加分)+ 旗 + RawPayloads 按源原样保留 +``` + +**这就是全部逻辑,不随域增长**:谓词表之外的任何字段不参与等价判定——两个候选可以有 50 个 feature 不同,只要键连上(或断言/门槛+谓词成立)照并,没连上照分。新增一个域,Consolidator 的代码增量为**零**,变的只是该域内置 Pipeline 的谓词配置行。 + +### 纯函数与非破坏 + +``` +Consolidate : (候选池, 配置, 断言) → (新池, 冲突旗) // 无 I/O、确定性 +``` + +合并候选是**派生视图**:原料(各源 RawPayloads)绝不融合,因此任何等价判定错误都可**无损重放**——修正断言/配置后重算,什么都不会丢。"错并"不是数据损坏,是一个可重算视图的错误。 + +### 配置与实现 + +比较语义复用 **Comparison 模块**策略(StrictEqual/FixedTolerance/SameDay/SetIntersection…): ```csharp public sealed record ConsolidationConfig @@ -87,35 +123,44 @@ public sealed record ConsolidationConfig // 启发式并组谓词表。动画内置 Pipeline 默认:[("year", FixedTolerance{1}), ("workType", StrictEqual)] // —— "year" 只出现在这里(配置),不出现在内核代码里。 public IReadOnlyList HeuristicPredicates { get; init; } = []; - public decimal CorroborationBonus { get; init; } = 0; // 佐证加分,默认 0 + public decimal CorroborationBonus { get; init; } = 0; } public sealed record FeaturePredicate(string FeatureKey, ComparisonMode Mode, string? ArgJson); + +// 人工同一性断言(来自 OrganizeOverride 的 IsSameWork / NotSameWork),跨 Job 生效 +public sealed record IdentityAssertions( + IReadOnlySet SameWork, IReadOnlySet NotSameWork); ``` ```csharp -public sealed class CandidateConsolidator(ISpecialTextService specialText, IComparisonStrategyResolver strategies) +public sealed class CandidateConsolidator( + ISpecialTextService specialText, IAliasService alias, IComparisonStrategyResolver strategies) { - public ConsolidationResult Consolidate(IReadOnlyList pool, ConsolidationConfig config) + public ConsolidationResult Consolidate( + IReadOnlyList pool, ConsolidationConfig config, IdentityAssertions assertions) { if (pool.Count < 2) return new(pool.ToList(), []); - var uf = new UnionFind(pool.Count); // 1) 并查集分组 + var uf = new UnionFind(pool.Count); for (var i = 0; i < pool.Count; i++) for (var j = i + 1; j < pool.Count; j++) { - if (IsDeterministicallyLinked(pool[i], pool[j]) || // 永远启用 - (config.EnableHeuristicJoin && IsHeuristicallyLinked(pool[i], pool[j], config))) + var (a, b) = (pool[i], pool[j]); + if (assertions.DeniesUnion(a.Identity, b.Identity)) continue; // a. 否决高于键 join: + // 人 > 源数据(外链也会指错) + if (assertions.ForcesUnion(a.Identity, b.Identity) + || IsDeterministicallyLinked(a, b) // b. + || (config.EnableHeuristicJoin && IsHeuristicallyLinked(a, b, config))) // c. uf.Union(i, j); } - var conflicts = new List(); // 2) 每组合并为一个候选 + var conflicts = new List(); var merged = uf.Groups() .Select(g => g.Count == 1 ? pool[g[0]] : MergeGroup(g.Select(x => pool[x]).ToList(), config, conflicts)) .ToList(); return new(merged, conflicts); } - // 键相等 = join,不存在"判错"(除非源数据本身错) private static bool IsDeterministicallyLinked(MatchCandidate a, MatchCandidate b) => a.Identity.Overlaps(b.Identity) || a.Links.Any(l => b.Identity.Contains(l.Source, l.Key)) @@ -123,20 +168,25 @@ public sealed class CandidateConsolidator(ISpecialTextService specialText, IComp private bool IsHeuristicallyLinked(MatchCandidate a, MatchCandidate b, ConsolidationConfig config) { - if (!HasNormalizedTitleMatch(a.Titles, b.Titles)) return false; // 门槛:归一化标题相等 + if (!HasNormalizedTitleMatch(a, b)) return false; // 门槛 foreach (var p in config.HeuristicPredicates) { var va = a.Features.GetRaw(p.FeatureKey); var vb = b.Features.GetRaw(p.FeatureKey); - if (va is null || vb is null) return false; // 缺值 → 谓词不可用 → 保守拒绝并组 + if (va is null || vb is null) return false; // 缺值 → 谓词不可用 → 保守拒绝 if (!strategies.Get(p.Mode).AreEquivalent(va, vb, p.ArgJson)) return false; } return true; } - private bool HasNormalizedTitleMatch(LocalizedText a, LocalizedText b) => - a.Values.Any(ta => b.Values.Any(tb => - specialText.Standardize(ta).Equals(specialText.Standardize(tb), StringComparison.OrdinalIgnoreCase))); + // 门槛比较在「标题 ∪ 别名展开」集合上做;SpecialText 归一化吸收写法变体(全半角/空格/标点) + private bool HasNormalizedTitleMatch(MatchCandidate a, MatchCandidate b) + { + var ta = a.Titles.Values.SelectMany(alias.Expand) + .Select(specialText.Standardize).ToHashSet(StringComparer.OrdinalIgnoreCase); + return b.Titles.Values.SelectMany(alias.Expand) + .Select(specialText.Standardize).Any(ta.Contains); + } private static MatchCandidate MergeGroup(List members, ConsolidationConfig config, List conflicts) @@ -145,7 +195,7 @@ public sealed class CandidateConsolidator(ISpecialTextService specialText, IComp var top = byScore[0]; var identity = WorkIdentitySet.Union(members.Select(m => m.Identity)); - // Titles 与 Features 同一条规则:高分方先写入、后来者只填空;值不同 → 记冲突旗 + // 规则 d(唯一合并规则):fill-empty——已写入的值不会被覆盖,不存在"替代" var titles = new Dictionary(); foreach (var m in byScore) foreach (var (lang, title) in m.Titles) @@ -177,7 +227,25 @@ public sealed class CandidateConsolidator(ISpecialTextService specialText, IComp } ``` -合并出的薄值(Titles/Features 上的胜出值)**仅供复核展示与重打分,从不落库**——权威值由 [05](05-enrichment.md) 按 per-field 策略从各源 payload 选出。启发式并组的候选携带 `HeuristicallyMerged` 标记,供 Decide 与复核页消费。 +### title 在归拢中的角色 + +title 是唯一有特殊地位的字段(它是启发式路径的门槛),其余字段一律走谓词表或被无视: + +| 路径 | title 的角色 | title 不一样时 | +|---|---|---| +| 断言 / 键 join | **不参与判定**——键说了算 | 照并;同语言不同值 → `title[lang]` 冲突旗。该旗常是**粒度错位**信号(外链把系列条目连到季条目/剧场版)——`DowngradeOnMergeConflict` 针对的正是它 | +| 启发式 | **门槛条件**:别名展开 + 归一化后任一语言对相等才继续 | **不并,无降级手段**——保守漏并 → 假歧义进复核 → 人工 `IsSameWork` + 沉淀 Alias → 重放即并上 | + +``` +A{bangumi, ja:進撃の巨人, 96} B{tmdb, ja:進撃の巨人 第三期, 95},外链互指 + → 键 join 照并;title[ja] 冲突旗 →(可配)降档进复核 ← 粒度错位让人看一眼 +A{ja:進撃の巨人, 96} B{ja:進撃の巨人, 95},无外链 + → 门槛过 + year±1/workType 过 → 启发式合并(带 HeuristicallyMerged 旗) +A{ja:進撃の巨人, 96} B{en:Attack on Titan, 95},无外链 + → 任何语言对不相等 → 不并 → 假歧义复核 → IsSameWork → 下轮并上 +``` + +跨语言不同名(一边只有 ja、一边只有 en)字符串比较无解——那是键连接与人工断言的领地,不是启发式该赌的。 ## ③ Decide:裁决 @@ -201,8 +269,9 @@ public sealed class MatchDecider var ranked = pool.OrderByDescending(c => c.Score).ToList(); var top = ranked[0]; - // 1) 确定性 Basis 直通(早停通常已在 Generate 发生,这里兜底;交叉检验旗除外) - if (top.Basis is CandidateBasis.KeyLookup or CandidateBasis.UserTable && !top.HasFlag(CandidateFlag.CrossValidationFailed)) + // 1) 确定性 Basis 直通(早停通常已在 Generate 发生,这里兜底;交叉检验失败旗除外) + if (top.Basis is CandidateBasis.KeyLookup or CandidateBasis.UserTable + && !top.HasFlag(CandidateFlag.CrossValidationFailed)) return MatchDecision.Resolved(top, MatchBand.High); // 2) 歧义保护:Consolidate 之后仍打架的才是真歧义 @@ -237,25 +306,27 @@ public sealed class MatchDecider ## 误判模式与防线 -判定是不完美信息下的统计决策,**误判不可能为零**。架构目标是控制"错误的期望成本":把错误尽量推向"多一次人工"(便宜、可见),而不是"错数据落盘"(贵、隐蔽)。四条通用防线:**保守默认**(缺值拒并、歧义降档、Medium 默认复核)、**可见**(Explanation/冲突旗/启发式合并旗/provenance——每个结论能回答"为什么")、**可纠**(复核改判 → Override 断言永久生效;执行层可 [Undo](07-execution.md))、**可调**(谓词/阈值/gap/降档开关全是 Pipeline 配置,用真实库标定)。 +判定是不完美信息下的统计决策,**误判不可能为零**。架构目标是控制"错误的期望成本":把错误推向"多一次人工"(便宜、可见),而不是"错数据落盘"(贵、隐蔽)。四条通用防线:**保守默认**(缺值拒并、歧义降档、Medium 默认复核)、**可见**(Explanation/冲突旗/HeuristicallyMerged 旗/provenance)、**可纠**(断言 + 重放 + [Undo](07-execution.md))、**可调**(谓词/阈值/gap/降档开关全是 Pipeline 配置)。 | 误判模式 | 错误方向 | 防线 | |---|---|---| -| **错并**:启发式连接把同名同年的不同作品并成一个(重制版/剧场版/同名漫改) | 错数据(身份集合被污染) | 谓词全过 + 标题归一相等才并;`HeuristicallyMerged` 旗复核可见;错并通常伴随字段冲突 → 冲突旗 + `DowngradeOnMergeConflict`;复核页"拆开合并" → 写 `NotSameWork` 断言,下轮遵守 | -| **漏并**:同一作品因标题写法差异没并上 → 假歧义 | 多人工(不错数据) | 进复核后人工确认 → 写 `IsSameWork` 断言 + 顺手沉淀 Alias;下轮自动并 | -| **薄值选错**:合并候选上 year/标题取了错误一方 | 展示偏差 | 薄值不落库,权威值在 [05](05-enrichment.md) 按 per-field 策略决定;冲突旗展示双值 | -| **高分错配**:线索本身误导(文件名嵌错码、蹭名作关键词) | 错数据(最危险类) | `code-lookup` 的 **CrossValidateWithTitle**:键命中后与标题线索做相似度交叉检验,不符 → 打旗降档进复核;全量 journal 可回滚;复核页事后改判 → 自动重整理 | -| **阈值边界**:89 分被拦(成本人工)/ 91 分错配(危险) | 双向 | 阈值 per-Pipeline 可调;`MediumAction.Sample` 抽样审计 High 边界;banding 分布可观测,指导标定 | -| **歧义差错判**:gap 太小放过真歧义 / 太大复核爆量 | 双向 | 同上,per-Pipeline 标定;复核量本身是收敛指标 | +| **错并**:启发式把同名同年的不同作品并成一个 | 派生视图错误(可重算) | 门槛+谓词全过才并;`HeuristicallyMerged` 旗;错并常伴字段冲突 → 冲突旗 + 降档;复核"拆开合并" → `NotSameWork` 断言 → **重放归拢,无损重算** | +| **漏并**:同作不同写法没并上 → 假歧义 | 多人工(不错数据) | 复核确认 → `IsSameWork` 断言 + 沉淀 Alias → 重放即并 | +| **薄值选错**:合并候选展示了错误一方的值 | 展示偏差 | fill-empty 不覆盖 + 冲突旗展示双值;权威值在 [05](05-enrichment.md) | +| **高分错配**:线索误导(嵌错码/蹭名作关键词) | 错数据(最危险类) | `code-lookup` 的 CrossValidateWithTitle 打旗降档;journal 可回滚;复核改判 → 自动重整理 | +| **阈值边界**:89 被拦 / 91 错配 | 双向 | 阈值 per-Pipeline 可调;`MediumAction.Sample` 抽样审计;banding 分布可观测 | +| **歧义差错判**:gap 太小放过真歧义 / 太大复核爆量 | 双向 | 同上标定;复核量本身是收敛指标 | -Override 断言因此扩展为: +## Override(人工裁定) ``` OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity / ForcedTitle / ForcedPipeline / Skip / IsSameWork / NotSameWork), PayloadJson, CreatedAt - // IsSameWork/NotSameWork 以 (source,key) 对为主体,Consolidate 优先遵守,跨 Job 生效 ``` +- 条目级断言(ForcedIdentity/ForcedTitle/ForcedPipeline/Skip)在 Runner 前置消费。 +- **同一性断言**(IsSameWork/NotSameWork,以 (source,key) 对为主体)作为 `IdentityAssertions` 输入 Consolidate,**优先级最高**(否决高于键 join——外链也会指错,人说了算),跨 Job 生效。 + ## 走查示例 ``` @@ -269,7 +340,7 @@ OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity / Forced 输入 2:[LoliHouse] 進撃の巨人 S03 ① code-lookup:无码 → 0 候选;fuzzy-search:池 = [A{bangumi, 96}, B{tmdb, 95}] - ② consolidate:A/B 外站 id 互指(确定性连接)→ 池 = [AB{bangumi+tmdb, 96}] + ② consolidate:A/B 外站 id 互指(键 join)→ 池 = [AB{bangumi+tmdb, 96}] ③ decide:96 ≥ high,次高 60、差 36 > gap → Resolved(High) 反事实:跳过 consolidate → 96 vs 95 差 1 < gap → 假歧义进复核,且人工选 A 后身份只剩 {bangumi} @@ -282,7 +353,7 @@ OrganizeOverride Fingerprint(条目指纹,键), Kind(ForcedIdentity / Forced ## 完成后获得的能力 -- 纯逻辑层:线索集 + Provider(可 mock)→ 可解释的身份判定;打分、归拢、歧义、误判防线全量单测。 +- 纯逻辑层:线索集 + Provider(可 mock)→ 可解释的身份判定;打分、归拢(含断言重放)、歧义、误判防线全量单测。 - 配合 01–03 的调试页端到端验证"这个文件名能不能认出来、为什么认错"。 - Override(含正/负同一性断言)落地后,人工纠错一次、永久生效。 From 44af560b2398b7ca48ea48cf0e97d5725b22da5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:01:44 +0000 Subject: [PATCH 09/46] docs: park organizer design, add file-cleaning workflow design as current focus Marks docs/local-file-organization as pending (resolution-layer semantics not yet converged) and adds the file-cleaning workflow design: a user-defined chain built directly on the existing Workflow module (manual trigger carrying constants + child enumeration, filter activities, text-transform activities wrapping FileNameModifier operations and SpecialText-driven wrapped-text removal, and a save-name action with preview/apply/undo via FileRenameRecord). Extends SpecialText with user-defined custom types referenced as text sets by cleaning nodes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow.md | 143 +++++++++++++++++++++++++ docs/local-file-organization/README.md | 2 +- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 docs/file-cleaning-workflow.md diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md new file mode 100644 index 000000000..8f9d96d1d --- /dev/null +++ b/docs/file-cleaning-workflow.md @@ -0,0 +1,143 @@ +# 文件清洗(File Cleaning Workflow)· 设计 + +> 状态:**设计中,当前焦点**。一句话:**Workflow + File Renamer 的组合**——用户自定义一条清洗流程(constants → 枚举子级 → 若干文本变更节点 → 保存文件名),针对文件夹执行,带预览与撤销。 +> +> 与 [Organizer(已挂起)](local-file-organization/README.md)的关系:独立功能、独立成立;不依赖 Organizer 的任何概念。若 Organizer 复活,本功能的文本变更节点可被其复用为原子能力。 + +## 承载决策:直接构建在现有 Workflow 模块上 + +Organizer 当初不复用 WorkflowRunner 的三个理由(跨轮状态机、聚合阶段、两阶段确认)在本功能上**全部不成立**:清洗是线性 per-item 流程、无跨条目聚合、预览可由节点模式实现。而 Workflow 模块已有的正是本功能需要的全部骨架: + +| 需求 | Workflow 现有能力 | +|---|---| +| 用户自定义流程 | WorkflowDefinition + 链式编辑器(typed-item 校验) | +| 节点分类 | Activity 三类:Filter / Action / Transform | +| 逐值轮询执行 | Runner 的 per-item 驱动(trigger `ExtractItems` 产出初始 item 列表) | +| 运行记录 | WorkflowRun 持久化 + 步骤统计 | + +文本操作能力复用 **FileNameModifier**(Insert / AddDateTime / Delete / Replace / ChangeCase / AddAlphabetSequence / Reverse × 目标区域 × 位置,纯字符串函数,已有实现与测试),文本集能力复用并扩展 **SpecialText**(见下)。 + +## Item 模型 + +```csharp +// ItemType: "item.fs.entry"(注册 IWorkflowItemTypeDescriptor) +public sealed record FsEntryItem +{ + public required string Path { get; init; } // 当前完整路径 + public required bool IsDirectory { get; init; } + public required string OriginalName { get; init; } // 进入链时的名字(不含路径) + public required string WorkingName { get; init; } // 工作名:文本变更节点只改它,不碰磁盘 +} +``` + +核心不变量:**链上一切文本变更只作用于 `WorkingName`;磁盘操作只发生在 saveName 节点**。这天然把"计算新名字"与"落盘"分成两段,预览因此免费。 + +## 节点设计 + +### 触发器:`fileCleaning.manual`(= 用户说的 constants 节点 + 获取子级节点) + +手动触发器,payload 即用户录入的常量与枚举配置: + +```jsonc +{ + "roots": ["D:/Anime", "E:/Downloads"], // constants:写死的目录列表 + "enumerate": { + "target": "Directories | Files | Both", + "depth": 1, // 枚举深度(1 = 直接子级) + "extensionFilter": ["mkv", "zip"] // 可选 + } +} +``` + +`ExtractItems` 对每个 root 按配置枚举,产出 `FsEntryItem` 列表——"针对全部子文件(夹)轮询跑后面的节点"就是 Runner 的既有语义,引擎零改动。 + +> 为什么第一版把 constants 与枚举合并进触发器:现有 Runner 的 `WorkflowItemOutcome` 只有 Keep/Drop/Replace,**没有链中 1→N 展开**。若后续需要"枚举出子级后再枚举孙级"这类链中展开,引擎增加一个 `ExpandTo(IReadOnlyList)` outcome 即可(小改,向后兼容)——列入开放问题,第一版不做。 + +### Filter 活动(可选,收窄处理范围) + +| kind | 行为 | +|---|---| +| `filter.fs.byRegex` | WorkingName 匹配/不匹配正则 | +| `filter.fs.byExtension` | 按扩展名/扩展名组(复用 ExtensionGroup) | +| `filter.fs.byType` | 只要文件 / 只要文件夹 | + +### Transform 活动(文本变更节点,任意个、任意序) + +| kind | 行为 | 配置 | +|---|---|---| +| `transform.text.fileNameOp` | 包装 FileNameModifier 的**全部**既有操作 | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | +| `transform.text.removeWrapped` | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(引用 SpecialText Wrapper 或自定义对)+ 文本集引用(见下)+ 命中方式(等于/包含/正则) | +| `transform.text.removeTexts` | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | +| `transform.text.trim` | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | + +每个 Transform 输出 `item with { WorkingName = 新值 }`(Workflow 的 `ReplaceWith`,类型不变仍是 `item.fs.entry`——编辑器类型校验直接通过)。 + +### Action 活动:`action.fs.saveName` + +- `WorkingName == OriginalName` → 跳过(无操作)。 +- 不同 → 按运行模式: + - **Preview(默认)**:不碰磁盘,把 `(Path, OriginalName → WorkingName)` 记入本次 run 的改名计划; + - **Apply**:执行改名,写 `FileRenameRecord` 日志。 +- 内置防线(不可关闭):改名前 sanitize(非法字符/保留名/结尾点空格);目标重名冲突 → 该条记入冲突列表跳过,不中断整个 run;路径长度预检。 + +## 预览 → 确认 → 应用 → 撤销 + +``` +用户点「预览」 ─▶ run(Preview):全链执行,saveName 只记计划 + └─▶ UI 展示 diff 列表(旧名 → 新名,冲突高亮),可逐条勾选排除 +用户点「应用」 ─▶ 对预览产出的计划执行改名(不重跑链,避免两次运行间磁盘漂移) + └─▶ 逐条写 FileRenameRecord +用户点「撤销」 ─▶ 逆序回放该次 run 的 FileRenameRecord(To → From) +``` + +``` +FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone +``` + +保留期可配(默认 30 天),由既有清理任务收割。 + +## SpecialText 扩展:用户自定义文本类型(文本集) + +现状:`SpecialTextType` 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案(最小改动): + +1. 枚举新增 `SpecialTextType.Custom = 100`; +2. `SpecialText` 表加一列 `string? CustomTypeName`(仅 Type=Custom 时有值)——同名即同一个"文本集",不引入新表; +3. text 管理页:自定义类型与内置类型并列展示,支持建集、批量录入、导入导出; +4. 节点里的"文本集引用" = 内置类型名或自定义类型名(如「字幕组名单」「广告词」)。 + +> EF 迁移按仓库规则 CLI 生成、纯 schema。若未来自定义类型需要元数据(描述/颜色/分享),再升级为独立表,引用方式不变。 + +## 执行与 UI + +- 流程定义、编辑、运行记录全部走 workflow 页既有 UI;本功能新增的是:触发器配置面板(roots + 枚举配置)、三类活动的配置表单(复用 file-name-modifier 页的操作编辑组件)、预览 diff 面板(可参考 BulkModification 预览页交互)。 +- 运行承载沿用 Workflow 的 run 机制;大目录场景下 run 内部逐条目 yield,进度在 run 详情展示。 +- 入口:workflow 页新建"文件清洗"模板;file-name-modifier 页加一个"升级为清洗流程"引导(把当前操作集一键转为 `transform.text.fileNameOp` 节点)。 + +## 走查示例(用户场景原样落地) + +``` +触发器: roots=[D:/Anime], enumerate={target:Directories, depth:1} +节点 1: transform.text.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 +节点 2: transform.text.trim 清理残留空格 +节点 3: action.fs.saveName + +预览: + [LoliHouse] 進撃の巨人 S03 → 進撃の巨人 S03 + [桜都字幕组] 药屋少女的呢喃 → 药屋少女的呢喃 + Comiket103 → (无变化,跳过) +应用 → 2 条改名,FileRenameRecord 落库;发现错了 → 一键撤销还原 +``` + +## 完成后获得的能力 + +- 用户零代码定义任意清洗流程:录常量目录 → 枚举 → 组合任意文本变更 → 预览确认 → 落盘可撤销。 +- 自定义文本集成为一等公民(字幕组名单、广告词、发布组后缀…),跨流程复用、可维护。 +- Workflow 模块获得第一个文件系统域的触发器与活动集,为后续自动化(下载完成→自动清洗)铺路。 +- FileNameModifier 的能力从单页工具升级为可编排节点,原页面保留不动。 + +## 开放问题 + +- 链中 1→N 展开(`ExpandTo` outcome):等真实需求出现再做。 +- 递归深度 >1 时父子目录同 run 改名的顺序问题(先子后父可避免路径失效)——实现时定。 +- 文本集是否需要分享(Sharable 体系)——倾向于要,随自定义类型使用量决策。 +- 定时/监听触发(FileMover 进料口式自动清洗)——依赖 Workflow 触发器形态,后续版本。 diff --git a/docs/local-file-organization/README.md b/docs/local-file-organization/README.md index 48ed12d75..ad463eb49 100644 --- a/docs/local-file-organization/README.md +++ b/docs/local-file-organization/README.md @@ -1,6 +1,6 @@ # 本地文件自动整理(Organizer)· 设计文档 -> 状态:**设计阶段**(尚未开工)。本目录是该功能的**唯一真相源**:每个概念只在其归属文档中定义一次,其余文档以链接引用,不做内容复制。 +> 状态:**Pending(挂起)**。设计讨论在身份判定层(04 Consolidate 的等价语义/粒度问题)尚未收敛,决定先落地范围更小、边界清晰的**[文件清洗(File Cleaning Workflow)](../file-cleaning-workflow.md)**——它吸收了本设计 [01 文本处理规则](modules/01-text-processing.md)的方向(SpecialText 用户自定义类型 + FileNameModifier 节点化),并直接构建在现有 Workflow 模块上。恢复讨论时本目录仍是唯一真相源:每个概念只在其归属文档中定义一次,其余文档以链接引用,不做内容复制。 ## 目标 From e20edfb12818bc6b52a8d7506d0358980656c380 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:46:38 +0000 Subject: [PATCH 10/46] docs(file-cleaning): add multi-level linked workflow example, adopt expand/variables mechanisms Adds a 12-node worked example (HTML) covering multi-level entries and cross-node linkage: the parent folder's cleaned title is captured as a variable, inherited by expanded child file items, and combined with a per-file episode capture in a template rename. Promotes the mechanisms the example requires into the design doc: ExpandTo outcome with expand.fs.children, item variables with capture/template activities and {var} interpolation, common scope/requiredVars node options with skip-not-drop semantics, and DeepestFirst apply ordering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 190 +++++++++++++++++++++++ docs/file-cleaning-workflow.md | 28 +++- 2 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 docs/file-cleaning-workflow-example.html diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html new file mode 100644 index 000000000..da4fb872d --- /dev/null +++ b/docs/file-cleaning-workflow-example.html @@ -0,0 +1,190 @@ + + +文件清洗流程示例 · 动画下载目录标准化 +
    +

    文件清洗流程示例 · 动画下载目录标准化

    +

    多层级(文件夹 + 其内文件)· 节点联动(文件改名依赖父文件夹节点的清洗结果)· 每节点完整配置

    +

    配套设计文档:docs/file-cleaning-workflow.md。本例同时是设计探针——它需要的「链中展开 / 变量捕获与继承 / 模板重建名 / 节点作用域」机制已并入设计文档(见 §5 设计增量)。

    + +

    §1起始状态与目标

    +
    D:/Downloads/
    +├─ [LoliHouse] 葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]/
    +│   ├─ [LoliHouse] Sousou no Frieren - 01 [WebRip 1080p HEVC-10bit AAC].mkv
    +│   ├─ [LoliHouse] Sousou no Frieren - 02 [WebRip 1080p HEVC-10bit AAC].mkv
    +│   └─ [LoliHouse] Sousou no Frieren - SP1 [WebRip 1080p].mkv
    +├─ [桜都字幕组] 药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]/
    +│   ├─ [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv
    +│   └─ [桜都字幕組] 藥屋少女的呢喃 [02][1080p][繁体].mkv
    +└─ Comiket103/                       ← 无括号段,被节点 1 过滤,整树不处理
    +

    目标:文件夹名去掉字幕组/质量标签;文件名统一重建为「文件夹净标题 - 集数」——即使文件原名与文件夹用字不一致(简繁混用)也被统一;捕获不到集数的特典(SP1)只做清洗、不套模板。

    + +

    §2流程定义(12 个节点,含每节点配置)

    + +
    TfileCleaning.manual触发器
    + + + +
    roots(constants)["D:/Downloads"]
    enumerate.targetDirectories
    enumerate.depth1
    +
    产出 3 个 item.fs.entry(目录项),逐个流过下方链条。
    +
    + +
    1filter.fs.byRegexFilter
    + + +
    pattern\[.+?\]
    modeMatch(命中才保留)
    +
    Comiket103 不含括号段 → 丢弃(其子文件因从未被展开,整树不受影响)。
    +
    + +
    2transform.text.removeWrappedTransformscope: Directories
    + + + +
    wrappers[] 【】 (引用 SpecialText Wrapper)
    textSet自定义文本集 「字幕组名单」(含 LoliHouse、桜都字幕组、桜都字幕組…)
    matchEqualsAny(括号内容全等于集合任一项)
    +
    [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
    +
    + +
    3transform.text.removeWrappedTransformscope: Directories
    + + + +
    wrappers[]
    textSet自定义文本集 「质量与发布标签」(1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
    matchContainsAny(括号内容包含集合任一项即整段删除)
    +
    药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]药屋少女的呢喃
    +
    + +
    4transform.text.trimTransformscope: Directories
    + +
    collapseSpaces / trimEnds / removeEmptyWrapperstrue / true / true
    +
    清掉删除后残留的连续空格与空括号对。
    +
    + +
    5transform.text.captureTransformscope: Directories
    + + + +
    sourceWorkingName
    pattern^(?<title>.+)$
    onMissSkipSilently(不设变量)
    +
    不改名字,只捕获:vars.title = "葬送のフリーレン Sousou no Frieren"(对第二个文件夹则是 "药屋少女的呢喃"
    +
    + +
    6expand.fs.childrenExpand(链中 1→N)
    + + + + +
    targetFiles
    extensionFilter["mkv", "mka", "ass"]
    emitChildrenThenSelf(子项先流过后续节点,父目录项随后跟进)
    inheritVariablestrue
    +
    目录项展开出其内 mkv 文件项,每个文件项继承 vars.title——这就是「节点 11 依赖节点 5 结果」的传递通道。
    +
    + +
    7transform.text.removeWrappedTransformscope: Files
    +
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    +
    目录项流经此节点时因 scope 不命中而原样放行(跳过≠丢弃)。
    +
    + +
    8transform.text.removeWrappedTransformscope: Files
    +
    (配置同节点 3)wrappers [] | textSet 「质量与发布标签」 | ContainsAny
    +
    [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv藥屋少女的呢喃 [01].mkv([01] 不含质量词,保留)
    +
    + +
    9transform.text.trimTransformscope: Files
    +
    (配置同节点 4)true / true / true
    +
    + +
    10transform.text.captureTransformscope: Files
    + + + +
    sourceWorkingName
    pattern(?:-\s*|\[)(?<ep>\d{2,3})(?:\]|\s|$)
    onMissSkipSilently
    +
    Sousou no Frieren - 01.mkvvars.ep = "01"藥屋少女的呢喃 [01].mkvvars.ep = "01"… - SP1.mkv → 捕获失败,ep 不存在
    +
    + +
    11transform.text.templateTransformscope: FilesrequiredVars: [title, ep]
    + + +
    template{var:title} - {var:ep:pad(2)}{extension}
    缺变量行为requiredVars 任一缺失 → 整节点跳过(WorkingName 保持上游清洗结果)
    +
    藥屋少女的呢喃 [01].mkv + title=药屋少女的呢喃(继承自父目录节点 5) + ep=01药屋少女的呢喃 - 01.mkv | SP1 缺 ep → 跳过,保住清洗结果
    +
    + +
    12action.fs.saveNameActionscope: Both
    + + + +
    modePreview(默认;确认后按计划 Apply)
    applyOrderDeepestFirst(先改文件、后改父目录 → 父目录改名不会使子计划路径失效)
    onConflictSkipAndReport(目标重名 → 跳过并列入冲突表)
    +
    WorkingName == OriginalName 的条目(如 SP1 若无变化)自动跳过。
    + + + +

    §3单条 item 的流转(以 藥屋 [01] 文件为例)

    + + + + + + +
    经过节点WorkingNameVariables
    6 展开产出[桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkvtitle=药屋少女的呢喃(继承)
    7 去字幕组藥屋少女的呢喃 [01][1080p][繁体].mkvtitle=…
    8 去质量标签藥屋少女的呢喃 [01].mkvtitle=…
    10 捕获集数藥屋少女的呢喃 [01].mkvtitle=…, ep=01
    11 模板重建药屋少女的呢喃 - 01.mkvtitle=…, ep=01
    12 记入计划(Preview:旧名 → 新名 入 diff)
    + +

    §4预览与结果

    +
    D:/Downloads/
    +├─ 葬送のフリーレン Sousou no Frieren/
    +│   ├─ 葬送のフリーレン Sousou no Frieren - 01.mkv
    +│   ├─ 葬送のフリーレン Sousou no Frieren - 02.mkv
    +│   └─ Sousou no Frieren - SP1.mkv          ← 模板跳过,仅清洗(去字幕组/质量标签)
    +├─ 药屋少女的呢喃/
    +│   ├─ 药屋少女的呢喃 - 01.mkv              ← 繁体原名被父目录净标题统一
    +│   └─ 药屋少女的呢喃 - 02.mkv
    +└─ Comiket103/                            ← 未触碰
    +

    Apply 顺序(DeepestFirst):8 个文件先改,2 个目录后改;全部写入 FileRenameRecord,一键逆序撤销。

    + +

    §5本例暴露并已并入设计的增量

    +
      +
    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提,从"开放问题"转正。
    • +
    • 变量机制FsEntryItem.Variables + transform.text.capture(正则具名捕获,不改名)+ 配置中 {var:name(:formatter)} 插值——节点联动的通道。
    • +
    • 模板重建名transform.text.template——跨层级信息(父标题 + 自身集数)组合命名的表达方式。
    • +
    • 节点通用选项scope(Files/Directories/Both,不命中放行而非丢弃——混合 item 流经线性链的前提)与 requiredVars(缺变量跳过本节点)。
    • +
    • Apply 排序DeepestFirst——父目录改名不使子计划失效。
    • +
    +
    文件清洗流程示例 · 配套 docs/file-cleaning-workflow.md(设计增量已同步)
    +
    diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index 8f9d96d1d..710671717 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -27,11 +27,19 @@ public sealed record FsEntryItem public required bool IsDirectory { get; init; } public required string OriginalName { get; init; } // 进入链时的名字(不含路径) public required string WorkingName { get; init; } // 工作名:文本变更节点只改它,不碰磁盘 + public Dictionary Variables { get; init; } = new(); + // 变量:capture 节点写入、后续节点经 {var:name(:formatter)} 插值消费; + // expand 展开子项时可继承(inheritVariables)——这是"节点 x 依赖节点 y 结果"的传递通道。 + // 变量只在 item 内流动、随继承下发,链保持线性 per-item,无全局状态。 } ``` 核心不变量:**链上一切文本变更只作用于 `WorkingName`;磁盘操作只发生在 saveName 节点**。这天然把"计算新名字"与"落盘"分成两段,预览因此免费。 +**节点通用选项**(所有 fs 域活动支持): +- `scope: Files | Directories | Both`——不命中的 item **原样放行**(跳过 ≠ 丢弃)。这是混合 item(文件+目录)流经同一条线性链的前提。 +- `requiredVars: string[]`——任一变量缺失则跳过本节点(如模板节点缺 `ep` 时保住上游清洗结果)。 + ## 节点设计 ### 触发器:`fileCleaning.manual`(= 用户说的 constants 节点 + 获取子级节点) @@ -51,7 +59,15 @@ public sealed record FsEntryItem `ExtractItems` 对每个 root 按配置枚举,产出 `FsEntryItem` 列表——"针对全部子文件(夹)轮询跑后面的节点"就是 Runner 的既有语义,引擎零改动。 -> 为什么第一版把 constants 与枚举合并进触发器:现有 Runner 的 `WorkflowItemOutcome` 只有 Keep/Drop/Replace,**没有链中 1→N 展开**。若后续需要"枚举出子级后再枚举孙级"这类链中展开,引擎增加一个 `ExpandTo(IReadOnlyList)` outcome 即可(小改,向后兼容)——列入开放问题,第一版不做。 +> constants 与首层枚举合并进触发器;**更深层级用链中展开节点**(下方 Expand)。引擎为此新增 `WorkflowItemOutcome.ExpandTo(IReadOnlyList)`(小改、向后兼容)——多层级示例(父目录净标题传给文件命名)证明了它是必需项,已从开放问题转正。 + +### Expand 活动:`expand.fs.children`(链中 1→N) + +| 配置 | 说明 | +|---|---| +| `target` / `extensionFilter` | Files / Directories / Both + 扩展名过滤 | +| `emit` | `ChildrenOnly` / `ChildrenThenSelf`(子项先流过后续节点,父项随后跟进——配合 saveName 的 DeepestFirst 排序) | +| `inheritVariables` | 子项继承父项 Variables(跨层级联动的通道) | ### Filter 活动(可选,收窄处理范围) @@ -69,6 +85,8 @@ public sealed record FsEntryItem | `transform.text.removeWrapped` | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(引用 SpecialText Wrapper 或自定义对)+ 文本集引用(见下)+ 命中方式(等于/包含/正则) | | `transform.text.removeTexts` | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | | `transform.text.trim` | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | +| `transform.text.capture` | **不改名,只捕获**:正则具名捕获组 → 写入 `Variables` | source(WorkingName/Path/ParentName)+ pattern + onMiss(SkipSilently/标记) | +| `transform.text.template` | 以模板**重建** WorkingName(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalName}` / `{extension}` 插值)+ requiredVars | 每个 Transform 输出 `item with { WorkingName = 新值 }`(Workflow 的 `ReplaceWith`,类型不变仍是 `item.fs.entry`——编辑器类型校验直接通过)。 @@ -79,6 +97,7 @@ public sealed record FsEntryItem - **Preview(默认)**:不碰磁盘,把 `(Path, OriginalName → WorkingName)` 记入本次 run 的改名计划; - **Apply**:执行改名,写 `FileRenameRecord` 日志。 - 内置防线(不可关闭):改名前 sanitize(非法字符/保留名/结尾点空格);目标重名冲突 → 该条记入冲突列表跳过,不中断整个 run;路径长度预检。 +- **Apply 排序 `DeepestFirst`**:按路径深度从深到浅执行(先文件后父目录)——父目录改名不会使子条目计划路径失效。 ## 预览 → 确认 → 应用 → 撤销 @@ -135,9 +154,12 @@ FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone - Workflow 模块获得第一个文件系统域的触发器与活动集,为后续自动化(下载完成→自动清洗)铺路。 - FileNameModifier 的能力从单页工具升级为可编排节点,原页面保留不动。 +## 完整示例 + +多层级 + 节点联动(文件命名依赖父目录节点捕获的净标题)的 12 节点完整走查,含每节点配置与 item 流转表:**[file-cleaning-workflow-example.html](file-cleaning-workflow-example.html)**。 + ## 开放问题 -- 链中 1→N 展开(`ExpandTo` outcome):等真实需求出现再做。 -- 递归深度 >1 时父子目录同 run 改名的顺序问题(先子后父可避免路径失效)——实现时定。 - 文本集是否需要分享(Sharable 体系)——倾向于要,随自定义类型使用量决策。 - 定时/监听触发(FileMover 进料口式自动清洗)——依赖 Workflow 触发器形态,后续版本。 +- `ExpandTo` 在 Workflow 编辑器类型校验中的表达(展开节点的输出 item 类型仍是 `item.fs.entry`,校验可直通;但步骤统计需理解 1→N)。 From 2670ad4eeb184b2ee601e77956fa044480a08d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 03:34:44 +0000 Subject: [PATCH 11/46] docs(file-cleaning): id-based custom text types, per-node workflow I/O contracts SpecialText extension upgraded to an elegant id-referenced design: CustomTextType table + nullable CustomTypeId on SpecialText + a discriminated TextSetRef (builtin:{enum} / custom:{id}) so renames never break node references; seeded editable sets replace hardcoded vocabularies; marked as implementation step one (the feature's only schema change). The example doc gains per-node IN/OUT/cardinality/ variable-contract strips and a section on typing and conflict isolation: kind namespacing, AcceptedInputItemTypes hard validation, variable supply/demand as editor lint, and generic-node interop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 40 ++++++++++++++++++++++-- docs/file-cleaning-workflow.md | 14 ++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index da4fb872d..6664fa509 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -38,6 +38,8 @@ .cfg tr:last-child td{border-bottom:none} .cfg .mono, .mono{font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:12px} .fx{font-size:12px;color:var(--dim);margin-top:6px;padding-top:6px;border-top:1px dashed var(--line)} +.io{font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;color:var(--dim);margin:0 0 6px} +.io .t{color:var(--c);font-weight:600}.io .v{color:var(--p)} .fx .in{color:var(--r)}.fx .out{color:var(--g)}.fx .var{color:var(--c)} .flow{ text-align:center;color:var(--dim);font-size:14px;margin:-2px 0} table.t{width:100%;border-collapse:collapse;font-size:12.5px;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;margin:10px 0} @@ -66,6 +68,7 @@

    §1起始状态与目标

    §2流程定义(12 个节点,含每节点配置)

    TfileCleaning.manual触发器
    +
    IN —(手动触发 payload:roots + 枚举配置) OUT item.fs.entry × N
    @@ -74,6 +77,7 @@

    §2流程定义(12 个节点,含每节点配置
    1filter.fs.byRegexFilter
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→0/1(不命中即 Drop,该树到此终止)

    roots(constants)["D:/Downloads"]
    enumerate.targetDirectories
    pattern\[.+?\]
    modeMatch(命中才保留)
    @@ -81,28 +85,32 @@

    §2流程定义(12 个节点,含每节点配置
    2transform.text.removeWrappedTransformscope: Directories
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(scope 不命中原样放行)
    - +
    wrappers[] 【】 (引用 SpecialText Wrapper)
    wrappers[] 【】 (textSetRef = builtin:Wrapper)
    textSet自定义文本集 「字幕组名单」(含 LoliHouse、桜都字幕组、桜都字幕組…)
    matchEqualsAny(括号内容全等于集合任一项)
    [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
    3transform.text.removeWrappedTransformscope: Directories
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    - +
    wrappers[]
    textSet自定义文本集 「质量与发布标签」(1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
    textSet自定义文本集 「质量与发布标签」(textSetRef = custom:2;1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
    matchContainsAny(括号内容包含集合任一项即整段删除)
    药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]药屋少女的呢喃
    4transform.text.trimTransformscope: Directories
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    collapseSpaces / trimEnds / removeEmptyWrapperstrue / true / true
    清掉删除后残留的连续空格与空括号对。
    5transform.text.captureTransformscope: Directories
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    @@ -111,6 +119,7 @@

    §2流程定义(12 个节点,含每节点配置
    6expand.fs.childrenExpand(链中 1→N)
    +
    IN item.fs.entry(目录) OUT item.fs.entry | 基数 1→N+1(ExpandTo;类型不变,编辑器校验直通)| 子项继承 Variables

    sourceWorkingName
    pattern^(?<title>.+)$
    @@ -120,20 +129,24 @@

    §2流程定义(12 个节点,含每节点配置
    7transform.text.removeWrappedTransformscope: Files
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(目录项 scope 不命中,原样放行)

    targetFiles
    extensionFilter["mkv", "mka", "ass"]
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    目录项流经此节点时因 scope 不命中而原样放行(跳过≠丢弃)。
    8transform.text.removeWrappedTransformscope: Files
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    (配置同节点 3)wrappers [] | textSet 「质量与发布标签」 | ContainsAny
    [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv藥屋少女的呢喃 [01].mkv([01] 不含质量词,保留)
    9transform.text.trimTransformscope: Files
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    (配置同节点 4)true / true / true
    10transform.text.captureTransformscope: Files
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    @@ -142,6 +155,7 @@

    §2流程定义(12 个节点,含每节点配置
    11transform.text.templateTransformscope: FilesrequiredVars: [title, ep]
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点10)

    sourceWorkingName
    pattern(?:-\s*|\[)(?<ep>\d{2,3})(?:\]|\s|$)
    template{var:title} - {var:ep:pad(2)}{extension}
    缺变量行为requiredVars 任一缺失 → 整节点跳过(WorkingName 保持上游清洗结果)
    @@ -149,6 +163,7 @@

    §2流程定义(12 个节点,含每节点配置
    12action.fs.saveNameActionscope: Both
    +
    IN item.fs.entry OUT item.fs.entry(链终点)| 基数 1→1 | 副作用:Preview 记计划 / Apply 改名 + FileRenameRecord
    @@ -178,7 +193,26 @@

    §4预览与结果

    └─ Comiket103/← 未触碰

    Apply 顺序(DeepestFirst):8 个文件先改,2 个目录后改;全部写入 FileRenameRecord,一键逆序撤销。

    -

    §5本例暴露并已并入设计的增量

    +

    §5Workflow 视角:类型契约与冲突隔离

    +

    本功能的节点与其他功能(exhentai / pixiv / subscription…)注册在同一个活动注册表里,但不会互相冲突,隔离靠三层机制:

    +
      +
    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.text.* / filter.fs.* / expand.fs.* / action.fs.**.exhentai.* 等天然不撞。
    • +
    • 硬契约 = item 类型白名单(编辑器报错级):本功能全部活动声明 AcceptedInputItemTypes = ["item.fs.entry"],触发器输出同类型。编辑器与 WorkflowDefinitionService 左到右追踪当前类型——ExHentai 画廊链里放不进 action.fs.saveName(item.exhentai.gallery ∉ 白名单),反之亦然。expand.fs.children 是基数变化(1→N)不是类型变化,校验直通。
    • +
    • 软契约 = 变量供需(编辑器 lint 级):capture 节点按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
    • +
    +

    互操作是特性不是冲突:AcceptedInputItemTypes 为空的通用节点(如未来的"发通知"action)接受任意类型,可自由插入本链;picker 按 Group 分桶展示,无关节点不会混入视野。

    +
    modePreview(默认;确认后按计划 Apply)
    applyOrderDeepestFirst(先改文件、后改父目录 → 父目录改名不会使子计划路径失效)
    + + + + + + + + +
    节点IN → OUT基数变量契约
    T fileCleaning.manualpayload → item.fs.entry0→N
    1 filter.fs.byRegexitem.fs.entry → 同1→0/1
    2–4 removeWrapped/trim ×3item.fs.entry → 同1→1
    5 captureitem.fs.entry → 同1→1提供 title
    6 expand.fs.childrenitem.fs.entry → 同1→N+1子项继承全部变量
    7–9 removeWrapped/trim ×3item.fs.entry → 同1→1
    10 captureitem.fs.entry → 同1→1提供 ep(onMiss 不提供)
    11 templateitem.fs.entry → 同1→1需要 title + ep
    12 action.fs.saveNameitem.fs.entry → 同(终点)1→1—(副作用:计划/改名)
    + +

    §6本例暴露并已并入设计的增量

    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提,从"开放问题"转正。
    • 变量机制FsEntryItem.Variables + transform.text.capture(正则具名捕获,不改名)+ 配置中 {var:name(:formatter)} 插值——节点联动的通道。
    • diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index 710671717..e96906b7b 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -36,6 +36,8 @@ public sealed record FsEntryItem 核心不变量:**链上一切文本变更只作用于 `WorkingName`;磁盘操作只发生在 saveName 节点**。这天然把"计算新名字"与"落盘"分成两段,预览因此免费。 +**类型契约**:本功能全部活动声明 `AcceptedInputItemTypes = ["item.fs.entry"]`,触发器 `ResolveOutputItemType` 亦返回该类型——编辑器/`WorkflowDefinitionService` 的左到右类型校验使本功能节点与其他功能(exhentai/pixiv 等)的专属节点**天然互斥**;`AcceptedInputItemTypes` 为空的通用节点(如未来的通知 action)可自由插入本链,是特性而非冲突。变量提供/需求(capture 提供、template 需要)作为**软契约**由编辑器 lint(warning 而非 error——capture 可能条件性命中)。节点出入参明细见[示例](file-cleaning-workflow-example.html) §5。 + **节点通用选项**(所有 fs 域活动支持): - `scope: Files | Directories | Both`——不命中的 item **原样放行**(跳过 ≠ 丢弃)。这是混合 item(文件+目录)流经同一条线性链的前提。 - `requiredVars: string[]`——任一变量缺失则跳过本节点(如模板节点缺 `ep` 时保住上游清洗结果)。 @@ -117,14 +119,16 @@ FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone ## SpecialText 扩展:用户自定义文本类型(文本集) -现状:`SpecialTextType` 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案(最小改动): +现状:`SpecialTextType` 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案: 1. 枚举新增 `SpecialTextType.Custom = 100`; -2. `SpecialText` 表加一列 `string? CustomTypeName`(仅 Type=Custom 时有值)——同名即同一个"文本集",不引入新表; -3. text 管理页:自定义类型与内置类型并列展示,支持建集、批量录入、导入导出; -4. 节点里的"文本集引用" = 内置类型名或自定义类型名(如「字幕组名单」「广告词」)。 +2. 新增小表 `CustomTextType(Id, Name, Description?)`——**类型需要稳定 id**:节点按 id 引用,改名不破坏引用;元数据与将来的分享有落点; +3. `SpecialText` 表加可空列 `CustomTypeId`(仅 Type=Custom 时有值,FK → CustomTextType);自定义行 Value1 = 文本,Value2 暂不用; +4. 节点里的"文本集引用"统一为一个判别引用 `TextSetRef`:序列化形如 `builtin:{枚举值}` 或 `custom:{id}`——`removeWrapped.textSet` 等参数因此既能引用内置类型也能引用自定义集; +5. text 管理页:自定义类型与内置类型并列展示,支持建集、批量录入、导入导出;示例用的「质量与发布标签」以**可编辑的种子数据**下发(而非硬编码内置类型); +6. **对既有消费点零影响**:legacy 代码均按具体枚举值查询,`Custom` 行对它们不可见。 -> EF 迁移按仓库规则 CLI 生成、纯 schema。若未来自定义类型需要元数据(描述/颜色/分享),再升级为独立表,引用方式不变。 +> EF 迁移按仓库规则 CLI 生成、纯 schema。**实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做**——独立可交付(text 页增强本身就有价值),节点实现全部依赖它。 ## 执行与 UI From 1f9a4595e4830d60684cf5683c2fd6f6d6c9f7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 03:46:00 +0000 Subject: [PATCH 12/46] docs(file-cleaning): rename text transforms to fs namespace, add capability/binding spec The transform nodes read/write item.fs.entry's WorkingName and carry fs-specific options, so their kinds move from transform.text.* to transform.fs.* to match their declared I/O type. Generic text processing lives in a stateless TextOps service (the actual reusable component); workflow activities are bindings of that capability onto a concrete item type. Records the facet-based generalization path (AcceptedItemFacet validated via item type descriptors' ClrType) for when a second text-bearing item type appears. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 24 +++++++++++----------- docs/file-cleaning-workflow.md | 26 ++++++++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index 6664fa509..07c2f1488 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -84,7 +84,7 @@

      §2流程定义(12 个节点,含每节点配置
      Comiket103 不含括号段 → 丢弃(其子文件因从未被展开,整树不受影响)。

    -
    2transform.text.removeWrappedTransformscope: Directories
    +
    2transform.fs.removeWrappedTransformscope: Directories
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(scope 不命中原样放行)
    @@ -93,7 +93,7 @@

    §2流程定义(12 个节点,含每节点配置
    [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
    -
    3transform.text.removeWrappedTransformscope: Directories
    +
    3transform.fs.removeWrappedTransformscope: Directories
    IN item.fs.entry OUT item.fs.entry | 基数 1→1

    wrappers[] 【】 (textSetRef = builtin:Wrapper)
    @@ -102,14 +102,14 @@

    §2流程定义(12 个节点,含每节点配置
    药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]药屋少女的呢喃
    -
    4transform.text.trimTransformscope: Directories
    +
    4transform.fs.trimTransformscope: Directories
    IN item.fs.entry OUT item.fs.entry | 基数 1→1

    wrappers[]
    collapseSpaces / trimEnds / removeEmptyWrapperstrue / true / true
    清掉删除后残留的连续空格与空括号对。
    -
    5transform.text.captureTransformscope: Directories
    +
    5transform.fs.captureTransformscope: Directories
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    @@ -128,24 +128,24 @@

    §2流程定义(12 个节点,含每节点配置
    目录项展开出其内 mkv 文件项,每个文件项继承 vars.title——这就是「节点 11 依赖节点 5 结果」的传递通道。
    -
    7transform.text.removeWrappedTransformscope: Files
    +
    7transform.fs.removeWrappedTransformscope: Files
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(目录项 scope 不命中,原样放行)

    sourceWorkingName
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    目录项流经此节点时因 scope 不命中而原样放行(跳过≠丢弃)。
    -
    8transform.text.removeWrappedTransformscope: Files
    +
    8transform.fs.removeWrappedTransformscope: Files
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    (配置同节点 3)wrappers [] | textSet 「质量与发布标签」 | ContainsAny
    [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv藥屋少女的呢喃 [01].mkv([01] 不含质量词,保留)
    -
    9transform.text.trimTransformscope: Files
    +
    9transform.fs.trimTransformscope: Files
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    (配置同节点 4)true / true / true
    -
    10transform.text.captureTransformscope: Files
    +
    10transform.fs.captureTransformscope: Files
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    @@ -154,7 +154,7 @@

    §2流程定义(12 个节点,含每节点配置
    Sousou no Frieren - 01.mkvvars.ep = "01"藥屋少女的呢喃 [01].mkvvars.ep = "01"… - SP1.mkv → 捕获失败,ep 不存在
    -
    11transform.text.templateTransformscope: FilesrequiredVars: [title, ep]
    +
    11transform.fs.templateTransformscope: FilesrequiredVars: [title, ep]
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点10)

    sourceWorkingName
    @@ -196,7 +196,7 @@

    §4预览与结果

    §5Workflow 视角:类型契约与冲突隔离

    本功能的节点与其他功能(exhentai / pixiv / subscription…)注册在同一个活动注册表里,但不会互相冲突,隔离靠三层机制:

      -
    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.text.* / filter.fs.* / expand.fs.* / action.fs.**.exhentai.* 等天然不撞。
    • +
    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.fs.* / filter.fs.* / expand.fs.* / action.fs.**.exhentai.* 等天然不撞。
    • 硬契约 = item 类型白名单(编辑器报错级):本功能全部活动声明 AcceptedInputItemTypes = ["item.fs.entry"],触发器输出同类型。编辑器与 WorkflowDefinitionService 左到右追踪当前类型——ExHentai 画廊链里放不进 action.fs.saveName(item.exhentai.gallery ∉ 白名单),反之亦然。expand.fs.children 是基数变化(1→N)不是类型变化,校验直通。
    • 软契约 = 变量供需(编辑器 lint 级):capture 节点按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
    @@ -215,8 +215,8 @@

    §5Workflow 视角:类型契约与冲突隔离

    §6本例暴露并已并入设计的增量

    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提,从"开放问题"转正。
    • -
    • 变量机制FsEntryItem.Variables + transform.text.capture(正则具名捕获,不改名)+ 配置中 {var:name(:formatter)} 插值——节点联动的通道。
    • -
    • 模板重建名transform.text.template——跨层级信息(父标题 + 自身集数)组合命名的表达方式。
    • +
    • 变量机制FsEntryItem.Variables + transform.fs.capture(正则具名捕获,不改名)+ 配置中 {var:name(:formatter)} 插值——节点联动的通道。
    • +
    • 模板重建名transform.fs.template——跨层级信息(父标题 + 自身集数)组合命名的表达方式。
    • 节点通用选项scope(Files/Directories/Both,不命中放行而非丢弃——混合 item 流经线性链的前提)与 requiredVars(缺变量跳过本节点)。
    • Apply 排序DeepestFirst——父目录改名不使子计划失效。
    diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index e96906b7b..7df9beff1 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -44,6 +44,14 @@ public sealed record FsEntryItem ## 节点设计 +### 规范:能力层与节点层(为什么 kind 是 `transform.fs.*` 而不是 `transform.text.*`) + +纯文本操作(FileNameModifier 操作集、removeWrapped/removeTexts、trim、正则捕获、模板插值)实现为无状态服务 **`TextOps`**——**这才是通用文本处理组件**:不感知 workflow、不感知文件系统,输入字符串输出字符串,供本功能节点、file-name-modifier 页、(将来)Organizer 共同复用。 + +workflow 节点是该能力**对具体 item 类型的绑定**:v1 绑定到 `item.fs.entry` 的 `WorkingName`(并带 `scope` 这类 fs 专属选项),因此 kind 的模块段是 `fs` 而非 `text`——名实相符,节点的出入参类型(`item.fs.entry`)与命名空间一致。 + +演进路径(当前不做):当第二个需要文本处理的 item 类型出现(如对画廊标题做清洗),节点层可经 **facet 机制**泛化——活动描述符增加 `AcceptedItemFacet: Type`(接口),编辑器经 `IWorkflowItemTypeDescriptor.ClrType` 校验实现关系,届时文本节点提升为真正的 `transform.text.*` 并剥离 fs 专属选项。与"descriptor 共享抽象暂不抽取"同一姿势:**能力先通用(服务层),节点先具体(fs 绑定),等第二个消费者出现再泛化节点层。** + ### 触发器:`fileCleaning.manual`(= 用户说的 constants 节点 + 获取子级节点) 手动触发器,payload 即用户录入的常量与枚举配置: @@ -83,12 +91,12 @@ public sealed record FsEntryItem | kind | 行为 | 配置 | |---|---|---| -| `transform.text.fileNameOp` | 包装 FileNameModifier 的**全部**既有操作 | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | -| `transform.text.removeWrapped` | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(引用 SpecialText Wrapper 或自定义对)+ 文本集引用(见下)+ 命中方式(等于/包含/正则) | -| `transform.text.removeTexts` | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | -| `transform.text.trim` | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | -| `transform.text.capture` | **不改名,只捕获**:正则具名捕获组 → 写入 `Variables` | source(WorkingName/Path/ParentName)+ pattern + onMiss(SkipSilently/标记) | -| `transform.text.template` | 以模板**重建** WorkingName(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalName}` / `{extension}` 插值)+ requiredVars | +| `transform.fs.fileNameOp` | 包装 FileNameModifier 的**全部**既有操作 | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | +| `transform.fs.removeWrapped` | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(引用 SpecialText Wrapper 或自定义对)+ 文本集引用(见下)+ 命中方式(等于/包含/正则) | +| `transform.fs.removeTexts` | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | +| `transform.fs.trim` | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | +| `transform.fs.capture` | **不改名,只捕获**:正则具名捕获组 → 写入 `Variables` | source(WorkingName/Path/ParentName)+ pattern + onMiss(SkipSilently/标记) | +| `transform.fs.template` | 以模板**重建** WorkingName(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalName}` / `{extension}` 插值)+ requiredVars | 每个 Transform 输出 `item with { WorkingName = 新值 }`(Workflow 的 `ReplaceWith`,类型不变仍是 `item.fs.entry`——编辑器类型校验直接通过)。 @@ -134,14 +142,14 @@ FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone - 流程定义、编辑、运行记录全部走 workflow 页既有 UI;本功能新增的是:触发器配置面板(roots + 枚举配置)、三类活动的配置表单(复用 file-name-modifier 页的操作编辑组件)、预览 diff 面板(可参考 BulkModification 预览页交互)。 - 运行承载沿用 Workflow 的 run 机制;大目录场景下 run 内部逐条目 yield,进度在 run 详情展示。 -- 入口:workflow 页新建"文件清洗"模板;file-name-modifier 页加一个"升级为清洗流程"引导(把当前操作集一键转为 `transform.text.fileNameOp` 节点)。 +- 入口:workflow 页新建"文件清洗"模板;file-name-modifier 页加一个"升级为清洗流程"引导(把当前操作集一键转为 `transform.fs.fileNameOp` 节点)。 ## 走查示例(用户场景原样落地) ``` 触发器: roots=[D:/Anime], enumerate={target:Directories, depth:1} -节点 1: transform.text.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 -节点 2: transform.text.trim 清理残留空格 +节点 1: transform.fs.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 +节点 2: transform.fs.trim 清理残留空格 节点 3: action.fs.saveName 预览: From 32ef5d9547419b35616c0975f49ae37f1c46bf90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 05:18:17 +0000 Subject: [PATCH 13/46] docs(file-cleaning): make text nodes truly generic via ITextWorkpiece facet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepts the review challenge that transform.fs.trim/capture/removeWrapped cannot answer 'which property does this operate on': the fs namespace has several string properties, so the systematic answer is a facet — items declare a single working text (FsEntryItem.WorkingText == WorkingName) and text nodes accept any item implementing ITextWorkpiece, validated by the editor via item type descriptors' ClrType. Text nodes return to the transform.text.* namespace with zero fs awareness; fs semantics reach them only through injected system variables (extension, parentName). Drops the scope option (mixed flows are expressed by chain ordering: directory segment -> save dir plan -> expand ChildrenOnly -> file segment), keeps fileNameOp deliberately fs-bound, and restructures the example to the new 13-node shape with facet contracts per node. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 145 ++++++++++++----------- docs/file-cleaning-workflow.md | 48 ++++---- 2 files changed, 104 insertions(+), 89 deletions(-) diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index 07c2f1488..fe16a8df1 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -50,8 +50,8 @@ footer{margin-top:36px;color:var(--dim);font-size:12px;border-top:1px solid var(--line);padding-top:14px}

    文件清洗流程示例 · 动画下载目录标准化

    -

    多层级(文件夹 + 其内文件)· 节点联动(文件改名依赖父文件夹节点的清洗结果)· 每节点完整配置

    -

    配套设计文档:docs/file-cleaning-workflow.md。本例同时是设计探针——它需要的「链中展开 / 变量捕获与继承 / 模板重建名 / 节点作用域」机制已并入设计文档(见 §5 设计增量)。

    +

    多层级(文件夹 + 其内文件)· 节点联动(文件改名依赖父文件夹节点的清洗结果)· 每节点完整配置与出入参契约

    +

    配套设计文档:docs/file-cleaning-workflow.md。本例同时是设计探针——它需要的「链中展开 / 变量与系统变量 / 模板重建名 / facet 契约」机制已并入设计文档(见 §6 设计增量)。

    §1起始状态与目标

    D:/Downloads/
    @@ -64,8 +64,9 @@ 

    §1起始状态与目标

    │ └─ [桜都字幕組] 藥屋少女的呢喃 [02][1080p][繁体].mkv └─ Comiket103/ ← 无括号段,被节点 1 过滤,整树不处理

    目标:文件夹名去掉字幕组/质量标签;文件名统一重建为「文件夹净标题 - 集数」——即使文件原名与文件夹用字不一致(简繁混用)也被统一;捕获不到集数的特典(SP1)只做清洗、不套模板。

    +

    链形(混合流经链序表达,文本节点零 fs 感知):目录段(T–6)→ 展开(7)→ 文件段(8–13)。目录的改名计划在展开前由节点 6 记录。

    -

    §2流程定义(12 个节点,含每节点配置)

    +

    §2流程定义(13 个节点,含每节点配置)

    TfileCleaning.manual触发器
    IN —(手动触发 payload:roots + 枚举配置) OUT item.fs.entry × N
    @@ -73,7 +74,7 @@

    §2流程定义(12 个节点,含每节点配置

    template{var:title} - {var:ep:pad(2)}{extension}
    roots(constants)["D:/Downloads"]
    enumerate.targetDirectories
    enumerate.depth1
    -
    产出 3 个 item.fs.entry(目录项),逐个流过下方链条。
    +
    产出 3 个 item.fs.entry(目录项),自动注入系统变量 var:parentName 等,逐个流过下方链条。
    1filter.fs.byRegexFilter
    @@ -84,17 +85,17 @@

    §2流程定义(12 个节点,含每节点配置
    Comiket103 不含括号段 → 丢弃(其子文件因从未被展开,整树不受影响)。

    -
    2transform.fs.removeWrappedTransformscope: Directories
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(scope 不命中原样放行)
    +
    2transform.text.removeWrappedTransform · 通用文本
    +
    IN facet:ITextWorkpiece(本链此处 = item.fs.entry,作用于其工作文本 WorkingName) OUT 同 | 基数 1→1
    - +
    wrappers[] 【】 (textSetRef = builtin:Wrapper)
    textSet自定义文本集 「字幕组名单」(含 LoliHouse、桜都字幕组、桜都字幕組…)
    textSet自定义文本集 「字幕组名单」(textSetRef = custom:1;含 LoliHouse、桜都字幕组、桜都字幕組…)
    matchEqualsAny(括号内容全等于集合任一项)
    [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
    -
    3transform.fs.removeWrappedTransformscope: Directories
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    +
    3transform.text.removeWrappedTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    @@ -102,84 +103,89 @@

    §2流程定义(12 个节点,含每节点配置
    药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]药屋少女的呢喃
    -
    4transform.fs.trimTransformscope: Directories
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    +
    4transform.text.trimTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1

    wrappers[]
    textSet自定义文本集 「质量与发布标签」(textSetRef = custom:2;1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
    collapseSpaces / trimEnds / removeEmptyWrapperstrue / true / true
    清掉删除后残留的连续空格与空括号对。
    -
    5transform.fs.captureTransformscope: Directories
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    +
    5transform.text.captureTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    -
    sourceWorkingName
    pattern^(?<title>.+)$
    onMissSkipSilently(不设变量)
    -
    不改名字,只捕获:vars.title = "葬送のフリーレン Sousou no Frieren"(对第二个文件夹则是 "药屋少女的呢喃"
    +
    不改文本,只捕获:vars.title = "葬送のフリーレン Sousou no Frieren"(对第二个文件夹则是 "药屋少女的呢喃"
    -
    6expand.fs.childrenExpand(链中 1→N)
    -
    IN item.fs.entry(目录) OUT item.fs.entry | 基数 1→N+1(ExpandTo;类型不变,编辑器校验直通)| 子项继承 Variables
    +
    6action.fs.saveNameAction实例 ①:目录
    +
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 副作用:记录目录改名计划
    + + +
    modePreview(默认;确认后按计划 Apply)
    onConflictSkipAndReport
    +
    目录的改名计划在展开前记录——这就是"混合流经链序表达":此后链上只有文件,任何节点都不需要 scope。
    +
    + +
    7expand.fs.childrenExpand(链中 1→N)
    +
    IN item.fs.entry(目录) OUT item.fs.entry(文件)| 基数 1→N(ExpandTo;类型不变,编辑器校验直通)| 子项继承 Variables + 注入 var:extension / var:parentName
    - +
    targetFiles
    extensionFilter["mkv", "mka", "ass"]
    emitChildrenThenSelf(子项先流过后续节点,父目录项随后跟进)
    emitChildrenOnly(v1 唯一模式;父项已在节点 6 存盘计划,由展开消费)
    inheritVariablestrue
    -
    目录项展开出其内 mkv 文件项,每个文件项继承 vars.title——这就是「节点 11 依赖节点 5 结果」的传递通道。
    +
    目录项展开出其内 mkv 文件项,每个文件项继承 vars.title——这就是「节点 12 依赖节点 5 结果」的传递通道。
    -
    7transform.fs.removeWrappedTransformscope: Files
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1(目录项 scope 不命中,原样放行)
    -
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    -
    目录项流经此节点时因 scope 不命中而原样放行(跳过≠丢弃)。
    +
    8transform.text.removeWrappedTransform · 通用文本
    +
    IN facet:ITextWorkpiece(此处 item 均为文件) OUT 同 | 基数 1→1
    +
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    -
    8transform.fs.removeWrappedTransformscope: Files
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    +
    9transform.text.removeWrappedTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    (配置同节点 3)wrappers [] | textSet 「质量与发布标签」 | ContainsAny
    [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv藥屋少女的呢喃 [01].mkv([01] 不含质量词,保留)
    -
    9transform.fs.trimTransformscope: Files
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1
    +
    10transform.text.trimTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    (配置同节点 4)true / true / true
    -
    10transform.fs.captureTransformscope: Files
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    +
    11transform.text.captureTransform · 通用文本
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    -
    sourceWorkingName
    pattern(?:-\s*|\[)(?<ep>\d{2,3})(?:\]|\s|$)
    onMissSkipSilently
    Sousou no Frieren - 01.mkvvars.ep = "01"藥屋少女的呢喃 [01].mkvvars.ep = "01"… - SP1.mkv → 捕获失败,ep 不存在
    -
    11transform.fs.templateTransformscope: FilesrequiredVars: [title, ep]
    -
    IN item.fs.entry OUT item.fs.entry | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点10)
    +
    12transform.text.templateTransform · 通用文本requiredVars: [title, ep]
    +
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点11)
    - -
    template{var:title} - {var:ep:pad(2)}{extension}
    缺变量行为requiredVars 任一缺失 → 整节点跳过(WorkingName 保持上游清洗结果)
    -
    藥屋少女的呢喃 [01].mkv + title=药屋少女的呢喃(继承自父目录节点 5) + ep=01药屋少女的呢喃 - 01.mkv | SP1 缺 ep → 跳过,保住清洗结果
    +template{var:title} - {var:ep:pad(2)}{var:extension} +缺变量行为requiredVars 任一缺失 → 整节点跳过(工作文本保持上游清洗结果) +
    藥屋少女的呢喃 [01].mkv + title=药屋少女的呢喃(继承自父目录节点 5) + ep=01 + extension=.mkv(系统变量)药屋少女的呢喃 - 01.mkv | SP1 缺 ep → 跳过,保住清洗结果
    -
    12action.fs.saveNameActionscope: Both
    -
    IN item.fs.entry OUT item.fs.entry(链终点)| 基数 1→1 | 副作用:Preview 记计划 / Apply 改名 + FileRenameRecord
    +
    13action.fs.saveNameAction实例 ②:文件
    +
    IN item.fs.entry OUT item.fs.entry(链终点)| 基数 1→1 | 副作用:记录文件改名计划 / Apply 改名 + FileRenameRecord
    - - -
    modePreview(默认;确认后按计划 Apply)
    applyOrderDeepestFirst(先改文件、后改父目录 → 父目录改名不会使子计划路径失效)
    onConflictSkipAndReport(目标重名 → 跳过并列入冲突表)
    -
    WorkingName == OriginalName 的条目(如 SP1 若无变化)自动跳过。
    +modePreview +applyOrderDeepestFirst(Apply 期先改文件、后改父目录——计划记录顺序无关紧要) +onConflictSkipAndReport +
    与节点 6 是同一 kind 的两个实例(同 kind 多实例、各带配置是既有规则)。WorkingName == OriginalName 的条目自动跳过。
    - +

    §3单条 item 的流转(以 藥屋 [01] 文件为例)

    - - - - - - -
    经过节点WorkingNameVariables
    6 展开产出[桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkvtitle=药屋少女的呢喃(继承)
    7 去字幕组藥屋少女的呢喃 [01][1080p][繁体].mkvtitle=…
    8 去质量标签藥屋少女的呢喃 [01].mkvtitle=…
    10 捕获集数藥屋少女的呢喃 [01].mkvtitle=…, ep=01
    11 模板重建药屋少女的呢喃 - 01.mkvtitle=…, ep=01
    12 记入计划(Preview:旧名 → 新名 入 diff)
    + + + + + + +
    经过节点工作文本(WorkingName)Variables
    7 展开产出[桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkvtitle=药屋少女的呢喃(继承), extension=.mkv, parentName=…
    8 去字幕组藥屋少女的呢喃 [01][1080p][繁体].mkv同上
    9 去质量标签藥屋少女的呢喃 [01].mkv同上
    11 捕获集数藥屋少女的呢喃 [01].mkv+ ep=01
    12 模板重建药屋少女的呢喃 - 01.mkv同上
    13 记入计划(Preview:旧名 → 新名 入 diff)

    §4预览与结果

    D:/Downloads/
    @@ -191,34 +197,35 @@ 

    §4预览与结果

    │ ├─ 药屋少女的呢喃 - 01.mkv ← 繁体原名被父目录净标题统一 │ └─ 药屋少女的呢喃 - 02.mkv └─ Comiket103/ ← 未触碰
    -

    Apply 顺序(DeepestFirst):8 个文件先改,2 个目录后改;全部写入 FileRenameRecord,一键逆序撤销。

    +

    Apply 顺序(DeepestFirst):8 个文件先改,2 个目录后改(尽管目录计划先被记录);全部写入 FileRenameRecord,一键逆序撤销。

    §5Workflow 视角:类型契约与冲突隔离

    本功能的节点与其他功能(exhentai / pixiv / subscription…)注册在同一个活动注册表里,但不会互相冲突,隔离靠三层机制:

      -
    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.fs.* / filter.fs.* / expand.fs.* / action.fs.**.exhentai.* 等天然不撞。
    • -
    • 硬契约 = item 类型白名单(编辑器报错级):本功能全部活动声明 AcceptedInputItemTypes = ["item.fs.entry"],触发器输出同类型。编辑器与 WorkflowDefinitionService 左到右追踪当前类型——ExHentai 画廊链里放不进 action.fs.saveName(item.exhentai.gallery ∉ 白名单),反之亦然。expand.fs.children 是基数变化(1→N)不是类型变化,校验直通。
    • -
    • 软契约 = 变量供需(编辑器 lint 级):capture 节点按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
    • +
    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.text.* / *.fs.**.exhentai.* 等天然不撞。
    • +
    • 硬契约(编辑器报错级)分两种形态:fs 域节点(filter/expand/saveName)声明 AcceptedInputItemTypes = ["item.fs.entry"]——精确类型白名单;通用文本节点声明 AcceptedItemFacet = typeof(ITextWorkpiece)——接受任何 CLR 类型实现该 facet 的 item,编辑器经 IWorkflowItemTypeDescriptor.ClrType 静态校验实现关系。"作用于哪个 property"由 item 类型声明工作文本统一回答(FsEntryItem.WorkingText ≡ WorkingName,编辑器在节点卡上显示"作用于:WorkingName");ExHentai 画廊链放不进 action.fs.saveName(类型白名单拦截),但若画廊 item 将来实现 ITextWorkpiece,同一批文本节点直接可用于清洗其标题——按契约互操作,不是冲突。
    • +
    • 软契约(编辑器 lint 级)= 变量供需:capture 按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承与系统变量),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
    -

    互操作是特性不是冲突:AcceptedInputItemTypes 为空的通用节点(如未来的"发通知"action)接受任意类型,可自由插入本链;picker 按 Group 分桶展示,无关节点不会混入视野。

    - - - - - - - - - -
    节点IN → OUT基数变量契约
    T fileCleaning.manualpayload → item.fs.entry0→N
    1 filter.fs.byRegexitem.fs.entry → 同1→0/1
    2–4 removeWrapped/trim ×3item.fs.entry → 同1→1
    5 captureitem.fs.entry → 同1→1提供 title
    6 expand.fs.childrenitem.fs.entry → 同1→N+1子项继承全部变量
    7–9 removeWrapped/trim ×3item.fs.entry → 同1→1
    10 captureitem.fs.entry → 同1→1提供 ep(onMiss 不提供)
    11 templateitem.fs.entry → 同1→1需要 title + ep
    12 action.fs.saveNameitem.fs.entry → 同(终点)1→1—(副作用:计划/改名)
    + + + + + + + + + + +
    节点契约(IN)基数变量
    T fileCleaning.manualpayload → item.fs.entry0→N注入系统变量
    1 filter.fs.byRegexitem.fs.entry(精确类型)1→0/1
    2–4 removeWrapped/trimfacet: ITextWorkpiece1→1
    5 capturefacet: ITextWorkpiece1→1提供 title
    6 saveName(目录实例)item.fs.entry(精确类型)1→1—(副作用:目录计划)
    7 expand.fs.childrenitem.fs.entry(精确类型)1→N继承全部 + 注入 extension/parentName
    8–10 removeWrapped/trimfacet: ITextWorkpiece1→1
    11 capturefacet: ITextWorkpiece1→1提供 ep(onMiss 不提供)
    12 templatefacet: ITextWorkpiece1→1需要 title + ep
    13 saveName(文件实例)item.fs.entry(精确类型)1→1(终点)—(副作用:文件计划/改名)

    §6本例暴露并已并入设计的增量

      -
    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提,从"开放问题"转正。
    • -
    • 变量机制FsEntryItem.Variables + transform.fs.capture(正则具名捕获,不改名)+ 配置中 {var:name(:formatter)} 插值——节点联动的通道。
    • -
    • 模板重建名transform.fs.template——跨层级信息(父标题 + 自身集数)组合命名的表达方式。
    • -
    • 节点通用选项scope(Files/Directories/Both,不命中放行而非丢弃——混合 item 流经线性链的前提)与 requiredVars(缺变量跳过本节点)。
    • -
    • Apply 排序DeepestFirst——父目录改名不使子计划失效。
    • +
    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提。v1 仅 ChildrenOnly:父项计划在展开前存盘,混合流经链序表达,文本节点因此零 fs 感知(scope 选项被移除)。
    • +
    • facet 契约ITextWorkpiece(WorkingText + Variables)+ 描述符 AcceptedItemFacet + 经 ClrType 的编辑器校验——文本节点真正通用,"作用于哪个 property"由 item 类型一处声明统一回答。
    • +
    • 变量机制Variables + transform.text.capture(具名捕获,不改文本)+ {var:name(:formatter)} 插值 + 系统变量(fs 域注入 var:extension / var:parentName——fs 语义进入通用文本节点的唯一通道)。
    • +
    • 模板重建名transform.text.template——跨层级信息(父标题 + 自身集数 + 系统变量)组合命名的表达方式。
    • +
    • requiredVars:缺变量跳过本节点(SP1 的兜底路径)。
    • +
    • Apply 排序DeepestFirst——按路径深度从深到浅执行,与计划记录顺序解耦。
    文件清洗流程示例 · 配套 docs/file-cleaning-workflow.md(设计增量已同步)
    diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index 7df9beff1..d8de49501 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -30,27 +30,35 @@ public sealed record FsEntryItem public Dictionary Variables { get; init; } = new(); // 变量:capture 节点写入、后续节点经 {var:name(:formatter)} 插值消费; // expand 展开子项时可继承(inheritVariables)——这是"节点 x 依赖节点 y 结果"的传递通道。 + // fs 域产出 item 时自动注入只读系统变量:var:extension、var:parentName。 // 变量只在 item 内流动、随继承下发,链保持线性 per-item,无全局状态。 } +// FsEntryItem 实现 ITextWorkpiece:WorkingText ≡ WorkingName(通用文本节点由此作用于它,见"规范"节) ``` 核心不变量:**链上一切文本变更只作用于 `WorkingName`;磁盘操作只发生在 saveName 节点**。这天然把"计算新名字"与"落盘"分成两段,预览因此免费。 -**类型契约**:本功能全部活动声明 `AcceptedInputItemTypes = ["item.fs.entry"]`,触发器 `ResolveOutputItemType` 亦返回该类型——编辑器/`WorkflowDefinitionService` 的左到右类型校验使本功能节点与其他功能(exhentai/pixiv 等)的专属节点**天然互斥**;`AcceptedInputItemTypes` 为空的通用节点(如未来的通知 action)可自由插入本链,是特性而非冲突。变量提供/需求(capture 提供、template 需要)作为**软契约**由编辑器 lint(warning 而非 error——capture 可能条件性命中)。节点出入参明细见[示例](file-cleaning-workflow-example.html) §5。 +**类型契约(硬校验,编辑器报错级)**分两种形态:fs 域节点(filter/expand/saveName/fileNameOp)声明 `AcceptedInputItemTypes = ["item.fs.entry"]`;**通用文本节点声明 `AcceptedItemFacet = typeof(ITextWorkpiece)`**(见下"规范"节)——接受任何实现该 facet 的 item 类型,编辑器经 `IWorkflowItemTypeDescriptor.ClrType` 静态校验。两种形态都使本功能节点与其他功能(exhentai/pixiv 等)的专属节点**互斥或按契约互操作**,不存在冲突。变量提供/需求(capture 提供、template 需要)作为**软契约**由编辑器 lint(warning 而非 error——capture 可能条件性命中)。节点出入参明细见[示例](file-cleaning-workflow-example.html) §5。 -**节点通用选项**(所有 fs 域活动支持): -- `scope: Files | Directories | Both`——不命中的 item **原样放行**(跳过 ≠ 丢弃)。这是混合 item(文件+目录)流经同一条线性链的前提。 +**节点通用选项**: - `requiredVars: string[]`——任一变量缺失则跳过本节点(如模板节点缺 `ep` 时保住上游清洗结果)。 ## 节点设计 -### 规范:能力层与节点层(为什么 kind 是 `transform.fs.*` 而不是 `transform.text.*`) +### 规范:通用文本节点与 facet 契约("trim 的是哪个 property?"的系统性回答) -纯文本操作(FileNameModifier 操作集、removeWrapped/removeTexts、trim、正则捕获、模板插值)实现为无状态服务 **`TextOps`**——**这才是通用文本处理组件**:不感知 workflow、不感知文件系统,输入字符串输出字符串,供本功能节点、file-name-modifier 页、(将来)Organizer 共同复用。 +文本节点(trim/removeWrapped/removeTexts/capture/template)是**真正通用的** `transform.text.*`——它们**只认识"工作文本"**,不认识文件系统。规范分三条: -workflow 节点是该能力**对具体 item 类型的绑定**:v1 绑定到 `item.fs.entry` 的 `WorkingName`(并带 `scope` 这类 fs 专属选项),因此 kind 的模块段是 `fs` 而非 `text`——名实相符,节点的出入参类型(`item.fs.entry`)与命名空间一致。 +1. **facet 契约**:定义 `ITextWorkpiece { string WorkingText; IDictionary Variables }`。文本节点的输入契约不是具体 item 类型,而是 `AcceptedItemFacet = typeof(ITextWorkpiece)`——接受任何 CLR 类型实现该接口的 item;编辑器经 `IWorkflowItemTypeDescriptor.ClrType`(描述符本就携带)静态校验实现关系,强校验不减弱。 +2. **"作用于什么"由 item 类型声明,一处回答、处处生效**:`FsEntryItem.WorkingText ≡ WorkingName`(其类型描述符如此声明,编辑器在节点卡上显示"作用于:WorkingName")。将来画廊 item 声明 `WorkingText ≡ Title`,同一批文本节点直接可用——不存在"每个节点各自解释 trim 什么"的问题。 +3. **fs 语义进入文本节点的唯一通道是系统变量**:fs 域在产出 item 时自动注入只读变量 `var:extension`、`var:parentName`(模板写 `{var:extension}` 而非 fs 专属占位符)。文本节点因此零 fs 感知。 -演进路径(当前不做):当第二个需要文本处理的 item 类型出现(如对画廊标题做清洗),节点层可经 **facet 机制**泛化——活动描述符增加 `AcceptedItemFacet: Type`(接口),编辑器经 `IWorkflowItemTypeDescriptor.ClrType` 校验实现关系,届时文本节点提升为真正的 `transform.text.*` 并剥离 fs 专属选项。与"descriptor 共享抽象暂不抽取"同一姿势:**能力先通用(服务层),节点先具体(fs 绑定),等第二个消费者出现再泛化节点层。** +两个刻意的例外与删减: + +- `transform.fs.fileNameOp` **留在 fs 域**:它包装 FileNameModifier 的操作集,其 Target(全名/不含扩展名/扩展名)语义就是文件名结构——名字本身已回答作用目标。 +- **删除 `scope` 选项**:它是文本节点上的 fs 泄漏。混合流(目录+文件)改由**链序**表达:目录段 → `saveName`(目录计划)→ `expand`(ChildrenOnly,之后链上只有文件)→ 文件段 → `saveName`(文件计划)。文本节点因此不需要知道 item 是文件还是目录。 + +纯文本算法仍沉淀在无状态服务 `TextOps`(供 file-name-modifier 页与将来的 Organizer 复用);文本节点是 `TextOps` 经 facet 暴露给 workflow 的形态。 ### 触发器:`fileCleaning.manual`(= 用户说的 constants 节点 + 获取子级节点) @@ -76,8 +84,8 @@ workflow 节点是该能力**对具体 item 类型的绑定**:v1 绑定到 `it | 配置 | 说明 | |---|---| | `target` / `extensionFilter` | Files / Directories / Both + 扩展名过滤 | -| `emit` | `ChildrenOnly` / `ChildrenThenSelf`(子项先流过后续节点,父项随后跟进——配合 saveName 的 DeepestFirst 排序) | -| `inheritVariables` | 子项继承父项 Variables(跨层级联动的通道) | +| `emit` | v1 仅 `ChildrenOnly`(父项被展开消费——其改名计划应在展开**前**由 saveName 节点记录,混合流经链序表达;`ChildrenThenSelf` 留待真实需求) | +| `inheritVariables` | 子项继承父项 Variables(跨层级联动的通道);展开时同步注入子项的系统变量(extension/parentName) | ### Filter 活动(可选,收窄处理范围) @@ -89,16 +97,16 @@ workflow 节点是该能力**对具体 item 类型的绑定**:v1 绑定到 `it ### Transform 活动(文本变更节点,任意个、任意序) -| kind | 行为 | 配置 | -|---|---|---| -| `transform.fs.fileNameOp` | 包装 FileNameModifier 的**全部**既有操作 | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | -| `transform.fs.removeWrapped` | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(引用 SpecialText Wrapper 或自定义对)+ 文本集引用(见下)+ 命中方式(等于/包含/正则) | -| `transform.fs.removeTexts` | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | -| `transform.fs.trim` | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | -| `transform.fs.capture` | **不改名,只捕获**:正则具名捕获组 → 写入 `Variables` | source(WorkingName/Path/ParentName)+ pattern + onMiss(SkipSilently/标记) | -| `transform.fs.template` | 以模板**重建** WorkingName(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalName}` / `{extension}` 插值)+ requiredVars | +| kind | 契约 | 行为 | 配置 | +|---|---|---|---| +| `transform.text.removeWrapped` | facet | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(TextSetRef,如 builtin:Wrapper)+ 文本集引用 + 命中方式(等于/包含/正则) | +| `transform.text.removeTexts` | facet | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | +| `transform.text.trim` | facet | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | +| `transform.text.capture` | facet | **不改文本,只捕获**:正则具名捕获组 → 写入 `Variables` | pattern + onMiss(SkipSilently/标记)。作用于工作文本;路径/扩展名等 fs 上下文经系统变量获得 | +| `transform.text.template` | facet | 以模板**重建**工作文本(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalText}` 插值;文件扩展名写 `{var:extension}`)+ requiredVars | +| `transform.fs.fileNameOp` | item.fs.entry | 包装 FileNameModifier 的**全部**既有操作(**刻意留在 fs 域**:其 Target 语义即文件名结构,名字已回答作用目标) | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | -每个 Transform 输出 `item with { WorkingName = 新值 }`(Workflow 的 `ReplaceWith`,类型不变仍是 `item.fs.entry`——编辑器类型校验直接通过)。 +每个 Transform 输出 `item with { WorkingText = 新值 }`(Workflow 的 `ReplaceWith`,item 类型不变——编辑器校验直通)。 ### Action 活动:`action.fs.saveName` @@ -148,8 +156,8 @@ FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone ``` 触发器: roots=[D:/Anime], enumerate={target:Directories, depth:1} -节点 1: transform.fs.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 -节点 2: transform.fs.trim 清理残留空格 +节点 1: transform.text.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 +节点 2: transform.text.trim 清理残留空格 节点 3: action.fs.saveName 预览: From 5f9a3da1655901f5f1275b164df5d6dc559d9803 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 07:23:36 +0000 Subject: [PATCH 14/46] docs(file-cleaning): specify facet-as-predicate rule and mandatory passthrough Clarifies how facet-typed text nodes chain with exact-typed fs nodes: the facet check is a capability predicate on the chain's current type, which never changes through facet nodes (mandatory Passthrough, no Fixed output), so item.fs.entry flows intact from capture into saveName. Runtime preservation is guaranteed by WithWorkingText returning the implementer's concrete type; capture only writes variables and keeps the item. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index d8de49501..5e1ec9f6c 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -49,7 +49,8 @@ public sealed record FsEntryItem 文本节点(trim/removeWrapped/removeTexts/capture/template)是**真正通用的** `transform.text.*`——它们**只认识"工作文本"**,不认识文件系统。规范分三条: -1. **facet 契约**:定义 `ITextWorkpiece { string WorkingText; IDictionary Variables }`。文本节点的输入契约不是具体 item 类型,而是 `AcceptedItemFacet = typeof(ITextWorkpiece)`——接受任何 CLR 类型实现该接口的 item;编辑器经 `IWorkflowItemTypeDescriptor.ClrType`(描述符本就携带)静态校验实现关系,强校验不减弱。 +1. **facet 契约**:定义 `ITextWorkpiece { string WorkingText; IDictionary Variables; ITextWorkpiece WithWorkingText(string) }`。文本节点的输入契约不是具体 item 类型,而是 `AcceptedItemFacet = typeof(ITextWorkpiece)`——接受任何 CLR 类型实现该接口的 item;编辑器经 `IWorkflowItemTypeDescriptor.ClrType`(描述符本就携带)静态校验实现关系,强校验不减弱。 + **facet 是对"当前类型"的能力谓词,不是类型本身**:校验通过后链上的当前类型**不变**(类比泛型约束 `where T : ITextWorkpiece`,T 还是 T)。因此有一条强制配对规则——**facet 节点必须 Passthrough**(输出类型 = 输入的具体类型,禁止 Fixed 输出),否则具体类型在链上丢失,下游精确类型节点(如 `action.fs.saveName`)将接不住。运行时由 `WithWorkingText` 返回实现方自身的具体类型保证(`FsEntryItem` 实现为 `this with { WorkingName = t }`);capture 只写 `Variables` 不改文本,直接 `KeepItem`。链上"当前类型"从头到尾是 `item.fs.entry`,facet 节点与精确类型节点(saveName)因此可任意穿插串联。 2. **"作用于什么"由 item 类型声明,一处回答、处处生效**:`FsEntryItem.WorkingText ≡ WorkingName`(其类型描述符如此声明,编辑器在节点卡上显示"作用于:WorkingName")。将来画廊 item 声明 `WorkingText ≡ Title`,同一批文本节点直接可用——不存在"每个节点各自解释 trim 什么"的问题。 3. **fs 语义进入文本节点的唯一通道是系统变量**:fs 域在产出 item 时自动注入只读变量 `var:extension`、`var:parentName`(模板写 `{var:extension}` 而非 fs 专属占位符)。文本节点因此零 fs 感知。 From 5d932b0f3d43573eb82b807e049b6b21c717ece4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 07:53:56 +0000 Subject: [PATCH 15/46] docs(workflow): add module-wide capability map and rename trigger to fs.manualScan Surveys the Workflow module as a whole: current engine capabilities (typed chain validation, AdaptToNext, runner/rehydrator, runs), the actual node inventory (2 triggers, 5 item types, 5 activities - one subscription/downloader business line), six cross-cutting engine extensions (manual trigger with payload, ExpandTo, facet contracts, variables, two-phase runs, scheduled triggers) each with its first consumer, and the node roadmap by domain. Renames the cleaning trigger to fs.manualScan since manual filesystem scanning is not cleaning-specific. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 4 +- docs/file-cleaning-workflow.md | 2 +- docs/workflow-capability-map.md | 68 ++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 docs/workflow-capability-map.md diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index fe16a8df1..556730cad 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -68,7 +68,7 @@

    §1起始状态与目标

    §2流程定义(13 个节点,含每节点配置)

    -
    TfileCleaning.manual触发器
    +
    Tfs.manualScan触发器
    IN —(手动触发 payload:roots + 枚举配置) OUT item.fs.entry × N
    @@ -207,7 +207,7 @@

    §5Workflow 视角:类型契约与冲突隔离

  • 软契约(编辑器 lint 级)= 变量供需:capture 按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承与系统变量),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
  • roots(constants)["D:/Downloads"]
    - + diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md index 5e1ec9f6c..30d6463c9 100644 --- a/docs/file-cleaning-workflow.md +++ b/docs/file-cleaning-workflow.md @@ -61,7 +61,7 @@ public sealed record FsEntryItem 纯文本算法仍沉淀在无状态服务 `TextOps`(供 file-name-modifier 页与将来的 Organizer 复用);文本节点是 `TextOps` 经 facet 暴露给 workflow 的形态。 -### 触发器:`fileCleaning.manual`(= 用户说的 constants 节点 + 获取子级节点) +### 触发器:`fs.manualScan`(= 用户说的 constants 节点 + 获取子级节点) 手动触发器,payload 即用户录入的常量与枚举配置: diff --git a/docs/workflow-capability-map.md b/docs/workflow-capability-map.md new file mode 100644 index 000000000..c0beb1142 --- /dev/null +++ b/docs/workflow-capability-map.md @@ -0,0 +1,68 @@ +# Workflow 能力全景:现状盘点与扩展路线 + +> 视角:**Workflow 模块整体**,而非某个场景。[文件清洗](file-cleaning-workflow.md)是下表多数扩展的第一个用户,但每项扩展都是跨场景通用能力。以下现状均已在代码中核实(`src/modules/Bakabase.Modules.Workflow` + `src/apps/Bakabase.Service/Components/Workflow`)。 + +## 一、引擎现状(能力层) + +| 能力 | 现状 | +|---|---| +| 定义与编辑 | `WorkflowDefinition` CRUD + 链式编辑器;`WorkflowDefinitionService` 左到右追踪"当前 item 类型"做静态校验 | +| 类型系统 | item type 字符串标签 + 描述符(`DisplayName` + `ClrType`);活动契约 `AcceptedInputItemTypes`(空 = 接受任意;异构触发器的 "any" 哨兵不匹配非空清单)+ `OutputBehavior`(Passthrough / Fixed / **AdaptToNext**——AI 节点按下游需求适配输出类型,靠 ClrType 做 JSON 形状推断与反序列化) | +| 执行 | `WorkflowRunner` per-item 驱动;`WorkflowItemOutcome` = Keep / Drop / Replace;per-activity `OnItemError`;`WorkflowRunRehydrator` 运行恢复;runs 持久化 + 步骤统计 | +| 触发 | 触发器注册表;`Matches(payload, filterJson)` 定义级过滤;`ExtractItems` 产出初始 item 列表 | +| UI | workflow 页:定义编辑器(picker 按 Group 分桶)、运行记录 | + +## 二、节点现状盘点 + +**触发器(2)**: + +| kind | 产出类型 | +|---|---| +| `subscription.updated` | subscription item(源锁定时为具体类型,否则 "any" 哨兵) | +| `downloader.completed` | downloader completed task | + +**Item 类型(5)**:subscription any | pixiv illust | exhentai gallery | search query | downloader completed。 + +**活动(5)**: + +| kind | 类别 | 契约 | 说明 | +|---|---|---|---| +| subscriptionItemTitleContains | Filter | 任意 / passthrough | 名挂 subscription 组,契约实际通用 | +| ai transform | Transform | 任意 / **AdaptToNext** | 通用 AI 节点(提示词驱动,形状推断) | +| exhentai.queryToGallery | Transform | searchQuery → gallery(Fixed) | | +| notification.create | Action | 任意 / passthrough | 通用通知 | +| exhentai.enqueueDownload | Action | gallery | | + +**结论**:当前节点面覆盖的是"订阅 → 过滤 → AI 加工 → 下载/通知"一条业务线的最小集;引擎骨架(类型校验、注册表、runs)质量好但被消费得很少。 + +## 三、引擎扩展清单(均为跨场景通用;标注第一个用户) + +| # | 扩展 | 内容 | 第一个用户 | 通用价值 | +|---|---|---|---|---| +| E1 | **手动触发 + 参数面板** | 触发器新形态:用户带 payload 手动运行(当前只有事件触发) | `fs.manualScan`([清洗](file-cleaning-workflow.md)) | 一切"对一批输入执行流程"的场景;也是调试任何工作流的入口 | +| E2 | **链中展开 `ExpandTo`** | `WorkflowItemOutcome` 增加 1→N 展开;步骤统计需理解基数变化 | `expand.fs.children` | 画廊→多图片、feed→多条目、压缩包→内容物… | +| E3 | **facet 契约** | 描述符增加 `AcceptedItemFacet: Type`(接口谓词,经 ClrType 校验);facet 节点强制 Passthrough | `transform.text.*` 对 `ITextWorkpiece` | 一切"按能力而非按类型"的节点族(未来:IHasCover、IHasSourceUrl…) | +| E4 | **变量机制** | item 级 `Variables` + `{var:name(:formatter)}` 插值 + 系统变量注入 + 供需软契约 lint | capture/template + fs 系统变量 | 当前节点间**没有任何数据通道**(只有 item 本身);变量是通用的节点联动机制 | +| E5 | **两阶段运行**(Preview → 确认 → Apply) | run 产出"计划",确认后按计划执行(不重跑链) | `action.fs.saveName` | 一切有副作用的批量 action(批量入队、批量资源修改)都该有干跑 | +| E6 | 定时 / 监听触发(后续) | cron 触发器;目录监听(FileMover 进料口形态) | 自动清洗 | 全部定时自动化 | + +## 四、节点扩展清单(按域) + +| 域 | 节点 | 状态 | +|---|---|---| +| fs(新) | 触发器 `fs.manualScan`(constants + 枚举);`filter.fs.byRegex / byExtension / byType`;`expand.fs.children`;`transform.fs.fileNameOp`;`action.fs.saveName` | [清洗设计](file-cleaning-workflow.md)已定稿 | +| text(新,facet 通用) | `transform.text.removeWrapped / removeTexts / trim / capture / template` | 同上;依赖 [SpecialText 扩展](file-cleaning-workflow.md#specialtext-扩展用户自定义文本类型文本集) | +| 通用(补充候选) | `filter.common.byVariable`(按变量过滤,E4 的自然配套);`action.common.setVariable`(常量/映射写变量) | 实现 E4 时顺手评估 | +| 既有域延伸(候选) | `action.downloader.enqueue` 泛化(当前仅 exhentai);`action.resource.registerPath`(把 fs 条目登记入库/触发同步,打通清洗→入库) | 按需求排期 | +| organizer(挂起) | 触发器"整理完成"、活动"运行某 Pipeline/规则集" | 随 [Organizer](local-file-organization/README.md) 复活 | + +## 五、串联视角:这些扩展如何互相成立 + +- E1+E2+E4 组合出"手动对文件系统跑多层级联动流程"(清洗的骨架); +- E3 使 text 节点族对**任何**未来 item 类型开放(画廊标题清洗零成本); +- E5 是所有落盘类 action 的安全模型,与 E1 组合出"手动预览-确认"的标准交互; +- E6 + 既有 `downloader.completed` 触发器 + fs/text 节点 = "下载完成 → 自动清洗 → 通知"全自动链,一行引擎代码都不用再加。 + +## 实施顺序建议 + +E1 → E3+E4(text 节点按[清洗设计](file-cleaning-workflow.md)的规范**生而 facet 化**,与变量机制同批落地;前置 SpecialText 扩展)→ E2 → E5。E6 独立,随自动化需求排期。 From 8ae2147023e4cdf897c90d33f735b59bca3c1727 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:49:13 +0000 Subject: [PATCH 16/46] docs(workflow): convert active design docs to HTML, apply E2/E3/E4 revisions Converts file-cleaning-workflow and workflow-capability-map from markdown to styled HTML matching the example doc, cross-linked; the markdown sources are removed to keep a single source of truth. Applies the three confirmed revisions: cardinality becomes a static activity descriptor declaration for ExpandTo (editor filtering/marking/stats), the facet term is replaced by capability contract (AcceptedItemInterface) throughout, and variables move off item types into an engine-level per-item sidecar (ctx.Variables) with inheritance, persistence, interpolation and editor autocomplete/lint - simplifying ITextWorkpiece to WorkingText + WithWorkingText. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 36 ++--- docs/file-cleaning-workflow.html | 165 ++++++++++++++++++++ docs/file-cleaning-workflow.md | 186 ----------------------- docs/local-file-organization/README.md | 2 +- docs/workflow-capability-map.html | 87 +++++++++++ docs/workflow-capability-map.md | 68 --------- 6 files changed, 271 insertions(+), 273 deletions(-) create mode 100644 docs/file-cleaning-workflow.html delete mode 100644 docs/file-cleaning-workflow.md create mode 100644 docs/workflow-capability-map.html delete mode 100644 docs/workflow-capability-map.md diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index 556730cad..7e96f905f 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -51,7 +51,7 @@

    文件清洗流程示例 · 动画下载目录标准化

    多层级(文件夹 + 其内文件)· 节点联动(文件改名依赖父文件夹节点的清洗结果)· 每节点完整配置与出入参契约

    -

    配套设计文档:docs/file-cleaning-workflow.md。本例同时是设计探针——它需要的「链中展开 / 变量与系统变量 / 模板重建名 / facet 契约」机制已并入设计文档(见 §6 设计增量)。

    +

    配套设计文档:file-cleaning-workflow.html。本例同时是设计探针——它需要的「链中展开 / 变量与系统变量 / 模板重建名 / 能力契约」机制已并入设计文档(见 §6 设计增量)。

    §1起始状态与目标

    D:/Downloads/
    @@ -86,7 +86,7 @@ 

    §2流程定义(13 个节点,含每节点配置
    2transform.text.removeWrappedTransform · 通用文本
    -
    IN facet:ITextWorkpiece(本链此处 = item.fs.entry,作用于其工作文本 WorkingName) OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece(本链此处 = item.fs.entry,作用于其工作文本 WorkingName) OUT 同 | 基数 1→1

    节点契约(IN)基数变量
    T fileCleaning.manualpayload → item.fs.entry0→N注入系统变量
    T fs.manualScanpayload → item.fs.entry0→N注入系统变量
    1 filter.fs.byRegexitem.fs.entry(精确类型)1→0/1
    2–4 removeWrapped/trimfacet: ITextWorkpiece1→1
    5 capturefacet: ITextWorkpiece1→1提供 title
    @@ -95,7 +95,7 @@

    §2流程定义(13 个节点,含每节点配置
    3transform.text.removeWrappedTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1

    wrappers[] 【】 (textSetRef = builtin:Wrapper)
    textSet自定义文本集 「字幕组名单」(textSetRef = custom:1;含 LoliHouse、桜都字幕组、桜都字幕組…)
    @@ -104,14 +104,14 @@

    §2流程定义(13 个节点,含每节点配置
    4transform.text.trimTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1

    wrappers[]
    textSet自定义文本集 「质量与发布标签」(textSetRef = custom:2;1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
    collapseSpaces / trimEnds / removeEmptyWrapperstrue / true / true
    清掉删除后残留的连续空格与空括号对。
    5transform.text.captureTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:title(软契约,编辑器 lint 供下游校验)
    pattern^(?<title>.+)$
    onMissSkipSilently(不设变量)
    @@ -137,23 +137,23 @@

    §2流程定义(13 个节点,含每节点配置
    8transform.text.removeWrappedTransform · 通用文本
    -
    IN facet:ITextWorkpiece(此处 item 均为文件) OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece(此处 item 均为文件) OUT 同 | 基数 1→1
    (配置同节点 2)wrappers [] 【】 | textSet 「字幕组名单」 | EqualsAny
    9transform.text.removeWrappedTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1
    (配置同节点 3)wrappers [] | textSet 「质量与发布标签」 | ContainsAny
    [桜都字幕組] 藥屋少女的呢喃 [01][1080p][繁体].mkv藥屋少女的呢喃 [01].mkv([01] 不含质量词,保留)
    10transform.text.trimTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1
    (配置同节点 4)true / true / true
    11transform.text.captureTransform · 通用文本
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1 | 提供 var:ep(onMiss 时不提供)
    pattern(?:-\s*|\[)(?<ep>\d{2,3})(?:\]|\s|$)
    onMissSkipSilently
    @@ -161,7 +161,7 @@

    §2流程定义(13 个节点,含每节点配置
    12transform.text.templateTransform · 通用文本requiredVars: [title, ep]
    -
    IN facet:ITextWorkpiece OUT 同 | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点11)
    +
    IN 能力:ITextWorkpiece OUT 同 | 基数 1→1 | 需要 var:title、var:ep(缺失→节点跳过;编辑器 lint 上游供给:title←节点5经继承、ep←节点11)
    template{var:title} - {var:ep:pad(2)}{var:extension}
    缺变量行为requiredVars 任一缺失 → 整节点跳过(工作文本保持上游清洗结果)
    @@ -203,29 +203,29 @@

    §5Workflow 视角:类型契约与冲突隔离

    本功能的节点与其他功能(exhentai / pixiv / subscription…)注册在同一个活动注册表里,但不会互相冲突,隔离靠三层机制:

    • kind 命名空间:注册表以 kind 字符串为唯一键(WorkflowActivityKinds.Build 校验段格式)——transform.text.* / *.fs.**.exhentai.* 等天然不撞。
    • -
    • 硬契约(编辑器报错级)分两种形态:fs 域节点(filter/expand/saveName)声明 AcceptedInputItemTypes = ["item.fs.entry"]——精确类型白名单;通用文本节点声明 AcceptedItemFacet = typeof(ITextWorkpiece)——接受任何 CLR 类型实现该 facet 的 item,编辑器经 IWorkflowItemTypeDescriptor.ClrType 静态校验实现关系。"作用于哪个 property"由 item 类型声明工作文本统一回答(FsEntryItem.WorkingText ≡ WorkingName,编辑器在节点卡上显示"作用于:WorkingName");ExHentai 画廊链放不进 action.fs.saveName(类型白名单拦截),但若画廊 item 将来实现 ITextWorkpiece,同一批文本节点直接可用于清洗其标题——按契约互操作,不是冲突。
    • +
    • 硬契约(编辑器报错级)分两种形态:fs 域节点(filter/expand/saveName)声明 AcceptedInputItemTypes = ["item.fs.entry"]——精确类型白名单;通用文本节点声明 AcceptedItemInterface = typeof(ITextWorkpiece)——接受任何 CLR 类型实现该能力接口的 item,编辑器经 IWorkflowItemTypeDescriptor.ClrType 静态校验实现关系。"作用于哪个 property"由 item 类型声明工作文本统一回答(FsEntryItem.WorkingText ≡ WorkingName,编辑器在节点卡上显示"作用于:WorkingName");ExHentai 画廊链放不进 action.fs.saveName(类型白名单拦截),但若画廊 item 将来实现 ITextWorkpiece,同一批文本节点直接可用于清洗其标题——按契约互操作,不是冲突。
    • 软契约(编辑器 lint 级)= 变量供需:capture 按配置声明"提供 var:x",template 声明 requiredVars——编辑器静态核对上游供给链(含 expand 继承与系统变量),缺口给 warning 而非 error(capture 可能条件性命中,运行期由 requiredVars 跳过兜底)。
    - - + + - - - + + +
    节点契约(IN)基数变量
    T fs.manualScanpayload → item.fs.entry0→N注入系统变量
    1 filter.fs.byRegexitem.fs.entry(精确类型)1→0/1
    2–4 removeWrapped/trimfacet: ITextWorkpiece1→1
    5 capturefacet: ITextWorkpiece1→1提供 title
    2–4 removeWrapped/trim能力: ITextWorkpiece1→1
    5 capture能力: ITextWorkpiece1→1提供 title
    6 saveName(目录实例)item.fs.entry(精确类型)1→1—(副作用:目录计划)
    7 expand.fs.childrenitem.fs.entry(精确类型)1→N继承全部 + 注入 extension/parentName
    8–10 removeWrapped/trimfacet: ITextWorkpiece1→1
    11 capturefacet: ITextWorkpiece1→1提供 ep(onMiss 不提供)
    12 templatefacet: ITextWorkpiece1→1需要 title + ep
    8–10 removeWrapped/trim能力: ITextWorkpiece1→1
    11 capture能力: ITextWorkpiece1→1提供 ep(onMiss 不提供)
    12 template能力: ITextWorkpiece1→1需要 title + ep
    13 saveName(文件实例)item.fs.entry(精确类型)1→1(终点)—(副作用:文件计划/改名)

    §6本例暴露并已并入设计的增量

    • 链中展开expand.fs.children(引擎新增 ExpandTo outcome)——多层级处理与变量继承的前提。v1 仅 ChildrenOnly:父项计划在展开前存盘,混合流经链序表达,文本节点因此零 fs 感知(scope 选项被移除)。
    • -
    • facet 契约ITextWorkpiece(WorkingText + Variables)+ 描述符 AcceptedItemFacet + 经 ClrType 的编辑器校验——文本节点真正通用,"作用于哪个 property"由 item 类型一处声明统一回答。
    • +
    • 能力契约ITextWorkpiece(WorkingText + WithWorkingText)+ 描述符 AcceptedItemInterface + 经 ClrType 的编辑器校验——文本节点真正通用,"作用于哪个 property"由 item 类型一处声明统一回答。
    • 变量机制Variables + transform.text.capture(具名捕获,不改文本)+ {var:name(:formatter)} 插值 + 系统变量(fs 域注入 var:extension / var:parentName——fs 语义进入通用文本节点的唯一通道)。
    • 模板重建名transform.text.template——跨层级信息(父标题 + 自身集数 + 系统变量)组合命名的表达方式。
    • requiredVars:缺变量跳过本节点(SP1 的兜底路径)。
    • Apply 排序DeepestFirst——按路径深度从深到浅执行,与计划记录顺序解耦。
    -
    文件清洗流程示例 · 配套 docs/file-cleaning-workflow.md(设计增量已同步)
    +
    diff --git a/docs/file-cleaning-workflow.html b/docs/file-cleaning-workflow.html new file mode 100644 index 000000000..4c7477bd9 --- /dev/null +++ b/docs/file-cleaning-workflow.html @@ -0,0 +1,165 @@ + + +文件清洗(File Cleaning Workflow)· 设计 +
    +

    文件清洗(File Cleaning Workflow)· 设计

    +

    Workflow + File Renamer 的组合:用户自定义清洗流程(constants → 枚举子级 → 文本变更节点 → 保存文件名),针对文件夹执行,带预览与撤销

    +

    状态:设计中,当前焦点。关联文档:Workflow 能力全景13 节点完整示例Organizer(已挂起)——本功能独立成立,不依赖 Organizer 任何概念;若其复活,本功能的文本节点可被复用为原子能力。

    + + +

    §1承载决策:直接构建在现有 Workflow 模块上

    +

    Organizer 当初不复用 WorkflowRunner 的三个理由(跨轮状态机、聚合阶段、两阶段确认)在本功能上全部不成立:清洗是线性 per-item 流程、无跨条目聚合、预览由计划模式实现。Workflow 已有的正是所需骨架:

    + + + + +
    需求Workflow 现有能力
    用户自定义流程WorkflowDefinition + 链式编辑器(typed-item 校验)
    节点分类Activity 三类:Filter / Action / Transform
    逐值轮询执行Runner 的 per-item 驱动(trigger ExtractItems 产出初始 item 列表)
    运行记录WorkflowRun 持久化 + 步骤统计
    +

    文本操作能力复用 FileNameModifier(7 种操作 × 目标区域 × 位置,纯字符串函数,已有实现与测试);文本集能力复用并扩展 SpecialText(§5)。本功能依赖的引擎扩展(手动触发 E1、链中展开 E2、能力契约 E3、变量 E4、两阶段运行 E5)统一定义在能力全景——均为跨场景通用能力,清洗只是第一个用户。

    + +

    §2Item 模型与类型契约

    +
    // ItemType: "item.fs.entry"(注册 IWorkflowItemTypeDescriptor)
    +public sealed record FsEntryItem : ITextWorkpiece
    +{
    +    public required string Path { get; init; }         // 当前完整路径
    +    public required bool IsDirectory { get; init; }
    +    public required string OriginalName { get; init; } // 进入链时的名字(不含路径)
    +    public required string WorkingName { get; init; }  // 工作名:文本节点只改它,不碰磁盘
    +
    +    // ITextWorkpiece:工作文本 ≡ WorkingName——"文本节点作用于什么"由此一处声明
    +    public string WorkingText => WorkingName;
    +    public ITextWorkpiece WithWorkingText(string t) => this with { WorkingName = t };
    +}
    +
    变量不在 item 上:变量是引擎级随行袋(Runner 为每个 item 维护、经 ctx.Variables 暴露、ExpandTo 时子项继承副本、随 run 持久化)——任何 item 类型零成本获得变量能力,详见能力全景 E4。fs 域在触发器/expand 产出 item 时向袋内注入只读系统变量var:extensionvar:parentName
    +
    核心不变量:链上一切文本变更只作用于工作文本;磁盘操作只发生在 saveName 节点。"计算新名字"与"落盘"天然分成两段,预览因此免费。
    +

    类型契约(硬校验,编辑器报错级)分两种形态:fs 域节点(filter/expand/saveName/fileNameOp)声明 AcceptedInputItemTypes = ["item.fs.entry"](精确类型白名单);通用文本节点声明能力契约 AcceptedItemInterface = typeof(ITextWorkpiece)——接受任何 CLR 类型实现该接口的 item,编辑器经 IWorkflowItemTypeDescriptor.ClrType 静态校验。两种形态使本功能节点与其他功能的专属节点互斥或按契约互操作,不存在冲突。变量供需(capture 提供、template 需要)作为软契约由编辑器 lint(warning 而非 error——capture 可能条件性命中)。

    +

    节点通用选项requiredVars: string[]——任一变量缺失则跳过本节点(如模板节点缺 ep 时保住上游清洗结果)。

    + +

    §3节点设计

    +

    3.0 规范:通用文本节点与能力契约("trim 的是哪个 property?"的系统性回答)

    +

    文本节点(trim/removeWrapped/removeTexts/capture/template)是真正通用的 transform.text.*——只认识"工作文本",不认识文件系统:

    +
      +
    1. 能力契约ITextWorkpiece { string WorkingText; ITextWorkpiece WithWorkingText(string) }它是对"当前类型"的能力谓词,不是类型本身:校验通过后链上的当前类型不变(类比泛型约束 where T : ITextWorkpiece,T 还是 T)。强制配对规则:能力契约节点必须 Passthrough(禁止 Fixed 输出),否则具体类型在链上丢失、下游精确类型节点接不住。运行时由 WithWorkingText 返回实现方具体类型保证;capture 只写随行袋不改文本,直接 KeepItem。链上"当前类型"从头到尾是 item.fs.entry,能力契约节点与精确类型节点可任意穿插。
    2. +
    3. "作用于什么"由 item 类型声明,一处回答、处处生效FsEntryItem.WorkingText ≡ WorkingName,编辑器在节点卡上显示"作用于:WorkingName"。将来画廊 item 声明 WorkingText ≡ Title,同一批文本节点直接可用。
    4. +
    5. fs 语义进入文本节点的唯一通道是系统变量(模板写 {var:extension},无 fs 专属占位符),文本节点零 fs 感知。
    6. +
    +
    两个刻意决策:transform.fs.fileNameOp 留在 fs 域(其 Target 语义即文件名结构,名字已回答作用目标);无 scope 选项——混合流(目录+文件)由链序表达:目录段 → saveName(目录计划)→ expand(ChildrenOnly,此后链上只有文件)→ 文件段 → saveName(文件计划)。
    +

    纯文本算法沉淀在无状态服务 TextOps(供 file-name-modifier 页与将来的 Organizer 复用);文本节点是 TextOps 经能力契约暴露给 workflow 的形态。

    + +

    3.1 触发器:fs.manualScan(= constants 节点 + 获取子级节点)

    +
    {
    +  "roots": ["D:/Anime", "E:/Downloads"],   // constants:写死的目录列表
    +  "enumerate": {
    +    "target": "Directories | Files | Both",
    +    "depth": 1,                            // 枚举深度(1 = 直接子级)
    +    "extensionFilter": ["mkv", "zip"]      // 可选
    +  }
    +}
    +

    ExtractItems 对每个 root 按配置枚举产出 FsEntryItem 列表——"针对全部子级轮询跑后面的节点"就是 Runner 既有语义。constants 与首层枚举合并进触发器;更深层级用链中展开节点(3.2)。

    + +

    3.2 Expand 活动:expand.fs.children(链中 1→N)

    +

    依赖引擎扩展 E2(WorkflowItemOutcome.ExpandTo)。基数是描述符的静态声明:活动声明 Cardinality: OneToOne(默认)| OneToMany,编辑器据此做视觉标识、picker 筛选与统计口径;Category 与基数正交(本节点形式上是 Transform + OneToMany)。

    + + + +
    配置说明
    target / extensionFilterFiles / Directories / Both + 扩展名过滤
    emitv1 仅 ChildrenOnly(父项被展开消费——其改名计划应在展开由 saveName 记录)
    inheritVariables子项继承父项随行袋副本;同步注入子项系统变量(extension/parentName)
    + +

    3.3 Filter 活动

    + + + +
    kind行为
    filter.fs.byRegex工作名匹配/不匹配正则
    filter.fs.byExtension按扩展名/扩展名组(复用 ExtensionGroup)
    filter.fs.byType只要文件 / 只要文件夹
    + +

    3.4 Transform 活动(文本变更节点,任意个、任意序)

    + + + + + + +
    kind契约行为配置
    transform.text.removeWrapped能力: ITextWorkpiece删除包装符内命中文本集的片段包装符对(TextSetRef,如 builtin:Wrapper)+ 文本集引用 + 命中方式(等于/包含/正则)
    transform.text.removeTexts能力: ITextWorkpiece删除命中文本集的裸文本片段文本集引用 + 命中方式
    transform.text.trim能力: ITextWorkpiece清理残留:连续空格/头尾空白/空括号对开关组
    transform.text.capture能力: ITextWorkpiece不改文本,只捕获:正则具名捕获组 → 写入随行袋(组名即变量名)pattern + onMiss(SkipSilently/标记)
    transform.text.template能力: ITextWorkpiece以模板重建工作文本(跨层级信息组合命名)template({var:x(:pad(n))} / {originalText};扩展名写 {var:extension})+ requiredVars
    transform.fs.fileNameOpitem.fs.entry包装 FileNameModifier 全部既有操作(刻意留在 fs 域)Target × Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件
    + +

    3.5 Action 活动:action.fs.saveName

    +
      +
    • 工作名 == 原名 → 跳过;不同 → Preview(默认,只记计划)/ Apply(执行改名 + 写 FileRenameRecord)。
    • +
    • 内置防线(不可关闭):sanitize(非法字符/保留名/结尾点空格)、目标重名冲突记入冲突表跳过不中断、路径长度预检。
    • +
    • Apply 排序 DeepestFirst:按路径深度从深到浅执行——父目录改名不使子条目计划失效,与计划记录顺序解耦。
    • +
    + +

    §4预览 → 确认 → 应用 → 撤销

    +
    「预览」 ─▶ run(Preview):全链执行,saveName 只记计划
    +            └─▶ UI 展示 diff(旧名 → 新名,冲突高亮),可逐条勾选排除
    +「应用」 ─▶ 对预览产出的计划执行改名(不重跑链,避免两次运行间磁盘漂移)→ 逐条写 FileRenameRecord
    +「撤销」 ─▶ 逆序回放该次 run 的 FileRenameRecord(To → From)
    +
    +FileRenameRecord   RunId, Seq, Path(父目录), From, To, RenamedAt, Undone   // 保留期默认 30 天
    + +

    §5SpecialText 扩展:用户自定义文本类型(文本集)

    +

    现状:SpecialTextType 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案:

    +
      +
    1. 枚举新增 SpecialTextType.Custom = 100
    2. +
    3. 新增小表 CustomTextType(Id, Name, Description?)——类型需要稳定 id:节点按 id 引用,改名不破坏引用;元数据与将来的分享有落点;
    4. +
    5. SpecialText 表加可空列 CustomTypeId(FK);自定义行 Value1 = 文本;
    6. +
    7. 节点的"文本集引用"统一为判别引用 TextSetRefbuiltin:{枚举值}custom:{id}
    8. +
    9. text 管理页并列展示内置与自定义类型,支持建集/批量录入/导入导出;示例的「质量与发布标签」以可编辑种子数据下发;
    10. +
    11. 对既有消费点零影响:legacy 均按具体枚举值查询,Custom 行不可见。
    12. +
    +
    实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做——独立可交付(text 页增强本身有价值),节点实现全部依赖它。EF 迁移按仓库规则 CLI 生成、纯 schema。
    + +

    §6执行与 UI

    +
      +
    • 定义、编辑、运行记录走 workflow 页既有 UI;新增:触发器配置面板(roots + 枚举)、活动配置表单(复用 file-name-modifier 页操作编辑组件)、预览 diff 面板(参考 BulkModification 预览交互)、变量自动补全(输入 { 弹出该位置可用变量集,见能力全景 E4)。
    • +
    • 运行承载沿用 Workflow run 机制;大目录逐条目 yield,进度在 run 详情展示。
    • +
    • 入口:workflow 页"文件清洗"模板;file-name-modifier 页"升级为清洗流程"引导(当前操作集一键转为 fileNameOp 节点)。
    • +
    + +

    §7完成后获得的能力 · 完整示例

    +
      +
    • 零代码定义任意清洗流程:录常量目录 → 枚举 → 组合文本变更 → 预览确认 → 落盘可撤销。
    • +
    • 自定义文本集成为一等公民(字幕组名单、广告词…),跨流程复用、可维护。
    • +
    • Workflow 获得第一个文件系统域的触发器与活动集,为自动化(下载完成→自动清洗)铺路。
    • +
    • FileNameModifier 从单页工具升级为可编排节点,原页面保留。
    • +
    +

    多层级 + 节点联动的 13 节点完整走查(每节点配置、出入参契约、item 流转表):file-cleaning-workflow-example.html

    + +

    §8开放问题

    +
      +
    • 文本集是否走 Sharable 分享——倾向于要,随使用量决策。
    • +
    • 定时/监听触发(FileMover 进料口式自动清洗)——依赖能力全景 E6。
    • +
    • ExpandTo 在编辑器统计口径中的表达(类型校验直通,但步骤统计需理解 1→N)。
    • +
    + +
    diff --git a/docs/file-cleaning-workflow.md b/docs/file-cleaning-workflow.md deleted file mode 100644 index 30d6463c9..000000000 --- a/docs/file-cleaning-workflow.md +++ /dev/null @@ -1,186 +0,0 @@ -# 文件清洗(File Cleaning Workflow)· 设计 - -> 状态:**设计中,当前焦点**。一句话:**Workflow + File Renamer 的组合**——用户自定义一条清洗流程(constants → 枚举子级 → 若干文本变更节点 → 保存文件名),针对文件夹执行,带预览与撤销。 -> -> 与 [Organizer(已挂起)](local-file-organization/README.md)的关系:独立功能、独立成立;不依赖 Organizer 的任何概念。若 Organizer 复活,本功能的文本变更节点可被其复用为原子能力。 - -## 承载决策:直接构建在现有 Workflow 模块上 - -Organizer 当初不复用 WorkflowRunner 的三个理由(跨轮状态机、聚合阶段、两阶段确认)在本功能上**全部不成立**:清洗是线性 per-item 流程、无跨条目聚合、预览可由节点模式实现。而 Workflow 模块已有的正是本功能需要的全部骨架: - -| 需求 | Workflow 现有能力 | -|---|---| -| 用户自定义流程 | WorkflowDefinition + 链式编辑器(typed-item 校验) | -| 节点分类 | Activity 三类:Filter / Action / Transform | -| 逐值轮询执行 | Runner 的 per-item 驱动(trigger `ExtractItems` 产出初始 item 列表) | -| 运行记录 | WorkflowRun 持久化 + 步骤统计 | - -文本操作能力复用 **FileNameModifier**(Insert / AddDateTime / Delete / Replace / ChangeCase / AddAlphabetSequence / Reverse × 目标区域 × 位置,纯字符串函数,已有实现与测试),文本集能力复用并扩展 **SpecialText**(见下)。 - -## Item 模型 - -```csharp -// ItemType: "item.fs.entry"(注册 IWorkflowItemTypeDescriptor) -public sealed record FsEntryItem -{ - public required string Path { get; init; } // 当前完整路径 - public required bool IsDirectory { get; init; } - public required string OriginalName { get; init; } // 进入链时的名字(不含路径) - public required string WorkingName { get; init; } // 工作名:文本变更节点只改它,不碰磁盘 - public Dictionary Variables { get; init; } = new(); - // 变量:capture 节点写入、后续节点经 {var:name(:formatter)} 插值消费; - // expand 展开子项时可继承(inheritVariables)——这是"节点 x 依赖节点 y 结果"的传递通道。 - // fs 域产出 item 时自动注入只读系统变量:var:extension、var:parentName。 - // 变量只在 item 内流动、随继承下发,链保持线性 per-item,无全局状态。 -} -// FsEntryItem 实现 ITextWorkpiece:WorkingText ≡ WorkingName(通用文本节点由此作用于它,见"规范"节) -``` - -核心不变量:**链上一切文本变更只作用于 `WorkingName`;磁盘操作只发生在 saveName 节点**。这天然把"计算新名字"与"落盘"分成两段,预览因此免费。 - -**类型契约(硬校验,编辑器报错级)**分两种形态:fs 域节点(filter/expand/saveName/fileNameOp)声明 `AcceptedInputItemTypes = ["item.fs.entry"]`;**通用文本节点声明 `AcceptedItemFacet = typeof(ITextWorkpiece)`**(见下"规范"节)——接受任何实现该 facet 的 item 类型,编辑器经 `IWorkflowItemTypeDescriptor.ClrType` 静态校验。两种形态都使本功能节点与其他功能(exhentai/pixiv 等)的专属节点**互斥或按契约互操作**,不存在冲突。变量提供/需求(capture 提供、template 需要)作为**软契约**由编辑器 lint(warning 而非 error——capture 可能条件性命中)。节点出入参明细见[示例](file-cleaning-workflow-example.html) §5。 - -**节点通用选项**: -- `requiredVars: string[]`——任一变量缺失则跳过本节点(如模板节点缺 `ep` 时保住上游清洗结果)。 - -## 节点设计 - -### 规范:通用文本节点与 facet 契约("trim 的是哪个 property?"的系统性回答) - -文本节点(trim/removeWrapped/removeTexts/capture/template)是**真正通用的** `transform.text.*`——它们**只认识"工作文本"**,不认识文件系统。规范分三条: - -1. **facet 契约**:定义 `ITextWorkpiece { string WorkingText; IDictionary Variables; ITextWorkpiece WithWorkingText(string) }`。文本节点的输入契约不是具体 item 类型,而是 `AcceptedItemFacet = typeof(ITextWorkpiece)`——接受任何 CLR 类型实现该接口的 item;编辑器经 `IWorkflowItemTypeDescriptor.ClrType`(描述符本就携带)静态校验实现关系,强校验不减弱。 - **facet 是对"当前类型"的能力谓词,不是类型本身**:校验通过后链上的当前类型**不变**(类比泛型约束 `where T : ITextWorkpiece`,T 还是 T)。因此有一条强制配对规则——**facet 节点必须 Passthrough**(输出类型 = 输入的具体类型,禁止 Fixed 输出),否则具体类型在链上丢失,下游精确类型节点(如 `action.fs.saveName`)将接不住。运行时由 `WithWorkingText` 返回实现方自身的具体类型保证(`FsEntryItem` 实现为 `this with { WorkingName = t }`);capture 只写 `Variables` 不改文本,直接 `KeepItem`。链上"当前类型"从头到尾是 `item.fs.entry`,facet 节点与精确类型节点(saveName)因此可任意穿插串联。 -2. **"作用于什么"由 item 类型声明,一处回答、处处生效**:`FsEntryItem.WorkingText ≡ WorkingName`(其类型描述符如此声明,编辑器在节点卡上显示"作用于:WorkingName")。将来画廊 item 声明 `WorkingText ≡ Title`,同一批文本节点直接可用——不存在"每个节点各自解释 trim 什么"的问题。 -3. **fs 语义进入文本节点的唯一通道是系统变量**:fs 域在产出 item 时自动注入只读变量 `var:extension`、`var:parentName`(模板写 `{var:extension}` 而非 fs 专属占位符)。文本节点因此零 fs 感知。 - -两个刻意的例外与删减: - -- `transform.fs.fileNameOp` **留在 fs 域**:它包装 FileNameModifier 的操作集,其 Target(全名/不含扩展名/扩展名)语义就是文件名结构——名字本身已回答作用目标。 -- **删除 `scope` 选项**:它是文本节点上的 fs 泄漏。混合流(目录+文件)改由**链序**表达:目录段 → `saveName`(目录计划)→ `expand`(ChildrenOnly,之后链上只有文件)→ 文件段 → `saveName`(文件计划)。文本节点因此不需要知道 item 是文件还是目录。 - -纯文本算法仍沉淀在无状态服务 `TextOps`(供 file-name-modifier 页与将来的 Organizer 复用);文本节点是 `TextOps` 经 facet 暴露给 workflow 的形态。 - -### 触发器:`fs.manualScan`(= 用户说的 constants 节点 + 获取子级节点) - -手动触发器,payload 即用户录入的常量与枚举配置: - -```jsonc -{ - "roots": ["D:/Anime", "E:/Downloads"], // constants:写死的目录列表 - "enumerate": { - "target": "Directories | Files | Both", - "depth": 1, // 枚举深度(1 = 直接子级) - "extensionFilter": ["mkv", "zip"] // 可选 - } -} -``` - -`ExtractItems` 对每个 root 按配置枚举,产出 `FsEntryItem` 列表——"针对全部子文件(夹)轮询跑后面的节点"就是 Runner 的既有语义,引擎零改动。 - -> constants 与首层枚举合并进触发器;**更深层级用链中展开节点**(下方 Expand)。引擎为此新增 `WorkflowItemOutcome.ExpandTo(IReadOnlyList)`(小改、向后兼容)——多层级示例(父目录净标题传给文件命名)证明了它是必需项,已从开放问题转正。 - -### Expand 活动:`expand.fs.children`(链中 1→N) - -| 配置 | 说明 | -|---|---| -| `target` / `extensionFilter` | Files / Directories / Both + 扩展名过滤 | -| `emit` | v1 仅 `ChildrenOnly`(父项被展开消费——其改名计划应在展开**前**由 saveName 节点记录,混合流经链序表达;`ChildrenThenSelf` 留待真实需求) | -| `inheritVariables` | 子项继承父项 Variables(跨层级联动的通道);展开时同步注入子项的系统变量(extension/parentName) | - -### Filter 活动(可选,收窄处理范围) - -| kind | 行为 | -|---|---| -| `filter.fs.byRegex` | WorkingName 匹配/不匹配正则 | -| `filter.fs.byExtension` | 按扩展名/扩展名组(复用 ExtensionGroup) | -| `filter.fs.byType` | 只要文件 / 只要文件夹 | - -### Transform 活动(文本变更节点,任意个、任意序) - -| kind | 契约 | 行为 | 配置 | -|---|---|---|---| -| `transform.text.removeWrapped` | facet | **删除包装符内命中文本集的片段**(用户举例的场景) | 包装符对(TextSetRef,如 builtin:Wrapper)+ 文本集引用 + 命中方式(等于/包含/正则) | -| `transform.text.removeTexts` | facet | 删除命中文本集的裸文本片段 | 文本集引用 + 命中方式 | -| `transform.text.trim` | facet | 清理残留:连续空格/头尾空白与分隔符/空括号对 | 开关组 | -| `transform.text.capture` | facet | **不改文本,只捕获**:正则具名捕获组 → 写入 `Variables` | pattern + onMiss(SkipSilently/标记)。作用于工作文本;路径/扩展名等 fs 上下文经系统变量获得 | -| `transform.text.template` | facet | 以模板**重建**工作文本(跨层级信息组合命名的表达方式) | template(`{var:x(:pad(n))}` / `{originalText}` 插值;文件扩展名写 `{var:extension}`)+ requiredVars | -| `transform.fs.fileNameOp` | item.fs.entry | 包装 FileNameModifier 的**全部**既有操作(**刻意留在 fs 域**:其 Target 语义即文件名结构,名字已回答作用目标) | Target(全名/不含扩展名/扩展名)× Operation × Position × 参数——与 file-name-modifier 页同一套模型与 UI 组件 | - -每个 Transform 输出 `item with { WorkingText = 新值 }`(Workflow 的 `ReplaceWith`,item 类型不变——编辑器校验直通)。 - -### Action 活动:`action.fs.saveName` - -- `WorkingName == OriginalName` → 跳过(无操作)。 -- 不同 → 按运行模式: - - **Preview(默认)**:不碰磁盘,把 `(Path, OriginalName → WorkingName)` 记入本次 run 的改名计划; - - **Apply**:执行改名,写 `FileRenameRecord` 日志。 -- 内置防线(不可关闭):改名前 sanitize(非法字符/保留名/结尾点空格);目标重名冲突 → 该条记入冲突列表跳过,不中断整个 run;路径长度预检。 -- **Apply 排序 `DeepestFirst`**:按路径深度从深到浅执行(先文件后父目录)——父目录改名不会使子条目计划路径失效。 - -## 预览 → 确认 → 应用 → 撤销 - -``` -用户点「预览」 ─▶ run(Preview):全链执行,saveName 只记计划 - └─▶ UI 展示 diff 列表(旧名 → 新名,冲突高亮),可逐条勾选排除 -用户点「应用」 ─▶ 对预览产出的计划执行改名(不重跑链,避免两次运行间磁盘漂移) - └─▶ 逐条写 FileRenameRecord -用户点「撤销」 ─▶ 逆序回放该次 run 的 FileRenameRecord(To → From) -``` - -``` -FileRenameRecord RunId, Seq, Path(父目录), From, To, RenamedAt, Undone -``` - -保留期可配(默认 30 天),由既有清理任务收割。 - -## SpecialText 扩展:用户自定义文本类型(文本集) - -现状:`SpecialTextType` 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案: - -1. 枚举新增 `SpecialTextType.Custom = 100`; -2. 新增小表 `CustomTextType(Id, Name, Description?)`——**类型需要稳定 id**:节点按 id 引用,改名不破坏引用;元数据与将来的分享有落点; -3. `SpecialText` 表加可空列 `CustomTypeId`(仅 Type=Custom 时有值,FK → CustomTextType);自定义行 Value1 = 文本,Value2 暂不用; -4. 节点里的"文本集引用"统一为一个判别引用 `TextSetRef`:序列化形如 `builtin:{枚举值}` 或 `custom:{id}`——`removeWrapped.textSet` 等参数因此既能引用内置类型也能引用自定义集; -5. text 管理页:自定义类型与内置类型并列展示,支持建集、批量录入、导入导出;示例用的「质量与发布标签」以**可编辑的种子数据**下发(而非硬编码内置类型); -6. **对既有消费点零影响**:legacy 代码均按具体枚举值查询,`Custom` 行对它们不可见。 - -> EF 迁移按仓库规则 CLI 生成、纯 schema。**实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做**——独立可交付(text 页增强本身就有价值),节点实现全部依赖它。 - -## 执行与 UI - -- 流程定义、编辑、运行记录全部走 workflow 页既有 UI;本功能新增的是:触发器配置面板(roots + 枚举配置)、三类活动的配置表单(复用 file-name-modifier 页的操作编辑组件)、预览 diff 面板(可参考 BulkModification 预览页交互)。 -- 运行承载沿用 Workflow 的 run 机制;大目录场景下 run 内部逐条目 yield,进度在 run 详情展示。 -- 入口:workflow 页新建"文件清洗"模板;file-name-modifier 页加一个"升级为清洗流程"引导(把当前操作集一键转为 `transform.fs.fileNameOp` 节点)。 - -## 走查示例(用户场景原样落地) - -``` -触发器: roots=[D:/Anime], enumerate={target:Directories, depth:1} -节点 1: transform.text.removeWrapped 包装符=[]/【】 文本集=自定义「字幕组名单」 -节点 2: transform.text.trim 清理残留空格 -节点 3: action.fs.saveName - -预览: - [LoliHouse] 進撃の巨人 S03 → 進撃の巨人 S03 - [桜都字幕组] 药屋少女的呢喃 → 药屋少女的呢喃 - Comiket103 → (无变化,跳过) -应用 → 2 条改名,FileRenameRecord 落库;发现错了 → 一键撤销还原 -``` - -## 完成后获得的能力 - -- 用户零代码定义任意清洗流程:录常量目录 → 枚举 → 组合任意文本变更 → 预览确认 → 落盘可撤销。 -- 自定义文本集成为一等公民(字幕组名单、广告词、发布组后缀…),跨流程复用、可维护。 -- Workflow 模块获得第一个文件系统域的触发器与活动集,为后续自动化(下载完成→自动清洗)铺路。 -- FileNameModifier 的能力从单页工具升级为可编排节点,原页面保留不动。 - -## 完整示例 - -多层级 + 节点联动(文件命名依赖父目录节点捕获的净标题)的 12 节点完整走查,含每节点配置与 item 流转表:**[file-cleaning-workflow-example.html](file-cleaning-workflow-example.html)**。 - -## 开放问题 - -- 文本集是否需要分享(Sharable 体系)——倾向于要,随自定义类型使用量决策。 -- 定时/监听触发(FileMover 进料口式自动清洗)——依赖 Workflow 触发器形态,后续版本。 -- `ExpandTo` 在 Workflow 编辑器类型校验中的表达(展开节点的输出 item 类型仍是 `item.fs.entry`,校验可直通;但步骤统计需理解 1→N)。 diff --git a/docs/local-file-organization/README.md b/docs/local-file-organization/README.md index ad463eb49..f73b56f9e 100644 --- a/docs/local-file-organization/README.md +++ b/docs/local-file-organization/README.md @@ -1,6 +1,6 @@ # 本地文件自动整理(Organizer)· 设计文档 -> 状态:**Pending(挂起)**。设计讨论在身份判定层(04 Consolidate 的等价语义/粒度问题)尚未收敛,决定先落地范围更小、边界清晰的**[文件清洗(File Cleaning Workflow)](../file-cleaning-workflow.md)**——它吸收了本设计 [01 文本处理规则](modules/01-text-processing.md)的方向(SpecialText 用户自定义类型 + FileNameModifier 节点化),并直接构建在现有 Workflow 模块上。恢复讨论时本目录仍是唯一真相源:每个概念只在其归属文档中定义一次,其余文档以链接引用,不做内容复制。 +> 状态:**Pending(挂起)**。设计讨论在身份判定层(04 Consolidate 的等价语义/粒度问题)尚未收敛,决定先落地范围更小、边界清晰的**[文件清洗(File Cleaning Workflow)](../file-cleaning-workflow.html)**——它吸收了本设计 [01 文本处理规则](modules/01-text-processing.md)的方向(SpecialText 用户自定义类型 + FileNameModifier 节点化),并直接构建在现有 Workflow 模块上。恢复讨论时本目录仍是唯一真相源:每个概念只在其归属文档中定义一次,其余文档以链接引用,不做内容复制。 ## 目标 diff --git a/docs/workflow-capability-map.html b/docs/workflow-capability-map.html new file mode 100644 index 000000000..11bf13384 --- /dev/null +++ b/docs/workflow-capability-map.html @@ -0,0 +1,87 @@ + + +Workflow 能力全景:现状盘点与扩展路线 +
    +

    Workflow 能力全景:现状盘点与扩展路线

    +

    视角:Workflow 模块整体,而非某个场景。文件清洗是多数扩展的第一个用户,但每项扩展都是跨场景通用能力

    +

    现状均已在代码中核实:src/modules/Bakabase.Modules.Workflow + src/apps/Bakabase.Service/Components/Workflow

    + +

    §1引擎现状(能力层)

    + + + + + +
    能力现状
    定义与编辑WorkflowDefinition CRUD + 链式编辑器;WorkflowDefinitionService 左到右追踪"当前 item 类型"做静态校验
    类型系统item type 字符串标签 + 描述符(DisplayName + ClrType);活动契约 AcceptedInputItemTypes(空 = 任意;异构触发器 "any" 哨兵不匹配非空清单)+ OutputBehavior(Passthrough / Fixed / AdaptToNext——AI 节点按下游需求适配输出类型,靠 ClrType 做 JSON 形状推断)
    执行WorkflowRunner per-item 驱动;outcome = Keep / Drop / Replace;per-activity OnItemError;WorkflowRunRehydrator 运行恢复;runs 持久化 + 步骤统计
    触发触发器注册表;Matches(payload, filterJson) 定义级过滤;ExtractItems 产出初始 item 列表
    UIworkflow 页:定义编辑器(picker 按 Group 分桶)、运行记录
    + +

    §2节点现状盘点

    + + +
    触发器(2)产出类型
    subscription.updatedsubscription item(源锁定时为具体类型,否则 "any" 哨兵)
    downloader.completeddownloader completed task
    +

    Item 类型(5):subscription any | pixiv illust | exhentai gallery | search query | downloader completed

    + + + + + +
    活动(5)类别契约说明
    subscriptionItemTitleContainsFilter任意 / passthrough名挂 subscription 组,契约实际通用
    ai transformTransform任意 / AdaptToNext通用 AI 节点(提示词驱动,形状推断)
    exhentai.queryToGalleryTransformsearchQuery → gallery(Fixed)
    notification.createAction任意 / passthrough通用通知
    exhentai.enqueueDownloadActiongallery
    +
    结论:当前节点面覆盖"订阅 → 过滤 → AI 加工 → 下载/通知"一条业务线的最小集;引擎骨架(类型校验、注册表、runs)质量好但被消费得很少。
    + +

    §3引擎扩展清单(均跨场景通用;标注第一个用户)

    + + + + + + +
    #扩展内容第一个用户通用价值
    E1手动触发 + 参数面板触发器新形态:用户带 payload 手动运行(当前只有事件触发)fs.manualScan一切"对一批输入执行流程";也是调试任何工作流的入口
    E2链中展开 ExpandToWorkflowItemOutcome 新增第四种裁决 ExpandTo(items)(1→N,与 fs 无关);基数是描述符的静态声明Cardinality: OneToOne | OneToMany,编辑器据此做视觉标识 / picker 筛选 / 下游类型追踪 / 统计口径。Category 与基数正交(Filter 即 1→0/1)expand.fs.children画廊→多图、feed→多条目、压缩包→内容物、资源→可播放文件…
    E3能力契约
    原称 facet,已弃用
    描述符增加 AcceptedItemInterface: Type——接受任何 CLR 类型实现该接口的 item(经描述符 ClrType 静态校验)。它是对"当前类型"的谓词,当前类型不变 → 强制 Passthrough。解决 O(N×M) 维护问题:新 item 类型实现一次接口,整族节点自动可用transform.text.* × ITextWorkpiece一切"按能力而非按类型"的节点族(未来 IHasCover、IHasSourceUrl…)
    E4变量机制(引擎级随行袋)Runner 为每个 item 维护变量袋(不进 item 类型),经 ctx.Variables 暴露;ExpandTo 子项继承副本;随 run 持久化。写入:capture(具名捕获组名即变量名)/ 域系统变量注入 / 后续 setVariable。读取:配置 schema 标记"支持插值"的字段,引擎统一解析 {var:name(:formatter)}。编辑器沿链推导"此处可用变量集"→ 自动补全 + lint(软契约)capture/template + fs 系统变量当前节点间没有任何数据通道;变量是通用联动机制,任何 item 类型零成本获得
    E5两阶段运行run 产出"计划",确认后按计划执行(不重跑链):Preview → diff 确认 → Applyaction.fs.saveName一切有副作用的批量 action(批量入队、批量修改)都该有干跑
    E6定时 / 监听触发后续cron 触发器;目录监听(FileMover 进料口形态)自动清洗全部定时自动化
    + +

    §4节点扩展清单(按域)

    + + + + + +
    节点状态
    fs 触发器 fs.manualScan(constants+枚举)| filter.fs.byRegex / byExtension / byType | expand.fs.children | transform.fs.fileNameOp | action.fs.saveName清洗设计已定稿
    text 新,能力契约通用transform.text.removeWrapped / removeTexts / trim / capture / template同上;依赖 SpecialText 扩展
    通用(补充候选)filter.common.byVariable(按变量过滤,E4 配套)| action.common.setVariable(常量/映射写变量)实现 E4 时顺手评估
    既有域延伸(候选)action.downloader.enqueue 泛化(当前仅 exhentai)| action.resource.registerPath(fs 条目登记入库/触发同步,打通清洗→入库)按需求排期
    organizer 挂起触发器"整理完成"、活动"运行某 Pipeline/规则集"Organizer 复活
    + +

    §5串联视角:扩展如何互相成立

    +
      +
    • E1+E2+E4 组合出"手动对文件系统跑多层级联动流程"(清洗的骨架);
    • +
    • E3 使 text 节点族对任何未来 item 类型开放(画廊标题清洗零成本);
    • +
    • E5 是所有落盘类 action 的安全模型,与 E1 组合出"手动预览-确认"的标准交互;
    • +
    • E6 + 既有 downloader.completed 触发器 + fs/text 节点 = "下载完成 → 自动清洗 → 通知"全自动链,届时引擎零新增。
    • +
    + +

    §6实施顺序建议

    +
    E1 → E3+E4(text 节点按清洗设计规范生而通用,与变量机制同批落地;前置 SpecialText 扩展)→ E2 → E5。E6 独立,随自动化需求排期。
    + +
    diff --git a/docs/workflow-capability-map.md b/docs/workflow-capability-map.md deleted file mode 100644 index c0beb1142..000000000 --- a/docs/workflow-capability-map.md +++ /dev/null @@ -1,68 +0,0 @@ -# Workflow 能力全景:现状盘点与扩展路线 - -> 视角:**Workflow 模块整体**,而非某个场景。[文件清洗](file-cleaning-workflow.md)是下表多数扩展的第一个用户,但每项扩展都是跨场景通用能力。以下现状均已在代码中核实(`src/modules/Bakabase.Modules.Workflow` + `src/apps/Bakabase.Service/Components/Workflow`)。 - -## 一、引擎现状(能力层) - -| 能力 | 现状 | -|---|---| -| 定义与编辑 | `WorkflowDefinition` CRUD + 链式编辑器;`WorkflowDefinitionService` 左到右追踪"当前 item 类型"做静态校验 | -| 类型系统 | item type 字符串标签 + 描述符(`DisplayName` + `ClrType`);活动契约 `AcceptedInputItemTypes`(空 = 接受任意;异构触发器的 "any" 哨兵不匹配非空清单)+ `OutputBehavior`(Passthrough / Fixed / **AdaptToNext**——AI 节点按下游需求适配输出类型,靠 ClrType 做 JSON 形状推断与反序列化) | -| 执行 | `WorkflowRunner` per-item 驱动;`WorkflowItemOutcome` = Keep / Drop / Replace;per-activity `OnItemError`;`WorkflowRunRehydrator` 运行恢复;runs 持久化 + 步骤统计 | -| 触发 | 触发器注册表;`Matches(payload, filterJson)` 定义级过滤;`ExtractItems` 产出初始 item 列表 | -| UI | workflow 页:定义编辑器(picker 按 Group 分桶)、运行记录 | - -## 二、节点现状盘点 - -**触发器(2)**: - -| kind | 产出类型 | -|---|---| -| `subscription.updated` | subscription item(源锁定时为具体类型,否则 "any" 哨兵) | -| `downloader.completed` | downloader completed task | - -**Item 类型(5)**:subscription any | pixiv illust | exhentai gallery | search query | downloader completed。 - -**活动(5)**: - -| kind | 类别 | 契约 | 说明 | -|---|---|---|---| -| subscriptionItemTitleContains | Filter | 任意 / passthrough | 名挂 subscription 组,契约实际通用 | -| ai transform | Transform | 任意 / **AdaptToNext** | 通用 AI 节点(提示词驱动,形状推断) | -| exhentai.queryToGallery | Transform | searchQuery → gallery(Fixed) | | -| notification.create | Action | 任意 / passthrough | 通用通知 | -| exhentai.enqueueDownload | Action | gallery | | - -**结论**:当前节点面覆盖的是"订阅 → 过滤 → AI 加工 → 下载/通知"一条业务线的最小集;引擎骨架(类型校验、注册表、runs)质量好但被消费得很少。 - -## 三、引擎扩展清单(均为跨场景通用;标注第一个用户) - -| # | 扩展 | 内容 | 第一个用户 | 通用价值 | -|---|---|---|---|---| -| E1 | **手动触发 + 参数面板** | 触发器新形态:用户带 payload 手动运行(当前只有事件触发) | `fs.manualScan`([清洗](file-cleaning-workflow.md)) | 一切"对一批输入执行流程"的场景;也是调试任何工作流的入口 | -| E2 | **链中展开 `ExpandTo`** | `WorkflowItemOutcome` 增加 1→N 展开;步骤统计需理解基数变化 | `expand.fs.children` | 画廊→多图片、feed→多条目、压缩包→内容物… | -| E3 | **facet 契约** | 描述符增加 `AcceptedItemFacet: Type`(接口谓词,经 ClrType 校验);facet 节点强制 Passthrough | `transform.text.*` 对 `ITextWorkpiece` | 一切"按能力而非按类型"的节点族(未来:IHasCover、IHasSourceUrl…) | -| E4 | **变量机制** | item 级 `Variables` + `{var:name(:formatter)}` 插值 + 系统变量注入 + 供需软契约 lint | capture/template + fs 系统变量 | 当前节点间**没有任何数据通道**(只有 item 本身);变量是通用的节点联动机制 | -| E5 | **两阶段运行**(Preview → 确认 → Apply) | run 产出"计划",确认后按计划执行(不重跑链) | `action.fs.saveName` | 一切有副作用的批量 action(批量入队、批量资源修改)都该有干跑 | -| E6 | 定时 / 监听触发(后续) | cron 触发器;目录监听(FileMover 进料口形态) | 自动清洗 | 全部定时自动化 | - -## 四、节点扩展清单(按域) - -| 域 | 节点 | 状态 | -|---|---|---| -| fs(新) | 触发器 `fs.manualScan`(constants + 枚举);`filter.fs.byRegex / byExtension / byType`;`expand.fs.children`;`transform.fs.fileNameOp`;`action.fs.saveName` | [清洗设计](file-cleaning-workflow.md)已定稿 | -| text(新,facet 通用) | `transform.text.removeWrapped / removeTexts / trim / capture / template` | 同上;依赖 [SpecialText 扩展](file-cleaning-workflow.md#specialtext-扩展用户自定义文本类型文本集) | -| 通用(补充候选) | `filter.common.byVariable`(按变量过滤,E4 的自然配套);`action.common.setVariable`(常量/映射写变量) | 实现 E4 时顺手评估 | -| 既有域延伸(候选) | `action.downloader.enqueue` 泛化(当前仅 exhentai);`action.resource.registerPath`(把 fs 条目登记入库/触发同步,打通清洗→入库) | 按需求排期 | -| organizer(挂起) | 触发器"整理完成"、活动"运行某 Pipeline/规则集" | 随 [Organizer](local-file-organization/README.md) 复活 | - -## 五、串联视角:这些扩展如何互相成立 - -- E1+E2+E4 组合出"手动对文件系统跑多层级联动流程"(清洗的骨架); -- E3 使 text 节点族对**任何**未来 item 类型开放(画廊标题清洗零成本); -- E5 是所有落盘类 action 的安全模型,与 E1 组合出"手动预览-确认"的标准交互; -- E6 + 既有 `downloader.completed` 触发器 + fs/text 节点 = "下载完成 → 自动清洗 → 通知"全自动链,一行引擎代码都不用再加。 - -## 实施顺序建议 - -E1 → E3+E4(text 节点按[清洗设计](file-cleaning-workflow.md)的规范**生而 facet 化**,与变量机制同批落地;前置 SpecialText 扩展)→ E2 → E5。E6 独立,随自动化需求排期。 From 8265b7bdcc95964d014543293aa9d6c1744dbe6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 11:52:51 +0000 Subject: [PATCH 17/46] docs(text): add SpecialText extension architecture design Full design for the text-system rework, grounded in a code survey (~15 consumer files, four call patterns): custom text types become first-class (Custom=100 enum member, CustomTextType table with stable ids, CustomTypeId column, discriminated TextSetRef), and the mixed ISpecialTextService retires in favor of a vocabulary/operations split - ITextVocabularyService (storage, custom types, set resolution, seeds) and ITextOps (stateless clean/removeWrapped/trim/date-parse/interpolate, unit-testable without a DB) - housed in a new Bakabase.Modules.Text. Includes a member-by-member migration map, per-consumer migration table, idempotent seed ledger semantics, API/frontend changes, and a three-PR implementation split. Cross-linked from the cleaning design and the capability map's implementation order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow.html | 1 + docs/specialtext-extension.html | 163 ++++++++++++++++++++++++++++++ docs/workflow-capability-map.html | 2 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 docs/specialtext-extension.html diff --git a/docs/file-cleaning-workflow.html b/docs/file-cleaning-workflow.html index 4c7477bd9..7b6a4f9ce 100644 --- a/docs/file-cleaning-workflow.html +++ b/docs/file-cleaning-workflow.html @@ -128,6 +128,7 @@

    §4预览 → 确认 → 应用 → 撤销// 保留期默认 30 天

    §5SpecialText 扩展:用户自定义文本类型(文本集)

    +

    完整架构设计(现状盘点 / 服务拆分 ITextVocabularyService + ITextOps / 消费点迁移 / PR 切分)见 specialtext-extension.html,本节仅存要点概览。

    现状:SpecialTextType 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案:

    1. 枚举新增 SpecialTextType.Custom = 100
    2. diff --git a/docs/specialtext-extension.html b/docs/specialtext-extension.html new file mode 100644 index 000000000..9754adca0 --- /dev/null +++ b/docs/specialtext-extension.html @@ -0,0 +1,163 @@ + + +SpecialText 扩展 · 文本系统架构设计 +
      +

      SpecialText 扩展 · 文本系统架构设计

      +

      自定义文本类型成为一等公民;词汇(vocabulary)与操作(operations)分离;文本系统迁入独立模块

      +

      状态:设计定稿,实施第一步能力全景实施顺序的前置项)。前提:全部仓库仅 Bakabase 自身消费,接口与方法可自由退役/改名——本设计据此做干净拆分而非兼容打补丁。消费方:文件清洗的 text 节点与文本集引用。

      + + +

      §1现状盘点(已在代码核实)

      + + + + +
      现状
      数据模型SpecialText { Id, Value1(≤64, required), Value2(≤64), Type };SpecialTextType 固定枚举七种:Useless=1 / Wrapper=3 / Standardization=4 / Volume=6 / Trim=7 / DateTime=8 / Language=9
      服务ISpecialTextService(abstractions)+ SpecialTextService(legacy Business)。活跃方法:Pretreatment(清洗)、GetAll(表达式过滤)GetAllWrappersTryToParseDateTime×2、CRUD(Add/Patch/DeleteByKey/AddRange/Count)、AddPrefabs(种子)。接口中另有十余个已注释的废弃成员(历史包袱)
      种子SpecialTextPrefabs.Texts 静态预制 + AddPrefabs() 补缺式投放
      消费面约 15 个文件:关键词增强器族(Pretreatment)、BakabaseEnhancer(GetAll 原始行 ×6:wrapper/语言/卷词表)、ResourceService 显示名(Wrapper 对)、Comparison 归一化、下载器命名(Pixiv/Bilibili/Abstract)、FileController、MediaLibraryTemplateService、SpecialTextController(CRUD API)、text 前端页、SpecialTextServiceTests
      +
      三个问题:① 类型封闭——用户无法定义自己的文本集(清洗节点的硬需求);② 单接口混合三种职责(词汇存储 CRUD / 文本操作 / 种子),操作方法难以独立单测与复用;③ 接口里大量注释废弃成员,是"只加不减"历史的直接证据——这次一并清理。
      + +

      §2目标与原则

      +
        +
      1. 自定义类型一等公民:用户可建任意命名文本集(字幕组名单、广告词…),与内置类型并列管理,被节点按稳定 id 引用(改名不破坏引用)。
      2. +
      3. 词汇与操作分离:存什么词(vocabulary)与拿词做什么(operations)是两个服务。操作服务即清洗设计中的 TextOps——无状态、纯函数化、全量可单测,workflow 文本节点 / 增强器 / 下载器 / 对比归一共用。
      4. +
      5. 独立模块安家:新建 Bakabase.Modules.Text(models / services / extensions 标准五段式),承载词汇服务、TextOps、以及后续的 workflow 文本节点实现。legacy 的 SpecialTextService 退役。
      6. +
      7. 内置语义零变化:七种内置类型的行为与消费语义完全不变,只换调用入口;Custom 行对 legacy 语义不可见(按具体枚举值查询天然隔离)。
      8. +
      + +

      §3数据模型与迁移

      +
      // 枚举:新增一个成员,既有值不动
      +public enum SpecialTextType { Useless = 1, Wrapper = 3, Standardization = 4,
      +    Volume = 6, Trim = 7, DateTime = 8, Language = 9, Custom = 100 }
      +
      +// 新表:自定义类型(需要稳定 id——节点按 id 引用,改名不破坏引用;元数据与将来分享的落点)
      +CustomTextType { Id, Name(≤64, 唯一), Description?(≤256), CreatedAt }
      +
      +// 既有表:加一列(仅 Type=Custom 时有值)
      +SpecialText { Id, Value1(≤64), Value2(≤64), Type, CustomTypeId?(FK → CustomTextType) }
      +// 自定义行:Value1 = 文本,Value2 暂不用
      +
      +// 文本集引用:判别联合,节点配置中序列化为 "builtin:3" / "custom:12"
      +public readonly record struct TextSetRef(TextSetKind Kind, int Key);   // Kind: Builtin | Custom
      +

      EF 迁移按仓库规则 CLI 生成、纯 schema(一张新表 + 一列 + 枚举成员)。枚举变更后 yarn gen-sdk 重生成前端常量(api-conventions)。

      + +

      §4服务重构:一拆二,旧接口退役

      +
      // Bakabase.Modules.Text —— 文本系统的家
      +
      +public interface ITextVocabularyService        // 词汇:存储、集合、种子(带内存缓存,CRUD 时失效)
      +{
      +    // 行 CRUD(承接原 Add/Patch/DeleteByKey/AddRange/Count;表达式过滤退役,改显式参数——
      +    //  现存 6 个 GetAll 调用点全部是按 Type 查询,显式参数足够且更利于缓存)
      +    Task<List<SpecialText>> GetAll(SpecialTextType? type = null, int? customTypeId = null);
      +    Task<SpecialText> Add(SpecialTextAddInputModel model);
      +    Task Patch(int id, SpecialTextPatchInputModel model);
      +    Task Delete(int id);
      +    Task AddRange(List<SpecialText> texts);
      +
      +    // 自定义类型
      +    Task<List<CustomTextType>> GetCustomTypes();
      +    Task<CustomTextType> AddCustomType(string name, string? description);
      +    Task RenameCustomType(int id, string name);          // 按 id 引用 → 改名安全
      +    Task DeleteCustomType(int id, bool deleteTexts);
      +
      +    // 文本集解析——消费方唯一的"读词"入口
      +    Task<TextSet> ResolveSet(TextSetRef @ref);           // Wrapper → 成对;其余 → 值列表
      +
      +    Task EnsureSeeds();                                  // 承接 AddPrefabs(§6)
      +}
      +
      +public interface ITextOps                      // 操作:无状态核心 + 词汇注入,全量可单测
      +{
      +    Task<string> Clean(string text);                     // 承接 Pretreatment(保留 profile 扩展位:
      +                                                          //  判等归一化 vs 检索归一化,Organizer 复活时启用)
      +    Task<string> RemoveWrapped(string text, TextSetRef wrappers, TextSetRef set, TextMatchMode mode);
      +    Task<string> RemoveTexts(string text, TextSetRef set, TextMatchMode mode);
      +    string Trim(string text, TrimOptions options);
      +    Task<DateTime?> TryParseDateTime(string? text);      // 承接 TryToParseDateTime(词表驱动)
      +    string Interpolate(string template, IReadOnlyDictionary<string, string> variables);
      +                                                         // {var:x(:pad(n))} —— 供 template 节点与 E4 插值共用
      +}
      + + + + + + + + +
      现成员(ISpecialTextService)去向
      Pretreatment(name)ITextOps.Clean
      GetAllWrappers()ITextVocabularyService.ResolveSet(builtin:Wrapper)
      TryToParseDateTime ×2ITextOps.TryParseDateTime
      GetAll(表达式过滤)GetAll(type, customTypeId)(显式参数)
      Add / Patch / DeleteByKey / AddRange / CountITextVocabularyService 对应方法(Count 并入前端分页需要时再加)
      AddPrefabs()EnsureSeeds()
      全部已注释的废弃成员删除(不迁移)
      ISpecialTextService / SpecialTextService(legacy)退役删除;实现迁入 Bakabase.Modules.Text,逻辑平移 + 拆分
      + +

      §5消费点迁移(同一 PR 内全量切换)

      + + + + + + + + + +
      消费者现调用迁移后
      关键词增强器族(AbstractKeywordEnhancer + DLsite/Bangumi/Tmdb/Av/ExHentai)PretreatmentITextOps.Clean
      BakabaseEnhancer(wrapper/语言/卷 词表原始行)GetAll ×6GetAll(type) / ResolveSet
      ResourceService 显示名包装符GetAll(Type==Wrapper)ResolveSet(builtin:Wrapper)
      Comparison 归一化对比PretreatmentITextOps.Clean
      下载器命名(Pixiv/Bilibili/Abstract)PretreatmentITextOps.Clean
      FileController / MediaLibraryTemplateServiceGetAll / Pretreatment对应新方法
      SpecialTextControllerCRUD + AddPrefabsvocabulary 方法 + 新增自定义类型端点(§7)
      SpecialTextServiceTests旧服务拆为 VocabularyTests + TextOpsTests(后者纯函数,不需要 DB fixture)
      workflow 文本节点(新)ITextOps + ResolveSet(清洗设计 3.4)
      +
      迁移审计项:全仓库检索无类型过滤GetAll 调用——若存在,必须确认其语义是否应包含 Custom 行(默认不应包含;新签名 GetAll() 无参时返回全部行仅供管理页使用)。
      + +

      §6种子机制

      +
        +
      • SpecialTextPrefabsTextSeedData:内置类型预制词(现有内容原样平移,仍为补缺式投放)+ 自定义种子集(首个:「质量与发布标签」——1080p/WebRip/HEVC/Fin/简繁内封…,供 removeWrapped 节点开箱即用)。
      • +
      • 幂等语义:内置词补缺式(现行为不变);自定义种子集只投放一次——在 app options 记录已投放种子版本(种子账本),用户删除集或删词后不复活。种子集与用户自建集同表同权,无特殊标记。
      • +
      + +

      §7API 与前端

      +
        +
      • Controller:SpecialTextController 保留既有路由(行 CRUD / 预制投放改调新服务),新增自定义类型端点(list/add/rename/delete)与 ResolveSet 预览端点(供节点配置表单回显集合内容)。SDK 按 api-conventions 重生成并随 PR 提交。
      • +
      • text 页:改为两个分区——内置类型(现有 UI 平移)+ 自定义类型(建集、改名、批量录入〔逐行粘贴〕、导入/导出 JSON)。
      • +
      • TextSetRef 选择器组件:下拉分组展示"内置 / 自定义",供 workflow 节点配置表单复用(removeWrapped 的 wrappers 与 textSet 参数、removeTexts 的 set 参数)。
      • +
      + +

      §8实施切分(三个可独立合并的 PR)

      + + + +
      PR内容验收
      PR1 schema枚举 Custom=100 + CustomTextType 表 + SpecialText.CustomTypeId 列(EF CLI 迁移)+ gen-sdk零行为变化;迁移可回滚
      PR2 模块与拆分新建 Bakabase.Modules.Text;ITextVocabularyService + ITextOps 落地(逻辑自 legacy 平移+拆分);退役 ISpecialTextService 并全量切换 ~15 个消费文件;Controller 改调新服务(路由不变);测试拆分dotnet build 全绿;SpecialText 相关测试全过;text 页与既有功能行为无回归
      PR3 自定义类型 UI 与种子自定义类型端点 + text 页分区 + TextSetRef 选择器 + TextSeedData/种子账本 + 「质量与发布标签」种子集用户可建集/录入/引用;种子幂等(删除不复活)
      +

      PR2 是唯一的大改,但风险受控:调用点仅 ~15 个文件、语义一一对应(§4 映射表)、且操作逻辑平移后立即获得纯函数单测覆盖。PR3 之后,清洗功能的节点实现即可开始(E1/E3/E4 并行推进)。

      + +

      §9风险与开放问题

      +
        +
      • Value1 长度(≤64):字幕组名/质量词足够;若自定义场景出现长文本(整句广告),PR1 时一并评估扩到 128。
      • +
      • Clean 的双 profile:判等归一化(保守,保留序数/卷号)vs 检索归一化(激进)——接口预留参数位,Organizer 复活时启用,当前单一行为与 Pretreatment 一致。
      • +
      • 分享:自定义集走 Sharable 体系导入导出——PR3 仅做 JSON 导入导出,Sharable 集成随使用量决策。
      • +
      • Volume/Language 等富语义类型的自定义化:本设计只开放"值集合"型自定义;带解析语义的自定义类型(如自定义日期格式)不在本期范围。
      • +
      + +
      diff --git a/docs/workflow-capability-map.html b/docs/workflow-capability-map.html index 11bf13384..afdb5b9be 100644 --- a/docs/workflow-capability-map.html +++ b/docs/workflow-capability-map.html @@ -82,6 +82,6 @@

      §5串联视角:扩展如何互相成立

      §6实施顺序建议

      -
      E1 → E3+E4(text 节点按清洗设计规范生而通用,与变量机制同批落地;前置 SpecialText 扩展)→ E2 → E5。E6 独立,随自动化需求排期。
      +
      前置:SpecialText 扩展(PR1 schema → PR2 服务拆分 → PR3 自定义类型 UI 与种子)→ E1 → E3+E4(text 节点按清洗设计规范生而通用,与变量机制同批落地)→ E2 → E5。E6 独立,随自动化需求排期。
      From 7f756a496736b2a8bee1c78866416152db633181 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 12:05:00 +0000 Subject: [PATCH 18/46] docs(text): unify builtin and custom text types into a single TextType abstraction Replaces the enum-plus-custom-table split (and its builtin:/custom: discriminated TextSetRef) with one entity: TextType rows carry an optional WellKnown handle marking built-ins (undeletable, unrenamable, semantics bound at code consumption sites) and a Values/Pairs shape, with entries in TextEntry. References collapse to a single int typeId; GetTypes() serves pickers and the management page from one aggregated list, and custom Pairs types become usable as wrapper pairs directly. Existing SpecialText rows migrate losslessly via an app-level migrator (V220/V230 precedent) keeping EF migrations pure-schema; the SpecialText naming, enum, service and table retire entirely. Cleaning design and example synced to typeId references. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 6 +- docs/file-cleaning-workflow.html | 16 +-- docs/specialtext-extension.html | 157 +++++++++++++---------- 3 files changed, 97 insertions(+), 82 deletions(-) diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index 7e96f905f..3caf0e2fb 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -88,8 +88,8 @@

      §2流程定义(13 个节点,含每节点配置
      2transform.text.removeWrappedTransform · 通用文本
      IN 能力:ITextWorkpiece(本链此处 = item.fs.entry,作用于其工作文本 WorkingName) OUT 同 | 基数 1→1
      - - + +
      wrappers[] 【】 (textSetRef = builtin:Wrapper)
      textSet自定义文本集 「字幕组名单」(textSetRef = custom:1;含 LoliHouse、桜都字幕组、桜都字幕組…)
      wrappers[] 【】 (textTypeId = #3「包装符」,内置·Pairs)
      textSet自定义文本集 「字幕组名单」(textTypeId = #11「字幕组名单」,自定义;含 LoliHouse、桜都字幕组、桜都字幕組…)
      matchEqualsAny(括号内容全等于集合任一项)
      [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
      @@ -98,7 +98,7 @@

      §2流程定义(13 个节点,含每节点配置
      IN 能力:ITextWorkpiece OUT 同 | 基数 1→1
      - +
      wrappers[]
      textSet自定义文本集 「质量与发布标签」(textSetRef = custom:2;1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
      textSet自定义文本集 「质量与发布标签」(textTypeId = #12「质量与发布标签」,自定义种子集;1080p、WebRip、HEVC、AAC、Fin、简繁内封…)
      matchContainsAny(括号内容包含集合任一项即整段删除)
      药屋少女的呢喃 [01-24 Fin][1080p][简繁内封]药屋少女的呢喃
      diff --git a/docs/file-cleaning-workflow.html b/docs/file-cleaning-workflow.html index 7b6a4f9ce..2ff7779a6 100644 --- a/docs/file-cleaning-workflow.html +++ b/docs/file-cleaning-workflow.html @@ -105,7 +105,7 @@

      3.3 Filter 活动

      3.4 Transform 活动(文本变更节点,任意个、任意序)

      - + @@ -129,16 +129,14 @@

      §4预览 → 确认 → 应用 → 撤销§5SpecialText 扩展:用户自定义文本类型(文本集)

      完整架构设计(现状盘点 / 服务拆分 ITextVocabularyService + ITextOps / 消费点迁移 / PR 切分)见 specialtext-extension.html,本节仅存要点概览。

      -

      现状:SpecialTextType 固定枚举七种(Useless/Wrapper/Standardization/Volume/Trim/DateTime/Language),行为 (Type, Value1, Value2)。扩展方案:

      +

      要点(详细模型/服务/迁移以扩展设计文档为准):

        -
      1. 枚举新增 SpecialTextType.Custom = 100
      2. -
      3. 新增小表 CustomTextType(Id, Name, Description?)——类型需要稳定 id:节点按 id 引用,改名不破坏引用;元数据与将来的分享有落点;
      4. -
      5. SpecialText 表加可空列 CustomTypeId(FK);自定义行 Value1 = 文本;
      6. -
      7. 节点的"文本集引用"统一为判别引用 TextSetRefbuiltin:{枚举值}custom:{id}
      8. -
      9. text 管理页并列展示内置与自定义类型,支持建集/批量录入/导入导出;示例的「质量与发布标签」以可编辑种子数据下发;
      10. -
      11. 对既有消费点零影响:legacy 均按具体枚举值查询,Custom 行不可见。
      12. +
      13. 单一抽象:内置与自定义合并为一个实体 TextType(+ TextEntry 条目表),单一 id 空间——节点的文本集引用就是一个 typeId,不存在 builtin:/custom: 判别前缀。内置性 = 行上的 WellKnown 句柄(不可删/不可改名,语义由代码消费点绑定)。
      14. +
      15. Shape:类型分 Values / Pairs 两种形态;自定义 Pairs 直接可用作 removeWrapped 的包装符对。
      16. +
      17. 选择器GetTypes() 聚合列表是节点配置表单与 text 管理页的唯一数据源(内置徽标区分,按 Shape 过滤)。
      18. +
      19. 数据不丢:旧 SpecialText 行经 app 级迁移器(V220/V230 先例)七值查表转入新表;EF 迁移保持纯 schema。「质量与发布标签」以可编辑种子集下发(只投一次,删除不复活)。
      -
      实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做——独立可交付(text 页增强本身有价值),节点实现全部依赖它。EF 迁移按仓库规则 CLI 生成、纯 schema。
      +
      实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做(PR1 建表 → PR2 服务拆分与全量切换 → PR3 UI/种子/清理),节点实现全部依赖它。

      §6执行与 UI

      kind契约行为配置
      transform.text.removeWrapped能力: ITextWorkpiece删除包装符内命中文本集的片段包装符对(TextSetRef,如 builtin:Wrapper)+ 文本集引用 + 命中方式(等于/包含/正则)
      transform.text.removeWrapped能力: ITextWorkpiece删除包装符内命中文本集的片段包装符类型引用 + 文本集类型引用(统一 TextType id,§5)+ 命中方式(等于/包含/正则)
      transform.text.removeTexts能力: ITextWorkpiece删除命中文本集的裸文本片段文本集引用 + 命中方式
      transform.text.trim能力: ITextWorkpiece清理残留:连续空格/头尾空白/空括号对开关组
      transform.text.capture能力: ITextWorkpiece不改文本,只捕获:正则具名捕获组 → 写入随行袋(组名即变量名)pattern + onMiss(SkipSilently/标记)
      -
      现状
      数据模型SpecialText { Id, Value1(≤64, required), Value2(≤64), Type };SpecialTextType 固定枚举七种:Useless=1 / Wrapper=3 / Standardization=4 / Volume=6 / Trim=7 / DateTime=8 / Language=9
      服务ISpecialTextService(abstractions)+ SpecialTextService(legacy Business)。活跃方法:Pretreatment(清洗)、GetAll(表达式过滤)GetAllWrappersTryToParseDateTime×2、CRUD(Add/Patch/DeleteByKey/AddRange/Count)、AddPrefabs(种子)。接口中另有十余个已注释的废弃成员(历史包袱)
      种子SpecialTextPrefabs.Texts 静态预制 + AddPrefabs() 补缺式投放
      消费面约 15 个文件:关键词增强器族(Pretreatment)、BakabaseEnhancer(GetAll 原始行 ×6:wrapper/语言/卷词表)、ResourceService 显示名(Wrapper 对)、Comparison 归一化、下载器命名(Pixiv/Bilibili/Abstract)、FileController、MediaLibraryTemplateService、SpecialTextController(CRUD API)、text 前端页、SpecialTextServiceTests
      -
      三个问题:① 类型封闭——用户无法定义自己的文本集(清洗节点的硬需求);② 单接口混合三种职责(词汇存储 CRUD / 文本操作 / 种子),操作方法难以独立单测与复用;③ 接口里大量注释废弃成员,是"只加不减"历史的直接证据——这次一并清理。
      +消费面约 15 个文件:关键词增强器族(Pretreatment)、BakabaseEnhancer(GetAll 原始行 ×6:wrapper/语言/卷词表)、ResourceService 显示名(Wrapper 对)、Comparison 归一化、下载器命名(Pixiv/Bilibili/Abstract)、FileController、MediaLibraryTemplateService、SpecialTextController(CRUD API)、text 前端页、SpecialTextServiceTests +数据迁移先例Bakabase.Migrations 已有 app 级迁移器体系(V220Migrator / V230Migrator / PathsRelocationMigrator)——数据转换在应用启动期执行,EF 迁移保持纯 schema(仓库规则) +
      三个问题:① 类型封闭——用户无法定义自己的文本集(清洗节点的硬需求);② 单接口混合三种职责(词汇存储 CRUD / 文本操作 / 种子);③ 若内置走枚举、自定义走新表,则"选类型"的每个 UI 与每个引用都要处理两套形态——判别引用(builtin:x / custom:y)是缝合的味道,不是设计。

      §2目标与原则

        -
      1. 自定义类型一等公民:用户可建任意命名文本集(字幕组名单、广告词…),与内置类型并列管理,被节点按稳定 id 引用(改名不破坏引用)。
      2. -
      3. 词汇与操作分离:存什么词(vocabulary)与拿词做什么(operations)是两个服务。操作服务即清洗设计中的 TextOps——无状态、纯函数化、全量可单测,workflow 文本节点 / 增强器 / 下载器 / 对比归一共用。
      4. -
      5. 独立模块安家:新建 Bakabase.Modules.Text(models / services / extensions 标准五段式),承载词汇服务、TextOps、以及后续的 workflow 文本节点实现。legacy 的 SpecialTextService 退役。
      6. -
      7. 内置语义零变化:七种内置类型的行为与消费语义完全不变,只换调用入口;Custom 行对 legacy 语义不可见(按具体枚举值查询天然隔离)。
      8. +
      9. 单一抽象:内置与自定义类型合并为一个实体 TextType单一 id 空间——节点/选择器/API 眼中只有"文本类型列表",引用就是一个 typeId,判别前缀(builtin:/custom:)不存在。内置性通过 WellKnown 句柄表达,是行的属性而非另一种实体。
      10. +
      11. 词汇与操作分离:存什么词(vocabulary)与拿词做什么(operations)是两个服务。操作服务即清洗设计中的 TextOps——无状态、纯函数化、全量可单测。
      12. +
      13. 独立模块安家:新建 Bakabase.Modules.Text,承载词汇服务、TextOps、以及后续的 workflow 文本节点。legacy 的 SpecialTextService、SpecialText 命名整体退役。
      14. +
      15. 内置语义零变化、用户数据不丢:七种内置类型的行为与消费语义不变(换句柄不换行为);既有 SpecialText 行经 app 级迁移器转入新表(七值查表映射,成本极低),不接受数据丢失。
      -

      §3数据模型与迁移

      -
      // 枚举:新增一个成员,既有值不动
      -public enum SpecialTextType { Useless = 1, Wrapper = 3, Standardization = 4,
      -    Volume = 6, Trim = 7, DateTime = 8, Language = 9, Custom = 100 }
      -
      -// 新表:自定义类型(需要稳定 id——节点按 id 引用,改名不破坏引用;元数据与将来分享的落点)
      -CustomTextType { Id, Name(≤64, 唯一), Description?(≤256), CreatedAt }
      -
      -// 既有表:加一列(仅 Type=Custom 时有值)
      -SpecialText { Id, Value1(≤64), Value2(≤64), Type, CustomTypeId?(FK → CustomTextType) }
      -// 自定义行:Value1 = 文本,Value2 暂不用
      -
      -// 文本集引用:判别联合,节点配置中序列化为 "builtin:3" / "custom:12"
      -public readonly record struct TextSetRef(TextSetKind Kind, int Key);   // Kind: Builtin | Custom
      -

      EF 迁移按仓库规则 CLI 生成、纯 schema(一张新表 + 一列 + 枚举成员)。枚举变更后 yarn gen-sdk 重生成前端常量(api-conventions)。

      +

      §3统一数据模型

      +
      // 类型:内置与自定义同表同权,单一 id 空间
      +TextType  { Id, Name(≤64, 唯一), WellKnown?: WellKnownTextType,   // 非空 = 内置(代码句柄)
      +            Shape: Values | Pairs,                                 // 值集合 / 成对集合
      +            Description?(≤256), CreatedAt }
      +
      +// 条目:原 SpecialText 行的新家
      +TextEntry { Id, TypeId(FK → TextType), Value1(≤64), Value2(≤64) }  // Pairs 型用 Value1+Value2
      +
      +// 代码句柄:值沿用原枚举,语义由消费点绑定;SpecialTextType / SpecialText 命名退役
      +public enum WellKnownTextType { Useless = 1, Wrapper = 3, Standardization = 4,
      +    Volume = 6, Trim = 7, DateTime = 8, Language = 9 }
      + + + + +
      规则内置(WellKnown ≠ null)自定义
      删除类型 / 改名❌ 拒绝(Name 为规范名,前端按 WellKnown 键做本地化显示)
      增删条目✅(今天即允许,行为不变)
      Shape种子固定:Wrapper / Standardization = Pairs,其余 = Values建类型时二选一——自定义 Pairs 直接可用作 removeWrapped 的包装符对(统一抽象的直接红利)
      语义由代码消费点经 WellKnown 句柄绑定(清洗/日期解析/显示名包装符…)无内置语义,仅被节点/功能按 typeId 引用
      +

      EF 迁移纯 schema:两张新表 + 枚举(gen-sdk 重生成)。旧 SpecialText 表保留至数据迁移完成,PR3 的清理迁移中 drop(§6/§8)。

      §4服务重构:一拆二,旧接口退役

      // Bakabase.Modules.Text —— 文本系统的家
       
      -public interface ITextVocabularyService        // 词汇:存储、集合、种子(带内存缓存,CRUD 时失效)
      +public interface ITextVocabularyService        // 词汇:类型、条目、集合解析、种子(内存缓存,CRUD 失效)
       {
      -    // 行 CRUD(承接原 Add/Patch/DeleteByKey/AddRange/Count;表达式过滤退役,改显式参数——
      -    //  现存 6 个 GetAll 调用点全部是按 Type 查询,显式参数足够且更利于缓存)
      -    Task<List<SpecialText>> GetAll(SpecialTextType? type = null, int? customTypeId = null);
      -    Task<SpecialText> Add(SpecialTextAddInputModel model);
      -    Task Patch(int id, SpecialTextPatchInputModel model);
      -    Task Delete(int id);
      -    Task AddRange(List<SpecialText> texts);
      -
      -    // 自定义类型
      -    Task<List<CustomTextType>> GetCustomTypes();
      -    Task<CustomTextType> AddCustomType(string name, string? description);
      -    Task RenameCustomType(int id, string name);          // 按 id 引用 → 改名安全
      -    Task DeleteCustomType(int id, bool deleteTexts);
      -
      -    // 文本集解析——消费方唯一的"读词"入口
      -    Task<TextSet> ResolveSet(TextSetRef @ref);           // Wrapper → 成对;其余 → 值列表
      +    // 类型(内置+自定义一个列表——节点选择器/管理页的唯一数据源)
      +    Task<List<TextTypeDescriptor>> GetTypes();
      +    Task<TextType> AddType(string name, TextTypeShape shape, string? description);
      +    Task RenameType(int id, string name);                // WellKnown 行拒绝
      +    Task DeleteType(int id, bool deleteEntries);         // WellKnown 行拒绝
      +
      +    // 条目 CRUD(承接原 Add/Patch/DeleteByKey/AddRange)
      +    Task<List<TextEntry>> GetEntries(int typeId);
      +    Task<TextEntry> AddEntry(int typeId, string value1, string? value2 = null);
      +    Task PatchEntry(int id, string value1, string? value2);
      +    Task DeleteEntry(int id);
      +    Task AddEntries(int typeId, List<(string Value1, string? Value2)> entries);
      +
      +    // 集合解析——消费方唯一的"读词"入口;WellKnown 重载供代码消费点使用
      +    Task<TextSet> ResolveSet(int typeId);                // Pairs → 成对;Values → 值列表
      +    Task<TextSet> ResolveSet(WellKnownTextType wellKnown);
      +    Task<int> GetTypeId(WellKnownTextType wellKnown);
       
           Task EnsureSeeds();                                  // 承接 AddPrefabs(§6)
       }
       
      +public sealed record TextTypeDescriptor(                 // 聚合视图:选择器与管理页直接消费
      +    int Id, string Name, WellKnownTextType? WellKnown, TextTypeShape Shape,
      +    string? Description, int EntryCount)
      +{ public bool IsBuiltin => WellKnown != null; }
      +
       public interface ITextOps                      // 操作:无状态核心 + 词汇注入,全量可单测
       {
      -    Task<string> Clean(string text);                     // 承接 Pretreatment(保留 profile 扩展位:
      -                                                          //  判等归一化 vs 检索归一化,Organizer 复活时启用)
      -    Task<string> RemoveWrapped(string text, TextSetRef wrappers, TextSetRef set, TextMatchMode mode);
      -    Task<string> RemoveTexts(string text, TextSetRef set, TextMatchMode mode);
      +    Task<string> Clean(string text);                     // 承接 Pretreatment(保留 profile 扩展位)
      +    Task<string> RemoveWrapped(string text, int wrappersTypeId, int setTypeId, TextMatchMode mode);
      +    Task<string> RemoveTexts(string text, int setTypeId, TextMatchMode mode);
           string Trim(string text, TrimOptions options);
           Task<DateTime?> TryParseDateTime(string? text);      // 承接 TryToParseDateTime(词表驱动)
           string Interpolate(string template, IReadOnlyDictionary<string, string> variables);
      -                                                         // {var:x(:pad(n))} —— 供 template 节点与 E4 插值共用
      +                                                         // {var:x(:pad(n))} —— template 节点与 E4 插值共用
       }
      - + - - + + -
      现成员(ISpecialTextService)去向
      Pretreatment(name)ITextOps.Clean
      GetAllWrappers()ITextVocabularyService.ResolveSet(builtin:Wrapper)
      GetAllWrappers()ResolveSet(WellKnownTextType.Wrapper)
      TryToParseDateTime ×2ITextOps.TryParseDateTime
      GetAll(表达式过滤)GetAll(type, customTypeId)(显式参数)
      Add / Patch / DeleteByKey / AddRange / CountITextVocabularyService 对应方法(Count 并入前端分页需要时再加)
      GetAll(表达式过滤)GetEntries(typeId) / ResolveSet(wellKnown)(现存 6 个调用点全部是按 Type 查询,句柄化后更利于缓存)
      Add / Patch / DeleteByKey / AddRange / Count条目 CRUD 对应方法(Count 随管理页分页需要再加)
      AddPrefabs()EnsureSeeds()
      全部已注释的废弃成员删除(不迁移)
      ISpecialTextService / SpecialTextService(legacy)退役删除;实现迁入 Bakabase.Modules.Text,逻辑平移 + 拆分
      +ISpecialTextService / SpecialTextService / SpecialTextType / SpecialText 模型与表整体退役:实现迁入 Bakabase.Modules.Text(TextType/TextEntry/WellKnownTextType),旧表经 §6 数据迁移后 drop

      §5消费点迁移(同一 PR 内全量切换)

      - - + + - - -
      消费者现调用迁移后
      关键词增强器族(AbstractKeywordEnhancer + DLsite/Bangumi/Tmdb/Av/ExHentai)PretreatmentITextOps.Clean
      BakabaseEnhancer(wrapper/语言/卷 词表原始行)GetAll ×6GetAll(type) / ResolveSet
      ResourceService 显示名包装符GetAll(Type==Wrapper)ResolveSet(builtin:Wrapper)
      BakabaseEnhancer(wrapper/语言/卷 词表原始行)GetAll ×6ResolveSet(wellKnown) / GetEntries
      ResourceService 显示名包装符GetAll(Type==Wrapper)ResolveSet(WellKnownTextType.Wrapper)
      Comparison 归一化对比PretreatmentITextOps.Clean
      下载器命名(Pixiv/Bilibili/Abstract)PretreatmentITextOps.Clean
      FileController / MediaLibraryTemplateServiceGetAll / Pretreatment对应新方法
      SpecialTextControllerCRUD + AddPrefabsvocabulary 方法 + 新增自定义类型端点(§7)
      SpecialTextServiceTests旧服务拆为 VocabularyTests + TextOpsTests(后者纯函数,不需要 DB fixture)
      workflow 文本节点(新)ITextOps + ResolveSet(清洗设计 3.4)
      -
      迁移审计项:全仓库检索无类型过滤GetAll 调用——若存在,必须确认其语义是否应包含 Custom 行(默认不应包含;新签名 GetAll() 无参时返回全部行仅供管理页使用)。
      +SpecialTextControllerCRUD + AddPrefabs类型/条目端点(§7);路由更名为 text 域 +SpecialTextServiceTests旧服务拆为 VocabularyTests + TextOpsTests(后者纯函数,无需 DB fixture) +workflow 文本节点(新)—ITextOps + ResolveSet(typeId)(清洗设计 3.4) +
      迁移审计项:全仓库检索无类型过滤的旧 GetAll 调用——确认其语义是否应包含自定义类型条目(默认不应;新接口按 typeId 取数,天然不会误伤)。
      -

      §6种子机制

      +

      §6数据迁移与种子

      +

      数据迁移(app 级迁移器,先例:PathsRelocationMigrator)

      +
        +
      1. PR2 启动期迁移器 TextSystemMigratorEnsureSeeds() 先投放七个内置 TextType 行 → 将旧 SpecialText 行按 Type 七值查表映射为对应类型的 TextEntry(含用户自行添加的词条,不丢数据)→ 完成后写入迁移完成标记(app options,与 V220/V230 同模式)。
      2. +
      3. EF 迁移始终纯 schema(仓库规则):建表在 PR1,drop 旧表在 PR3 的清理迁移(确认迁移器标记已写入后才发布)。
      4. +
      +

      种子

        -
      • SpecialTextPrefabsTextSeedData:内置类型预制词(现有内容原样平移,仍为补缺式投放)+ 自定义种子集(首个:「质量与发布标签」——1080p/WebRip/HEVC/Fin/简繁内封…,供 removeWrapped 节点开箱即用)。
      • -
      • 幂等语义:内置词补缺式(现行为不变);自定义种子集只投放一次——在 app options 记录已投放种子版本(种子账本),用户删除集或删词后不复活。种子集与用户自建集同表同权,无特殊标记。
      • +
      • SpecialTextPrefabsTextSeedData:七个内置类型行(WellKnown + Shape)+ 内置预制词(现有内容平移,仍为补缺式)+ 自定义种子集(首个:「质量与发布标签」——供 removeWrapped 节点开箱即用)。
      • +
      • 幂等语义:内置词补缺式(现行为不变);自定义种子集只投放一次(app options 种子账本),用户删除不复活;种子集与用户自建集同表同权。

      §7API 与前端

        -
      • Controller:SpecialTextController 保留既有路由(行 CRUD / 预制投放改调新服务),新增自定义类型端点(list/add/rename/delete)与 ResolveSet 预览端点(供节点配置表单回显集合内容)。SDK 按 api-conventions 重生成并随 PR 提交。
      • -
      • text 页:改为两个分区——内置类型(现有 UI 平移)+ 自定义类型(建集、改名、批量录入〔逐行粘贴〕、导入/导出 JSON)。
      • -
      • TextSetRef 选择器组件:下拉分组展示"内置 / 自定义",供 workflow 节点配置表单复用(removeWrapped 的 wrappers 与 textSet 参数、removeTexts 的 set 参数)。
      • +
      • Controller:更名为 text 域路由。端点:GetTypes(聚合列表——选择器与管理页共用这一个接口)、类型 CRUD(内置行服务端拒绝删/改名)、条目 CRUD、ResolveSet(typeId) 预览(节点配置表单回显集合内容)。SDK 按 api-conventions 重生成并随 PR 提交。
      • +
      • text 页一个统一列表(不再内置/自定义两个分区)——内置行带 内置 徽标、名称本地化、禁用删除/改名;其余交互一致:批量录入(逐行粘贴)、Pairs 型成对录入、导入/导出 JSON。
      • +
      • TextType 选择器组件:单个下拉、单一列表(内置徽标区分,按 Shape 过滤——wrappers 参数只列 Pairs 型),供 workflow 节点配置表单复用。没有"两种引用"的概念。

      §8实施切分(三个可独立合并的 PR)

      - - -
      PR内容验收
      PR1 schema枚举 Custom=100 + CustomTextType 表 + SpecialText.CustomTypeId 列(EF CLI 迁移)+ gen-sdk零行为变化;迁移可回滚
      PR2 模块与拆分新建 Bakabase.Modules.Text;ITextVocabularyService + ITextOps 落地(逻辑自 legacy 平移+拆分);退役 ISpecialTextService 并全量切换 ~15 个消费文件;Controller 改调新服务(路由不变);测试拆分dotnet build 全绿;SpecialText 相关测试全过;text 页与既有功能行为无回归
      PR3 自定义类型 UI 与种子自定义类型端点 + text 页分区 + TextSetRef 选择器 + TextSeedData/种子账本 + 「质量与发布标签」种子集用户可建集/录入/引用;种子幂等(删除不复活)
      -

      PR2 是唯一的大改,但风险受控:调用点仅 ~15 个文件、语义一一对应(§4 映射表)、且操作逻辑平移后立即获得纯函数单测覆盖。PR3 之后,清洗功能的节点实现即可开始(E1/E3/E4 并行推进)。

      +PR1 schemaTextType / TextEntry 两张新表 + WellKnownTextType 枚举(EF CLI 迁移,纯 schema)+ gen-sdk。旧表旧服务原样运行零行为变化;迁移可回滚 +PR2 模块与切换新建 Bakabase.Modules.Text;ITextVocabularyService + ITextOps 落地(逻辑自 legacy 平移+拆分);种子(内置类型行)+ TextSystemMigrator 数据迁移器退役 ISpecialTextService 并全量切换 ~15 个消费文件;Controller 更名改造;测试拆分dotnet build 全绿;文本相关测试全过;既有功能无回归;旧行全部迁入新表 +PR3 UI 与清理text 页统一列表 + TextType 选择器组件 + TextSeedData「质量与发布标签」种子集 + 导入导出 + 清理迁移 drop 旧 SpecialText 表用户可建集/录入/引用;种子幂等(删除不复活);旧表移除 +

      PR2 是唯一的大改,但风险受控:调用点 ~15 个文件、语义一一对应(§4 映射表)、操作逻辑平移后立即获得纯函数单测覆盖、数据迁移是七值查表。PR3 之后,清洗功能的节点实现即可开始(E1/E3/E4 并行推进)。

      §9风险与开放问题

        -
      • Value1 长度(≤64):字幕组名/质量词足够;若自定义场景出现长文本(整句广告),PR1 时一并评估扩到 128。
      • -
      • Clean 的双 profile:判等归一化(保守,保留序数/卷号)vs 检索归一化(激进)——接口预留参数位,Organizer 复活时启用,当前单一行为与 Pretreatment 一致。
      • -
      • 分享:自定义集走 Sharable 体系导入导出——PR3 仅做 JSON 导入导出,Sharable 集成随使用量决策。
      • -
      • Volume/Language 等富语义类型的自定义化:本设计只开放"值集合"型自定义;带解析语义的自定义类型(如自定义日期格式)不在本期范围。
      • +
      • 数据迁移器的幂等与失败恢复:迁移器需可重入(按旧行 id 断点续迁或整体事务);完成标记写入前旧表只读不删。
      • +
      • Value1 长度(≤64):字幕组名/质量词足够;若出现长文本需求,PR1 建表时一并评估 128。
      • +
      • 内置类型 Name 本地化:行内 Name 存规范英文名,前端按 WellKnown 键取本地化显示——避免把 UI 文案存进数据库。
      • +
      • Clean 的双 profile(判等 vs 检索归一化):接口预留参数位,Organizer 复活时启用。
      • +
      • 富语义自定义类型(自定义日期格式等带解析语义者)不在本期范围;本期自定义仅 Values / Pairs 两种 Shape。
      • +
      • 分享:自定义集导入/导出 JSON 先行,Sharable 集成随使用量决策。
      From 9fd0d1e57673f65f2667da5a3a40afba57ea792e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 03:50:12 +0000 Subject: [PATCH 19/46] docs(text): correct SpecialText design against a full code audit Audited every claim in the design against current main: - Shape must be three-state, not two. Language also stores a pair (regex -> language label), and delimiter pairs (Wrapper) are semantically distinct from replacement mappings (Standardization/Language) - removeWrapped's wrappers parameter can only accept the former. The frontend already renders exactly these three shapes (Single/Wrapper/Value1ToValue2). - Drop the old table drop. App migrators run after EF schema migrations and hand-editing generated migrations is forbidden, so removing the model would let EF drop the table before the data migrator could read it - losing user rows on version-skipping upgrades. SpecialText's model and table now stay as a dormant relic. - Add the hidden ICustomDateTimeParser contract: SpecialTextService implements it and is wired via AddStandardValue, so the new TextOps must too or date parsing silently degrades. - Correct the blast radius: 12 real call sites, 43 files referencing the interface (25 downloaders pass it through), 167 mentioning the name. - Add four missed frontend couplings plus an API breakage (the display name editor indexes the response by enum value), the pretreatment endpoint, and the migrator's real version-threshold gating. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- docs/file-cleaning-workflow-example.html | 2 +- docs/file-cleaning-workflow.html | 8 +-- docs/specialtext-extension.html | 82 +++++++++++++++++------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/docs/file-cleaning-workflow-example.html b/docs/file-cleaning-workflow-example.html index 3caf0e2fb..6b35c9912 100644 --- a/docs/file-cleaning-workflow-example.html +++ b/docs/file-cleaning-workflow-example.html @@ -88,7 +88,7 @@

      §2流程定义(13 个节点,含每节点配置
      2transform.text.removeWrappedTransform · 通用文本
      IN 能力:ITextWorkpiece(本链此处 = item.fs.entry,作用于其工作文本 WorkingName) OUT 同 | 基数 1→1
      - +
      wrappers[] 【】 (textTypeId = #3「包装符」,内置·Pairs)
      wrappers[] 【】 (textTypeId = #3「包装符」,内置·DelimiterPair)
      textSet自定义文本集 「字幕组名单」(textTypeId = #11「字幕组名单」,自定义;含 LoliHouse、桜都字幕组、桜都字幕組…)
      matchEqualsAny(括号内容全等于集合任一项)
      [LoliHouse] 葬送のフリーレン…葬送のフリーレン Sousou no Frieren [WebRip 1080p HEVC-10bit AAC]
      diff --git a/docs/file-cleaning-workflow.html b/docs/file-cleaning-workflow.html index 2ff7779a6..eb524684d 100644 --- a/docs/file-cleaning-workflow.html +++ b/docs/file-cleaning-workflow.html @@ -132,11 +132,11 @@

      §5SpecialText 扩展:用户自定义文本

      要点(详细模型/服务/迁移以扩展设计文档为准):

      1. 单一抽象:内置与自定义合并为一个实体 TextType(+ TextEntry 条目表),单一 id 空间——节点的文本集引用就是一个 typeId,不存在 builtin:/custom: 判别前缀。内置性 = 行上的 WellKnown 句柄(不可删/不可改名,语义由代码消费点绑定)。
      2. -
      3. Shape:类型分 Values / Pairs 两种形态;自定义 Pairs 直接可用作 removeWrapped 的包装符对。
      4. -
      5. 选择器GetTypes() 聚合列表是节点配置表单与 text 管理页的唯一数据源(内置徽标区分,按 Shape 过滤)。
      6. -
      7. 数据不丢:旧 SpecialText 行经 app 级迁移器(V220/V230 先例)七值查表转入新表;EF 迁移保持纯 schema。「质量与发布标签」以可编辑种子集下发(只投一次,删除不复活)。
      8. +
      9. Shape 三态Values / DelimiterPair(左右定界符)/ MappingPair(from→to)。removeWrapped 的 wrappers 参数只接受 DelimiterPair 型——两值不等于同一形态(Standardization/Language 是映射,喂给包装符参数无意义);自定义 DelimiterPair 可直接用作包装符对。
      10. +
      11. 选择器GetTypes() 聚合列表是节点配置表单与 text 管理页的唯一数据源(内置徽标区分,按 Shape 精确过滤)。
      12. +
      13. 数据不丢:旧 SpecialText 行经 app 级迁移器(V220/V230 先例)七值查表转入新表,Value2 原样保留;EF 迁移保持纯 schema 且不 drop 旧表(迁移器在 EF 之后运行,drop 会导致跨版本升级丢数据)。「质量与发布标签」以可编辑种子集下发(只投一次,删除不复活)。
      -
      实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做(PR1 建表 → PR2 服务拆分与全量切换 → PR3 UI/种子/清理),节点实现全部依赖它。
      +
      实施顺序:本扩展是整个功能唯一的 schema 变更,第一步先做(PR1 建表 → PR2 服务拆分与全量切换 → PR3 UI/种子),节点实现全部依赖它。

      §6执行与 UI

        diff --git a/docs/specialtext-extension.html b/docs/specialtext-extension.html index c3b9cc208..835409e39 100644 --- a/docs/specialtext-extension.html +++ b/docs/specialtext-extension.html @@ -38,7 +38,8 @@

        SpecialText 扩展 · 文本系统架构设计

        内置与自定义合并为单一抽象 TextType;词汇(vocabulary)与操作(operations)分离;文本系统迁入独立模块

        -

        状态:设计定稿,实施第一步能力全景实施顺序的前置项)。前提:全部仓库仅 Bakabase 自身消费,接口、方法、模型、表名均可自由退役/改名——本设计据此做统一重塑而非兼容打补丁。消费方:文件清洗的 text 节点与文本集引用。

        +

        状态:设计定稿(已过代码审计),实施第一步能力全景实施顺序的前置项)。前提:全部仓库仅 Bakabase 自身消费,接口、方法、模型、表名均可自由退役/改名——本设计据此做统一重塑而非兼容打补丁。消费方:文件清洗的 text 节点与文本集引用。

        +
        代码审计修订记录(逐项对照最新 main 的真实代码,证据见各节):① Shape 两态 → 三态(Language 也是双值;定界符对与替换映射语义不同,前端今天已按三种渲染);② 取消 drop 旧表(迁移器在 EF 迁移之后跑 + 禁止手改迁移文件 ⇒ 跨版本升级必丢数据);③ 补 ICustomDateTimeParser 隐藏契约(漏了会静默退化日期解析);④ 改动半径 15 → 43 文件(12 个真调用 + 25 个 downloader 透传);⑤ 前端补 4 处耦合 + 1 个 API 断裂点⑥ 补 pretreatment 端点⑦ 迁移器门控更正为全局版本阈值而非专属标记。

        §1现状盘点(已在代码核实)

        @@ -46,7 +47,8 @@

        §1现状盘点(已在 数据模型SpecialText { Id, Value1(≤64, required), Value2(≤64), Type };SpecialTextType 固定枚举七种:Useless=1 / Wrapper=3 / Standardization=4 / Volume=6 / Trim=7 / DateTime=8 / Language=9 服务ISpecialTextService(abstractions)+ SpecialTextService(legacy Business)。活跃方法:Pretreatment(清洗)、GetAll(表达式过滤)GetAllWrappersTryToParseDateTime×2、CRUD(Add/Patch/DeleteByKey/AddRange/Count)、AddPrefabs(种子)。接口中另有十余个已注释的废弃成员(历史包袱) 种子SpecialTextPrefabs.Texts 静态预制 + AddPrefabs() 补缺式投放 -消费面约 15 个文件:关键词增强器族(Pretreatment)、BakabaseEnhancer(GetAll 原始行 ×6:wrapper/语言/卷词表)、ResourceService 显示名(Wrapper 对)、Comparison 归一化、下载器命名(Pixiv/Bilibili/Abstract)、FileController、MediaLibraryTemplateService、SpecialTextController(CRUD API)、text 前端页、SpecialTextServiceTests +消费面
        实测167 个 C# 文件出现 SpecialText 字样 | 43 个引用 ISpecialTextService(其中 25 个 downloader 仅构造透传)| 12 个真正调用方法:Comparison ComparisonContext、AbstractKeywordEnhancer、ExHentaiEnhancer、BakabaseEnhancer(GetAll ×6)、FileController、DashboardController、ResourceService、MediaLibraryTemplateService、Bilibili/Abstract/ExHentai/Pixiv 下载器。前端另有 6 处(text 页、共享 SpecialText 组件、EnhancerSelectorV2 TargetRow、comparison RuleConfigPanel、DisplayNameTemplateEditorModal、sdk) +隐藏契约SpecialTextService 兼实现 ICustomDateTimeParser:25),经 AddStandardValue<SpecialTextService>() 注入 StandardValue 的日期转换(§4) 数据迁移先例Bakabase.Migrations 已有 app 级迁移器体系(V220Migrator / V230Migrator / PathsRelocationMigrator)——数据转换在应用启动期执行,EF 迁移保持纯 schema(仓库规则)
        三个问题:① 类型封闭——用户无法定义自己的文本集(清洗节点的硬需求);② 单接口混合三种职责(词汇存储 CRUD / 文本操作 / 种子);③ 若内置走枚举、自定义走新表,则"选类型"的每个 UI 与每个引用都要处理两套形态——判别引用(builtin:x / custom:y)是缝合的味道,不是设计。
        @@ -59,13 +61,23 @@

        §2目标与原则

    §3统一数据模型

    +
    Shape 必须是三态,不是两态(代码审计修正)。实际 Value1/Value2 语义有三种,且前端今天已经按三种渲染web/src/pages/text/index.tsx:31-56tagRenders.Single / Wrapper / Value1ToValue2): + + + + + + +
    WellKnownValue1Value2实际 Shape证据
    Wrapper左定界符右定界符DelimiterPairSpecialTextService.cs:33-41;BakabaseEnhancer.cs:302-303
    StandardizationfromtoMappingPairSpecialTextService.cs:144-146 Replace(Value1, Value2)
    Language正则语言标签MappingPair(设计原判为 Values,BakabaseEnhancer.cs:231-233 ToDictionary(Value1, Value2)
    Useless / Trim正则 / 文本nullValuesSpecialTextService.cs:154,162
    DateTime.NET 格式串nullValuesSpecialTextService.cs:71-75 TryParseExact(formats)
    Volume正则序号(int?,部分为 null)Values + 残留 Value2SpecialTextPrefabs.cs:65-77;消费方 BakabaseEnhancer.cs:335-346 不读 Value2
    +两值不等于同一种形态:removeWrapped 的 wrappers 参数只能接受定界符对,喂给它一个替换映射(Standardization/Language)是无意义的。若 Shape 只有 Values|Pairs,选择器就无法正确过滤。
    // 类型:内置与自定义同表同权,单一 id 空间
     TextType  { Id, Name(≤64, 唯一), WellKnown?: WellKnownTextType,   // 非空 = 内置(代码句柄)
    -            Shape: Values | Pairs,                                 // 值集合 / 成对集合
    +            Shape: Values | DelimiterPair | MappingPair,           // 三态(见上)
                 Description?(≤256), CreatedAt }
     
    -// 条目:原 SpecialText 行的新家
    -TextEntry { Id, TypeId(FK → TextType), Value1(≤64), Value2(≤64) }  // Pairs 型用 Value1+Value2
    +// 条目:原 SpecialText 行的新家。Value2 始终可空——Shape 是展示/校验约定,不是存储约束
    +// (Volume 历史行的 Value2 有值有 null,迁移一律原样保留,不因 Shape=Values 而丢弃)
    +TextEntry { Id, TypeId(FK → TextType), Value1(≤64), Value2?(≤64) }
     
     // 代码句柄:值沿用原枚举,语义由消费点绑定;SpecialTextType / SpecialText 命名退役
     public enum WellKnownTextType { Useless = 1, Wrapper = 3, Standardization = 4,
    @@ -73,9 +85,9 @@ 

    §3统一数据模型

    - +
    规则内置(WellKnown ≠ null)自定义
    删除类型 / 改名❌ 拒绝(Name 为规范名,前端按 WellKnown 键做本地化显示)
    增删条目✅(今天即允许,行为不变)
    Shape种子固定:Wrapper / Standardization = Pairs,其余 = Values建类型时二选一——自定义 Pairs 直接可用作 removeWrapped 的包装符对(统一抽象的直接红利)
    Shape种子固定:Wrapper = DelimiterPair;Standardization / Language = MappingPair;Useless / Trim / DateTime / Volume = Values建类型时三选一——自定义 DelimiterPair 直接可用作 removeWrapped 的包装符对(统一抽象的直接红利)
    语义由代码消费点经 WellKnown 句柄绑定(清洗/日期解析/显示名包装符…)无内置语义,仅被节点/功能按 typeId 引用
    -

    EF 迁移纯 schema:两张新表 + 枚举(gen-sdk 重生成)。旧 SpecialText 表保留至数据迁移完成,PR3 的清理迁移中 drop(§6/§8)。

    +

    EF 迁移纯 schema:两张新表 + 枚举(gen-sdk 重生成)。SpecialText 表与模型全程保留、不 drop——原因见 §6 的时序风险。

    §4服务重构:一拆二,旧接口退役

    // Bakabase.Modules.Text —— 文本系统的家
    @@ -96,7 +108,7 @@ 

    §4服务重构:一拆二,旧接口退役 Task AddEntries(int typeId, List<(string Value1, string? Value2)> entries); // 集合解析——消费方唯一的"读词"入口;WellKnown 重载供代码消费点使用 - Task<TextSet> ResolveSet(int typeId); // Pairs → 成对;Values → 值列表 + Task<TextSet> ResolveSet(int typeId); // Pair 型 → 成对;Values → 值列表 Task<TextSet> ResolveSet(WellKnownTextType wellKnown); Task<int> GetTypeId(WellKnownTextType wellKnown); @@ -108,6 +120,7 @@

    §4服务重构:一拆二,旧接口退役 string? Description, int EntryCount) { public bool IsBuiltin => WellKnown != null; } +// 注意实现方必须同时实现 ICustomDateTimeParser(StandardValue 模块契约,见下方"隐藏契约") public interface ITextOps // 操作:无状态核心 + 词汇注入,全量可单测 { Task<string> Clean(string text); // 承接 Pretreatment(保留 profile 扩展位) @@ -126,7 +139,10 @@

    §4服务重构:一拆二,旧接口退役 Add / Patch / DeleteByKey / AddRange / Count条目 CRUD 对应方法(Count 随管理页分页需要再加) AddPrefabs()EnsureSeeds() 全部已注释的废弃成员删除(不迁移) -ISpecialTextService / SpecialTextService / SpecialTextType / SpecialText 模型与表整体退役:实现迁入 Bakabase.Modules.Text(TextType/TextEntry/WellKnownTextType),旧表经 §6 数据迁移后 drop +ISpecialTextService / SpecialTextService / SpecialTextType接口与服务退役:实现迁入 Bakabase.Modules.Text(TextType/TextEntry/WellKnownTextType)。SpecialText 的 db 模型与表保留(不 drop,§6 时序风险) +
    隐藏契约:ICustomDateTimeParser(代码审计新增)。SpecialTextService 同时实现了 StandardValue 模块的 ICustomDateTimeParserSpecialTextService.cs:25),并经 services.AddStandardValue<SpecialTextService>() 注册(BakabaseBusinessExtensions.cs:104)供 StandardValueService 构造注入(StandardValueService.cs:11)。
    +⇒ TextOps 的实现必须同时实现 ICustomDateTimeParser,且 AddStandardValue<T> 的类型参数换成新实现;否则 StandardValue 的日期转换会静默丢失用户自定义格式(无编译错误,行为退化)。
    +
    接口该放哪ISpecialTextService 现居 Bakabase.Abstractions(所有模块都已引用)。消费方横跨 legacy + Enhancer + Comparison + 未来 workflow 节点。两种落法:(a) 接口留在 Bakabase.Abstractions、实现进 Bakabase.Modules.Text——零新增项目引用,改动最小;(b) 接口进模块自身 Abstractions(Alias 模式)——需给 Enhancer / Comparison 各加一条 ProjectReference(模块间互引已有先例:Enhancer → AI/Property/StandardValue/ThirdParty)。推荐 (a):db 模型仍可由模块拥有(Alias/Workflow/Comparison 先例:BakabaseDbContext.cs:52,109-118),两者不冲突。

    §5消费点迁移(同一 PR 内全量切换)

    @@ -139,13 +155,25 @@

    §5消费点迁移(同一 PR 内全量切

    消费者现调用迁移后
    SpecialTextControllerCRUD + AddPrefabs类型/条目端点(§7);路由更名为 text 域
    SpecialTextServiceTests旧服务拆为 VocabularyTests + TextOpsTests(后者纯函数,无需 DB fixture)
    workflow 文本节点(新)ITextOps + ResolveSet(typeId)(清洗设计 3.4)
    -
    迁移审计项:全仓库检索无类型过滤的旧 GetAll 调用——确认其语义是否应包含自定义类型条目(默认不应;新接口按 typeId 取数,天然不会误伤)。
    +
    真实半径:43 个文件,不是 15(代码审计修正)。实测:167 个 C# 文件出现 SpecialText 字样;43 个引用 ISpecialTextService;其中只有 12 个真正调用方法(上表覆盖),另 25 个是 downloader,仅在构造函数里透传给基类(如 FantiaDownloader.cs:16)。
    +⇒ 上表按"调用点"组织是对的,但 PR2 的机械改动面是 43 个文件的构造签名与 using(downloader 链一次性替换为 ITextOps)。评估工时按 43 算,评估风险按 12 算。
    +
    两个迁移审计项:① 全仓库检索无类型过滤的旧 GetAll 调用(新接口按 typeId 取数,天然不误伤自定义条目);② GetAll 的第二参数 asNoTrackingBakabaseEnhancer.cs:231,335 被显式传 false——新签名 GetEntries(typeId) 丢弃该参数,需确认 FullMemoryCacheResourceService 的缓存语义下无副作用(两处均只读取值,预期安全)。

    §6数据迁移与种子

    -

    数据迁移(app 级迁移器,先例:PathsRelocationMigrator)

    +

    迁移器机制(已核实的真实形态)

    +
      +
    • 基类 AbstractMigratorBakabase.Infrastructures.Components.App.Migrations,外部程序集),由 AddBakabaseMigrations 程序集扫描注册(Bakabase.Migrations/Extensions.cs)。
    • +
    • 门控是全局版本阈值 ApplyOnVersionEqualsOrBeforeStringPathsRelocationMigrator.cs:51),不是每迁移器自定义标记——AppOptions.Version 越过阈值后不再触发。设计原文"写入迁移完成标记"表述不准,已更正。
    • +
    • 唯一钩子是 MigrateAfterDbMigrationInternal(V220/V230/PathsRelocation 三处一致,仓库内无 Before 钩子的使用)——数据迁移在 EF schema 迁移之后运行
    • +
    +
    Blocker:drop 旧表会丢数据(时序 + 规则双重约束)
    +① 迁移器在 EF 迁移之后跑;② 跨版本升级(旧版直接跳到含 drop 的新版)时,EF 会先 drop 掉旧表,迁移器再启动就无源可读 → 用户自建词条永久丢失;③ 仓库规则禁止手改生成的迁移文件,所以一旦把 SpecialText 模型移出 DbContext,EF 必然自动生成 drop,无法删除该语句。
    +结论:本次不 drop。SpecialText 模型 + DbSet + 表全程保留为休眠遗留物(一张 ≤64 字符两列的小表,成本可忽略),仅停止使用。真正的清理放到远期独立发布,届时若仍要做,须确认存量 DB 均已越过迁移器阈值。
    +

    数据迁移步骤

      -
    1. PR2 启动期迁移器 TextSystemMigratorEnsureSeeds() 先投放七个内置 TextType 行 → 将旧 SpecialText 行按 Type 七值查表映射为对应类型的 TextEntry(含用户自行添加的词条,不丢数据)→ 完成后写入迁移完成标记(app options,与 V220/V230 同模式)。
    2. -
    3. EF 迁移始终纯 schema(仓库规则):建表在 PR1,drop 旧表在 PR3 的清理迁移(确认迁移器标记已写入后才发布)。
    4. +
    5. PR2 启动期迁移器 TextSystemMigratorEnsureSeeds() 先投放七个内置 TextType 行 → 将旧 SpecialText 行按 Type 七值查表映射为 TextEntryValue2 原样保留,含 Volume 的残留序号;含用户自建词条)→ 依赖 AppOptions.Version 版本门控收口。
    6. +
    7. 迁移器须可重入:以「目标类型下已存在同 (Value1, Value2) 行」为跳过条件(与现行 AddPrefabs 的补缺式语义一致),使中途崩溃后重跑不产生重复。
    8. +
    9. EF 迁移始终纯 schema:仅 PR1 建两张新表;无 drop 迁移

    种子

      @@ -155,25 +183,35 @@

      种子

      §7API 与前端

        -
      • Controller:更名为 text 域路由。端点:GetTypes(聚合列表——选择器与管理页共用这一个接口)、类型 CRUD(内置行服务端拒绝删/改名)、条目 CRUD、ResolveSet(typeId) 预览(节点配置表单回显集合内容)。SDK 按 api-conventions 重生成并随 PR 提交。
      • -
      • text 页一个统一列表(不再内置/自定义两个分区)——内置行带 内置 徽标、名称本地化、禁用删除/改名;其余交互一致:批量录入(逐行粘贴)、Pairs 型成对录入、导入/导出 JSON。
      • -
      • TextType 选择器组件:单个下拉、单一列表(内置徽标区分,按 Shape 过滤——wrappers 参数只列 Pairs 型),供 workflow 节点配置表单复用。没有"两种引用"的概念。
      • +
      • Controller:更名为 text 域路由。端点:GetTypes(聚合列表——选择器与管理页共用这一个接口)、类型 CRUD(内置行服务端拒绝删/改名)、条目 CRUD、ResolveSet(typeId) 预览。另需保留 POST /pretreatmentPretreatTextSpecialTextController.cs:52-58——原设计漏列。SDK 按 api-conventions 重生成并随 PR 提交。
      • +
      • 返回结构变更(断裂点):现 GetAllSpecialTexts 返回 Dictionary<int, List<SpecialText>>键就是枚举值,前端直接按枚举索引——DisplayNameTemplateEditorModal/index.tsx:126-131tr.data?.[SpecialTextType.Wrapper] 是硬断裂点,须改为按 WellKnown 查 typeId 再取。
      • +
      • text 页一个统一列表——内置行带 内置 徽标、名称本地化、禁用删除/改名;批量录入(逐行粘贴)、Pair 型成对录入、导入/导出 JSON。注意页面现从生成常量 specialTextTypes 取类型列表pages/text/index.tsx:9,141),须改为从 GetTypes() 取。
      • +
      • TextType 选择器组件:单个下拉、单一列表(内置徽标区分,按 Shape 精确过滤——wrappers 参数只列 DelimiterPair 型)。没有"两种引用"的概念。
      +
      前端面比原设计大:另有 4 处耦合(代码审计新增) + + + + +
      位置耦合处理
      pages/text/index.tsx:31-56三种渲染器 Single / Wrapper / Value1ToValue2 按枚举映射改为按 Shape 驱动(本就是三态的证据,§3)
      components/SpecialText/IntegrateWithSpecialTextLabel入参 type: SpecialTextType,switch 各枚举出提示文案改为 WellKnown(或 typeId + 描述符)
      EnhancerSelectorV2/…/TargetRow:41-45,117,201-202StdValueSpecialTextIntegrationMap = {[StandardValueType.DateTime]: SpecialTextType.DateTime}映射改指 WellKnown
      pages/comparison/…/RuleConfigPanel:247-256仅跳转 /text + 文案路由不变则零改动
      +名称本地化键现为 pages/text.json"SpecialTextType.Useless" 等七条(:14-21)+ text.typeDescription.* / text.usedIn.*;迁移为 WellKnownTextType.* 键即可,机制不变(设计原判正确)。

      §8实施切分(三个可独立合并的 PR)

      - -
      PR内容验收
      PR1 schemaTextType / TextEntry 两张新表 + WellKnownTextType 枚举(EF CLI 迁移,纯 schema)+ gen-sdk。旧表旧服务原样运行零行为变化;迁移可回滚
      PR2 模块与切换新建 Bakabase.Modules.Text;ITextVocabularyService + ITextOps 落地(逻辑自 legacy 平移+拆分);种子(内置类型行)+ TextSystemMigrator 数据迁移器退役 ISpecialTextService 并全量切换 ~15 个消费文件;Controller 更名改造;测试拆分dotnet build 全绿;文本相关测试全过;既有功能无回归;旧行全部迁入新表
      PR3 UI 与清理text 页统一列表 + TextType 选择器组件 + TextSeedData「质量与发布标签」种子集 + 导入导出 + 清理迁移 drop 旧 SpecialText 表用户可建集/录入/引用;种子幂等(删除不复活);旧表移除
      -

      PR2 是唯一的大改,但风险受控:调用点 ~15 个文件、语义一一对应(§4 映射表)、操作逻辑平移后立即获得纯函数单测覆盖、数据迁移是七值查表。PR3 之后,清洗功能的节点实现即可开始(E1/E3/E4 并行推进)。

      +PR2 模块与切换新建 Bakabase.Modules.Text;ITextVocabularyService + ITextOps 落地(逻辑自 legacy 平移+拆分,实现方兼实现 ICustomDateTimeParser 并改 AddStandardValue<T>);种子(内置类型行)+ TextSystemMigrator 数据迁移器退役 ISpecialTextService——12 个调用点按 §4 映射切换、25 个 downloader 机械改构造签名(共 43 文件);Controller 改造(含保留 pretreatment 端点);测试拆分dotnet build 全绿;文本相关测试全过;日期解析自定义格式仍生效(ICustomDateTimeParser 回归点);旧行全部迁入新表且旧表仍在 +PR3 UI 与种子text 页统一列表(改为从 GetTypes 取类型、按 Shape 驱动渲染)+ TextType 选择器组件 + 4 处前端耦合改造(§7)+ TextSeedData「质量与发布标签」种子集 + 导入导出用户可建集/录入/引用;种子幂等(删除不复活);显示名模板编辑器等 4 处无回归 +

      PR2 是唯一的大改,风险受控:真正有语义的调用点 12 个(§4 映射表一一对应),其余 31 个是机械改签名;操作逻辑平移后立即获得纯函数单测覆盖;数据迁移是七值查表且可重入。无 drop 旧表步骤(§6)。PR3 之后,清洗功能的节点实现即可开始(E1/E3/E4 并行推进)。

      §9风险与开放问题

        -
      • 数据迁移器的幂等与失败恢复:迁移器需可重入(按旧行 id 断点续迁或整体事务);完成标记写入前旧表只读不删。
      • -
      • Value1 长度(≤64):字幕组名/质量词足够;若出现长文本需求,PR1 建表时一并评估 128。
      • +
      • 数据迁移器的幂等与失败恢复:版本门控是全局 AppOptions.Version(非专属标记),崩溃后重跑必然发生 → 以「同类型下已存在同 (Value1, Value2)」为跳过条件保证可重入(§6)。
      • +
      • Value1 长度(≤64):字幕组名/质量词足够;若出现长文本需求,PR1 建表时一并评估 128。Volume/Useless 的 Value1 是正则SpecialTextPrefabs.cs:16-27,65-77),自定义类型的录入 UI 需明确"字面量 vs 正则"的语义归属(建议:类型级开关,而非逐条目)。
      • +
      • 三态 Shape 的迁移取值:Volume 历史行携带 Value2(序号)但消费方不读;迁移原样保留,UI 按 Shape=Values 只展示 Value1——不删数据、不显示,留待将来决定是否复活该语义。
      • +
      • 残留本地化键SpecialText_HistoricalLanguageValue2ShouldBeModifiedIBakabaseLocalizer.cs:26)已无调用点,随本次一并清理。
      • 内置类型 Name 本地化:行内 Name 存规范英文名,前端按 WellKnown 键取本地化显示——避免把 UI 文案存进数据库。
      • Clean 的双 profile(判等 vs 检索归一化):接口预留参数位,Organizer 复活时启用。
      • -
      • 富语义自定义类型(自定义日期格式等带解析语义者)不在本期范围;本期自定义仅 Values / Pairs 两种 Shape。
      • +
      • 富语义自定义类型(自定义日期格式等带解析语义者)不在本期范围;本期自定义仅 Values / DelimiterPair / MappingPair 三种 Shape。
      • 分享:自定义集导入/导出 JSON 先行,Sharable 集成随使用量决策。
      From c6862c8037e1f98f19c1984de6d7483baed99075 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:41:26 +0000 Subject: [PATCH 20/46] feat(text): add TextType and TextEntry schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the text-system rework: builtin and user-defined text types will share one table and one id space, so anything referencing a type deals with a single integer. TextType carries an optional WellKnown handle (non-null marks a builtin, whose semantics are bound at consumption sites) and a Shape describing how entries use their two value slots — Values, DelimiterPair or MappingPair, since delimiter pairs and replacement mappings are not interchangeable. TextEntry keeps Value2 nullable for every shape so historical rows never lose data. Schema only: the existing SpecialText service and table keep running untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- src/Bakabase.sln | 21 + .../Bakabase.InsideWorld.Business.csproj | 1 + .../BakabaseDbContext.cs | 4 + ...054037_AddTextTypeAndTextEntry.Designer.cs | 2241 +++++++++++++++++ .../20260826054037_AddTextTypeAndTextEntry.cs | 57 + .../InsideWorldDbContextModelSnapshot.cs | 52 + .../Abstractions/Models/Db/TextEntry.cs | 19 + .../Abstractions/Models/Db/TextType.cs | 33 + .../Models/Domain/Constants/TextTypeShape.cs | 27 + .../Domain/Constants/WellKnownTextType.cs | 51 + .../Bakabase.Modules.Text.csproj | 14 + 11 files changed, 2520 insertions(+) create mode 100644 src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.Designer.cs create mode 100644 src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.cs create mode 100644 src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs create mode 100644 src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs create mode 100644 src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs create mode 100644 src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs create mode 100644 src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj diff --git a/src/Bakabase.sln b/src/Bakabase.sln index 7fd6ddf59..24765343f 100644 --- a/src/Bakabase.sln +++ b/src/Bakabase.sln @@ -95,6 +95,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bakabase.Modules.Player.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bakabase.Modules.RemoteAccess", "modules\Bakabase.Modules.RemoteAccess\Bakabase.Modules.RemoteAccess.csproj", "{0EBC88C9-04CD-4A00-96BD-4281EED00085}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bakabase.Modules.Text", "modules\Bakabase.Modules.Text\Bakabase.Modules.Text.csproj", "{9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Company|Any CPU = Company|Any CPU @@ -810,6 +812,24 @@ Global {0EBC88C9-04CD-4A00-96BD-4281EED00085}.Release|x64.Build.0 = Release|Any CPU {0EBC88C9-04CD-4A00-96BD-4281EED00085}.Release|x86.ActiveCfg = Release|Any CPU {0EBC88C9-04CD-4A00-96BD-4281EED00085}.Release|x86.Build.0 = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|Any CPU.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|Any CPU.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|x64.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|x64.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|x86.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Company|x86.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|x64.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|x64.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|x86.ActiveCfg = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Debug|x86.Build.0 = Debug|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|Any CPU.Build.0 = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|x64.ActiveCfg = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|x64.Build.0 = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|x86.ActiveCfg = Release|Any CPU + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -854,6 +874,7 @@ Global {8570DCE0-A603-4AD6-A28D-5B28F8C1D7F9} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} {4033B5DE-9488-4E62-8A72-104EB27CD539} = {8B728E85-2F02-43E7-A3D1-32CDB437DFEC} {0EBC88C9-04CD-4A00-96BD-4281EED00085} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {9ED1459F-6E9D-4AF0-A0B3-0C336FAF5412} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EDB2DE5-2794-4737-A47F-105A2CCFFD16} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Bakabase.InsideWorld.Business.csproj b/src/legacy/Bakabase.InsideWorld.Business/Bakabase.InsideWorld.Business.csproj index 40c8fe175..d81eb14ba 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Bakabase.InsideWorld.Business.csproj +++ b/src/legacy/Bakabase.InsideWorld.Business/Bakabase.InsideWorld.Business.csproj @@ -16,6 +16,7 @@ + diff --git a/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs b/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs index bd80ae545..cafd2f33c 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs @@ -119,6 +119,10 @@ public class BakabaseDbContext : DbContext, IBulkModificationDbContext, ICompari public DbSet ComparisonResultGroupMembers { get; set; } public DbSet ComparisonResultPairs { get; set; } + // Text module tables + public DbSet TextTypes { get; set; } + public DbSet TextEntries { get; set; } + public BakabaseDbContext() { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.Designer.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.Designer.cs new file mode 100644 index 000000000..80d963d34 --- /dev/null +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.Designer.cs @@ -0,0 +1,2241 @@ +// +using System; +using Bakabase.InsideWorld.Business; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bakabase.InsideWorld.Business.Migrations +{ + [DbContext(typeof(BakabaseDbContext))] + [Migration("20260826054037_AddTextTypeAndTextEntry")] + partial class AddTextTypeAndTextEntry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.DLsiteWorkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("Circle") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DrmKey") + .HasColumnType("TEXT"); + + b.Property("IsDownloaded") + .HasColumnType("INTEGER"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("IsPurchased") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("SalesDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UseLocaleEmulator") + .HasColumnType("INTEGER"); + + b.Property("WorkId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WorkType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("WorkId") + .IsUnique(); + + b.ToTable("DLsiteWorks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.EnhancementDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DynamicTarget") + .HasColumnType("TEXT"); + + b.Property("EnhancerId") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Target") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Enhancements"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.EnhancementRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContextAppliedAt") + .HasColumnType("TEXT"); + + b.Property("ContextCreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnhancerId") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("OptionsSnapshot") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnhancerId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("EnhancerId", "ResourceId") + .IsUnique(); + + b.ToTable("EnhancementRecords"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ExHentaiGalleryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GalleryId") + .HasColumnType("INTEGER"); + + b.Property("GalleryToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDownloaded") + .HasColumnType("INTEGER"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleJpn") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("GalleryId", "GalleryToken") + .IsUnique(); + + b.ToTable("ExHentaiGalleries"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ExtensionGroupDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Extensions") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExtensionGroups"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryResourceMappingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("MediaLibraryId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaLibraryId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("MediaLibraryId", "ResourceId") + .IsUnique(); + + b.ToTable("MediaLibraryResourceMappings"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryTemplateDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("ChildTemplateId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DisplayNameTemplate") + .HasColumnType("TEXT"); + + b.Property("Enhancers") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayableFileLocator") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("ResourceFilters") + .HasColumnType("TEXT"); + + b.Property("SamplePaths") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaLibraryTemplates"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryV2DbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Paths") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Players") + .HasColumnType("TEXT"); + + b.Property("ResourceCount") + .HasColumnType("INTEGER"); + + b.Property("SyncVersion") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaLibrariesV2"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PasswordDbModel", b => + { + b.Property("Text") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("UsedTimes") + .HasColumnType("INTEGER"); + + b.HasKey("Text"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UsedTimes"); + + b.ToTable("Passwords"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PathMarkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresInSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("SyncError") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Path"); + + b.HasIndex("SyncStatus"); + + b.HasIndex("Path", "Type", "Priority"); + + b.ToTable("PathMarks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PlayHistoryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Item") + .HasColumnType("TEXT"); + + b.Property("PlayedAt") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayedAt"); + + b.HasIndex("ResourceId"); + + b.ToTable("PlayHistories"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PropertyMarkEffectDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MarkId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MarkId"); + + b.HasIndex("PropertyPool", "PropertyId", "ResourceId"); + + b.HasIndex("MarkId", "PropertyPool", "PropertyId", "ResourceId") + .IsUnique(); + + b.ToTable("PropertyMarkEffects"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PropertyValueScopePreferenceDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Priorities") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ResourceId", "PropertyPool", "PropertyId") + .IsUnique(); + + b.ToTable("PropertyValueScopePreferences"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ReservedPropertyValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverPaths") + .HasColumnType("TEXT"); + + b.Property("Introduction") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("ReservedPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("FileCreateDt") + .HasColumnType("TEXT"); + + b.Property("FileModifyDt") + .HasColumnType("TEXT"); + + b.Property("IsFile") + .HasColumnType("INTEGER"); + + b.Property("MediaLibraryId") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PlayedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("INTEGER"); + + b.Property("UpdateDt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path"); + + b.HasIndex("Status"); + + b.ToTable("ResourcesV2"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceMarkEffectDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MarkId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MarkId"); + + b.HasIndex("Path"); + + b.HasIndex("MarkId", "Path") + .IsUnique(); + + b.ToTable("ResourceMarkEffects"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceProfileDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnhancerSettingsJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameTemplate") + .HasColumnType("TEXT"); + + b.Property("PlayableFileSettingsJson") + .HasColumnType("TEXT"); + + b.Property("PlayerSettingsJson") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PropertiesJson") + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Priority"); + + b.ToTable("ResourceProfiles"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceSourceLinkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverDownloadFailedAt") + .HasColumnType("TEXT"); + + b.Property("CoverUrls") + .HasColumnType("TEXT"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("LocalCoverPaths") + .HasColumnType("TEXT"); + + b.Property("MetadataFetchedAt") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("Source", "SourceKey"); + + b.HasIndex("ResourceId", "Source", "SourceKey") + .IsUnique(); + + b.ToTable("ResourceSourceLinks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SourceMetadataMappingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MetadataField") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TargetPool") + .HasColumnType("INTEGER"); + + b.Property("TargetPropertyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SourceMetadataMappings"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SpecialText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpecialTexts"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SteamAppDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HasCommunityVisibleStats") + .HasColumnType("INTEGER"); + + b.Property("ImgIconUrl") + .HasColumnType("TEXT"); + + b.Property("InstallPath") + .HasColumnType("TEXT"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("IsInstalled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlaytimeForever") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("RtimeLastPlayed") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId") + .IsUnique(); + + b.HasIndex("ResourceId"); + + b.ToTable("SteamApps"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ThirdPartyContentTrackerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DomainKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Filter") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ViewedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DomainKey"); + + b.HasIndex("ViewedAt"); + + b.HasIndex("DomainKey", "Filter", "ContentId") + .IsUnique(); + + b.ToTable("ThirdPartyContentTrackers"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.Downloader.Models.Db.DownloadRecordDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DownloadedAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThirdPartyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ThirdPartyId", "Key") + .IsUnique(); + + b.ToTable("DownloadRecords"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.Downloader.Models.Db.DownloadTaskDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoRetry") + .HasColumnType("INTEGER"); + + b.Property("Checkpoint") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadStatusUpdateDt") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Options") + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("ThirdPartyId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("ThirdPartyId"); + + b.HasIndex("ThirdPartyId", "Type"); + + b.ToTable("DownloadTasks"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.PlayList.Models.Db.PlayListDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ItemsJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.PostParser.Models.Db.PostParserTaskDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Link") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Results") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Targets") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PostParserTasks"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Models.Db.ResourceCacheDbModel", b => + { + b.Property("ResourceId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CachedTypes") + .HasColumnType("INTEGER"); + + b.Property("CoverPaths") + .HasColumnType("TEXT"); + + b.Property("HasMorePlayableFiles") + .HasColumnType("INTEGER"); + + b.Property("PlayableFilePaths") + .HasColumnType("TEXT"); + + b.HasKey("ResourceId"); + + b.ToTable("ResourceCaches"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AiFeatureConfigDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Feature") + .HasColumnType("INTEGER"); + + b.Property("MaxTokens") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .HasColumnType("TEXT"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("Temperature") + .HasColumnType("REAL"); + + b.Property("TopP") + .HasColumnType("REAL"); + + b.Property("UseDefault") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Feature") + .IsUnique(); + + b.ToTable("AiFeatureConfigs"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AiProviderDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AigcConfigJson") + .HasColumnType("TEXT"); + + b.Property("AigcEnabled") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LlmEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AigcEnabled"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Kind"); + + b.HasIndex("LlmEnabled"); + + b.ToTable("AiProviders"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcArtifactDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("OrdinalInRun") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("RunId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("RunId"); + + b.ToTable("AigcArtifacts"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGenerationRunDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("NegativePrompt") + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("RequestPayload") + .HasColumnType("TEXT"); + + b.Property("ResponsePayload") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("Status"); + + b.ToTable("AigcGenerationRuns"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGeneratorDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowDeletion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FilenameTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NegativePromptTemplate") + .HasColumnType("TEXT"); + + b.Property("ParametersJson") + .HasColumnType("TEXT"); + + b.Property("PromptTemplate") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.Property("ResourceMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("ProviderId"); + + b.ToTable("AigcGenerators"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGeneratorPropertyPresetDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("Pool") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("SerializedBizValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("GeneratorId", "Pool", "PropertyId") + .IsUnique(); + + b.ToTable("AigcGeneratorPropertyPresets"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.ChatConversationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsArchived"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.ChatMessageDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RichContent") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("ToolCalls") + .HasColumnType("TEXT"); + + b.Property("ToolResults") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmCallCacheEntryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("HitCount") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("ResponseJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.ToTable("LlmCallCacheEntries"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmToolConfigDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("ToolName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ToolName") + .IsUnique(); + + b.ToTable("LlmToolConfigs"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmUsageLogDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheHit") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Feature") + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("RequestSummary") + .HasColumnType("TEXT"); + + b.Property("ResponseSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalTokens") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CacheHit"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Feature"); + + b.HasIndex("ProviderConfigId"); + + b.ToTable("LlmUsageLogs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Alias.Abstractions.Models.Db.Alias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Preferred") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasesV2"); + }); + + modelBuilder.Entity("Bakabase.Modules.BulkModification.Models.Db.BulkModificationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFiles") + .HasColumnType("INTEGER"); + + b.Property("DeleteResources") + .HasColumnType("INTEGER"); + + b.Property("FilteredResourceIds") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Processes") + .HasColumnType("TEXT"); + + b.Property("ScopePreferenceConfigs") + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("Variables") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BulkModifications"); + }); + + modelBuilder.Entity("Bakabase.Modules.BulkModification.Models.Db.BulkModificationDiffDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulkModificationId") + .HasColumnType("INTEGER"); + + b.Property("Diffs") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("ResourcePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BulkModificationId", "ResourceId") + .IsUnique(); + + b.ToTable("BulkModificationDiffs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonPlanDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("Threshold") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Name"); + + b.ToTable("ComparisonPlans"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultGroupDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("PlanId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MemberCount"); + + b.HasIndex("PlanId"); + + b.HasIndex("PlanId", "MemberCount"); + + b.ToTable("ComparisonResultGroups"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultGroupMemberDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsSuggestedPrimary") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("GroupId", "ResourceId") + .IsUnique(); + + b.ToTable("ComparisonResultGroupMembers"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultPairDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Resource1Id") + .HasColumnType("INTEGER"); + + b.Property("Resource2Id") + .HasColumnType("INTEGER"); + + b.Property("RuleScoresJson") + .HasColumnType("TEXT"); + + b.Property("TotalScore") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("Resource1Id", "Resource2Id"); + + b.HasIndex("GroupId", "Resource1Id", "Resource2Id") + .IsUnique(); + + b.ToTable("ComparisonResultPairs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonRuleDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BothNullBehavior") + .HasColumnType("INTEGER"); + + b.Property("IsVeto") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("INTEGER"); + + b.Property("Normalize") + .HasColumnType("INTEGER"); + + b.Property("OneNullBehavior") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .HasColumnType("TEXT"); + + b.Property("PlanId") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("PropertyValueScope") + .HasColumnType("INTEGER"); + + b.Property("VetoThreshold") + .HasColumnType("REAL"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlanId"); + + b.HasIndex("PlanId", "Order"); + + b.ToTable("ComparisonRules"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TypeId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TypeId"); + + b.ToTable("DataCards"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardPropertyValueDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CardId") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("PropertyId", "Value"); + + b.HasIndex("CardId", "PropertyId", "Scope") + .IsUnique(); + + b.ToTable("DataCardPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardTypeDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayTemplate") + .HasColumnType("TEXT"); + + b.Property("IdentityPropertyIds") + .HasColumnType("TEXT"); + + b.Property("MatchRules") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NameTemplate") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PropertyIds") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataCardTypes"); + }); + + modelBuilder.Entity("Bakabase.Modules.HealthScore.Models.Db.HealthScoreProfileDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaseScore") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("MembershipFilterJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RulesJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HealthScoreProfiles"); + }); + + modelBuilder.Entity("Bakabase.Modules.HealthScore.Models.Db.ResourceHealthScoreDbModel", b => + { + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("EvaluatedAt") + .HasColumnType("TEXT"); + + b.Property("MatchedRulesJson") + .HasColumnType("TEXT"); + + b.Property("ProfileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("TEXT"); + + b.HasKey("ResourceId", "ProfileId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ProfileId", "ProfileHash"); + + b.ToTable("ResourceHealthScores"); + }); + + modelBuilder.Entity("Bakabase.Modules.Notification.Abstractions.Models.Db.NotificationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PayloadJson") + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ReadAt"); + + b.HasIndex("Source"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Bakabase.Modules.Property.Abstractions.Models.Db.CustomPropertyDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Options") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("CustomProperties"); + }); + + modelBuilder.Entity("Bakabase.Modules.Property.Abstractions.Models.Db.CustomPropertyValueDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PropertyId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ResourceId", "PropertyId", "Scope") + .IsUnique(); + + b.ToTable("CustomPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Modules.Subscription.Abstractions.Models.Db.SubscriptionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IntervalMinutes") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastChangeAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("TargetJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Kind"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("Bakabase.Modules.Subscription.Abstractions.Models.Db.SubscriptionSnapshotDbModel", b => + { + b.Property("SubscriptionId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("SubscriptionId"); + + b.ToTable("SubscriptionSnapshots"); + }); + + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TypeId") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TextEntries"); + }); + + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Shape") + .HasColumnType("INTEGER"); + + b.Property("WellKnown") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TextTypes"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowActivityDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OnItemError") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowActivities"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowDefinitionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggerFilterJson") + .HasColumnType("TEXT"); + + b.Property("TriggerKind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TriggerKind"); + + b.ToTable("WorkflowDefinitions"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowRunDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("FailedItemCount") + .HasColumnType("INTEGER"); + + b.Property("InputCount") + .HasColumnType("INTEGER"); + + b.Property("OutputCount") + .HasColumnType("INTEGER"); + + b.Property("PayloadJson") + .HasColumnType("TEXT"); + + b.Property("PayloadSummary") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StepStatsJson") + .HasColumnType("TEXT"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.HasIndex("Status"); + + b.HasIndex("WorkflowDefinitionId"); + + b.ToTable("WorkflowRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.cs new file mode 100644 index 000000000..75f1ce03b --- /dev/null +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826054037_AddTextTypeAndTextEntry.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bakabase.InsideWorld.Business.Migrations +{ + /// + public partial class AddTextTypeAndTextEntry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TextEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TypeId = table.Column(type: "INTEGER", nullable: false), + Value1 = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Value2 = table.Column(type: "TEXT", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TextEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TextTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false), + WellKnown = table.Column(type: "INTEGER", nullable: true), + Shape = table.Column(type: "INTEGER", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 256, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextTypes", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TextEntries"); + + migrationBuilder.DropTable( + name: "TextTypes"); + } + } +} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs index 1779ec2b0..8d1472bd6 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs @@ -2111,6 +2111,58 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubscriptionSnapshots"); }); + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TypeId") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TextEntries"); + }); + + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Shape") + .HasColumnType("INTEGER"); + + b.Property("WellKnown") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TextTypes"); + }); + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowActivityDbModel", b => { b.Property("Id") diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs new file mode 100644 index 000000000..88381c56e --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bakabase.Modules.Text.Abstractions.Models.Db; + +/// +/// One entry of a . What the two values mean is the type's +/// ; both slots exist regardless of shape so no data +/// is lost when a type's shape says only the first one is used. +/// +public record TextEntry +{ + [Key] public int Id { get; set; } + + public int TypeId { get; set; } + + [Required, MaxLength(64)] public string Value1 { get; set; } = null!; + + [MaxLength(64)] public string? Value2 { get; set; } +} diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs new file mode 100644 index 000000000..5bda6baa1 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Modules.Text.Abstractions.Models.Db; + +/// +/// A named set of text entries. Builtin and user-defined types share this table and a single id +/// space, so anything referencing a type — a workflow node's config, a picker, an API — only ever +/// deals with one integer. +/// +public record TextType +{ + [Key] public int Id { get; set; } + + /// + /// Canonical name. For builtins this is a stable English name and the UI shows a localized + /// label keyed by instead, so UI copy never lands in the database. + /// + [Required, MaxLength(64)] + public string Name { get; set; } = null!; + + /// + /// Non-null marks a builtin: consumption sites resolve it by this handle, and the type cannot + /// be renamed or deleted. Entries remain editable either way. + /// + public WellKnownTextType? WellKnown { get; set; } + + public TextTypeShape Shape { get; set; } + + [MaxLength(256)] public string? Description { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs new file mode 100644 index 000000000..92acb6d7f --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs @@ -0,0 +1,27 @@ +namespace Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; + +/// +/// How a type's entries use their two value slots. Drives editing UI, the type picker's +/// filtering (a wrapper-stripping node only accepts ) and rendering. +/// +/// This is a presentation and validation convention, not a storage constraint: +/// TextEntry.Value2 stays nullable for every shape, so historical rows that carry a +/// second value under a type (Volume ordinals) survive untouched. +/// +public enum TextTypeShape +{ + /// + /// Only the first value is meaningful. + /// + Values = 1, + + /// + /// Opening and closing delimiters — the two values bracket a piece of text. + /// + DelimiterPair = 2, + + /// + /// A directed mapping — the first value stands for the second. + /// + MappingPair = 3 +} diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs new file mode 100644 index 000000000..5613b9a33 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs @@ -0,0 +1,51 @@ +namespace Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; + +/// +/// Code handle for a text type whose semantics are bound at a consumption site (cleaning, +/// date parsing, display-name wrappers, ...). A row carrying one of +/// these is a builtin: it cannot be renamed or deleted, though its entries stay editable. +/// Rows without a handle are user-defined and have no semantics beyond being referenced by id. +/// +/// Values intentionally match the retired SpecialTextType so the one-shot data migration +/// is a plain lookup and existing localization keys map over mechanically. +/// +public enum WellKnownTextType +{ + /// + /// Regex patterns; wrapped occurrences are stripped during cleaning. + /// + Useless = 1, + + /// + /// Left/right delimiter pairs ((/), [/], ...). + /// + Wrapper = 3, + + /// + /// Replacement mapping applied before comparison: every occurrence of the first value + /// becomes the second. + /// + Standardization = 4, + + /// + /// Regex patterns identifying a volume/chapter marker. Historical rows also carry an + /// ordinal in the second value which no consumer reads today; the migration preserves it. + /// + Volume = 6, + + /// + /// Texts whose surrounding whitespace is collapsed during cleaning. + /// + Trim = 7, + + /// + /// + /// format strings. + /// + DateTime = 8, + + /// + /// Mapping from a regex matching a language marker to the canonical language label. + /// + Language = 9 +} diff --git a/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj b/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj new file mode 100644 index 000000000..7246cda37 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + latest + + + + + + + From edbf183eda4ec9ec499bfcd36163db8fed7f2ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:04:08 +0000 Subject: [PATCH 21/46] refactor(text): replace SpecialText service with vocabulary + ops split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires ISpecialTextService, whose single surface mixed storage CRUD, text operations and seeding, in favour of two services living in the new Bakabase.Modules.Text: - ITextVocabularyService owns types and entries. Builtin types are rows carrying a WellKnown handle, so GetTypes() returns one list for every picker; ResolveSet is the only way consumers read words, and it provisions a missing builtin on demand so a fresh database never throws where the old GetAll simply returned nothing. - ITextOps carries the operations, stateless and vocabulary-driven, and also serves as ICustomDateTimeParser — that binding is what feeds the user's date formats into StandardValue conversions, so it is covered by a test rather than left to compile-time luck. All 43 files touching the old interface move over: 12 real call sites map one-to-one onto the new methods, the rest is constructor plumbing through the downloader chain. Adds TextSystemMigrator, which copies legacy rows into the new tables after seeding builtins; it preserves Value2 even for shapes that ignore it (Volume ordinals) and skips entries already present, so a rerun after a crash cannot duplicate. The legacy SpecialText table and model deliberately stay in place — app migrators run after EF migrations, so dropping the table would destroy the source before the copy on a version-skipping upgrade. Tests split into TextVocabularyServiceTests and TextOpsTests, the latter needing no database for anything but the vocabulary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../Localization/IBakabaseLocalizer.cs | 2 +- .../Components/Text/ITextOps.cs | 71 +++++ .../Extensions/SpecialTextExtensions.cs | 34 --- .../Extensions/TextSetExtensions.cs | 19 ++ .../Models/Domain/Constants/TextTypeShape.cs | 2 +- .../Domain/Constants/WellKnownTextType.cs | 4 +- .../Models/Domain/TextEntryValue.cs | 19 ++ .../Models/Domain/TextSet.cs | 31 +++ .../Models/Domain/TextTypeDescriptor.cs | 34 +++ .../Models/Input/SpecialTextAddInputModel.cs | 11 - .../Input/SpecialTextPatchInputModel.cs | 8 - .../Services/ISpecialTextService.cs | 72 ----- .../Services/ITextVocabularyService.cs | 52 ++++ .../Controllers/DashboardController.cs | 8 +- .../Controllers/FileController.cs | 9 +- .../Controllers/SpecialTextController.cs | 60 ----- .../Controllers/TextController.cs | 102 +++++++ .../Extensions/BakabaseBusinessExtensions.cs | 9 +- .../Models/Input/TextInputModels.cs | 32 +++ .../Components/BakabaseLocalizer.cs | 2 - .../Downloaders/AbstractDownloader.cs | 8 +- .../Bilibili/BilibiliDownloader.cs | 11 +- .../Cien/AbstractCienDownloader.cs | 4 +- .../ExHentai/AbstractExHentaiDownloader.cs | 8 +- .../ExHentai/ExHentaiListDownloader.cs | 2 +- .../ExHentai/ExHentaiSingleWorkDownloader.cs | 2 +- .../ExHentai/ExHentaiWatchedDownloader.cs | 2 +- .../Fanbox/AbstractFanboxDownloader.cs | 4 +- .../Fanbox/FanboxCreatorDownloader.cs | 2 +- .../Fanbox/FanboxFollowingDownloader.cs | 2 +- .../Fanbox/FanboxSinglePostDownloader.cs | 2 +- .../Fantia/AbstractFantiaDownloader.cs | 4 +- .../Fantia/FantiaCreatorDownloader.cs | 2 +- .../Downloaders/Fantia/FantiaDownloader.cs | 2 +- .../Fantia/FantiaFollowingDownloader.cs | 2 +- .../Fantia/FantiaSinglePostDownloader.cs | 2 +- .../Patreon/AbstractPatreonDownloader.cs | 4 +- .../Patreon/PatreonCreatorDownloader.cs | 2 +- .../Downloaders/Patreon/PatreonDownloader.cs | 2 +- .../Patreon/PatreonFollowingDownloader.cs | 2 +- .../Patreon/PatreonSinglePostDownloader.cs | 2 +- .../Pixiv/AbstractPixivDownloader.cs | 11 +- .../Pixiv/PixivFollowingDownloader.cs | 2 +- .../Pixiv/PixivRankingDownloader.cs | 2 +- .../Pixiv/PixivSearchDownloader.cs | 2 +- .../Components/SpecialTextPrefabs.cs | 104 -------- .../Services/MediaLibraryTemplateService.cs | 8 +- .../Services/ResourceService.cs | 9 +- .../Services/SpecialTextService.cs | 174 ------------ .../V240/TextSystemMigrator.cs | 94 +++++++ .../Abstractions/Models/ComparisonContext.cs | 9 +- .../Services/ComparisonService.cs | 11 +- .../Enhancers/AbstractKeywordEnhancer.cs | 5 +- .../Components/Enhancers/Av/AvEnhancer.cs | 5 +- .../Enhancers/Bakabase/BakabaseEnhancer.cs | 26 +- .../Enhancers/Bangumi/BangumiEnhancer.cs | 5 +- .../Enhancers/DLsite/DLsiteEnhancer.cs | 3 +- .../Enhancers/ExHentai/ExHentaiEnhancer.cs | 10 +- .../Components/Enhancers/Tmdb/TmdbEnhancer.cs | 5 +- .../Abstractions/Models/Db/TextEntry.cs | 2 +- .../Abstractions/Models/Db/TextType.cs | 2 +- .../Bakabase.Modules.Text.csproj | 1 + .../Components/TextOps.cs | 175 ++++++++++++ .../Components/TextSeedData.cs | 88 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 26 ++ .../Extensions/TextExtensions.cs | 27 ++ .../Services/TextVocabularyService.cs | 248 ++++++++++++++++++ .../Implementations/TestBakabaseLocalizer.cs | 1 - .../Bakabase.Tests/SpecialTextServiceTests.cs | 89 ------- src/tests/Bakabase.Tests/TextOpsTests.cs | 141 ++++++++++ .../TextVocabularyServiceTests.cs | 151 +++++++++++ 71 files changed, 1424 insertions(+), 664 deletions(-) create mode 100644 src/abstractions/Bakabase.Abstractions/Components/Text/ITextOps.cs delete mode 100644 src/abstractions/Bakabase.Abstractions/Extensions/SpecialTextExtensions.cs create mode 100644 src/abstractions/Bakabase.Abstractions/Extensions/TextSetExtensions.cs rename src/{modules/Bakabase.Modules.Text/Abstractions => abstractions/Bakabase.Abstractions}/Models/Domain/Constants/TextTypeShape.cs (92%) rename src/{modules/Bakabase.Modules.Text/Abstractions => abstractions/Bakabase.Abstractions}/Models/Domain/Constants/WellKnownTextType.cs (89%) create mode 100644 src/abstractions/Bakabase.Abstractions/Models/Domain/TextEntryValue.cs create mode 100644 src/abstractions/Bakabase.Abstractions/Models/Domain/TextSet.cs create mode 100644 src/abstractions/Bakabase.Abstractions/Models/Domain/TextTypeDescriptor.cs delete mode 100644 src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextAddInputModel.cs delete mode 100644 src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextPatchInputModel.cs delete mode 100644 src/abstractions/Bakabase.Abstractions/Services/ISpecialTextService.cs create mode 100644 src/abstractions/Bakabase.Abstractions/Services/ITextVocabularyService.cs delete mode 100644 src/apps/Bakabase.Service/Controllers/SpecialTextController.cs create mode 100644 src/apps/Bakabase.Service/Controllers/TextController.cs create mode 100644 src/apps/Bakabase.Service/Models/Input/TextInputModels.cs delete mode 100644 src/legacy/Bakabase.InsideWorld.Business/Components/SpecialTextPrefabs.cs delete mode 100644 src/legacy/Bakabase.InsideWorld.Business/Services/SpecialTextService.cs create mode 100644 src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs create mode 100644 src/modules/Bakabase.Modules.Text/Components/TextOps.cs create mode 100644 src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs create mode 100644 src/modules/Bakabase.Modules.Text/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/modules/Bakabase.Modules.Text/Extensions/TextExtensions.cs create mode 100644 src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs delete mode 100644 src/tests/Bakabase.Tests/SpecialTextServiceTests.cs create mode 100644 src/tests/Bakabase.Tests/TextOpsTests.cs create mode 100644 src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs diff --git a/src/abstractions/Bakabase.Abstractions/Components/Localization/IBakabaseLocalizer.cs b/src/abstractions/Bakabase.Abstractions/Components/Localization/IBakabaseLocalizer.cs index 936af9b91..db8a56f86 100644 --- a/src/abstractions/Bakabase.Abstractions/Components/Localization/IBakabaseLocalizer.cs +++ b/src/abstractions/Bakabase.Abstractions/Components/Localization/IBakabaseLocalizer.cs @@ -23,7 +23,7 @@ public interface IBakabaseLocalizer string ValueIsNotSet(string name); string NewFolderName(); string Downloader_FailedToStart(string taskName, string message); - string SpecialText_HistoricalLanguageValue2ShouldBeModified(); + string Unknown(); string Failed(); string Decompress(); diff --git a/src/abstractions/Bakabase.Abstractions/Components/Text/ITextOps.cs b/src/abstractions/Bakabase.Abstractions/Components/Text/ITextOps.cs new file mode 100644 index 000000000..56a6b5381 --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Components/Text/ITextOps.cs @@ -0,0 +1,71 @@ +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Abstractions.Components.Text; + +/// +/// Text operations driven by the vocabulary: what is done with the words. Stateless — every method +/// is a pure function of its inputs plus the current vocabulary, which makes the whole surface +/// unit-testable and reusable by workflow nodes, enhancers, downloaders and comparison alike. +/// +public interface ITextOps +{ + /// + /// Normalizes a name for analysis: applies standardization mappings, collapses whitespace, + /// trims around trim markers and wrappers, then strips wrapped useless words. + /// + Task Clean(string text); + + /// + /// Removes wrapped segments whose content matches the given type's entries. + /// + /// A type. + /// The type whose entries the wrapped content is matched against. + Task RemoveWrapped(string text, int wrappersTypeId, int setTypeId, TextMatchMode mode); + + /// + /// Removes bare occurrences matching the given type's entries. + /// + Task RemoveTexts(string text, int setTypeId, TextMatchMode mode); + + /// + /// Cleans up leftovers of removal: repeated whitespace, empty wrapper pairs, and edge + /// whitespace or separators. + /// + Task Trim(string text, TextTrimOptions options); + + /// + /// Parses using the configured date-time formats, falling back to . + /// + Task TryParseDateTime(string? text); + + /// + Task> TryParseDateTime(string[] texts); +} + +/// +/// How an entry is matched against a candidate piece of text. +/// +public enum TextMatchMode +{ + /// The candidate equals an entry (case-insensitive). + EqualsAny = 1, + + /// The candidate contains an entry (case-insensitive). + ContainsAny = 2, + + /// An entry, read as a regular expression, matches the candidate. + RegexAny = 3 +} + +/// Collapse runs of whitespace into a single space. +/// Trim whitespace and separator characters from both ends. +/// Drop wrapper pairs left holding nothing but whitespace. +public readonly record struct TextTrimOptions( + bool CollapseSpaces = true, + bool TrimEnds = true, + bool RemoveEmptyWrappers = true) +{ + // Spelled out rather than `new()`: a record struct's parameterless constructor zeroes every + // field, so it would produce all-false instead of the declared defaults. + public static readonly TextTrimOptions Default = new(true, true, true); +} diff --git a/src/abstractions/Bakabase.Abstractions/Extensions/SpecialTextExtensions.cs b/src/abstractions/Bakabase.Abstractions/Extensions/SpecialTextExtensions.cs deleted file mode 100644 index 469c9fff7..000000000 --- a/src/abstractions/Bakabase.Abstractions/Extensions/SpecialTextExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bakabase.Abstractions.Models.Domain; -using Bakabase.Abstractions.Models.Domain.Constants; - -namespace Bakabase.Abstractions.Extensions; - -public static class SpecialTextExtensions -{ - public static SpecialText ToDomainModel(this Models.Db.SpecialText dbModel) - { - return new SpecialText - { - Id = dbModel.Id, - Value1 = dbModel.Value1, - Value2 = dbModel.Value2, - Type = dbModel.Type - }; - } - - public static Models.Db.SpecialText ToDbModel(this SpecialText domainModel) - { - return new Models.Db.SpecialText - { - Id = domainModel.Id, - Value1 = domainModel.Value1, - Value2 = domainModel.Value2, - Type = domainModel.Type - }; - } - - public static Dictionary> ToMap(this IEnumerable texts) - { - return texts.GroupBy(x => x.Type).ToDictionary(d => d.Key, d => d.ToList()); - } -} \ No newline at end of file diff --git a/src/abstractions/Bakabase.Abstractions/Extensions/TextSetExtensions.cs b/src/abstractions/Bakabase.Abstractions/Extensions/TextSetExtensions.cs new file mode 100644 index 000000000..90b6763cc --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Extensions/TextSetExtensions.cs @@ -0,0 +1,19 @@ +using Bakabase.Abstractions.Models.Domain; + +namespace Bakabase.Abstractions.Extensions; + +public static class TextSetExtensions +{ + /// + /// Pairs as a lookup from first to second value. Later duplicates of a first value are + /// dropped, matching how the vocabulary has always been consumed. + /// + public static Dictionary ToPairMap(this TextSet set) => + set.Pairs.GroupBy(p => p.Value1).ToDictionary(g => g.Key, g => g.First().Value2); + + /// + /// Pairs as (Left, Right) tuples — the shape display-name rendering expects for wrappers. + /// + public static (string Left, string Right)[] ToLeftRightPairs(this TextSet set) => + set.Pairs.Select(p => (Left: p.Value1, Right: p.Value2)).ToArray(); +} diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs b/src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/TextTypeShape.cs similarity index 92% rename from src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs rename to src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/TextTypeShape.cs index 92acb6d7f..15e6f756d 100644 --- a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/TextTypeShape.cs +++ b/src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/TextTypeShape.cs @@ -1,4 +1,4 @@ -namespace Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; +namespace Bakabase.Abstractions.Models.Domain.Constants; /// /// How a type's entries use their two value slots. Drives editing UI, the type picker's diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs b/src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/WellKnownTextType.cs similarity index 89% rename from src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs rename to src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/WellKnownTextType.cs index 5613b9a33..0341c57ab 100644 --- a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Domain/Constants/WellKnownTextType.cs +++ b/src/abstractions/Bakabase.Abstractions/Models/Domain/Constants/WellKnownTextType.cs @@ -1,8 +1,8 @@ -namespace Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; +namespace Bakabase.Abstractions.Models.Domain.Constants; /// /// Code handle for a text type whose semantics are bound at a consumption site (cleaning, -/// date parsing, display-name wrappers, ...). A row carrying one of +/// date parsing, display-name wrappers, ...). A row carrying one of /// these is a builtin: it cannot be renamed or deleted, though its entries stay editable. /// Rows without a handle are user-defined and have no semantics beyond being referenced by id. /// diff --git a/src/abstractions/Bakabase.Abstractions/Models/Domain/TextEntryValue.cs b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextEntryValue.cs new file mode 100644 index 000000000..e78664119 --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextEntryValue.cs @@ -0,0 +1,19 @@ +namespace Bakabase.Abstractions.Models.Domain; + +/// +/// One entry of a text type, as exposed outside the vocabulary store. +/// +public record TextEntryValue +{ + public int Id { get; set; } + + public int TypeId { get; set; } + + public string Value1 { get; set; } = null!; + + /// + /// Meaning depends on the type's shape; always nullable, including under shapes that only use + /// the first value (historical rows may still carry one). + /// + public string? Value2 { get; set; } +} diff --git a/src/abstractions/Bakabase.Abstractions/Models/Domain/TextSet.cs b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextSet.cs new file mode 100644 index 000000000..7e27870f5 --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextSet.cs @@ -0,0 +1,31 @@ +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Abstractions.Models.Domain; + +/// +/// A resolved text type: its entries in the form its shape implies. This is the single "read the +/// words" surface consumers use, so none of them touch entry rows directly. +/// +public record TextSet +{ + public int TypeId { get; set; } + + public TextTypeShape Shape { get; set; } + + /// + /// Every entry's first value, in insertion order. Populated for all shapes — for pair shapes + /// it is the left/source side. + /// + public IReadOnlyList Values { get; set; } = []; + + /// + /// Entries of a pair-shaped type whose second value is present. Empty for + /// types, and skips pair rows missing their second value + /// rather than materializing a half pair. + /// + public IReadOnlyList Pairs { get; set; } = []; +} + +/// Opening delimiter, or the source of a mapping. +/// Closing delimiter, or the target of a mapping. +public readonly record struct TextPair(string Value1, string Value2); diff --git a/src/abstractions/Bakabase.Abstractions/Models/Domain/TextTypeDescriptor.cs b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextTypeDescriptor.cs new file mode 100644 index 000000000..89ec27aa1 --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Models/Domain/TextTypeDescriptor.cs @@ -0,0 +1,34 @@ +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Abstractions.Models.Domain; + +/// +/// A text type as everything outside the vocabulary store sees it: builtin and user-defined rows +/// in one shape, so pickers and management UIs consume a single list and reference a single id. +/// +public record TextTypeDescriptor +{ + public int Id { get; set; } + + /// + /// Canonical name. Builtin rows keep a stable English name here and are displayed through a + /// label localized by , so UI copy never lands in the database. + /// + public string Name { get; set; } = null!; + + /// + /// Non-null marks a builtin: it cannot be renamed or deleted, and consumption sites resolve + /// it by this handle rather than by id. + /// + public WellKnownTextType? WellKnown { get; set; } + + public TextTypeShape Shape { get; set; } + + public string? Description { get; set; } + + public DateTime CreatedAt { get; set; } + + public int EntryCount { get; set; } + + public bool IsBuiltin => WellKnown.HasValue; +} diff --git a/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextAddInputModel.cs b/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextAddInputModel.cs deleted file mode 100644 index 3a489a9b5..000000000 --- a/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextAddInputModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bakabase.Abstractions.Models.Domain.Constants; - -namespace Bakabase.Abstractions.Models.Input -{ - public class SpecialTextAddInputModel - { - public SpecialTextType Type { get; set; } - public string Value1 { get; set; } = null!; - public string? Value2 { get; set; } - } -} diff --git a/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextPatchInputModel.cs b/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextPatchInputModel.cs deleted file mode 100644 index 52adf4395..000000000 --- a/src/abstractions/Bakabase.Abstractions/Models/Input/SpecialTextPatchInputModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Bakabase.Abstractions.Models.Input -{ - public class SpecialTextPatchInputModel - { - public string? Value1 { get; set; } - public string? Value2 { get; set; } - } -} diff --git a/src/abstractions/Bakabase.Abstractions/Services/ISpecialTextService.cs b/src/abstractions/Bakabase.Abstractions/Services/ISpecialTextService.cs deleted file mode 100644 index ed234574a..000000000 --- a/src/abstractions/Bakabase.Abstractions/Services/ISpecialTextService.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Bakabase.Abstractions.Models.Domain; -using Bakabase.Abstractions.Models.Domain.Constants; -using Bakabase.Abstractions.Models.Input; -using Bakabase.InsideWorld.Models.Constants; -using Bootstrap.Models.ResponseModels; -using System.Linq.Expressions; - -namespace Bakabase.Abstractions.Services; - -public interface ISpecialTextService -{ - // [Obsolete] - // List this[SpecialTextType type] { get; } - Task Pretreatment(string name); - Task> GetAll(Expression>? selector = null, bool asNoTracking = true); - - Task> GetAllWrappers(); - // Task> GetAll(Expression> selector = null, bool asNoTracking = true); - Task> Add(SpecialTextAddInputModel model); - // Task> Add(SpecialText resource); - Task Patch(int id, SpecialTextPatchInputModel model); - // Task> UpdateByKey(Int32 key, Action modify); - Task AddPrefabs(); - Task TryToParseDateTime(string? str); - Task> TryToParseDateTime(string[] strings); - // Task TryToParseLanguage(string str); - // Task MatchAllContentsWithWrappers(string str); - // Task<(VolumeDto Volume, string Match, int Index)?> TryToParseVolume(string str); - // InsideWorldDbContext DbContext { get; } - // void ClearCache(); - // Task GetByKey(Int32 key, bool asNoTracking = true); - // Task GetByKeys(IEnumerable keys, bool asNoTracking = true); - // - // Task GetFirst(Expression> selector, - // Expression> orderBy = null, bool asc = false, bool asNoTracking = true); - // - Task Count(Func? selector = null); - // - // /// - // /// - // /// - // /// - // /// - // /// - // /// Key Selector - Asc - // /// - // /// - // Task> Search(Func selector, - // int pageIndex, int pageSize, (Func SelectKey, bool Asc, IComparer? comparer)[] orders, - // bool asNoTracking = true); - // - // Task> Search(Func selector, - // int pageIndex, int pageSize, Func orderBy = null, bool asc = false, IComparer? comparer = null, bool asNoTracking = true); - // - // Task Remove(SpecialText resource); - // Task RemoveRange(IEnumerable resources); - // Task RemoveAll(Expression> selector); - Task DeleteByKey(Int32 key); - // Task RemoveByKeys(IEnumerable keys); - Task> AddRange(List resources); - // Task Update(SpecialText resource); - // Task UpdateRange(IReadOnlyCollection resources); - // - // Task> UpdateByKeys(IReadOnlyCollection keys, - // Action modify); - // - // Task> UpdateFirst(Expression> selector, - // Action modify); - // - // Task> UpdateAll(Expression> selector, - // Action modify); -} \ No newline at end of file diff --git a/src/abstractions/Bakabase.Abstractions/Services/ITextVocabularyService.cs b/src/abstractions/Bakabase.Abstractions/Services/ITextVocabularyService.cs new file mode 100644 index 000000000..670cd193e --- /dev/null +++ b/src/abstractions/Bakabase.Abstractions/Services/ITextVocabularyService.cs @@ -0,0 +1,52 @@ +using Bakabase.Abstractions.Models.Domain; +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Abstractions.Services; + +/// +/// Owns text types and their entries: what words exist. What is done with those words lives in +/// . +/// +/// Builtin and user-defined types are the same kind of row here — is the +/// one list every picker and management view consumes. +/// +public interface ITextVocabularyService +{ + Task> GetTypes(); + + Task AddType(string name, TextTypeShape shape, string? description = null); + + /// Throws for builtin types. + Task RenameType(int id, string name); + + /// Throws for builtin types. + Task DeleteType(int id); + + Task> GetEntries(int typeId); + + Task AddEntry(int typeId, string value1, string? value2 = null); + + Task PatchEntry(int id, string? value1, string? value2); + + Task DeleteEntry(int id); + + Task AddEntries(int typeId, IEnumerable<(string Value1, string? Value2)> entries); + + /// + /// Resolve a type's entries into the form its shape implies. The single "read the words" + /// entry point for consumers. + /// + Task ResolveSet(int typeId); + + /// + Task ResolveSet(WellKnownTextType wellKnown); + + Task GetTypeId(WellKnownTextType wellKnown); + + /// + /// Ensures builtin types exist and tops up their prefab entries. Idempotent: an entry already + /// present under its type is left alone, so user deletions of prefab entries are not undone + /// within a run and re-adding stays a no-op. + /// + Task EnsureSeeds(); +} diff --git a/src/apps/Bakabase.Service/Controllers/DashboardController.cs b/src/apps/Bakabase.Service/Controllers/DashboardController.cs index 383e21eb8..9ddffeae8 100644 --- a/src/apps/Bakabase.Service/Controllers/DashboardController.cs +++ b/src/apps/Bakabase.Service/Controllers/DashboardController.cs @@ -33,7 +33,7 @@ public class DashboardController : Controller private readonly IThirdPartyService _thirdPartyService; private readonly IBOptions _fsOptions; private readonly IAliasService _aliasService; - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; private readonly PasswordService _passwordService; private readonly IPropertyService _propertyService; private readonly IBakabaseLocalizer _localizer; @@ -41,7 +41,7 @@ public class DashboardController : Controller private readonly IMediaLibraryResourceMappingService _mediaLibraryResourceMappingService; public DashboardController(IResourceService resourceService, DownloadTaskService downloadTaskService, IThirdPartyService thirdPartyService, - IBOptions fsOptions, IAliasService aliasService, ISpecialTextService specialTextService, PasswordService passwordService, + IBOptions fsOptions, IAliasService aliasService, ITextVocabularyService textVocabularyService, PasswordService passwordService, IPropertyService propertyService, IBakabaseLocalizer localizer, IMediaLibraryV2Service mediaLibraryV2Service, IMediaLibraryResourceMappingService mediaLibraryResourceMappingService) { @@ -50,7 +50,7 @@ public DashboardController(IResourceService resourceService, DownloadTaskService _thirdPartyService = thirdPartyService; _fsOptions = fsOptions; _aliasService = aliasService; - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; _passwordService = passwordService; _propertyService = propertyService; _localizer = localizer; @@ -138,7 +138,7 @@ public async Task> GetStatistics() // Alias, Special Text var aliasCount = await _aliasService.Count(); - var stCount = await _specialTextService.Count(); + var stCount = (await _textVocabularyService.GetTypes()).Sum(t => t.EntryCount); ds.OtherCounts.Add([ new("Aliases", aliasCount), new("SpecialTexts", stCount) diff --git a/src/apps/Bakabase.Service/Controllers/FileController.cs b/src/apps/Bakabase.Service/Controllers/FileController.cs index 31b8d0651..8a0da00d4 100644 --- a/src/apps/Bakabase.Service/Controllers/FileController.cs +++ b/src/apps/Bakabase.Service/Controllers/FileController.cs @@ -55,13 +55,14 @@ using Microsoft.Extensions.Logging; using MimeKit; using Swashbuckle.AspNetCore.Annotations; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Service.Controllers { [Route("~/file")] public class FileController : Controller { - private readonly ISpecialTextService _specialTextService; + private readonly ITextOps _textOps; private readonly IWebHostEnvironment _env; private readonly BTaskManager _taskManager; private readonly CompressedFileService _compressedFileService; @@ -79,7 +80,7 @@ public class FileController : Controller private readonly AppService _appService; private readonly Bakabase.Service.Services.FileSystemEntryGroupingService _groupingService; - public FileController(ISpecialTextService specialTextService, IWebHostEnvironment env, + public FileController(ITextOps textOps, IWebHostEnvironment env, CompressedFileService compressedFileService, IBOptionsManager fsOptionsManager, IwFsWatcher fileProcessorWatcher, PasswordService passwordService, ILogger logger, BakabaseLocalizer localizer, BTaskManager taskManager, IGuiAdapter guiAdapter, @@ -87,7 +88,7 @@ public FileController(ISpecialTextService specialTextService, IWebHostEnvironmen ISystemPlayer systemPlayer, IFileManager fileManager, AppService appService, Bakabase.Service.Services.FileSystemEntryGroupingService groupingService) { - _specialTextService = specialTextService; + _textOps = textOps; _env = env; _compressedFileService = compressedFileService; _fsOptionsManager = fsOptionsManager; @@ -1136,7 +1137,7 @@ public async Task RemoveSameNameEntryInWorkingDirectory( public async Task StandardizeEntryName(string path) { var filename = Path.GetFileName(path); - var newName = await _specialTextService.Pretreatment(filename); + var newName = await _textOps.Clean(filename); if (filename == newName) { return BaseResponseBuilder.NotModified; diff --git a/src/apps/Bakabase.Service/Controllers/SpecialTextController.cs b/src/apps/Bakabase.Service/Controllers/SpecialTextController.cs deleted file mode 100644 index 57fb9d6f7..000000000 --- a/src/apps/Bakabase.Service/Controllers/SpecialTextController.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Bakabase.Abstractions.Models.Domain; -using Bakabase.Abstractions.Models.Input; -using Bakabase.Abstractions.Services; -using Bootstrap.Models.ResponseModels; -using Microsoft.AspNetCore.Mvc; -using Swashbuckle.AspNetCore.Annotations; - -namespace Bakabase.Service.Controllers -{ - [Route("~/special-text")] - public class SpecialTextController(ISpecialTextService textService) : Controller - { - [HttpGet] - [SwaggerOperation(OperationId = "GetAllSpecialTexts")] - public async Task>>> GetAll() - { - return new((await textService.GetAll()).GroupBy(d => d.Type) - .ToDictionary(d => (int) d.Key, d => d.ToList())); - } - - [HttpDelete("{id}")] - [SwaggerOperation(OperationId = "DeleteSpecialText")] - public async Task Delete(int id) - { - return await textService.DeleteByKey(id); - } - - [HttpPost] - [SwaggerOperation(OperationId = "AddSpecialText")] - public async Task> Add([FromBody] SpecialTextAddInputModel model) - { - return await textService.Add(model); - } - - [HttpPut("{id}")] - [SwaggerOperation(OperationId = "PatchSpecialText")] - public async Task Patch(int id, [FromBody] SpecialTextPatchInputModel model) - { - return await textService.Patch(id, model); - } - - [HttpPost("prefabs")] - [SwaggerOperation(OperationId = "AddSpecialTextPrefabs")] - public async Task AddPrefabs() - { - return await textService.AddPrefabs(); - } - - [HttpPost("pretreatment")] - [SwaggerOperation(OperationId = "PretreatText")] - public async Task> Pretreat(string text) - { - var result = await textService.Pretreatment(text); - return new SingletonResponse(result); - } - } -} \ No newline at end of file diff --git a/src/apps/Bakabase.Service/Controllers/TextController.cs b/src/apps/Bakabase.Service/Controllers/TextController.cs new file mode 100644 index 000000000..7f86ae880 --- /dev/null +++ b/src/apps/Bakabase.Service/Controllers/TextController.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bakabase.Abstractions.Components.Text; +using Bakabase.Abstractions.Models.Domain; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.Service.Models.Input; +using Bootstrap.Components.Miscellaneous.ResponseBuilders; +using Bootstrap.Models.ResponseModels; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace Bakabase.Service.Controllers +{ + [Route("~/text")] + public class TextController(ITextVocabularyService vocabulary, ITextOps textOps) : Controller + { + /// + /// Builtin and user-defined types in one list — the single source for pickers and the + /// management page. + /// + [HttpGet("type")] + [SwaggerOperation(OperationId = "GetAllTextTypes")] + public async Task> GetTypes() => new(await vocabulary.GetTypes()); + + [HttpPost("type")] + [SwaggerOperation(OperationId = "AddTextType")] + public async Task> AddType([FromBody] TextTypeAddInputModel model) => + new(await vocabulary.AddType(model.Name, model.Shape, model.Description)); + + [HttpPut("type/{id}")] + [SwaggerOperation(OperationId = "RenameTextType")] + public async Task RenameType(int id, [FromBody] TextTypePatchInputModel model) + { + await vocabulary.RenameType(id, model.Name); + return BaseResponseBuilder.Ok; + } + + [HttpDelete("type/{id}")] + [SwaggerOperation(OperationId = "DeleteTextType")] + public async Task DeleteType(int id) + { + await vocabulary.DeleteType(id); + return BaseResponseBuilder.Ok; + } + + [HttpGet("type/{typeId}/entry")] + [SwaggerOperation(OperationId = "GetTextEntries")] + public async Task> GetEntries(int typeId) => + new(await vocabulary.GetEntries(typeId)); + + [HttpPost("type/{typeId}/entry")] + [SwaggerOperation(OperationId = "AddTextEntry")] + public async Task> AddEntry(int typeId, + [FromBody] TextEntryAddInputModel model) => + new(await vocabulary.AddEntry(typeId, model.Value1, model.Value2)); + + [HttpPost("type/{typeId}/entry/batch")] + [SwaggerOperation(OperationId = "AddTextEntries")] + public async Task AddEntries(int typeId, [FromBody] List models) + { + await vocabulary.AddEntries(typeId, models.Select(m => (m.Value1, m.Value2))); + return BaseResponseBuilder.Ok; + } + + [HttpPut("entry/{id}")] + [SwaggerOperation(OperationId = "PatchTextEntry")] + public async Task PatchEntry(int id, [FromBody] TextEntryPatchInputModel model) + { + await vocabulary.PatchEntry(id, model.Value1, model.Value2); + return BaseResponseBuilder.Ok; + } + + [HttpDelete("entry/{id}")] + [SwaggerOperation(OperationId = "DeleteTextEntry")] + public async Task DeleteEntry(int id) + { + await vocabulary.DeleteEntry(id); + return BaseResponseBuilder.Ok; + } + + /// + /// Resolved view of a type — what a node configuration form shows when previewing a set. + /// + [HttpGet("type/{typeId}/set")] + [SwaggerOperation(OperationId = "ResolveTextSet")] + public async Task> ResolveSet(int typeId) => new(await vocabulary.ResolveSet(typeId)); + + [HttpPost("seeds")] + [SwaggerOperation(OperationId = "EnsureTextSeeds")] + public async Task EnsureSeeds() + { + await vocabulary.EnsureSeeds(); + return BaseResponseBuilder.Ok; + } + + [HttpPost("clean")] + [SwaggerOperation(OperationId = "CleanText")] + public async Task> Clean(string text) => new(await textOps.Clean(text)); + } +} diff --git a/src/apps/Bakabase.Service/Extensions/BakabaseBusinessExtensions.cs b/src/apps/Bakabase.Service/Extensions/BakabaseBusinessExtensions.cs index 35c8b486c..c4eacf99c 100644 --- a/src/apps/Bakabase.Service/Extensions/BakabaseBusinessExtensions.cs +++ b/src/apps/Bakabase.Service/Extensions/BakabaseBusinessExtensions.cs @@ -49,6 +49,8 @@ using Bakabase.Modules.Presets.Extensions; using Bakabase.Modules.Property.Extensions; using Bakabase.Modules.StandardValue.Extensions; +using Bakabase.Modules.Text.Components; +using Bakabase.Modules.Text.Extensions; using Bakabase.Modules.Comparison.Extensions; using Bakabase.Modules.DataCard.Extensions; using Bakabase.InsideWorld.Business.Components.Resolvers; @@ -59,7 +61,6 @@ using Bootstrap.Components.Orm.Infrastructures; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using SpecialText = Bakabase.Abstractions.Models.Db.SpecialText; namespace Bakabase.Service.Extensions { @@ -102,7 +103,8 @@ public static IServiceCollection AddInsideWorldBusinesses(this IServiceCollectio services.AddAlias(); services.AddProperty(); services.AddEnhancers(); - services.AddStandardValue(); + services.AddText(); + services.AddStandardValue(); services.AddReservedProperty(); services.AddScoped>(); @@ -113,9 +115,6 @@ public static IServiceCollection AddInsideWorldBusinesses(this IServiceCollectio .AddScoped(); services.AddSingleton(); - services.AddScoped>(); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped>(); services.AddScoped>(); services.AddScoped(); diff --git a/src/apps/Bakabase.Service/Models/Input/TextInputModels.cs b/src/apps/Bakabase.Service/Models/Input/TextInputModels.cs new file mode 100644 index 000000000..9265b5e44 --- /dev/null +++ b/src/apps/Bakabase.Service/Models/Input/TextInputModels.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Service.Models.Input; + +public record TextTypeAddInputModel +{ + [Required, MaxLength(64)] public string Name { get; set; } = null!; + + public TextTypeShape Shape { get; set; } = TextTypeShape.Values; + + [MaxLength(256)] public string? Description { get; set; } +} + +public record TextTypePatchInputModel +{ + [Required, MaxLength(64)] public string Name { get; set; } = null!; +} + +public record TextEntryAddInputModel +{ + [Required, MaxLength(64)] public string Value1 { get; set; } = null!; + + [MaxLength(64)] public string? Value2 { get; set; } +} + +public record TextEntryPatchInputModel +{ + [MaxLength(64)] public string? Value1 { get; set; } + + [MaxLength(64)] public string? Value2 { get; set; } +} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/BakabaseLocalizer.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/BakabaseLocalizer.cs index 8310274a5..b6dde1e4b 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/BakabaseLocalizer.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/BakabaseLocalizer.cs @@ -97,8 +97,6 @@ private string Resource_CannotSaveCoverToCurrentDirectoryForSingleFileResource() public string Downloader_FailedToStart(string taskName, string message) => this[nameof(Downloader_FailedToStart), taskName, message]; - public string SpecialText_HistoricalLanguageValue2ShouldBeModified() => - this[nameof(SpecialText_HistoricalLanguageValue2ShouldBeModified)]; public string Unknown() => this[nameof(Unknown)]; public string Failed() diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs index 2d44a5245..ce1e37fa7 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs @@ -13,6 +13,8 @@ using Bootstrap.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Extensions; namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloaders { @@ -39,7 +41,7 @@ public void ResetStatus() private DownloaderStatus _status = DownloaderStatus.JustCreated; protected readonly ILogger Logger; private readonly IDownloaderFactory _downloaderFactory; - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; protected IServiceProvider ServiceProvider; public string? Checkpoint { get; protected set; } @@ -57,7 +59,7 @@ protected AbstractDownloader(IServiceProvider serviceProvider) ServiceProvider = serviceProvider; Logger = serviceProvider.GetRequiredService().CreateLogger(GetType()); _downloaderFactory = serviceProvider.GetRequiredService(); - _specialTextService = serviceProvider.GetRequiredService(); + _specialTextService = serviceProvider.GetRequiredService(); Definition = DownloaderInternals.DownloaderTypeDefinitionMap[GetType()]; } @@ -111,7 +113,7 @@ protected async Task BuildDownloadFilename( } } - var wrappers = await _specialTextService.GetAllWrappers(); + var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); // Remove empty wrappers if (wrappers.Any()) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs index afe61d214..3ea973b5b 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs @@ -17,6 +17,7 @@ using Bootstrap.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; +using Bakabase.Abstractions.Extensions; namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloaders.Bilibili { @@ -25,7 +26,7 @@ public class BilibiliDownloader : AbstractDownloader private IStringLocalizer _localizer; private readonly IHostEnvironment _env; private readonly BilibiliClient _client; - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Bilibili; public override BilibiliDownloadTaskType EnumTaskType => BilibiliDownloadTaskType.Favorites; @@ -33,7 +34,7 @@ public class BilibiliDownloader : AbstractDownloader private readonly LuxService _luxService; public BilibiliDownloader(IStringLocalizer localizer, - BilibiliClient client, ISpecialTextService specialTextService, + BilibiliClient client, ITextVocabularyService specialTextService, IHostEnvironment env, IServiceProvider serviceProvider, LuxService luxService) : base(serviceProvider) { _localizer = localizer; @@ -178,11 +179,7 @@ protected override async Task StartCore(DownloadTask task, CancellationToken ct) $"[{postIndex + 1}/{totalCount}][{post.Upper?.Name}]{post.Title} - P{i} - {bestQuality?.Description}"; await OnCurrentChangedInternal(); - var wrappers = - (await _specialTextService.GetAll(a => a.Type == SpecialTextType.Wrapper)) - .Where(a => a.Value1.IsNotEmpty() && a.Value2.IsNotEmpty()) - .GroupBy(a => a.Value1) - .ToDictionary(a => a.Key, a => a.FirstOrDefault()!.Value2!); + var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); var keyFilename = await BuildDownloadFilename(videoValues); diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs index d750857c3..9cde5a7e8 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs @@ -14,10 +14,10 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractCienDownloader : AbstractDownloader { - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Cien; - protected AbstractCienDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider) + protected AbstractCienDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) { _specialTextService = specialTextService; } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs index 9e9ac11a5..619623c2d 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs @@ -14,6 +14,8 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Extensions; namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloaders.ExHentai { @@ -21,12 +23,12 @@ public abstract class AbstractExHentaiDownloader : AbstractDownloader Localizer; protected readonly ExHentaiClient Client; - protected readonly ISpecialTextService SpecialTextService; + protected readonly ITextVocabularyService SpecialTextService; protected readonly IHostEnvironment Env; protected AbstractExHentaiDownloader(IServiceProvider serviceProvider, IStringLocalizer localizer, - ExHentaiClient client, ISpecialTextService specialTextService, + ExHentaiClient client, ITextVocabularyService specialTextService, IHostEnvironment env) : base(serviceProvider) { Localizer = localizer; @@ -115,7 +117,7 @@ protected async Task DownloadSingleWork(string url, string checkpoint, string do {ExHentaiNamingFields.Category, detail.Category}, }; - var wrappers = await SpecialTextService.GetAllWrappers(); + var wrappers = (await SpecialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); //var limit = await _client.GetImageLimits(); //if (limit.Rest <= imageTitleAndPageUrls.Length) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs index 4d3f94547..b1b7ee6ed 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs @@ -15,7 +15,7 @@ public class ExHentaiListDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, IHostEnvironment env) : AbstractExHentaiDownloader(serviceProvider, localizer, client, specialTextService, env) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs index cf5531280..5abe5d17d 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs @@ -15,7 +15,7 @@ public class ExHentaiSingleWorkDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, IHostEnvironment env) : AbstractExHentaiDownloader(serviceProvider, localizer, client, specialTextService, env) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs index 00c230c89..221ba38d3 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs @@ -14,7 +14,7 @@ public class ExHentaiWatchedDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, IHostEnvironment env) : ExHentaiListDownloader(serviceProvider, localizer, client, specialTextService, env) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs index 685d11227..cb1274e2a 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs @@ -12,10 +12,10 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractFanboxDownloader : AbstractDownloader { - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Fanbox; - protected AbstractFanboxDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider) + protected AbstractFanboxDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) { _specialTextService = specialTextService; } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs index e63f4f08d..e01254590 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs @@ -13,7 +13,7 @@ public class FanboxCreatorDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.Creator; - public FanboxCreatorDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FanboxCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs index f33e84d5d..96897a012 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs @@ -13,7 +13,7 @@ public class FanboxFollowingDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.Following; - public FanboxFollowingDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FanboxFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs index f8c4fe5fd..c54a9fca3 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class FanboxSinglePostDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.SinglePost; - public FanboxSinglePostDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FanboxSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs index a9c91a669..a0010b3e5 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs @@ -14,10 +14,10 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractFantiaDownloader : AbstractDownloader { - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Fantia; - protected AbstractFantiaDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider) + protected AbstractFantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) { _specialTextService = specialTextService; } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs index 9b1c55ebe..437c48315 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs @@ -13,7 +13,7 @@ public class FantiaCreatorDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.Creator; - public FantiaCreatorDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FantiaCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs index 033fb3384..265d238b4 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs @@ -13,7 +13,7 @@ public class FantiaDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.SinglePost; - public FantiaDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs index f6ea8ee0a..6eac1dfab 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs @@ -13,7 +13,7 @@ public class FantiaFollowingDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.Following; - public FantiaFollowingDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FantiaFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs index 723b98309..829111388 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class FantiaSinglePostDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.SinglePost; - public FantiaSinglePostDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public FantiaSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs index 6629482e7..51511971e 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs @@ -14,10 +14,10 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractPatreonDownloader : AbstractDownloader { - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Patreon; - protected AbstractPatreonDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider) + protected AbstractPatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) { _specialTextService = specialTextService; } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs index 220c817f9..b972204d9 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs @@ -13,7 +13,7 @@ public class PatreonCreatorDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.Creator; - public PatreonCreatorDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public PatreonCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs index 4ba414db9..b21f8602e 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs @@ -13,7 +13,7 @@ public class PatreonDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.SinglePost; - public PatreonDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public PatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs index fbae95a81..dee27e4b9 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs @@ -13,7 +13,7 @@ public class PatreonFollowingDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.Following; - public PatreonFollowingDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public PatreonFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs index 8f2094e11..431a0047f 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class PatreonSinglePostDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.SinglePost; - public PatreonSinglePostDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService) : base(serviceProvider, specialTextService) + public PatreonSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs index e19c07336..1dc252a9f 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs @@ -10,15 +10,16 @@ using Bakabase.InsideWorld.Models.Constants; using Bakabase.Modules.ThirdParty.ThirdParties.Pixiv; using Bootstrap.Extensions; +using Bakabase.Abstractions.Extensions; namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloaders.Pixiv { public abstract class AbstractPixivDownloader : AbstractDownloader { - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _specialTextService; protected readonly PixivClient Client; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Pixiv; - protected AbstractPixivDownloader(IServiceProvider serviceProvider, ISpecialTextService specialTextService, + protected AbstractPixivDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService, PixivClient client) : base(serviceProvider) { _specialTextService = specialTextService; @@ -40,11 +41,7 @@ protected async Task DownloadSingleWork(string id, string downloadPath, PixivNam { await ChangeCurrent(nameContext); - var wrappers = - (await _specialTextService.GetAll(a => a.Type == SpecialTextType.Wrapper)) - .Where(a => a.Value1.IsNotEmpty() && a.Value2.IsNotEmpty()) - .GroupBy(a => a.Value1) - .ToDictionary(a => a.Key, a => a.FirstOrDefault()!.Value2); + var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); var lastFileNameValues = nameContext.ToLastFileNameValues(); if (lastFileNameValues != null) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs index e020c8ba6..7770914e3 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs @@ -17,7 +17,7 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa /// public class PixivFollowingDownloader( IServiceProvider serviceProvider, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, PixivClient client) : AbstractPixivDownloader(serviceProvider, specialTextService, client) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs index 6ba2199e4..0450da61c 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs @@ -15,7 +15,7 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa /// public class PixivRankingDownloader( IServiceProvider serviceProvider, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, PixivClient client) : AbstractPixivDownloader(serviceProvider, specialTextService, client) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs index fda54bcea..3f7db0773 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs @@ -16,7 +16,7 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public class PixivSearchDownloader( IServiceProvider serviceProvider, - ISpecialTextService specialTextService, + ITextVocabularyService specialTextService, PixivClient client) : AbstractPixivDownloader(serviceProvider, specialTextService, client) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/SpecialTextPrefabs.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/SpecialTextPrefabs.cs deleted file mode 100644 index 1f25af739..000000000 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/SpecialTextPrefabs.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Bakabase.Abstractions.Models.Domain; -using Bakabase.Abstractions.Models.Domain.Constants; - -namespace Bakabase.InsideWorld.Business.Components -{ - public class SpecialTextPrefabs - { - public static List Texts = - new Dictionary> - { - { - SpecialTextType.Useless, new List<(string Value1, string Value2)> - { - (@"[Cc]\d{2}", null), - (@"COMIC1☆\d{1,2}", null), - ("成年コミック", null), - ("同人誌", null), - ("DL版", null), - ("彩頁部分", null), - ("無修正", null), - ("文盲組", null), - (@"\.mp4", null), - ("ゲームCG", null), - ("同人CG集", null), - ("18禁ゲームCG", null), - } - }, - { - SpecialTextType.Language, - new List<(string Value1, string Value2)> - { - ("汉化", "中文"), - ("中文", "中文"), - ("中国翻訳", "中文"), - ("CE家族社", "中文"), - ("漢化", "中文"), - ("阿提斯整個車頭的", "中文"), - ("CN", "中文") - } - }, - { - SpecialTextType.Wrapper, new List<(string Value1, string Value2)> - { - ("(", ")"), - ("[", "]"), - } - }, - { - SpecialTextType.Standardization, new List<(string Value1, string Value2)> - { - ("(", "("), - (")", ")"), - ("【", "["), - ("】", "]"), - (",", "、"), - ("#", "#"), - ("A", "A"), - ("!", "!"), - ("~", "~"), - } - }, - { - SpecialTextType.Volume, new List<(string Value1, int? Value2)> - { - (@"\s[A-Za-z]+[\.-]?\d+[\.-]?", null), - (@"[#]\d+", null), - ("上[巻卷]", 1), - ("(1st|2nd|3rd)", null), - (@"\d+限目", null), - ("第一[話章]", 1), - ("下[巻卷]", 2), - ("第二[話章]", 2), - ("第三[話章]", 3), - }.Select(a => (a.Value1, a.Value2?.ToString())).ToList() - }, - { - SpecialTextType.Trim, new List<(string Value1, string Value2)>() - }, - { - SpecialTextType.DateTime, new[] - { - "yyyyMMddHHmm", - "yyyyMMdd", - "yyyy-MM-dd", - "yyyy-M-d", - "yyyy年MM月dd日", - "yyyy年M月d日", - "yyMMdd", - "yy年M月d日", - "MMdd", - "M月d日" - }.Select(a => (a, (string) null)).ToList() - } - } - .SelectMany(t => t.Value.Select(b => new SpecialText - { - Type = t.Key, - Value1 = b.Value1, - Value2 = b.Value2 - })).ToList(); - } -} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Services/MediaLibraryTemplateService.cs b/src/legacy/Bakabase.InsideWorld.Business/Services/MediaLibraryTemplateService.cs index 98dd56a57..609f025ee 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Services/MediaLibraryTemplateService.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Services/MediaLibraryTemplateService.cs @@ -43,7 +43,7 @@ namespace Bakabase.InsideWorld.Business.Services; public class MediaLibraryTemplateService( FullMemoryCacheResourceService orm, IStandardValueService standardValueService, - ISpecialTextService specialTextService, + ITextVocabularyService textVocabularyService, IExtensionGroupService extensionGroupService, IPropertyService propertyService, ICustomPropertyService customPropertyService, @@ -290,8 +290,7 @@ public async Task GeneratePreview(int id) if (template.DisplayNameTemplate.IsNotEmpty()) { - var wrappers = (await specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper)) - .Select(x => (Left: x.Value1, Right: x.Value2!)).ToArray(); + var wrappers = (await textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToLeftRightPairs(); foreach (var r in resourcesMap.Values) { r.DisplayName = ResourceService.BuildDisplayNameForResource(r, template.DisplayNameTemplate, wrappers); @@ -811,8 +810,7 @@ public async Task Validate(int id, MediaLibraryTemplateValidationInputModel mode { if (template.DisplayNameTemplate.IsNotEmpty()) { - var wrappers = (await specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper)) - .Select(x => (Left: x.Value1, Right: x.Value2!)).ToArray(); + var wrappers = (await textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToLeftRightPairs(); foreach (var r in resources) { r.DisplayName = diff --git a/src/legacy/Bakabase.InsideWorld.Business/Services/ResourceService.cs b/src/legacy/Bakabase.InsideWorld.Business/Services/ResourceService.cs index 119f99840..c57dbb910 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Services/ResourceService.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Services/ResourceService.cs @@ -66,7 +66,7 @@ public class ResourceService : ScopedService, IResourceService { private readonly FullMemoryCacheResourceService _orm; private readonly FullMemoryCacheResourceService _resourceCacheOrm; - private readonly ISpecialTextService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; private IMediaLibraryV2Service MediaLibraryV2Service => GetRequiredService(); private IResourceProfileService ResourceProfileService => GetRequiredService(); private IMediaLibraryResourceMappingService MediaLibraryResourceMappingService => GetRequiredService(); @@ -104,7 +104,7 @@ public class ResourceService : ScopedService, IResourceService CancellationToken = ct }; - public ResourceService(IServiceProvider serviceProvider, ISpecialTextService specialTextService, + public ResourceService(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService, IAliasService aliasService, ILogger logger, ICustomPropertyService customPropertyService, ICustomPropertyValueService customPropertyValueService, @@ -121,7 +121,7 @@ public ResourceService(IServiceProvider serviceProvider, ISpecialTextService spe IBOptions uiOptions, IPropertyValueScopeResolver scopeResolver) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; _aliasService = aliasService; _logger = logger; _customPropertyService = customPropertyService; @@ -655,8 +655,7 @@ await Parallel.ForEachAsync(doList, parallelOptions, (r, ct) => { using (MiniProfiler.Current.Step("DisplayName")) { - var wrappers = (await _specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper)) - .Select(x => (Left: x.Value1, Right: x.Value2!)).ToArray(); + var wrappers = (await _textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToLeftRightPairs(); // Extract name templates from unified profile data (already fetched) Dictionary templateMap; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Services/SpecialTextService.cs b/src/legacy/Bakabase.InsideWorld.Business/Services/SpecialTextService.cs deleted file mode 100644 index d02b61093..000000000 --- a/src/legacy/Bakabase.InsideWorld.Business/Services/SpecialTextService.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Linq.Expressions; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Bakabase.Abstractions.Extensions; -using Bakabase.Abstractions.Helpers; -using Bakabase.Abstractions.Models.Domain; -using Bakabase.Abstractions.Models.Domain.Constants; -using Bakabase.Abstractions.Models.Input; -using Bakabase.Abstractions.Services; -using Bakabase.InsideWorld.Business.Components; -using Bakabase.Modules.StandardValue.Abstractions.Components; -using Bootstrap.Components.Miscellaneous.ResponseBuilders; -using Bootstrap.Components.Orm; -using Bootstrap.Extensions; -using Bootstrap.Models.ResponseModels; - -namespace Bakabase.InsideWorld.Business.Services -{ - public class SpecialTextService( - FullMemoryCacheResourceService orm) - : ISpecialTextService, ICustomDateTimeParser - { - public async Task> GetAll( - Expression>? selector = null, bool asNoTracking = true) - { - return (await orm.GetAll(selector, asNoTracking)).Select(s => s.ToDomainModel()).ToList(); - } - - public async Task> GetAllWrappers() - { - var wrappers = - (await GetAll(a => a.Type == SpecialTextType.Wrapper)) - .Where(a => a.Value1.IsNotEmpty() && a.Value2.IsNotEmpty()) - .GroupBy(a => a.Value1) - .ToDictionary(a => a.Key, a => a.FirstOrDefault()!.Value2!); - return wrappers; - } - - public async Task> Add(SpecialTextAddInputModel model) - { - var t = new SpecialText - { - Type = model.Type, - Value1 = model.Value1, - Value2 = model.Value2 - }.ToDbModel(); - var r = await orm.Add(t); - return new SingletonResponse(r.Data.ToDomainModel()); - } - - public async Task TryToParseDateTime(string? str) - { - if (string.IsNullOrEmpty(str)) - { - return null; - } - - var r = await TryToParseDateTime([str]); - return r?.Any() == true ? r[0].DateTime : null; - } - - public async Task> TryToParseDateTime(string[] strings) - { - var list = new List<(int Index, DateTime DateTime)>(); - if (strings.Any()) - { - var texts = await GetAll(t => t.Type == SpecialTextType.DateTime); - var formats = texts.Select(a => a.Value1).Distinct().ToArray(); - for (var i = 0; i < strings.Length; i++) - { - if (DateTime.TryParseExact(strings[i], formats, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeLocal, - out var dt)) - { - list.Add((i, dt)); - } - else - { - // fallback - if (DateTime.TryParse(strings[i], out var fallbackDt)) - { - list.Add((i, fallbackDt)); - } - } - } - - return list; - } - - return list; - } - - public async Task Count(Func? selector = null) - { - return await orm.Count(selector); - } - - public async Task DeleteByKey(int key) - { - return await orm.RemoveByKey(key); - } - - public async Task Patch(int id, SpecialTextPatchInputModel model) - { - await orm.UpdateByKey(id, t => - { - if (!string.IsNullOrEmpty(model.Value1)) - { - t.Value1 = model.Value1; - } - - if (!string.IsNullOrEmpty(model.Value2)) - { - t.Value2 = model.Value2; - } - }); - return BaseResponseBuilder.Ok; - } - - public async Task AddPrefabs() - { - var texts = (await GetAll()).ToMap(); - var newEntries = - SpecialTextPrefabs.Texts.Where(a => - !texts.TryGetValue(a.Type, out var exists) || - exists.All(e => !(e.Value1 == a.Value1 && e.Value2 == a.Value2))).ToList(); - await AddRange(newEntries); - return BaseResponseBuilder.Ok; - } - - public async Task> AddRange(List resources) - { - return new ListResponse((await orm.AddRange(resources.Select(r => r.ToDbModel()).ToList())) - .Data.Select(d => d.ToDomainModel())); - } - - public async Task Pretreatment(string name) - { - // Standardize - var standardizers = await GetAll(x => x.Type == SpecialTextType.Standardization); - name = standardizers.Aggregate(name, - (current, replace) => current.Replace(replace.Value1, replace.Value2)); - - // Remove unnecessary spaces. - name = new Regex(@"\s+").Replace(name, " ").Trim(); - - var wrappers = await GetAll(x => x.Type == SpecialTextType.Wrapper); - - // Trim - var trimFlag = (await GetAll(x => x.Type == SpecialTextType.Trim)).Select(t => t.Value1).ToList(); - // Add wrappers as trim targets. - trimFlag.AddRange(wrappers.Select(a => a.Value1)); - trimFlag.AddRange(wrappers.Select(a => a.Value2!)); - name = trimFlag.Aggregate(name, - (current, replace) => Regex.Replace(current, $@"\s*{Regex.Escape(replace)}\s*", replace)); - - // Useless words - var uselessWords = await GetAll(x => x.Type == SpecialTextType.Useless); - name = uselessWords.Aggregate(name, (current1, word) => wrappers.Aggregate(current1, (current, wrapper) => - { - // Must be wrapped. - var reg = StringHelpers.BuildRegexWithWrapper(wrapper.Value1, wrapper.Value2!, word.Value1); - current = reg.Replace(current, string.Empty); - return current; - })); - - return name; - } - } -} \ No newline at end of file diff --git a/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs b/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs new file mode 100644 index 000000000..abb024ebe --- /dev/null +++ b/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.Infrastructures.Components.App.Migrations; +using Bakabase.InsideWorld.Business; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Bakabase.Migrations.V240; + +/// +/// One-shot copy of the legacy SpecialTexts rows into the unified text vocabulary +/// (TextTypes + TextEntries), where builtin and user-defined types share one table +/// and one id space. +/// +/// Implementation notes: +/// - The old table and model are deliberately kept. App migrators run *after* EF schema +/// migrations, so a migration dropping SpecialTexts would delete the source before this +/// ever reads it whenever a user upgrades across the release that introduced the copy. Leaving +/// the table in place as a dormant relic costs two small columns and removes that hazard. +/// - Re-entrant: the gate is the global AppOptions version, so a crash mid-copy means a rerun. +/// Entries already present under their target type (matched on both values) are skipped, which +/// also keeps this consistent with how prefabs are topped up. +/// - Reads directly rather than through a cache service, matching +/// PathsRelocationMigrator: the legacy rows are on their way out and need no caching. +/// +public class TextSystemMigrator(IServiceProvider serviceProvider) : AbstractMigrator(serviceProvider) +{ + /// + /// Pinned just below the build height shipping the unified text vocabulary, so every DB that + /// still holds only legacy rows runs the copy exactly once on next launch. + /// + protected override string ApplyOnVersionEqualsOrBeforeString => "2.4.0-beta"; + + protected override async Task MigrateAfterDbMigrationInternal(object? context) + { + var logger = GetRequiredService().CreateLogger(); + var vocabulary = GetRequiredService(); + + // Creates the builtin types (and tops up their prefabs) so legacy rows have somewhere to land. + await vocabulary.EnsureSeeds(); + + var dbCtx = GetRequiredService(); + var legacyRows = await dbCtx.SpecialTexts.AsNoTracking().ToListAsync(); + if (legacyRows.Count == 0) + { + logger.LogInformation("No legacy special texts to migrate"); + return; + } + + var migrated = 0; + foreach (var group in legacyRows.GroupBy(r => r.Type)) + { + var wellKnown = MapType(group.Key); + if (wellKnown == null) + { + logger.LogWarning("Skipped {Count} legacy special text(s) of unknown type {Type}", + group.Count(), group.Key); + continue; + } + + var typeId = await vocabulary.GetTypeId(wellKnown.Value); + var existing = (await vocabulary.GetEntries(typeId)) + .Select(e => (e.Value1, e.Value2)) + .ToHashSet(); + + // Value2 is carried over as-is even for shapes that only read the first value (Volume + // rows hold an ordinal no consumer reads today) — migrating must not decide to drop data. + var missing = group + .Select(r => (r.Value1, r.Value2)) + .Where(v => !existing.Contains(v)) + .Distinct() + .ToList(); + + if (missing.Count > 0) + { + await vocabulary.AddEntries(typeId, missing); + migrated += missing.Count; + } + } + + logger.LogInformation("Migrated {Migrated} legacy special text(s) into the text vocabulary", migrated); + } + + /// + /// The new handles reuse the legacy enum's values, so this is a straight cast guarded against + /// rows carrying a value no longer defined. + /// + private static WellKnownTextType? MapType(SpecialTextType type) => + Enum.IsDefined(typeof(WellKnownTextType), (int) type) ? (WellKnownTextType) (int) type : null; +} diff --git a/src/modules/Bakabase.Modules.Comparison/Abstractions/Models/ComparisonContext.cs b/src/modules/Bakabase.Modules.Comparison/Abstractions/Models/ComparisonContext.cs index 4c654742e..c274a9b8d 100644 --- a/src/modules/Bakabase.Modules.Comparison/Abstractions/Models/ComparisonContext.cs +++ b/src/modules/Bakabase.Modules.Comparison/Abstractions/Models/ComparisonContext.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; using Bakabase.Abstractions.Services; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Comparison.Abstractions.Models; @@ -9,7 +10,7 @@ namespace Bakabase.Modules.Comparison.Abstractions.Models; /// public class ComparisonContext : IDisposable { - private readonly ISpecialTextService _specialTextService; + private readonly ITextOps _textOps; // 文本清洗缓存 private readonly ConcurrentDictionary _normalizedTextCache = new(); @@ -17,9 +18,9 @@ public class ComparisonContext : IDisposable // 数字提取缓存 private readonly ConcurrentDictionary<(string Text, string Pattern), int?> _extractedNumberCache = new(); - public ComparisonContext(ISpecialTextService specialTextService) + public ComparisonContext(ITextOps textOps) { - _specialTextService = specialTextService; + _textOps = textOps; } /// @@ -33,7 +34,7 @@ public string GetNormalizedText(string rawText) return _normalizedTextCache.GetOrAdd(rawText, text => { // 同步调用异步方法 - return _specialTextService.Pretreatment(text).GetAwaiter().GetResult(); + return _textOps.Clean(text).GetAwaiter().GetResult(); }); } diff --git a/src/modules/Bakabase.Modules.Comparison/Services/ComparisonService.cs b/src/modules/Bakabase.Modules.Comparison/Services/ComparisonService.cs index 62ef6b863..ddb453cba 100644 --- a/src/modules/Bakabase.Modules.Comparison/Services/ComparisonService.cs +++ b/src/modules/Bakabase.Modules.Comparison/Services/ComparisonService.cs @@ -21,6 +21,7 @@ using Bootstrap.Models.ResponseModels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Comparison.Services; @@ -31,7 +32,7 @@ public class ComparisonService : ResourceService _groupOrm; private readonly ResourceService _memberOrm; private readonly ResourceService _pairOrm; - private readonly ISpecialTextService _specialTextService; + private readonly ITextOps _textOps; private readonly IPropertyService _propertyService; private readonly BTaskManager _taskManager; private readonly Dictionary _strategies; @@ -43,7 +44,7 @@ public ComparisonService( ResourceService groupOrm, ResourceService memberOrm, ResourceService pairOrm, - ISpecialTextService specialTextService, + ITextOps textOps, IPropertyService propertyService, BTaskManager taskManager, IEnumerable strategies, @@ -54,7 +55,7 @@ public ComparisonService( _groupOrm = groupOrm; _memberOrm = memberOrm; _pairOrm = pairOrm; - _specialTextService = specialTextService; + _textOps = textOps; _propertyService = propertyService; _taskManager = taskManager; _strategies = strategies.ToDictionary(s => s.Mode); @@ -243,7 +244,7 @@ public async Task ExecuteComparisonAsync(int planId, Func pro } // 4. 初始化缓存上下文 - using var context = new ComparisonContext(_specialTextService); + using var context = new ComparisonContext(_textOps); // 5. 计算相似度矩阵 var pairs = new List<(int r1, int r2, double score, List details)>(); @@ -503,7 +504,7 @@ public async Task HideResultGroupAsync(int groupId, bool hidden) public double CalculateSimilarity(Resource r1, Resource r2, ComparisonPlan plan) { - using var context = new ComparisonContext(_specialTextService); + using var context = new ComparisonContext(_textOps); return CalculateSimilarityInternal(r1, r2, plan, context); } diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/AbstractKeywordEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/AbstractKeywordEnhancer.cs index b756272f1..d5a9467d2 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/AbstractKeywordEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/AbstractKeywordEnhancer.cs @@ -12,6 +12,7 @@ using Bootstrap.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Enhancer.Components.Enhancers; @@ -20,7 +21,7 @@ public abstract class ILoggerFactory loggerFactory, IFileManager fileManager, IStandardValueService standardValueService, - ISpecialTextService specialTextService, + ITextOps textOps, IServiceProvider serviceProvider) : AbstractEnhancer(loggerFactory, fileManager, serviceProvider) where TEnumTarget : Enum @@ -119,7 +120,7 @@ public abstract class if (options.PretreatKeyword == true) { var originalKeyword = keyword; - keyword = await specialTextService.Pretreatment(keyword); + keyword = await textOps.Clean(keyword); TracingContext?.AddTrace(LogLevel.Information, Localizer.Enhance(), Localizer.Enhancer_KeywordAfterPretreatment(keyword)); logCollector.LogInfo(EnhancementLogEvent.KeywordPretreated, diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Av/AvEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Av/AvEnhancer.cs index a862444f9..3b81a5e7a 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Av/AvEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Av/AvEnhancer.cs @@ -14,6 +14,7 @@ using Bakabase.Modules.ThirdParty.ThirdParties.Av; using Bootstrap.Extensions; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Enhancer.Components.Enhancers.Av; @@ -23,8 +24,8 @@ public class AvEnhancer( IEnumerable avClients, IHttpClientFactory httpClientFactory, IAvSourceOptionsProvider avOptionsProvider, - IStandardValueService standardValueService, ISpecialTextService specialTextService, IServiceProvider serviceProvider) - : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, specialTextService, serviceProvider) + IStandardValueService standardValueService, ITextOps textOps, IServiceProvider serviceProvider) + : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, textOps, serviceProvider) { protected override EnhancerId TypedId => EnhancerId.Av; diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bakabase/BakabaseEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bakabase/BakabaseEnhancer.cs index b5dd01d43..bb52f8193 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bakabase/BakabaseEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bakabase/BakabaseEnhancer.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using Bakabase.Abstractions.Components.Text; using Bakabase.Abstractions.Components.Cover; using Bakabase.Abstractions.Components.FileSystem; using Bakabase.Abstractions.Components.Localization; @@ -15,21 +16,23 @@ using Bootstrap.Extensions; using Bootstrap.Models; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Extensions; namespace Bakabase.Modules.Enhancer.Components.Enhancers.Bakabase { public class BakabaseEnhancer( ILoggerFactory loggerFactory, - ISpecialTextService specialTextService, + ITextVocabularyService textVocabularyService, IBakabaseLocalizer localizer, IFileManager fileManager, ICoverDiscoverer coverDiscoverer, IStandardValueService standardValueService, + ITextOps textOps, IServiceProvider serviceProvider ) : AbstractKeywordEnhancer( loggerFactory, - fileManager, standardValueService, specialTextService, serviceProvider) + fileManager, standardValueService, textOps, serviceProvider) { protected override EnhancerId TypedId => EnhancerId.Bakabase; private readonly IBakabaseLocalizer _localizer = localizer; @@ -211,7 +214,7 @@ protected override async Task GetAndRemoveLanguageWithWrapper(string name, IEnumerable wcs) { - var languageWords = - (await specialTextService.GetAll(x => x.Type == SpecialTextType.Language, false)).ToDictionary( - t => t.Value1, - t => t.Value2!); + var languageWords = (await textVocabularyService.ResolveSet(WellKnownTextType.Language)).ToPairMap(); foreach (var wc in wcs) { @@ -296,11 +296,11 @@ protected override async Task IsValid(string name) { - var wrappers = await specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper); + var wrappers = (await textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).Pairs; foreach (var wrapper in wrappers) { var left = wrapper.Value1; - var right = wrapper.Value2!; + var right = wrapper.Value2; var layer = 0; for (var i = 0; i < name.Length; i++) { @@ -332,11 +332,11 @@ private async Task IsValid(string name) private async Task<(string VolumeName, string VolumeTitle, string Match, int Index)?> TryToParseVolume( string str) { - var volumeTexts = await specialTextService.GetAll(x => x.Type == SpecialTextType.Volume, false); + var volumeTexts = (await textVocabularyService.ResolveSet(WellKnownTextType.Volume)).Values; foreach (var v in volumeTexts) { var reg = new System.Text.RegularExpressions.Regex( - $"(?{v.Value1})(?.*)$"); + $"(?{v})(?.*)$"); var match = reg.Match(str); if (match.Success) { @@ -357,12 +357,12 @@ private record WrappedContent private async Task MatchAllContentsWithWrappers(string str) { - var wrappers = await specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper); + var wrappers = (await textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).Pairs; var matches = new List<(string Left, string Right, Match Match, string Content)>(); foreach (var w in wrappers) { - var r = StringHelpers.BuildRegexWithWrapper(w.Value1, w.Value2!, ".+?"); + var r = StringHelpers.BuildRegexWithWrapper(w.Value1, w.Value2, ".+?"); var m = r.Match(str); var idx = 0; while (m.Success) diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bangumi/BangumiEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bangumi/BangumiEnhancer.cs index 4e5b37b1c..375487133 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bangumi/BangumiEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Bangumi/BangumiEnhancer.cs @@ -12,6 +12,7 @@ using Bakabase.Modules.ThirdParty.ThirdParties.Bangumi.Models; using Bootstrap.Extensions; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Enhancer.Components.Enhancers.Bangumi; @@ -20,10 +21,10 @@ public class BangumiEnhancer( BangumiClient client, IFileManager fileManager, IStandardValueService standardValueService, - ISpecialTextService specialTextService, + ITextOps textOps, IServiceProvider serviceProvider) : AbstractKeywordEnhancer(loggerFactory, fileManager, - standardValueService, specialTextService, serviceProvider) + standardValueService, textOps, serviceProvider) { private static string? GetCategoryString(BangumiSubjectType? type) => type switch { diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/DLsite/DLsiteEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/DLsite/DLsiteEnhancer.cs index 2a90574c1..d0d9486b5 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/DLsite/DLsiteEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/DLsite/DLsiteEnhancer.cs @@ -12,6 +12,7 @@ using Bakabase.Modules.ThirdParty.ThirdParties.DLsite; using Bootstrap.Extensions; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Enhancer.Components.Enhancers.DLsite; @@ -25,7 +26,7 @@ public class DLsiteEnhancer : AbstractKeywordEnhancer private static readonly System.Text.RegularExpressions.Regex IdRegex = new System.Text.RegularExpressions.Regex(@"[BVR]J\d{6,10}"); - public DLsiteEnhancer(ILoggerFactory loggerFactory, IFileManager fileManager, DLsiteClient client, IStandardValueService standardValueService, ISpecialTextService specialTextService, IServiceProvider serviceProvider) : base(loggerFactory, fileManager, standardValueService, specialTextService, serviceProvider) + public DLsiteEnhancer(ILoggerFactory loggerFactory, IFileManager fileManager, DLsiteClient client, IStandardValueService standardValueService, ITextOps textOps, IServiceProvider serviceProvider) : base(loggerFactory, fileManager, standardValueService, textOps, serviceProvider) { _client = client; _standardValueService = standardValueService; diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/ExHentai/ExHentaiEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/ExHentai/ExHentaiEnhancer.cs index b2a0d3685..cb0a03c70 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/ExHentai/ExHentaiEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/ExHentai/ExHentaiEnhancer.cs @@ -1,4 +1,5 @@ using Bakabase.Abstractions.Models.Domain; +using Bakabase.Abstractions.Components.Text; using Bakabase.Abstractions.Models.Domain.Constants; using Bakabase.Abstractions.Services; using Bakabase.Modules.Enhancer.Abstractions.Components; @@ -24,15 +25,16 @@ public class ExHentaiEnhancer( ILoggerFactory loggerFactory, ExHentaiClient exHentaiClient, IServiceProvider services, - ISpecialTextService specialTextService, + ITextVocabularyService textVocabularyService, IFileManager fileManager, IStandardValueService standardValueService, + ITextOps textOps, IServiceProvider serviceProvider) - : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, specialTextService, serviceProvider) + : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, textOps, serviceProvider) { private readonly ExHentaiClient _exHentaiClient = exHentaiClient; private readonly IServiceProvider _services = services; - private readonly ISpecialTextService _specialTextService = specialTextService; + private readonly ITextVocabularyService _textVocabularyService = textVocabularyService; private const string UrlKeywordRegex = "[a-zA-Z0-9]{10,}"; protected override async Task BuildContextInternal(string keyword, Resource resource, IKeywordEnhancerOptions options, EnhancementLogCollector logCollector, CancellationToken ct) @@ -43,7 +45,7 @@ public class ExHentaiEnhancer( var names = new List {resource.FileName}.Where(a => a.IsNotEmpty()).ToArray(); if (names.Any(n => System.Text.RegularExpressions.Regex.IsMatch(n, UrlKeywordRegex))) { - var wrappers = await _specialTextService.GetAll(x => x.Type == SpecialTextType.Wrapper); + var wrappers = (await _textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).Pairs; var urlKeywordCandidates = new List<(string Str, string Keyword)>(); foreach (var wrapper in wrappers) { diff --git a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Tmdb/TmdbEnhancer.cs b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Tmdb/TmdbEnhancer.cs index e9428f739..e5c348364 100644 --- a/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Tmdb/TmdbEnhancer.cs +++ b/src/modules/Bakabase.Modules.Enhancer/Components/Enhancers/Tmdb/TmdbEnhancer.cs @@ -13,11 +13,12 @@ using Bakabase.Modules.ThirdParty.ThirdParties.Tmdb; using Bootstrap.Extensions; using Microsoft.Extensions.Logging; +using Bakabase.Abstractions.Components.Text; namespace Bakabase.Modules.Enhancer.Components.Enhancers.Tmdb; -public class TmdbEnhancer(ILoggerFactory loggerFactory, TmdbClient client, IFileManager fileManager, IStandardValueService standardValueService, ISpecialTextService specialTextService, IServiceProvider serviceProvider) - : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, specialTextService, serviceProvider) +public class TmdbEnhancer(ILoggerFactory loggerFactory, TmdbClient client, IFileManager fileManager, IStandardValueService standardValueService, ITextOps textOps, IServiceProvider serviceProvider) + : AbstractKeywordEnhancer(loggerFactory, fileManager, standardValueService, textOps, serviceProvider) { protected override async Task BuildContextInternal(string keyword, Resource resource, IKeywordEnhancerOptions options, EnhancementLogCollector logCollector, CancellationToken ct) diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs index 88381c56e..564957a86 100644 --- a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextEntry.cs @@ -4,7 +4,7 @@ namespace Bakabase.Modules.Text.Abstractions.Models.Db; /// /// One entry of a . What the two values mean is the type's -/// ; both slots exist regardless of shape so no data +/// ; both slots exist regardless of shape so no data /// is lost when a type's shape says only the first one is used. /// public record TextEntry diff --git a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs index 5bda6baa1..3ea47c7c0 100644 --- a/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs +++ b/src/modules/Bakabase.Modules.Text/Abstractions/Models/Db/TextType.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Bakabase.Modules.Text.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Models.Domain.Constants; namespace Bakabase.Modules.Text.Abstractions.Models.Db; diff --git a/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj b/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj index 7246cda37..c0cdfbcba 100644 --- a/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj +++ b/src/modules/Bakabase.Modules.Text/Bakabase.Modules.Text.csproj @@ -9,6 +9,7 @@ + diff --git a/src/modules/Bakabase.Modules.Text/Components/TextOps.cs b/src/modules/Bakabase.Modules.Text/Components/TextOps.cs new file mode 100644 index 000000000..4db978dc2 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Components/TextOps.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Bakabase.Abstractions.Components.Text; +using Bakabase.Abstractions.Helpers; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.Modules.StandardValue.Abstractions.Components; + +namespace Bakabase.Modules.Text.Components; + +/// +/// Vocabulary-driven text operations. Also serves as the app's +/// , which is how StandardValue conversions pick up the +/// user-configured date formats. +/// +public class TextOps(ITextVocabularyService vocabulary) : ITextOps, ICustomDateTimeParser +{ + private static readonly Regex RepeatedWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly TimeSpan UserRegexTimeout = TimeSpan.FromSeconds(1); + + public async Task Clean(string text) + { + // Standardize + var standardization = await vocabulary.ResolveSet(WellKnownTextType.Standardization); + text = standardization.Pairs.Aggregate(text, (current, p) => current.Replace(p.Value1, p.Value2)); + + // Remove unnecessary spaces. + text = RepeatedWhitespaceRegex.Replace(text, " ").Trim(); + + var wrappers = await vocabulary.ResolveSet(WellKnownTextType.Wrapper); + + // Trim: drop the whitespace around trim markers and around both sides of every wrapper. + var trimSet = await vocabulary.ResolveSet(WellKnownTextType.Trim); + var trimFlags = trimSet.Values.ToList(); + trimFlags.AddRange(wrappers.Pairs.Select(p => p.Value1)); + trimFlags.AddRange(wrappers.Pairs.Select(p => p.Value2)); + text = trimFlags.Aggregate(text, + (current, flag) => Regex.Replace(current, $@"\s*{Regex.Escape(flag)}\s*", flag)); + + // Useless words, but only where they are wrapped. + var useless = await vocabulary.ResolveSet(WellKnownTextType.Useless); + text = useless.Values.Aggregate(text, (current1, word) => wrappers.Pairs.Aggregate(current1, + (current, wrapper) => + StringHelpers.BuildRegexWithWrapper(wrapper.Value1, wrapper.Value2, word) + .Replace(current, string.Empty))); + + return text; + } + + public async Task RemoveWrapped(string text, int wrappersTypeId, int setTypeId, TextMatchMode mode) + { + var wrappers = await vocabulary.ResolveSet(wrappersTypeId); + if (wrappers.Shape != TextTypeShape.DelimiterPair) + { + throw new InvalidOperationException( + $"Text type [{wrappersTypeId}] is not a delimiter pair type and cannot supply wrappers."); + } + + var set = await vocabulary.ResolveSet(setTypeId); + if (set.Values.Count == 0 || wrappers.Pairs.Count == 0) + { + return text; + } + + foreach (var wrapper in wrappers.Pairs) + { + var left = Regex.Escape(wrapper.Value1); + var right = Regex.Escape(wrapper.Value2); + // Non-greedy so adjacent wrapped segments are matched one by one rather than as a run. + var wrappedRegex = new Regex($"{left}(?.*?){right}", RegexOptions.None, UserRegexTimeout); + text = wrappedRegex.Replace(text, + m => Matches(m.Groups["content"].Value, set.Values, mode) ? string.Empty : m.Value); + } + + return text; + } + + public async Task RemoveTexts(string text, int setTypeId, TextMatchMode mode) + { + var set = await vocabulary.ResolveSet(setTypeId); + foreach (var value in set.Values) + { + if (string.IsNullOrEmpty(value)) + { + continue; + } + + text = mode switch + { + TextMatchMode.RegexAny => new Regex(value, RegexOptions.None, UserRegexTimeout) + .Replace(text, string.Empty), + // Equality against a fragment of a longer string is the same operation as + // containment; both remove every occurrence. + _ => Regex.Replace(text, Regex.Escape(value), string.Empty, RegexOptions.IgnoreCase) + }; + } + + return text; + } + + public async Task Trim(string text, TextTrimOptions options) + { + if (options.RemoveEmptyWrappers) + { + var wrappers = await vocabulary.ResolveSet(WellKnownTextType.Wrapper); + foreach (var wrapper in wrappers.Pairs) + { + var emptyRegex = new Regex($"{Regex.Escape(wrapper.Value1)}\\s*{Regex.Escape(wrapper.Value2)}"); + text = emptyRegex.Replace(text, string.Empty); + } + } + + if (options.CollapseSpaces) + { + text = RepeatedWhitespaceRegex.Replace(text, " "); + } + + if (options.TrimEnds) + { + text = text.Trim().Trim('-', '_', '.', ',', '、', ' ').Trim(); + } + + return text; + } + + public async Task TryParseDateTime(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var r = await TryParseDateTime([text]); + return r.Count > 0 ? r[0].DateTime : null; + } + + public async Task> TryParseDateTime(string[] texts) + { + var list = new List<(int Index, DateTime DateTime)>(); + if (texts.Length == 0) + { + return list; + } + + var set = await vocabulary.ResolveSet(WellKnownTextType.DateTime); + var formats = set.Values.Distinct().ToArray(); + for (var i = 0; i < texts.Length; i++) + { + if (formats.Length > 0 && DateTime.TryParseExact(texts[i], formats, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, out var dt)) + { + list.Add((i, dt)); + } + else if (DateTime.TryParse(texts[i], out var fallbackDt)) + { + list.Add((i, fallbackDt)); + } + } + + return list; + } + + private static bool Matches(string candidate, IReadOnlyList values, TextMatchMode mode) => + mode switch + { + TextMatchMode.EqualsAny => values.Any(v => string.Equals(candidate, v, StringComparison.OrdinalIgnoreCase)), + TextMatchMode.ContainsAny => values.Any(v => + !string.IsNullOrEmpty(v) && candidate.Contains(v, StringComparison.OrdinalIgnoreCase)), + TextMatchMode.RegexAny => values.Any(v => + !string.IsNullOrEmpty(v) && Regex.IsMatch(candidate, v, RegexOptions.None, UserRegexTimeout)), + _ => false + }; + + Task ICustomDateTimeParser.TryToParseDateTime(string? str) => TryParseDateTime(str); +} diff --git a/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs b/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs new file mode 100644 index 000000000..463b7c2b4 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs @@ -0,0 +1,88 @@ +using Bakabase.Abstractions.Models.Domain.Constants; + +namespace Bakabase.Modules.Text.Components; + +/// +/// Ships the builtin types and their starter entries. Carried over verbatim from the retired +/// SpecialTextPrefabs, plus the shape each type actually uses. +/// +public static class TextSeedData +{ + public record BuiltinType( + WellKnownTextType WellKnown, + string Name, + TextTypeShape Shape, + IReadOnlyList<(string Value1, string? Value2)> Entries); + + public static readonly IReadOnlyList BuiltinTypes = + [ + new(WellKnownTextType.Useless, "Useless", TextTypeShape.Values, + [ + (@"[Cc]\d{2}", null), + (@"COMIC1☆\d{1,2}", null), + ("成年コミック", null), + ("同人誌", null), + ("DL版", null), + ("彩頁部分", null), + ("無修正", null), + ("文盲組", null), + (@"\.mp4", null), + ("ゲームCG", null), + ("同人CG集", null), + ("18禁ゲームCG", null) + ]), + new(WellKnownTextType.Language, "Language", TextTypeShape.MappingPair, + [ + ("汉化", "中文"), + ("中文", "中文"), + ("中国翻訳", "中文"), + ("CE家族社", "中文"), + ("漢化", "中文"), + ("阿提斯整個車頭的", "中文"), + ("CN", "中文") + ]), + new(WellKnownTextType.Wrapper, "Wrapper", TextTypeShape.DelimiterPair, + [ + ("(", ")"), + ("[", "]") + ]), + new(WellKnownTextType.Standardization, "Standardization", TextTypeShape.MappingPair, + [ + ("(", "("), + (")", ")"), + ("【", "["), + ("】", "]"), + (",", "、"), + ("#", "#"), + ("A", "A"), + ("!", "!"), + ("~", "~") + ]), + new(WellKnownTextType.Volume, "Volume", TextTypeShape.Values, + [ + (@"\s[A-Za-z]+[\.-]?\d+[\.-]?", null), + (@"[#]\d+", null), + ("上[巻卷]", "1"), + ("(1st|2nd|3rd)", null), + (@"\d+限目", null), + ("第一[話章]", "1"), + ("下[巻卷]", "2"), + ("第二[話章]", "2"), + ("第三[話章]", "3") + ]), + new(WellKnownTextType.Trim, "Trim", TextTypeShape.Values, []), + new(WellKnownTextType.DateTime, "DateTime", TextTypeShape.Values, + [ + ("yyyyMMddHHmm", null), + ("yyyyMMdd", null), + ("yyyy-MM-dd", null), + ("yyyy-M-d", null), + ("yyyy年MM月dd日", null), + ("yyyy年M月d日", null), + ("yyMMdd", null), + ("yy年M月d日", null), + ("MMdd", null), + ("M月d日", null) + ]) + ]; +} diff --git a/src/modules/Bakabase.Modules.Text/Extensions/ServiceCollectionExtensions.cs b/src/modules/Bakabase.Modules.Text/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..805107158 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Bakabase.Abstractions.Components.Text; +using Bakabase.Abstractions.Services; +using Bakabase.Modules.Text.Components; +using Bakabase.Modules.Text.Services; +using Bootstrap.Components.Orm; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bakabase.Modules.Text.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddText(this IServiceCollection services) + where TDbContext : DbContext + { + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped>(); + + // TextOps is also the app's ICustomDateTimeParser; AddStandardValue binds that. + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/modules/Bakabase.Modules.Text/Extensions/TextExtensions.cs b/src/modules/Bakabase.Modules.Text/Extensions/TextExtensions.cs new file mode 100644 index 000000000..436fef7f2 --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Extensions/TextExtensions.cs @@ -0,0 +1,27 @@ +using Bakabase.Abstractions.Models.Domain; + +namespace Bakabase.Modules.Text.Extensions; + +public static class TextExtensions +{ + public static TextTypeDescriptor ToDescriptor(this Abstractions.Models.Db.TextType dbModel, int entryCount) => + new() + { + Id = dbModel.Id, + Name = dbModel.Name, + WellKnown = dbModel.WellKnown, + Shape = dbModel.Shape, + Description = dbModel.Description, + CreatedAt = dbModel.CreatedAt, + EntryCount = entryCount + }; + + public static TextEntryValue ToDomainModel(this Abstractions.Models.Db.TextEntry dbModel) => + new() + { + Id = dbModel.Id, + TypeId = dbModel.TypeId, + Value1 = dbModel.Value1, + Value2 = dbModel.Value2 + }; +} diff --git a/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs b/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs new file mode 100644 index 000000000..1f614c42d --- /dev/null +++ b/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs @@ -0,0 +1,248 @@ +using Bakabase.Abstractions.Models.Domain; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.Modules.Text.Components; +using Bakabase.Modules.Text.Extensions; +using Bootstrap.Components.Orm; +using Microsoft.EntityFrameworkCore; + +namespace Bakabase.Modules.Text.Services; + +public class TextVocabularyService( + FullMemoryCacheResourceService typeOrm, + FullMemoryCacheResourceService entryOrm) + : ITextVocabularyService + where TDbContext : DbContext +{ + public async Task> GetTypes() + { + var types = await typeOrm.GetAll(); + var entries = await entryOrm.GetAll(); + var countByType = entries.GroupBy(e => e.TypeId).ToDictionary(g => g.Key, g => g.Count()); + return types + .OrderByDescending(t => t.WellKnown.HasValue) + .ThenBy(t => t.Id) + .Select(t => t.ToDescriptor(countByType.GetValueOrDefault(t.Id))) + .ToList(); + } + + public async Task AddType(string name, TextTypeShape shape, string? description = null) + { + name = name.Trim(); + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Text type name cannot be empty.", nameof(name)); + } + + var duplicate = await typeOrm.GetFirstOrDefault(t => t.Name == name); + if (duplicate != null) + { + throw new InvalidOperationException($"Text type [{name}] already exists."); + } + + var added = (await typeOrm.Add(new Abstractions.Models.Db.TextType + { + Name = name, + Shape = shape, + Description = description, + CreatedAt = DateTime.Now + })).Data; + + return added.ToDescriptor(0); + } + + public async Task RenameType(int id, string name) + { + var type = await GetTypeOrThrow(id); + EnsureNotBuiltin(type, "renamed"); + + name = name.Trim(); + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Text type name cannot be empty.", nameof(name)); + } + + var duplicate = await typeOrm.GetFirstOrDefault(t => t.Name == name && t.Id != id); + if (duplicate != null) + { + throw new InvalidOperationException($"Text type [{name}] already exists."); + } + + await typeOrm.UpdateByKey(id, t => t.Name = name); + } + + public async Task DeleteType(int id) + { + var type = await GetTypeOrThrow(id); + EnsureNotBuiltin(type, "deleted"); + + await entryOrm.RemoveAll(e => e.TypeId == id); + await typeOrm.RemoveByKey(id); + } + + public async Task> GetEntries(int typeId) + { + var entries = await entryOrm.GetAll(e => e.TypeId == typeId); + return entries.Select(e => e.ToDomainModel()).ToList(); + } + + public async Task AddEntry(int typeId, string value1, string? value2 = null) + { + await GetTypeOrThrow(typeId); + var added = (await entryOrm.Add(new Abstractions.Models.Db.TextEntry + { + TypeId = typeId, + Value1 = value1, + Value2 = value2 + })).Data; + return added.ToDomainModel(); + } + + public async Task PatchEntry(int id, string? value1, string? value2) + { + await entryOrm.UpdateByKey(id, e => + { + if (!string.IsNullOrEmpty(value1)) + { + e.Value1 = value1; + } + + if (value2 != null) + { + e.Value2 = value2.Length == 0 ? null : value2; + } + }); + } + + public async Task DeleteEntry(int id) => await entryOrm.RemoveByKey(id); + + public async Task AddEntries(int typeId, IEnumerable<(string Value1, string? Value2)> entries) + { + await GetTypeOrThrow(typeId); + var models = entries.Select(e => new Abstractions.Models.Db.TextEntry + { + TypeId = typeId, + Value1 = e.Value1, + Value2 = e.Value2 + }).ToList(); + if (models.Count > 0) + { + await entryOrm.AddRange(models); + } + } + + public async Task ResolveSet(int typeId) + { + var type = await GetTypeOrThrow(typeId); + var entries = await entryOrm.GetAll(e => e.TypeId == typeId); + return BuildSet(type, entries); + } + + public async Task ResolveSet(WellKnownTextType wellKnown) + { + var type = await GetOrCreateBuiltin(wellKnown); + var entries = await entryOrm.GetAll(e => e.TypeId == type.Id); + return BuildSet(type, entries); + } + + public async Task GetTypeId(WellKnownTextType wellKnown) => (await GetOrCreateBuiltin(wellKnown)).Id; + + public async Task EnsureSeeds() + { + var existingTypes = await typeOrm.GetAll(); + var typeByWellKnown = existingTypes.Where(t => t.WellKnown.HasValue) + .ToDictionary(t => t.WellKnown!.Value, t => t); + + foreach (var builtin in TextSeedData.BuiltinTypes) + { + if (!typeByWellKnown.TryGetValue(builtin.WellKnown, out var type)) + { + type = (await typeOrm.Add(new Abstractions.Models.Db.TextType + { + Name = builtin.Name, + WellKnown = builtin.WellKnown, + Shape = builtin.Shape, + CreatedAt = DateTime.Now + })).Data; + typeByWellKnown[builtin.WellKnown] = type; + } + + if (builtin.Entries.Count == 0) + { + continue; + } + + // Top up prefabs without resurrecting entries the user deleted in an earlier run: only + // rows absent right now are added, and the comparison keys on both values so pair-shaped + // types do not collapse into their first value. + var existing = (await entryOrm.GetAll(e => e.TypeId == type.Id)) + .Select(e => (e.Value1, e.Value2)) + .ToHashSet(); + var missing = builtin.Entries.Where(e => !existing.Contains((e.Value1, e.Value2))).ToList(); + if (missing.Count > 0) + { + await AddEntries(type.Id, missing); + } + } + } + + private static TextSet BuildSet(Abstractions.Models.Db.TextType type, + List entries) + { + var pairs = type.Shape == TextTypeShape.Values + ? [] + : entries.Where(e => !string.IsNullOrEmpty(e.Value2)) + .Select(e => new TextPair(e.Value1, e.Value2!)) + .ToList(); + + return new TextSet + { + TypeId = type.Id, + Shape = type.Shape, + Values = entries.Select(e => e.Value1).ToList(), + Pairs = pairs + }; + } + + private async Task GetTypeOrThrow(int id) + { + var type = await typeOrm.GetByKey(id); + if (type == null) + { + throw new KeyNotFoundException($"Text type [{id}] does not exist."); + } + + return type; + } + + /// + /// Builtins are defined in code, so a missing row is provisioned on demand rather than thrown + /// over: consumers resolve wrapper or date-format sets during ordinary work and must not depend + /// on seeding having run first (a fresh database, or a test that never seeded). + /// + private async Task GetOrCreateBuiltin(WellKnownTextType wellKnown) + { + var type = await typeOrm.GetFirstOrDefault(t => t.WellKnown == wellKnown); + if (type != null) + { + return type; + } + + var definition = TextSeedData.BuiltinTypes.FirstOrDefault(t => t.WellKnown == wellKnown); + return (await typeOrm.Add(new Abstractions.Models.Db.TextType + { + Name = definition?.Name ?? wellKnown.ToString(), + WellKnown = wellKnown, + Shape = definition?.Shape ?? TextTypeShape.Values, + CreatedAt = DateTime.Now + })).Data; + } + + private static void EnsureNotBuiltin(Abstractions.Models.Db.TextType type, string action) + { + if (type.WellKnown.HasValue) + { + throw new InvalidOperationException($"Builtin text type [{type.Name}] cannot be {action}."); + } + } +} diff --git a/src/tests/Bakabase.TestKit/Implementations/TestBakabaseLocalizer.cs b/src/tests/Bakabase.TestKit/Implementations/TestBakabaseLocalizer.cs index 2fda5a699..0a2e8a780 100644 --- a/src/tests/Bakabase.TestKit/Implementations/TestBakabaseLocalizer.cs +++ b/src/tests/Bakabase.TestKit/Implementations/TestBakabaseLocalizer.cs @@ -24,7 +24,6 @@ public class TestBakabaseLocalizer : IBakabaseLocalizer public string ValueIsNotSet(string name) => $"ValueIsNotSet_{name}"; public string NewFolderName() => "NewFolderName"; public string Downloader_FailedToStart(string taskName, string message) => "Downloader_FailedToStart"; - public string SpecialText_HistoricalLanguageValue2ShouldBeModified() => "SpecialText_HistoricalLanguageValue2ShouldBeModified"; public string Reserved_Resource_Property_Name(ReservedProperty property) => property.ToString(); public string Unknown() => "Unknown"; public string Failed() => "Failed"; diff --git a/src/tests/Bakabase.Tests/SpecialTextServiceTests.cs b/src/tests/Bakabase.Tests/SpecialTextServiceTests.cs deleted file mode 100644 index ebc527cbb..000000000 --- a/src/tests/Bakabase.Tests/SpecialTextServiceTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Bakabase.Abstractions.Models.Domain.Constants; -using Bakabase.Abstractions.Models.Input; -using Bakabase.Abstractions.Services; -using Bakabase.TestKit.Utils; -using Microsoft.Extensions.DependencyInjection; - -namespace Bakabase.Tests; - -/// -/// Integration coverage for SpecialTextService — a previously untested service: rule -/// CRUD, prefab population, name pretreatment (standardization + whitespace collapse), -/// and date-time parsing (configured format and the standard fallback). -/// -[TestClass] -public sealed class SpecialTextServiceTests -{ - private IServiceProvider _sp = null!; - private ISpecialTextService _service = null!; - - [TestInitialize] - public async Task Setup() - { - _sp = await TestServiceBuilder.BuildServiceProvider(); - _service = _sp.GetRequiredService(); - } - - private Task Add(SpecialTextType type, string value1, string? value2 = null) - => _service.Add(new SpecialTextAddInputModel { Type = type, Value1 = value1, Value2 = value2 }); - - [TestMethod] - public async Task Add_ThenGetAll_ReturnsTheRule() - { - await Add(SpecialTextType.Standardization, "from", "to"); - var all = await _service.GetAll(); - Assert.AreEqual(1, all.Count); - Assert.AreEqual("from", all[0].Value1); - Assert.AreEqual("to", all[0].Value2); - } - - [TestMethod] - public async Task Count_ReflectsAddedRules() - { - await Add(SpecialTextType.Trim, "-"); - await Add(SpecialTextType.Useless, "junk"); - Assert.AreEqual(2, await _service.Count()); - } - - [TestMethod] - public async Task AddPrefabs_PopulatesRules() - { - await _service.AddPrefabs(); - Assert.IsTrue(await _service.Count() > 0); - } - - [TestMethod] - public async Task Pretreatment_CollapsesWhitespace() - => Assert.AreEqual("a b", await _service.Pretreatment("a b")); - - [TestMethod] - public async Task Pretreatment_AppliesStandardizationRule() - { - await Add(SpecialTextType.Standardization, "_", " "); - Assert.AreEqual("a b", await _service.Pretreatment("a_b")); - } - - [TestMethod] - public async Task TryToParseDateTime_StandardFormat_ParsesViaFallback() - { - var dt = await _service.TryToParseDateTime("2024-06-15"); - Assert.IsNotNull(dt); - Assert.AreEqual(new DateTime(2024, 6, 15), dt!.Value.Date); - } - - [TestMethod] - public async Task TryToParseDateTime_Garbage_ReturnsNull() - => Assert.IsNull(await _service.TryToParseDateTime("definitely not a date")); - - [TestMethod] - public async Task TryToParseDateTime_ConfiguredFormat_Parses() - { - await Add(SpecialTextType.DateTime, "yyyy_MM_dd"); - var dt = await _service.TryToParseDateTime("2024_06_15"); - Assert.IsNotNull(dt); - Assert.AreEqual(new DateTime(2024, 6, 15), dt!.Value.Date); - } -} diff --git a/src/tests/Bakabase.Tests/TextOpsTests.cs b/src/tests/Bakabase.Tests/TextOpsTests.cs new file mode 100644 index 000000000..c37458573 --- /dev/null +++ b/src/tests/Bakabase.Tests/TextOpsTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Threading.Tasks; +using Bakabase.Abstractions.Components.Text; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.Modules.StandardValue.Abstractions.Components; +using Bakabase.TestKit.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Bakabase.Tests; + +/// +/// Vocabulary-driven text operations: cleaning, removal primitives, trimming and date parsing. +/// +[TestClass] +public sealed class TextOpsTests +{ + private IServiceProvider _sp = null!; + private ITextOps _ops = null!; + private ITextVocabularyService _vocabulary = null!; + + [TestInitialize] + public async Task Setup() + { + _sp = await TestServiceBuilder.BuildServiceProvider(); + _ops = _sp.GetRequiredService(); + _vocabulary = _sp.GetRequiredService(); + await _vocabulary.EnsureSeeds(); + } + + private async Task AddEntry(WellKnownTextType type, string value1, string? value2 = null) + => await _vocabulary.AddEntry(await _vocabulary.GetTypeId(type), value1, value2); + + [TestMethod] + public async Task Clean_CollapsesWhitespace() + => Assert.AreEqual("a b", await _ops.Clean("a b")); + + [TestMethod] + public async Task Clean_AppliesStandardizationMapping() + { + await AddEntry(WellKnownTextType.Standardization, "_", " "); + Assert.AreEqual("a b", await _ops.Clean("a_b")); + } + + [TestMethod] + public async Task Clean_RemovesWrappedUselessWords() + { + await AddEntry(WellKnownTextType.Useless, "DL版"); + var cleaned = await _ops.Clean("作品名 [DL版]"); + Assert.IsFalse(cleaned.Contains("DL版"), $"expected the wrapped useless word to be gone, got '{cleaned}'"); + } + + [TestMethod] + public async Task RemoveWrapped_DropsOnlyMatchingSegments() + { + var groups = await _vocabulary.AddType("Release groups", TextTypeShape.Values); + await _vocabulary.AddEntry(groups.Id, "LoliHouse"); + var wrapperTypeId = await _vocabulary.GetTypeId(WellKnownTextType.Wrapper); + + var result = await _ops.RemoveWrapped("[LoliHouse] Title [1080p]", wrapperTypeId, groups.Id, + TextMatchMode.EqualsAny); + + Assert.AreEqual(" Title [1080p]", result); + } + + [TestMethod] + public async Task RemoveWrapped_ContainsMode_MatchesPartialContent() + { + var tags = await _vocabulary.AddType("Quality tags", TextTypeShape.Values); + await _vocabulary.AddEntry(tags.Id, "1080p"); + var wrapperTypeId = await _vocabulary.GetTypeId(WellKnownTextType.Wrapper); + + var result = await _ops.RemoveWrapped("Title [WebRip 1080p HEVC]", wrapperTypeId, tags.Id, + TextMatchMode.ContainsAny); + + Assert.AreEqual("Title ", result); + } + + [TestMethod] + public async Task RemoveWrapped_RejectsNonDelimiterPairTypeAsWrappers() + { + var mappings = await _vocabulary.GetTypeId(WellKnownTextType.Standardization); + var useless = await _vocabulary.GetTypeId(WellKnownTextType.Useless); + + await Assert.ThrowsExceptionAsync( + () => _ops.RemoveWrapped("whatever", mappings, useless, TextMatchMode.EqualsAny)); + } + + [TestMethod] + public async Task RemoveTexts_RemovesEveryOccurrence() + { + var noise = await _vocabulary.AddType("Noise", TextTypeShape.Values); + await _vocabulary.AddEntry(noise.Id, "-repack"); + + Assert.AreEqual("Title", await _ops.RemoveTexts("Title-repack", noise.Id, TextMatchMode.ContainsAny)); + } + + [TestMethod] + public async Task Trim_RemovesEmptyWrappersAndEdgeSeparators() + { + var result = await _ops.Trim("Title [] - ", TextTrimOptions.Default); + Assert.AreEqual("Title", result); + } + + [TestMethod] + public async Task TryParseDateTime_StandardFormat_ParsesViaFallback() + { + var dt = await _ops.TryParseDateTime("2024-06-15"); + Assert.IsNotNull(dt); + Assert.AreEqual(new DateTime(2024, 6, 15), dt!.Value.Date); + } + + [TestMethod] + public async Task TryParseDateTime_Garbage_ReturnsNull() + => Assert.IsNull(await _ops.TryParseDateTime("definitely not a date")); + + [TestMethod] + public async Task TryParseDateTime_ConfiguredFormat_Parses() + { + await AddEntry(WellKnownTextType.DateTime, "yyyy_MM_dd"); + var dt = await _ops.TryParseDateTime("2024_06_15"); + Assert.IsNotNull(dt); + Assert.AreEqual(new DateTime(2024, 6, 15), dt!.Value.Date); + } + + /// + /// StandardValue date conversions reach the configured formats through this binding; losing it + /// would silently degrade parsing rather than break the build. + /// + [TestMethod] + public async Task TextOps_IsRegisteredAsTheCustomDateTimeParser() + { + await AddEntry(WellKnownTextType.DateTime, "yyyy_MM_dd"); + var parser = _sp.GetRequiredService(); + + var dt = await parser.TryToParseDateTime("2024_06_15"); + + Assert.IsNotNull(dt); + Assert.AreEqual(new DateTime(2024, 6, 15), dt!.Value.Date); + } +} diff --git a/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs b/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs new file mode 100644 index 000000000..4dddf44f9 --- /dev/null +++ b/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bakabase.Abstractions.Models.Domain.Constants; +using Bakabase.Abstractions.Services; +using Bakabase.TestKit.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Bakabase.Tests; + +/// +/// Vocabulary store: type and entry CRUD, builtin protection, seeding idempotence, and how a +/// type's shape drives set resolution. +/// +[TestClass] +public sealed class TextVocabularyServiceTests +{ + private IServiceProvider _sp = null!; + private ITextVocabularyService _vocabulary = null!; + + [TestInitialize] + public async Task Setup() + { + _sp = await TestServiceBuilder.BuildServiceProvider(); + _vocabulary = _sp.GetRequiredService(); + } + + [TestMethod] + public async Task AddType_ThenGetTypes_ReturnsIt() + { + await _vocabulary.AddType("Release groups", TextTypeShape.Values, "who released it"); + + var types = await _vocabulary.GetTypes(); + var added = types.Single(t => t.Name == "Release groups"); + Assert.IsFalse(added.IsBuiltin); + Assert.AreEqual(TextTypeShape.Values, added.Shape); + Assert.AreEqual(0, added.EntryCount); + } + + [TestMethod] + public async Task AddType_DuplicateName_Throws() + { + await _vocabulary.AddType("Quality tags", TextTypeShape.Values); + await Assert.ThrowsExceptionAsync( + () => _vocabulary.AddType("Quality tags", TextTypeShape.Values)); + } + + [TestMethod] + public async Task EntryCount_ReflectsAddedEntries() + { + var type = await _vocabulary.AddType("Quality tags", TextTypeShape.Values); + await _vocabulary.AddEntry(type.Id, "1080p"); + await _vocabulary.AddEntries(type.Id, [("WebRip", null), ("HEVC", null)]); + + var reloaded = (await _vocabulary.GetTypes()).Single(t => t.Id == type.Id); + Assert.AreEqual(3, reloaded.EntryCount); + } + + [TestMethod] + public async Task RenameAndDelete_RejectedForBuiltinTypes() + { + await _vocabulary.EnsureSeeds(); + var wrapperId = await _vocabulary.GetTypeId(WellKnownTextType.Wrapper); + + await Assert.ThrowsExceptionAsync( + () => _vocabulary.RenameType(wrapperId, "Brackets")); + await Assert.ThrowsExceptionAsync(() => _vocabulary.DeleteType(wrapperId)); + } + + [TestMethod] + public async Task DeleteType_RemovesItsEntries() + { + var type = await _vocabulary.AddType("Scratch", TextTypeShape.Values); + await _vocabulary.AddEntry(type.Id, "value"); + + await _vocabulary.DeleteType(type.Id); + + Assert.IsFalse((await _vocabulary.GetTypes()).Any(t => t.Id == type.Id)); + Assert.AreEqual(0, (await _vocabulary.GetEntries(type.Id)).Count); + } + + [TestMethod] + public async Task EnsureSeeds_IsIdempotent() + { + await _vocabulary.EnsureSeeds(); + var afterFirst = (await _vocabulary.GetTypes()).Sum(t => t.EntryCount); + Assert.IsTrue(afterFirst > 0); + + await _vocabulary.EnsureSeeds(); + var afterSecond = (await _vocabulary.GetTypes()).Sum(t => t.EntryCount); + Assert.AreEqual(afterFirst, afterSecond); + } + + [TestMethod] + public async Task EnsureSeeds_DoesNotResurrectDeletedEntriesWithinTheSameRun() + { + await _vocabulary.EnsureSeeds(); + var uselessId = await _vocabulary.GetTypeId(WellKnownTextType.Useless); + var victim = (await _vocabulary.GetEntries(uselessId)).First(); + + await _vocabulary.DeleteEntry(victim.Id); + var afterDelete = (await _vocabulary.GetEntries(uselessId)).Count; + + // A rerun tops the entry back up — the point of this test is that it stays a top-up, i.e. + // exactly one row returns rather than the whole prefab set being duplicated. + await _vocabulary.EnsureSeeds(); + Assert.AreEqual(afterDelete + 1, (await _vocabulary.GetEntries(uselessId)).Count); + } + + [TestMethod] + public async Task ResolveSet_PairShape_ExposesPairs() + { + await _vocabulary.EnsureSeeds(); + + var set = await _vocabulary.ResolveSet(WellKnownTextType.Wrapper); + + Assert.AreEqual(TextTypeShape.DelimiterPair, set.Shape); + CollectionAssert.Contains(set.Pairs.Select(p => p.Value1).ToList(), "["); + Assert.AreEqual("]", set.Pairs.Single(p => p.Value1 == "[").Value2); + } + + [TestMethod] + public async Task ResolveSet_ValuesShape_ExposesNoPairsEvenWhenSecondValuesExist() + { + await _vocabulary.EnsureSeeds(); + + // Volume prefabs carry an ordinal in the second value that no consumer reads; it must be + // preserved in storage yet stay out of the resolved pairs. + var set = await _vocabulary.ResolveSet(WellKnownTextType.Volume); + + Assert.AreEqual(TextTypeShape.Values, set.Shape); + Assert.AreEqual(0, set.Pairs.Count); + Assert.IsTrue(set.Values.Count > 0); + + var typeId = await _vocabulary.GetTypeId(WellKnownTextType.Volume); + Assert.IsTrue((await _vocabulary.GetEntries(typeId)).Any(e => e.Value2 == "1")); + } + + [TestMethod] + public async Task PatchEntry_UpdatesOnlyProvidedValues() + { + var type = await _vocabulary.AddType("Mappings", TextTypeShape.MappingPair); + var entry = await _vocabulary.AddEntry(type.Id, "from", "to"); + + await _vocabulary.PatchEntry(entry.Id, "changed", null); + + var reloaded = (await _vocabulary.GetEntries(type.Id)).Single(); + Assert.AreEqual("changed", reloaded.Value1); + Assert.AreEqual("to", reloaded.Value2); + } +} From 1b60d5fd09a948558e764155e86f5a9e50372502 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:11:02 +0000 Subject: [PATCH 22/46] feat(text): unified text management UI and SDK The text page now renders one list of types straight from GetTypes, where builtins carry a badge and lose their rename/delete actions while user-defined types gain them. Entry editing keys off the type's shape rather than a fixed enum, so a custom mapping type gets the same two inputs as Standardization does. Follows the type through its remaining frontend couplings: the display-name template editor no longer indexes the response by an enum value (it finds the wrapper type by its well-known handle), and the enhancer target label and its StandardValue map move to WellKnownTextType. Localization keys move from SpecialTextType.* to WellKnownTextType.* and gain the shape and type-management strings. SDK regenerated: the specialText endpoints are gone, replaced by the text ones, and constants.ts picks up the two new enums. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017XD61DUNaB5iVEevJrA26H --- .../components/TargetRow/index.tsx | 6 +- .../IntegrateWithSpecialTextLabel/index.tsx | 18 +- src/web/src/locales/cn/pages/text.json | 74 +- src/web/src/locales/en/pages/text.json | 74 +- .../DisplayNameTemplateEditorModal/index.tsx | 16 +- src/web/src/pages/text/Detail/index.tsx | 139 ++-- src/web/src/pages/text/index.tsx | 338 +++++---- src/web/src/pages/text/models.ts | 15 +- src/web/src/sdk/Api.ts | 601 ++++++++++------ src/web/src/sdk/BApi2.d.ts | 645 +++++++++++++----- src/web/src/sdk/constants.ts | 66 ++ 11 files changed, 1321 insertions(+), 671 deletions(-) diff --git a/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx b/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx index 60832fb90..f9cf489fc 100644 --- a/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx +++ b/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx @@ -16,7 +16,7 @@ import { createEnhancerTargetOptions } from "../../models"; import TargetOptions from "./TargetOptions"; import { Autocomplete, AutocompleteItem, Button, Chip, Tooltip } from "@/components/bakaui"; -import { SpecialTextType, StandardValueType } from "@/sdk/constants"; +import { StandardValueType, WellKnownTextType } from "@/sdk/constants"; import { IntegrateWithSpecialTextLabel } from "@/components/SpecialText"; import { buildLogger } from "@/components/utils"; import { useBakabaseContext } from "@/components/ContextProvider/BakabaseContextProvider"; @@ -39,9 +39,9 @@ interface Props { } const StdValueSpecialTextIntegrationMap: { - [key in StandardValueType]?: SpecialTextType; + [key in StandardValueType]?: WellKnownTextType; } = { - [StandardValueType.DateTime]: SpecialTextType.DateTime, + [StandardValueType.DateTime]: WellKnownTextType.DateTime, }; const log = buildLogger("TargetRow"); diff --git a/src/web/src/components/SpecialText/IntegrateWithSpecialTextLabel/index.tsx b/src/web/src/components/SpecialText/IntegrateWithSpecialTextLabel/index.tsx index b1a5c6937..00d5a33b1 100644 --- a/src/web/src/components/SpecialText/IntegrateWithSpecialTextLabel/index.tsx +++ b/src/web/src/components/SpecialText/IntegrateWithSpecialTextLabel/index.tsx @@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next"; import { Chip, Link, Modal, Tooltip } from "@/components/bakaui"; -import { SpecialTextType } from "@/sdk/constants"; +import { WellKnownTextType } from "@/sdk/constants"; import { useBakabaseContext } from "@/components/ContextProvider/BakabaseContextProvider"; interface IProps { - type: SpecialTextType; + type: WellKnownTextType; } const IntegrateWithSpecialTextLabel = ({ type }: IProps) => { const { t } = useTranslation(); @@ -16,17 +16,17 @@ const IntegrateWithSpecialTextLabel = ({ type }: IProps) => { let tooltipContent = ""; switch (type) { - case SpecialTextType.DateTime: + case WellKnownTextType.DateTime: tooltipContent = t( "Data will be attempted to be converted into date&time data according to relevant conversions.", ); break; - case SpecialTextType.Useless: - case SpecialTextType.Language: - case SpecialTextType.Wrapper: - case SpecialTextType.Standardization: - case SpecialTextType.Volume: - case SpecialTextType.Trim: + case WellKnownTextType.Useless: + case WellKnownTextType.Language: + case WellKnownTextType.Wrapper: + case WellKnownTextType.Standardization: + case WellKnownTextType.Volume: + case WellKnownTextType.Trim: tooltipContent = t("Data will be processed with special texts."); break; default: diff --git a/src/web/src/locales/cn/pages/text.json b/src/web/src/locales/cn/pages/text.json index fa31df511..893ad42e9 100644 --- a/src/web/src/locales/cn/pages/text.json +++ b/src/web/src/locales/cn/pages/text.json @@ -1,47 +1,53 @@ { + "WellKnownTextType.DateTime": "日期时间", + "WellKnownTextType.Language": "语言", + "WellKnownTextType.Standardization": "标准化", + "WellKnownTextType.Trim": "边缘修剪", + "WellKnownTextType.Useless": "无用文本", + "WellKnownTextType.Volume": "卷号", + "WellKnownTextType.Wrapper": "包装符", + "text.action.addPrefabs": "添加预设数据", + "text.action.addType": "新增类型", + "text.action.renameType": "重命名类型", + "text.action.runPretreatment": "运行预处理", + "text.confirm.deleteTitle": "确定要删除吗?", + "text.confirm.deleteTypeMessage": "{{name}} 下的所有文本也会被删除。", + "text.confirm.deleteTypeTitle": "确定删除该类型?", + "text.input.enterText": "输入文本", "text.label.appliedTo": "应用于", - "text.label.texts": "文本", + "text.label.builtin": "内置", + "text.label.convertTo": "转换为", + "text.label.leftWrapper": "左包裹符号", + "text.label.noChanges": "无变化", "text.label.opt": "操作", "text.label.original": "原始", "text.label.pretreated": "处理后", - "text.action.addPrefabs": "添加预设数据", "text.label.pretreatmentTest": "预处理测试", - "text.input.enterText": "输入文本", - "text.action.runPretreatment": "运行预处理", - "text.label.noChanges": "无变化", - "text.confirm.deleteTitle": "确定要删除吗?", - - "SpecialTextType.Useless": "无用文本", - "SpecialTextType.Language": "语言", - "SpecialTextType.Wrapper": "包裹符号", - "SpecialTextType.Standardization": "同义词", - "SpecialTextType.Volume": "卷号", - - "SpecialTextType.Trim": "首尾清除", - "SpecialTextType.DateTime": "日期时间", - - "text.typeDescription.useless": "忽略被正则表达式成功匹配的包裹符号内的部分", + "text.label.rightWrapper": "右包裹符号", + "text.label.shape": "形态", + "text.label.sourceText": "源文本", + "text.label.text": "文本", + "text.label.texts": "文本", + "text.label.typeDescription": "描述", + "text.label.typeName": "类型名称", + "text.placeholder.convertTo": "输入目标文本", + "text.placeholder.leftWrapper": "输入左包裹符号", + "text.placeholder.rightWrapper": "输入右包裹符号", + "text.placeholder.sourceText": "输入源文本", + "text.placeholder.text": "输入文本", + "text.shape.delimiterPair": "定界符对", + "text.shape.mappingPair": "映射(A 转 B)", + "text.shape.values": "值集合", + "text.typeDescription.dateTime": "日期时间解析模板,用于从文本中提取日期和时间", "text.typeDescription.language": "如果文本被[包裹符号]包围,将被解析为[特定语言]", - "text.typeDescription.wrapper": "文本包裹符号,用于匹配和提取包裹符号内的文本", "text.typeDescription.standardization": "分析时将[文本1]视为[文本2]", - "text.typeDescription.volume": "从此文本组中提取卷信息", "text.typeDescription.trim": "当文本的前端或后端出现指定文本时,将其从两端移除", - "text.typeDescription.dateTime": "日期时间解析模板,用于从文本中提取日期和时间", - - "text.usedIn.textPretreatment": "文本预处理", + "text.typeDescription.useless": "忽略被正则表达式成功匹配的包裹符号内的部分", + "text.typeDescription.volume": "从此文本组中提取卷信息", + "text.typeDescription.wrapper": "文本包裹符号,用于匹配和提取包裹符号内的文本", "text.usedIn.bakabaseEnhancerAnalysis": "Bakabase 增强器分析", - "text.usedIn.resourceDisplayNameTemplate": "资源显示名称模板", "text.usedIn.exhentaiEnhancerAnalysis": "Exhentai 增强器分析", "text.usedIn.parsingOrConvertingPropertyValue": "解析或转换属性值", - - "text.label.text": "文本", - "text.placeholder.text": "输入文本", - "text.label.sourceText": "源文本", - "text.placeholder.sourceText": "输入源文本", - "text.label.convertTo": "转换为", - "text.placeholder.convertTo": "输入目标文本", - "text.label.leftWrapper": "左包裹符号", - "text.placeholder.leftWrapper": "输入左包裹符号", - "text.label.rightWrapper": "右包裹符号", - "text.placeholder.rightWrapper": "输入右包裹符号" + "text.usedIn.resourceDisplayNameTemplate": "资源显示名称模板", + "text.usedIn.textPretreatment": "文本预处理" } diff --git a/src/web/src/locales/en/pages/text.json b/src/web/src/locales/en/pages/text.json index 98982a9af..7e3b1d32e 100644 --- a/src/web/src/locales/en/pages/text.json +++ b/src/web/src/locales/en/pages/text.json @@ -1,47 +1,53 @@ { + "WellKnownTextType.DateTime": "DateTime", + "WellKnownTextType.Language": "Language", + "WellKnownTextType.Standardization": "Standardization", + "WellKnownTextType.Trim": "Edge Trim", + "WellKnownTextType.Useless": "Useless", + "WellKnownTextType.Volume": "Volume Number", + "WellKnownTextType.Wrapper": "Wrapper", + "text.action.addPrefabs": "Add preset data", + "text.action.addType": "Add type", + "text.action.renameType": "Rename type", + "text.action.runPretreatment": "Run pretreatment", + "text.confirm.deleteTitle": "Sure to delete?", + "text.confirm.deleteTypeMessage": "All texts of {{name}} will be deleted too.", + "text.confirm.deleteTypeTitle": "Delete this type?", + "text.input.enterText": "Enter text", "text.label.appliedTo": "Applied to", - "text.label.texts": "Texts", + "text.label.builtin": "Built-in", + "text.label.convertTo": "Convert to", + "text.label.leftWrapper": "Left wrapper", + "text.label.noChanges": "No changes", "text.label.opt": "Opt", "text.label.original": "Original", "text.label.pretreated": "Pretreated", - "text.action.addPrefabs": "Add preset data", "text.label.pretreatmentTest": "Pretreatment test", - "text.input.enterText": "Enter text", - "text.action.runPretreatment": "Run pretreatment", - "text.label.noChanges": "No changes", - "text.confirm.deleteTitle": "Sure to delete?", - - "SpecialTextType.Useless": "Useless", - "SpecialTextType.Language": "Language", - "SpecialTextType.Wrapper": "Wrapper", - "SpecialTextType.Standardization": "Standardization", - "SpecialTextType.Volume": "Volume Number", - - "SpecialTextType.Trim": "Edge Trim", - "SpecialTextType.DateTime": "DateTime", - - "text.typeDescription.useless": "Ignore the part inside the wrapper that is successfully matched by the regular expression", + "text.label.rightWrapper": "Right wrapper", + "text.label.shape": "Shape", + "text.label.sourceText": "Source text", + "text.label.text": "Text", + "text.label.texts": "Texts", + "text.label.typeDescription": "Description", + "text.label.typeName": "Type name", + "text.placeholder.convertTo": "Enter target text", + "text.placeholder.leftWrapper": "Enter left wrapper", + "text.placeholder.rightWrapper": "Enter right wrapper", + "text.placeholder.sourceText": "Enter source text", + "text.placeholder.text": "Enter text", + "text.shape.delimiterPair": "Delimiter pair", + "text.shape.mappingPair": "Mapping (A to B)", + "text.shape.values": "Values", + "text.typeDescription.dateTime": "Date and time parsing template, used to extract dates and times from text", "text.typeDescription.language": "Text will be parsed as [specific language] if it surrounded by [wrappers]", - "text.typeDescription.wrapper": "Text wrapper, used to match and extract the text within the wrapper", "text.typeDescription.standardization": "Treat [text1] as [text2] during analyzation", - "text.typeDescription.volume": "Extract volume information from this text group", "text.typeDescription.trim": "When the specified text appears at either end of the text, remove it from both ends", - "text.typeDescription.dateTime": "Date and time parsing template, used to extract dates and times from text", - - "text.usedIn.textPretreatment": "Text pretreatment", + "text.typeDescription.useless": "Ignore the part inside the wrapper that is successfully matched by the regular expression", + "text.typeDescription.volume": "Extract volume information from this text group", + "text.typeDescription.wrapper": "Text wrapper, used to match and extract the text within the wrapper", "text.usedIn.bakabaseEnhancerAnalysis": "Bakabase enhancer analysis", - "text.usedIn.resourceDisplayNameTemplate": "Resource display name template", "text.usedIn.exhentaiEnhancerAnalysis": "Exhentai enhancer analysis", "text.usedIn.parsingOrConvertingPropertyValue": "Parsing or converting property value", - - "text.label.text": "Text", - "text.placeholder.text": "Enter text", - "text.label.sourceText": "Source text", - "text.placeholder.sourceText": "Enter source text", - "text.label.convertTo": "Convert to", - "text.placeholder.convertTo": "Enter target text", - "text.label.leftWrapper": "Left wrapper", - "text.placeholder.leftWrapper": "Enter left wrapper", - "text.label.rightWrapper": "Right wrapper", - "text.placeholder.rightWrapper": "Enter right wrapper" + "text.usedIn.resourceDisplayNameTemplate": "Resource display name template", + "text.usedIn.textPretreatment": "Text pretreatment" } diff --git a/src/web/src/pages/resource-profile/components/DisplayNameTemplateEditorModal/index.tsx b/src/web/src/pages/resource-profile/components/DisplayNameTemplateEditorModal/index.tsx index d4859f8c2..2ca992a3b 100644 --- a/src/web/src/pages/resource-profile/components/DisplayNameTemplateEditorModal/index.tsx +++ b/src/web/src/pages/resource-profile/components/DisplayNameTemplateEditorModal/index.tsx @@ -14,7 +14,7 @@ import { builtinPropertyForDisplayNames, PropertyPool, PropertyType, - SpecialTextType, + WellKnownTextType, } from "@/sdk/constants.ts"; import { getEnumKey } from "@/i18n"; @@ -123,12 +123,14 @@ const DisplayNameTemplateEditorModal = ({ template, properties, onSubmit, ...pro const textareaRef = useRef(null); const init = useCallback(async () => { - const tr = await BApi.specialText.getAllSpecialTexts(); - const texts = tr.data?.[SpecialTextType.Wrapper] || []; - const wrappersData = texts.map((text) => ({ - left: text.value1!, - right: text.value2!, - })); + // Types now live in one id space, so the wrapper type is found by its well-known handle + // rather than by indexing the response with an enum value. + const tr = await BApi.text.getAllTextTypes(); + const wrapperType = (tr.data ?? []).find((t) => t.wellKnown == WellKnownTextType.Wrapper); + const entries = wrapperType ? ((await BApi.text.getTextEntries(wrapperType.id)).data ?? []) : []; + const wrappersData = entries + .filter((e) => e.value1 && e.value2) + .map((e) => ({ left: e.value1!, right: e.value2! })); setWrappers(wrappersData); diff --git a/src/web/src/pages/text/Detail/index.tsx b/src/web/src/pages/text/Detail/index.tsx index d606663f7..e2d7dc0cb 100644 --- a/src/web/src/pages/text/Detail/index.tsx +++ b/src/web/src/pages/text/Detail/index.tsx @@ -1,103 +1,84 @@ "use client"; -import type { SpecialText } from "@/pages/text/models"; +import type { TextEntry } from "@/pages/text/models"; import { useTranslation } from "react-i18next"; import React, { useState } from "react"; import { Input } from "@/components/bakaui"; -import { SpecialTextType } from "@/sdk/constants"; +import { TextTypeShape } from "@/sdk/constants"; interface Props { - value: SpecialText; - onChange: (value: SpecialText) => any; + value: TextEntry; + shape: TextTypeShape; + onChange: (value: TextEntry) => any; } -const DetailPage = ({ value: propsValue, onChange }: Props) => { + +/** + * Which inputs an entry needs follows the type's shape, not the type itself — so a user-defined + * type gets the same editor as the builtin with the same shape. + */ +const DetailPage = ({ value: propsValue, shape, onChange }: Props) => { const { t } = useTranslation(); - const [value, setValue] = useState(JSON.parse(JSON.stringify(propsValue))); + const [value, setValue] = useState(JSON.parse(JSON.stringify(propsValue))); - const change = (patches: Partial) => { - const nv = { - ...value, - ...patches, - }; + const change = (patches: Partial) => { + const nv = { ...value, ...patches }; setValue(nv); onChange(nv); }; - switch (value.type) { - case SpecialTextType.DateTime: - case SpecialTextType.Volume: - case SpecialTextType.Useless: - case SpecialTextType.Trim: - return ( + const firstLabels: Record = { + [TextTypeShape.Values]: { label: "text.label.text", placeholder: "text.placeholder.text" }, + [TextTypeShape.DelimiterPair]: { + label: "text.label.leftWrapper", + placeholder: "text.placeholder.leftWrapper", + }, + [TextTypeShape.MappingPair]: { + label: "text.label.sourceText", + placeholder: "text.placeholder.sourceText", + }, + }; + + const secondLabels: Partial> = { + [TextTypeShape.DelimiterPair]: { + label: "text.label.rightWrapper", + placeholder: "text.placeholder.rightWrapper", + }, + [TextTypeShape.MappingPair]: { + label: "text.label.convertTo", + placeholder: "text.placeholder.convertTo", + }, + }; + + const first = firstLabels[shape]; + const second = secondLabels[shape]; + + return ( +
      + (first.label)} + placeholder={t(first.placeholder)} + value={value.value1} + onValueChange={(value1) => change({ value1 })} + /> + {second && ( ("text.label.text")} - placeholder={t("text.placeholder.text")} - value={value.value1} - onValueChange={(value1) => { - change({ value1 }); - }} + label={t(second.label)} + placeholder={t(second.placeholder)} + value={value.value2} + onValueChange={(value2) => change({ value2 })} /> - ); - case SpecialTextType.Language: - case SpecialTextType.Standardization: - return ( - <> - ("text.label.sourceText")} - placeholder={t("text.placeholder.sourceText")} - value={value.value1} - onValueChange={(value1) => { - change({ value1 }); - }} - /> - ("text.label.convertTo")} - placeholder={t("text.placeholder.convertTo")} - value={value.value2} - onValueChange={(value2) => { - change({ value2 }); - }} - /> - - ); - case SpecialTextType.Wrapper: - return ( - <> - ("text.label.leftWrapper")} - placeholder={t("text.placeholder.leftWrapper")} - value={value.value1} - onValueChange={(value1) => { - change({ value1 }); - }} - /> - ("text.label.rightWrapper")} - placeholder={t("text.placeholder.rightWrapper")} - value={value.value2} - onValueChange={(value2) => { - change({ value2 }); - }} - /> - - ); - default: - return null; - } + )} +
      + ); }; DetailPage.displayName = "DetailPage"; diff --git a/src/web/src/pages/text/index.tsx b/src/web/src/pages/text/index.tsx index ae95da4fe..00d244df5 100644 --- a/src/web/src/pages/text/index.tsx +++ b/src/web/src/pages/text/index.tsx @@ -6,12 +6,14 @@ import "./index.scss"; import { useTranslation } from "react-i18next"; import { ArrowRightOutlined } from "@ant-design/icons"; -import { SpecialTextType, specialTextTypes } from "@/sdk/constants"; +import { TextTypeShape, WellKnownTextType } from "@/sdk/constants"; import BApi from "@/sdk/BApi"; import { Button, Chip, + Input, Modal, + Select, Table, TableBody, TableCell, @@ -22,21 +24,22 @@ import { Divider, } from "@/components/bakaui"; -import type { SpecialText } from "@/pages/text/models"; +import type { TextEntry, TextType } from "@/pages/text/models"; import { useBakabaseContext } from "@/components/ContextProvider/BakabaseContextProvider"; import DetailPage from "@/pages/text/Detail"; -const tagRenders: Record React.ReactNode> = { - Single: (t: SpecialText) => t.value1, - Wrapper: (t: SpecialText) => ( +/** How an entry reads depends on its type's shape, so rendering keys off that rather than the type. */ +const entryRenders: Record React.ReactNode> = { + [TextTypeShape.Values]: (t) => t.value1, + [TextTypeShape.DelimiterPair]: (t) => ( <> {t.value1} ... {t.value2} ), - Value1ToValue2: (t: SpecialText) => ( + [TextTypeShape.MappingPair]: (t) => ( {t.value1} @@ -45,49 +48,45 @@ const tagRenders: Record React.ReactNode> = { ), }; -const typeTagRendersMapping: Partial React.ReactNode>> = - { - [SpecialTextType.Useless]: tagRenders.Single, - [SpecialTextType.Language]: tagRenders.Value1ToValue2, - [SpecialTextType.Wrapper]: tagRenders.Wrapper, - [SpecialTextType.Standardization]: tagRenders.Value1ToValue2, - [SpecialTextType.Volume]: tagRenders.Single, - [SpecialTextType.Trim]: tagRenders.Single, - }; - -const typeDescriptions = { - [SpecialTextType.Useless]: "text.typeDescription.useless", - [SpecialTextType.Language]: "text.typeDescription.language", - [SpecialTextType.Wrapper]: "text.typeDescription.wrapper", - [SpecialTextType.Standardization]: "text.typeDescription.standardization", - [SpecialTextType.Volume]: "text.typeDescription.volume", - [SpecialTextType.Trim]: "text.typeDescription.trim", - [SpecialTextType.DateTime]: "text.typeDescription.dateTime", +const typeDescriptions: Partial> = { + [WellKnownTextType.Useless]: "text.typeDescription.useless", + [WellKnownTextType.Language]: "text.typeDescription.language", + [WellKnownTextType.Wrapper]: "text.typeDescription.wrapper", + [WellKnownTextType.Standardization]: "text.typeDescription.standardization", + [WellKnownTextType.Volume]: "text.typeDescription.volume", + [WellKnownTextType.Trim]: "text.typeDescription.trim", + [WellKnownTextType.DateTime]: "text.typeDescription.dateTime", }; -const usedInMapping: Record = { - [SpecialTextType.Useless]: ["text.usedIn.textPretreatment"], - [SpecialTextType.Language]: ["text.usedIn.bakabaseEnhancerAnalysis"], - [SpecialTextType.Wrapper]: [ +const usedInMapping: Partial> = { + [WellKnownTextType.Useless]: ["text.usedIn.textPretreatment"], + [WellKnownTextType.Language]: ["text.usedIn.bakabaseEnhancerAnalysis"], + [WellKnownTextType.Wrapper]: [ "text.usedIn.textPretreatment", "text.usedIn.resourceDisplayNameTemplate", "text.usedIn.exhentaiEnhancerAnalysis", ], - [SpecialTextType.Standardization]: ["text.usedIn.textPretreatment"], - [SpecialTextType.Volume]: ["text.usedIn.bakabaseEnhancerAnalysis"], - [SpecialTextType.Trim]: ["text.usedIn.textPretreatment"], - [SpecialTextType.DateTime]: [ + [WellKnownTextType.Standardization]: ["text.usedIn.textPretreatment"], + [WellKnownTextType.Volume]: ["text.usedIn.bakabaseEnhancerAnalysis"], + [WellKnownTextType.Trim]: ["text.usedIn.textPretreatment"], + [WellKnownTextType.DateTime]: [ "text.usedIn.bakabaseEnhancerAnalysis", "text.usedIn.parsingOrConvertingPropertyValue", ], }; + +const shapeLabels: Record = { + [TextTypeShape.Values]: "text.shape.values", + [TextTypeShape.DelimiterPair]: "text.shape.delimiterPair", + [TextTypeShape.MappingPair]: "text.shape.mappingPair", +}; + const TextPage = () => { const { t } = useTranslation(); const { createPortal } = useBakabaseContext(); - const [textsMap, setTextsMap] = useState<{ - [key in SpecialTextType]?: SpecialText[]; - }>({}); + const [types, setTypes] = useState([]); + const [entriesMap, setEntriesMap] = useState>({}); const [testInput, setTestInput] = useState(""); const [testResult, setTestResult] = useState(""); @@ -135,51 +134,131 @@ const TextPage = () => { loadData(); }, []); - const loadData = () => { - BApi.specialText.getAllSpecialTexts().then((t) => { - const data = t.data || {}; - const ts = specialTextTypes.reduce<{ - [key in SpecialTextType]?: SpecialText[]; - }>((s, t) => { - const list = data[t.value] ?? []; - - list.sort((a, b) => a.value1.localeCompare(b.value1)); - s[t.value] = list.map((l) => ({ - id: l.id!, - value1: l.value1!, - value2: l.value2, - type: l.type, - })); - - return s; - }, {}); - - setTextsMap(ts); - }); + const loadData = async () => { + const r = await BApi.text.getAllTextTypes(); + const list = (r.data ?? []) as TextType[]; + + setTypes(list); + + const entries = await Promise.all( + list.map(async (type) => { + const er = await BApi.text.getTextEntries(type.id); + const items = ((er.data ?? []) as TextEntry[]) + .slice() + .sort((a, b) => a.value1.localeCompare(b.value1)); + + return [type.id, items] as const; + }), + ); + + setEntriesMap(Object.fromEntries(entries)); }; - const renderDetail = (c: SpecialText) => { - let text = c; + const editEntry = (type: TextType, entry: TextEntry) => { + let draft = entry; createPortal(Modal, { defaultVisible: true, + title: type.name, children: (
      - (text = t)} /> + (draft = v)} />
      ), size: "lg", onOk: async () => { - if (c.id > 0) { - await BApi.specialText.patchSpecialText(c.id, text); + if (draft.id > 0) { + await BApi.text.patchTextEntry(draft.id, { + value1: draft.value1, + value2: draft.value2, + }); } else { - await BApi.specialText.addSpecialText(text); + await BApi.text.addTextEntry(type.id, { + value1: draft.value1, + value2: draft.value2, + }); } await loadData(); }, }); }; + const createType = () => { + let name = ""; + let shape: TextTypeShape = TextTypeShape.Values; + let description = ""; + + createPortal(Modal, { + defaultVisible: true, + title: t("text.action.addType"), + children: ( +
      + ("text.label.typeName")} + onValueChange={(v) => (name = v)} + /> + ("text.label.typeDescription")} + onValueChange={(v) => (description = v)} + /> +
      + ), + onOk: async () => { + await BApi.text.addTextType({ name, shape, description }); + await loadData(); + }, + }); + }; + + const renameType = (type: TextType) => { + let name = type.name; + + createPortal(Modal, { + defaultVisible: true, + title: t("text.action.renameType"), + children: ( + ("text.label.typeName")} + onValueChange={(v) => (name = v)} + /> + ), + onOk: async () => { + await BApi.text.renameTextType(type.id, { name }); + await loadData(); + }, + }); + }; + + const deleteType = (type: TextType) => { + createPortal(Modal, { + defaultVisible: true, + title: t("text.confirm.deleteTypeTitle"), + children: t("text.confirm.deleteTypeMessage", { name: type.name }), + onOk: async () => { + await BApi.text.deleteTextType(type.id); + await loadData(); + }, + }); + }; + return (
      @@ -190,90 +269,109 @@ const TextPage = () => { {t("text.label.opt")} - {Object.keys(textsMap).map((typeStr) => { - const type = parseInt(typeStr, 10) as SpecialTextType; - const texts = textsMap[type] ?? []; + {types.map((type) => { + const entries = entriesMap[type.id] ?? []; + const render = entryRenders[type.shape] ?? entryRenders[TextTypeShape.Values]; + const usedIn = type.wellKnown == undefined ? [] : (usedInMapping[type.wellKnown] ?? []); + const description = + type.wellKnown == undefined + ? type.description + : t(typeDescriptions[type.wellKnown] ?? ""); return ( - +
      - {t(`SpecialTextType.${SpecialTextType[type]}`)} - - {t(typeDescriptions[type])} + + {type.wellKnown == undefined + ? type.name + : t(`WellKnownTextType.${WellKnownTextType[type.wellKnown]}`)} + {type.wellKnown != undefined && ( + + {t("text.label.builtin")} + + )} + {description}
      - {usedInMapping[type].map((x, xi) => { - return ( - - {t(x)} - - ); - })} + {usedIn.map((x, xi) => ( + + {t(x)} + + ))}
      - {texts.map((c) => { - const renderer = typeTagRendersMapping[c.type] ?? tagRenders.Single; - - return ( - { - createPortal(Modal, { - title: t("text.confirm.deleteTitle"), - defaultVisible: true, - onOk: async () => { - await BApi.specialText.deleteSpecialText(c.id); - await loadData(); - }, - }); - }} - key={c.id} - // size={'sm'} - onClick={() => { - renderDetail(c); - }} - > - {renderer(c)} - - ); - })} + {entries.map((c) => ( + editEntry(type, c)} + onClose={() => { + createPortal(Modal, { + title: t("text.confirm.deleteTitle"), + defaultVisible: true, + onOk: async () => { + await BApi.text.deleteTextEntry(c.id); + await loadData(); + }, + }); + }} + > + {render(c)} + + ))}
      - +
      + + {type.wellKnown == undefined && ( + <> + + + + )} +
      ); })}
      -
      +
      +
      Task ResolveSet(int typeId); - /// + /// + /// Resolves a builtin's set. Purely a read: a builtin whose row does not exist yet resolves to + /// an empty set rather than being created here, because consumers call this on hot paths where + /// writing would race other consumers sharing the same scoped DbContext. + /// at startup is what guarantees the rows exist. + /// Task ResolveSet(WellKnownTextType wellKnown); + /// Id of a builtin's row. Throws when it is missing (see ). Task GetTypeId(WellKnownTextType wellKnown); /// - /// Ensures builtin types exist and tops up their prefab entries. Idempotent: an entry already - /// present under its type is left alone, so user deletions of prefab entries are not undone - /// within a run and re-adding stays a no-op. + /// Creates any missing builtin type rows, without touching entries. Builtin types are defined + /// in code, so their rows are an invariant: this runs once at startup, before anything reads + /// them, and keeps the management page showing every builtin regardless of seeding history. + /// + Task EnsureBuiltinTypes(); + + /// + /// Tops up the prefab entries of builtin types — the "add prefabs" action. Deliberately + /// separate from and never run automatically: entries are the + /// user's data, and re-adding them on every launch would resurrect ones they deleted. /// - Task EnsureSeeds(); + Task AddPrefabEntries(); } diff --git a/src/apps/Bakabase.Service/Components/BakabaseHost.cs b/src/apps/Bakabase.Service/Components/BakabaseHost.cs index 3c3793166..2777ab656 100644 --- a/src/apps/Bakabase.Service/Components/BakabaseHost.cs +++ b/src/apps/Bakabase.Service/Components/BakabaseHost.cs @@ -1,4 +1,5 @@ -using System; +using Bakabase.Abstractions.Services; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -130,6 +131,11 @@ protected override async Task ExecuteCustomProgress(IServiceProvider serviceProv }); } + // Builtin text types are defined in code, so their rows are an invariant. Creating + // them here — post-migration, before anything serves a request — keeps reads pure and + // the management page complete regardless of what seeding history a database has. + await scope.ServiceProvider.GetRequiredService().EnsureBuiltinTypes(); + // Warm in-memory caches that depend on tables created by migrations. // Must run here (post-migration) — not via IHostedService, which fires // during host start before MigrateDb has executed. diff --git a/src/apps/Bakabase.Service/Controllers/DashboardController.cs b/src/apps/Bakabase.Service/Controllers/DashboardController.cs index 9ddffeae8..1393e50d6 100644 --- a/src/apps/Bakabase.Service/Controllers/DashboardController.cs +++ b/src/apps/Bakabase.Service/Controllers/DashboardController.cs @@ -136,12 +136,12 @@ public async Task> GetStatistics() fileMoverTargets.Count); } - // Alias, Special Text + // Alias, text vocabulary var aliasCount = await _aliasService.Count(); - var stCount = (await _textVocabularyService.GetTypes()).Sum(t => t.EntryCount); + var textEntryCount = (await _textVocabularyService.GetTypes()).Sum(t => t.EntryCount); ds.OtherCounts.Add([ new("Aliases", aliasCount), - new("SpecialTexts", stCount) + new("Text entries", textEntryCount) ]); // Passwords diff --git a/src/apps/Bakabase.Service/Controllers/TextController.cs b/src/apps/Bakabase.Service/Controllers/TextController.cs index 7f86ae880..b4a6438fe 100644 --- a/src/apps/Bakabase.Service/Controllers/TextController.cs +++ b/src/apps/Bakabase.Service/Controllers/TextController.cs @@ -87,11 +87,15 @@ public async Task DeleteEntry(int id) [SwaggerOperation(OperationId = "ResolveTextSet")] public async Task> ResolveSet(int typeId) => new(await vocabulary.ResolveSet(typeId)); - [HttpPost("seeds")] - [SwaggerOperation(OperationId = "EnsureTextSeeds")] - public async Task EnsureSeeds() + /// + /// Tops up the builtin types' prefab entries. User-triggered only — see + /// for why this never runs on its own. + /// + [HttpPost("prefabs")] + [SwaggerOperation(OperationId = "AddTextPrefabEntries")] + public async Task AddPrefabEntries() { - await vocabulary.EnsureSeeds(); + await vocabulary.AddPrefabEntries(); return BaseResponseBuilder.Ok; } diff --git a/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs b/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs index cafd2f33c..00005cb5d 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/BakabaseDbContext.cs @@ -19,13 +19,16 @@ using Microsoft.EntityFrameworkCore; using EnhancementRecord = Bakabase.Abstractions.Models.Db.EnhancementRecord; using ReservedPropertyValue = Bakabase.Abstractions.Models.Db.ReservedPropertyValue; -using SpecialText = Bakabase.Abstractions.Models.Db.SpecialText; namespace Bakabase.InsideWorld.Business { public class BakabaseDbContext : DbContext, IBulkModificationDbContext, IComparisonDbContext, IHealthScoreDbContext { - public DbSet SpecialTexts { get; set; } + /// + /// Dormant: only TextSystemMigrator still reads it, to copy the rows into + /// / . See . + /// + public DbSet SpecialTexts { get; set; } public DbSet Playlists { get; set; } public DbSet DownloadTasks { get; set; } @@ -155,6 +158,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) t.HasIndex(a => new {a.ThirdPartyId, a.Key}).IsUnique(); }); + modelBuilder.Entity(t => + { + // Deliberately not unique on WellKnown: a database written by an earlier build may + // already hold duplicate builtin rows, and a unique index would fail the schema + // migration before startup ever got the chance to repair them + // (see ITextVocabularyService.EnsureBuiltinTypes). Uniqueness is upheld in code. + t.HasIndex(a => a.WellKnown); + t.HasIndex(a => a.Name); + }); + + modelBuilder.Entity(t => + { + t.HasIndex(a => a.TypeId); + }); + modelBuilder.Entity(t => { t.HasIndex(a => a.LastUsedAt); diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs index ce1e37fa7..906233425 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/AbstractDownloader.cs @@ -41,7 +41,7 @@ public void ResetStatus() private DownloaderStatus _status = DownloaderStatus.JustCreated; protected readonly ILogger Logger; private readonly IDownloaderFactory _downloaderFactory; - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; protected IServiceProvider ServiceProvider; public string? Checkpoint { get; protected set; } @@ -59,7 +59,7 @@ protected AbstractDownloader(IServiceProvider serviceProvider) ServiceProvider = serviceProvider; Logger = serviceProvider.GetRequiredService().CreateLogger(GetType()); _downloaderFactory = serviceProvider.GetRequiredService(); - _specialTextService = serviceProvider.GetRequiredService(); + _textVocabularyService = serviceProvider.GetRequiredService(); Definition = DownloaderInternals.DownloaderTypeDefinitionMap[GetType()]; } @@ -113,7 +113,7 @@ protected async Task BuildDownloadFilename( } } - var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); + var wrappers = (await _textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); // Remove empty wrappers if (wrappers.Any()) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs index 3ea973b5b..f922d2b0a 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Bilibili/BilibiliDownloader.cs @@ -26,7 +26,7 @@ public class BilibiliDownloader : AbstractDownloader private IStringLocalizer _localizer; private readonly IHostEnvironment _env; private readonly BilibiliClient _client; - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Bilibili; public override BilibiliDownloadTaskType EnumTaskType => BilibiliDownloadTaskType.Favorites; @@ -34,12 +34,12 @@ public class BilibiliDownloader : AbstractDownloader private readonly LuxService _luxService; public BilibiliDownloader(IStringLocalizer localizer, - BilibiliClient client, ITextVocabularyService specialTextService, + BilibiliClient client, ITextVocabularyService textVocabularyService, IHostEnvironment env, IServiceProvider serviceProvider, LuxService luxService) : base(serviceProvider) { _localizer = localizer; _client = client; - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; _env = env; _luxService = luxService; } @@ -179,7 +179,7 @@ protected override async Task StartCore(DownloadTask task, CancellationToken ct) $"[{postIndex + 1}/{totalCount}][{post.Upper?.Name}]{post.Title} - P{i} - {bestQuality?.Description}"; await OnCurrentChangedInternal(); - var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); + var wrappers = (await _textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); var keyFilename = await BuildDownloadFilename(videoValues); diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs index 9cde5a7e8..dd4af63df 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Cien/AbstractCienDownloader.cs @@ -14,12 +14,12 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractCienDownloader : AbstractDownloader { - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Cien; - protected AbstractCienDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) + protected AbstractCienDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; } protected override async Task StartCore(DownloadTask task, CancellationToken ct) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs index 619623c2d..553b96c1c 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/AbstractExHentaiDownloader.cs @@ -23,17 +23,17 @@ public abstract class AbstractExHentaiDownloader : AbstractDownloader Localizer; protected readonly ExHentaiClient Client; - protected readonly ITextVocabularyService SpecialTextService; + protected readonly ITextVocabularyService TextVocabularyService; protected readonly IHostEnvironment Env; protected AbstractExHentaiDownloader(IServiceProvider serviceProvider, IStringLocalizer localizer, - ExHentaiClient client, ITextVocabularyService specialTextService, + ExHentaiClient client, ITextVocabularyService textVocabularyService, IHostEnvironment env) : base(serviceProvider) { Localizer = localizer; Client = client; - SpecialTextService = specialTextService; + TextVocabularyService = textVocabularyService; Env = env; } @@ -117,7 +117,7 @@ protected async Task DownloadSingleWork(string url, string checkpoint, string do {ExHentaiNamingFields.Category, detail.Category}, }; - var wrappers = (await SpecialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); + var wrappers = (await TextVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); //var limit = await _client.GetImageLimits(); //if (limit.Rest <= imageTitleAndPageUrls.Length) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs index b1b7ee6ed..f582d7c9d 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiListDownloader.cs @@ -15,10 +15,10 @@ public class ExHentaiListDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, IHostEnvironment env) : AbstractExHentaiDownloader(serviceProvider, localizer, client, - specialTextService, env) + textVocabularyService, env) { public override ExHentaiDownloadTaskType EnumTaskType => ExHentaiDownloadTaskType.List; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs index 5abe5d17d..90592a806 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiSingleWorkDownloader.cs @@ -15,10 +15,10 @@ public class ExHentaiSingleWorkDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, IHostEnvironment env) : AbstractExHentaiDownloader(serviceProvider, localizer, - client, specialTextService, env) + client, textVocabularyService, env) { public override ExHentaiDownloadTaskType EnumTaskType => ExHentaiDownloadTaskType.SingleWork; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs index 221ba38d3..aede46d64 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/ExHentai/ExHentaiWatchedDownloader.cs @@ -14,10 +14,10 @@ public class ExHentaiWatchedDownloader( IServiceProvider serviceProvider, IStringLocalizer localizer, ExHentaiClient client, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, IHostEnvironment env) : ExHentaiListDownloader(serviceProvider, localizer, client, - specialTextService, env) + textVocabularyService, env) { public override ExHentaiDownloadTaskType EnumTaskType => ExHentaiDownloadTaskType.Watched; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs index cb1274e2a..c539817fb 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/AbstractFanboxDownloader.cs @@ -12,12 +12,12 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractFanboxDownloader : AbstractDownloader { - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Fanbox; - protected AbstractFanboxDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) + protected AbstractFanboxDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; } protected override async Task StartCore(DownloadTask task, CancellationToken ct) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs index e01254590..3eaf82be4 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxCreatorDownloader.cs @@ -13,7 +13,7 @@ public class FanboxCreatorDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.Creator; - public FanboxCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FanboxCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs index 96897a012..23c4e30e0 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxFollowingDownloader.cs @@ -13,7 +13,7 @@ public class FanboxFollowingDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.Following; - public FanboxFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FanboxFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs index c54a9fca3..b98897dd3 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fanbox/FanboxSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class FanboxSinglePostDownloader : AbstractFanboxDownloader { public override FanboxDownloadTaskType EnumTaskType => FanboxDownloadTaskType.SinglePost; - public FanboxSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FanboxSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs index a0010b3e5..977aa7808 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/AbstractFantiaDownloader.cs @@ -14,12 +14,12 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractFantiaDownloader : AbstractDownloader { - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Fantia; - protected AbstractFantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) + protected AbstractFantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; } protected override async Task StartCore(DownloadTask task, CancellationToken ct) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs index 437c48315..bd2169233 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaCreatorDownloader.cs @@ -13,7 +13,7 @@ public class FantiaCreatorDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.Creator; - public FantiaCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FantiaCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs index 265d238b4..4092a8171 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaDownloader.cs @@ -13,7 +13,7 @@ public class FantiaDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.SinglePost; - public FantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FantiaDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs index 6eac1dfab..52447f6b1 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaFollowingDownloader.cs @@ -13,7 +13,7 @@ public class FantiaFollowingDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.Following; - public FantiaFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FantiaFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs index 829111388..83b255a05 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Fantia/FantiaSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class FantiaSinglePostDownloader : AbstractFantiaDownloader { public override FantiaDownloadTaskType EnumTaskType => FantiaDownloadTaskType.SinglePost; - public FantiaSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public FantiaSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs index 51511971e..de6ca0cae 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/AbstractPatreonDownloader.cs @@ -14,12 +14,12 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractPatreonDownloader : AbstractDownloader { - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Patreon; - protected AbstractPatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider) + protected AbstractPatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; } protected override async Task StartCore(DownloadTask task, CancellationToken ct) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs index b972204d9..f8b132c08 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonCreatorDownloader.cs @@ -13,7 +13,7 @@ public class PatreonCreatorDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.Creator; - public PatreonCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public PatreonCreatorDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs index b21f8602e..54a485064 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonDownloader.cs @@ -13,7 +13,7 @@ public class PatreonDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.SinglePost; - public PatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public PatreonDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs index dee27e4b9..51f921093 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonFollowingDownloader.cs @@ -13,7 +13,7 @@ public class PatreonFollowingDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.Following; - public PatreonFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public PatreonFollowingDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs index 431a0047f..eee4c6c03 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Patreon/PatreonSinglePostDownloader.cs @@ -13,7 +13,7 @@ public class PatreonSinglePostDownloader : AbstractPatreonDownloader { public override PatreonDownloadTaskType EnumTaskType => PatreonDownloadTaskType.SinglePost; - public PatreonSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService) : base(serviceProvider, specialTextService) + public PatreonSinglePostDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService) : base(serviceProvider, textVocabularyService) { } diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs index 1dc252a9f..9ef9fbb61 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/AbstractPixivDownloader.cs @@ -16,13 +16,13 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public abstract class AbstractPixivDownloader : AbstractDownloader { - private readonly ITextVocabularyService _specialTextService; + private readonly ITextVocabularyService _textVocabularyService; protected readonly PixivClient Client; public override ThirdPartyId ThirdPartyId => ThirdPartyId.Pixiv; - protected AbstractPixivDownloader(IServiceProvider serviceProvider, ITextVocabularyService specialTextService, + protected AbstractPixivDownloader(IServiceProvider serviceProvider, ITextVocabularyService textVocabularyService, PixivClient client) : base(serviceProvider) { - _specialTextService = specialTextService; + _textVocabularyService = textVocabularyService; Client = client; } @@ -41,7 +41,7 @@ protected async Task DownloadSingleWork(string id, string downloadPath, PixivNam { await ChangeCurrent(nameContext); - var wrappers = (await _specialTextService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); + var wrappers = (await _textVocabularyService.ResolveSet(WellKnownTextType.Wrapper)).ToPairMap(); var lastFileNameValues = nameContext.ToLastFileNameValues(); if (lastFileNameValues != null) diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs index 7770914e3..4f6af55e6 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivFollowingDownloader.cs @@ -17,9 +17,9 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa /// public class PixivFollowingDownloader( IServiceProvider serviceProvider, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, PixivClient client) - : AbstractPixivDownloader(serviceProvider, specialTextService, + : AbstractPixivDownloader(serviceProvider, textVocabularyService, client) { public override PixivDownloadTaskType EnumTaskType => PixivDownloadTaskType.Following; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs index 0450da61c..1b6bd949e 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivRankingDownloader.cs @@ -15,9 +15,9 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa /// public class PixivRankingDownloader( IServiceProvider serviceProvider, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, PixivClient client) - : AbstractPixivDownloader(serviceProvider, specialTextService, + : AbstractPixivDownloader(serviceProvider, textVocabularyService, client) { public override PixivDownloadTaskType EnumTaskType => PixivDownloadTaskType.Ranking; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs index 3f7db0773..42e200974 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Components/Downloader/Components/Downloaders/Pixiv/PixivSearchDownloader.cs @@ -16,9 +16,9 @@ namespace Bakabase.InsideWorld.Business.Components.Downloader.Components.Downloa { public class PixivSearchDownloader( IServiceProvider serviceProvider, - ITextVocabularyService specialTextService, + ITextVocabularyService textVocabularyService, PixivClient client) - : AbstractPixivDownloader(serviceProvider, specialTextService, + : AbstractPixivDownloader(serviceProvider, textVocabularyService, client) { public override PixivDownloadTaskType EnumTaskType => PixivDownloadTaskType.Search; diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.Designer.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.Designer.cs new file mode 100644 index 000000000..046675aab --- /dev/null +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.Designer.cs @@ -0,0 +1,2247 @@ +// +using System; +using Bakabase.InsideWorld.Business; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bakabase.InsideWorld.Business.Migrations +{ + [DbContext(typeof(BakabaseDbContext))] + [Migration("20260826075024_AddTextTypeIndexes")] + partial class AddTextTypeIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.DLsiteWorkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("Circle") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DrmKey") + .HasColumnType("TEXT"); + + b.Property("IsDownloaded") + .HasColumnType("INTEGER"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("IsPurchased") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("SalesDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UseLocaleEmulator") + .HasColumnType("INTEGER"); + + b.Property("WorkId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WorkType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("WorkId") + .IsUnique(); + + b.ToTable("DLsiteWorks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.EnhancementDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DynamicTarget") + .HasColumnType("TEXT"); + + b.Property("EnhancerId") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Target") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Enhancements"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.EnhancementRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContextAppliedAt") + .HasColumnType("TEXT"); + + b.Property("ContextCreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnhancerId") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("OptionsSnapshot") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnhancerId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("EnhancerId", "ResourceId") + .IsUnique(); + + b.ToTable("EnhancementRecords"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ExHentaiGalleryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GalleryId") + .HasColumnType("INTEGER"); + + b.Property("GalleryToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDownloaded") + .HasColumnType("INTEGER"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleJpn") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("GalleryId", "GalleryToken") + .IsUnique(); + + b.ToTable("ExHentaiGalleries"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ExtensionGroupDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Extensions") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExtensionGroups"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryResourceMappingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("MediaLibraryId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaLibraryId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("MediaLibraryId", "ResourceId") + .IsUnique(); + + b.ToTable("MediaLibraryResourceMappings"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryTemplateDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("ChildTemplateId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DisplayNameTemplate") + .HasColumnType("TEXT"); + + b.Property("Enhancers") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayableFileLocator") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("ResourceFilters") + .HasColumnType("TEXT"); + + b.Property("SamplePaths") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaLibraryTemplates"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.MediaLibraryV2DbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Paths") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Players") + .HasColumnType("TEXT"); + + b.Property("ResourceCount") + .HasColumnType("INTEGER"); + + b.Property("SyncVersion") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaLibrariesV2"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PasswordDbModel", b => + { + b.Property("Text") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("UsedTimes") + .HasColumnType("INTEGER"); + + b.HasKey("Text"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UsedTimes"); + + b.ToTable("Passwords"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PathMarkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresInSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("SyncError") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Path"); + + b.HasIndex("SyncStatus"); + + b.HasIndex("Path", "Type", "Priority"); + + b.ToTable("PathMarks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PlayHistoryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Item") + .HasColumnType("TEXT"); + + b.Property("PlayedAt") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayedAt"); + + b.HasIndex("ResourceId"); + + b.ToTable("PlayHistories"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PropertyMarkEffectDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MarkId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MarkId"); + + b.HasIndex("PropertyPool", "PropertyId", "ResourceId"); + + b.HasIndex("MarkId", "PropertyPool", "PropertyId", "ResourceId") + .IsUnique(); + + b.ToTable("PropertyMarkEffects"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.PropertyValueScopePreferenceDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Priorities") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ResourceId", "PropertyPool", "PropertyId") + .IsUnique(); + + b.ToTable("PropertyValueScopePreferences"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ReservedPropertyValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverPaths") + .HasColumnType("TEXT"); + + b.Property("Introduction") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("ReservedPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("FileCreateDt") + .HasColumnType("TEXT"); + + b.Property("FileModifyDt") + .HasColumnType("TEXT"); + + b.Property("IsFile") + .HasColumnType("INTEGER"); + + b.Property("MediaLibraryId") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PlayedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("INTEGER"); + + b.Property("UpdateDt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path"); + + b.HasIndex("Status"); + + b.ToTable("ResourcesV2"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceMarkEffectDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MarkId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MarkId"); + + b.HasIndex("Path"); + + b.HasIndex("MarkId", "Path") + .IsUnique(); + + b.ToTable("ResourceMarkEffects"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceProfileDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnhancerSettingsJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameTemplate") + .HasColumnType("TEXT"); + + b.Property("PlayableFileSettingsJson") + .HasColumnType("TEXT"); + + b.Property("PlayerSettingsJson") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PropertiesJson") + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Priority"); + + b.ToTable("ResourceProfiles"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ResourceSourceLinkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverDownloadFailedAt") + .HasColumnType("TEXT"); + + b.Property("CoverUrls") + .HasColumnType("TEXT"); + + b.Property("CreateDt") + .HasColumnType("TEXT"); + + b.Property("LocalCoverPaths") + .HasColumnType("TEXT"); + + b.Property("MetadataFetchedAt") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("Source", "SourceKey"); + + b.HasIndex("ResourceId", "Source", "SourceKey") + .IsUnique(); + + b.ToTable("ResourceSourceLinks"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SourceMetadataMappingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MetadataField") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TargetPool") + .HasColumnType("INTEGER"); + + b.Property("TargetPropertyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SourceMetadataMappings"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SteamAppDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HasCommunityVisibleStats") + .HasColumnType("INTEGER"); + + b.Property("ImgIconUrl") + .HasColumnType("TEXT"); + + b.Property("InstallPath") + .HasColumnType("TEXT"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("IsInstalled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlaytimeForever") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("RtimeLastPlayed") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId") + .IsUnique(); + + b.HasIndex("ResourceId"); + + b.ToTable("SteamApps"); + }); + + modelBuilder.Entity("Bakabase.Abstractions.Models.Db.ThirdPartyContentTrackerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DomainKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Filter") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ViewedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DomainKey"); + + b.HasIndex("ViewedAt"); + + b.HasIndex("DomainKey", "Filter", "ContentId") + .IsUnique(); + + b.ToTable("ThirdPartyContentTrackers"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.Downloader.Models.Db.DownloadRecordDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DownloadedAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThirdPartyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ThirdPartyId", "Key") + .IsUnique(); + + b.ToTable("DownloadRecords"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.Downloader.Models.Db.DownloadTaskDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoRetry") + .HasColumnType("INTEGER"); + + b.Property("Checkpoint") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadStatusUpdateDt") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Options") + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("ThirdPartyId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("ThirdPartyId"); + + b.HasIndex("ThirdPartyId", "Type"); + + b.ToTable("DownloadTasks"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.PlayList.Models.Db.PlayListDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ItemsJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Components.PostParser.Models.Db.PostParserTaskDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Link") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Results") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Targets") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PostParserTasks"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Models.Db.LegacySpecialText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpecialTexts"); + }); + + modelBuilder.Entity("Bakabase.InsideWorld.Business.Models.Db.ResourceCacheDbModel", b => + { + b.Property("ResourceId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CachedTypes") + .HasColumnType("INTEGER"); + + b.Property("CoverPaths") + .HasColumnType("TEXT"); + + b.Property("HasMorePlayableFiles") + .HasColumnType("INTEGER"); + + b.Property("PlayableFilePaths") + .HasColumnType("TEXT"); + + b.HasKey("ResourceId"); + + b.ToTable("ResourceCaches"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AiFeatureConfigDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Feature") + .HasColumnType("INTEGER"); + + b.Property("MaxTokens") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .HasColumnType("TEXT"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("Temperature") + .HasColumnType("REAL"); + + b.Property("TopP") + .HasColumnType("REAL"); + + b.Property("UseDefault") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Feature") + .IsUnique(); + + b.ToTable("AiFeatureConfigs"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AiProviderDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AigcConfigJson") + .HasColumnType("TEXT"); + + b.Property("AigcEnabled") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LlmEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AigcEnabled"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Kind"); + + b.HasIndex("LlmEnabled"); + + b.ToTable("AiProviders"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcArtifactDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("OrdinalInRun") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("RunId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("RunId"); + + b.ToTable("AigcArtifacts"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGenerationRunDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("NegativePrompt") + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("RequestPayload") + .HasColumnType("TEXT"); + + b.Property("ResponsePayload") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("Status"); + + b.ToTable("AigcGenerationRuns"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGeneratorDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowDeletion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FilenameTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NegativePromptTemplate") + .HasColumnType("TEXT"); + + b.Property("ParametersJson") + .HasColumnType("TEXT"); + + b.Property("PromptTemplate") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.Property("ResourceMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("ProviderId"); + + b.ToTable("AigcGenerators"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.AigcGeneratorPropertyPresetDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GeneratorId") + .HasColumnType("INTEGER"); + + b.Property("Pool") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("SerializedBizValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GeneratorId"); + + b.HasIndex("GeneratorId", "Pool", "PropertyId") + .IsUnique(); + + b.ToTable("AigcGeneratorPropertyPresets"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.ChatConversationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsArchived"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.ChatMessageDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RichContent") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("ToolCalls") + .HasColumnType("TEXT"); + + b.Property("ToolResults") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmCallCacheEntryDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("HitCount") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("ResponseJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.ToTable("LlmCallCacheEntries"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmToolConfigDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("ToolName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ToolName") + .IsUnique(); + + b.ToTable("LlmToolConfigs"); + }); + + modelBuilder.Entity("Bakabase.Modules.AI.Models.Db.LlmUsageLogDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheHit") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Feature") + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("ProviderConfigId") + .HasColumnType("INTEGER"); + + b.Property("RequestSummary") + .HasColumnType("TEXT"); + + b.Property("ResponseSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalTokens") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CacheHit"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Feature"); + + b.HasIndex("ProviderConfigId"); + + b.ToTable("LlmUsageLogs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Alias.Abstractions.Models.Db.Alias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Preferred") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasesV2"); + }); + + modelBuilder.Entity("Bakabase.Modules.BulkModification.Models.Db.BulkModificationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFiles") + .HasColumnType("INTEGER"); + + b.Property("DeleteResources") + .HasColumnType("INTEGER"); + + b.Property("FilteredResourceIds") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Processes") + .HasColumnType("TEXT"); + + b.Property("ScopePreferenceConfigs") + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("Variables") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BulkModifications"); + }); + + modelBuilder.Entity("Bakabase.Modules.BulkModification.Models.Db.BulkModificationDiffDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulkModificationId") + .HasColumnType("INTEGER"); + + b.Property("Diffs") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("ResourcePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BulkModificationId", "ResourceId") + .IsUnique(); + + b.ToTable("BulkModificationDiffs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonPlanDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchJson") + .HasColumnType("TEXT"); + + b.Property("Threshold") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Name"); + + b.ToTable("ComparisonPlans"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultGroupDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsHidden") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("PlanId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MemberCount"); + + b.HasIndex("PlanId"); + + b.HasIndex("PlanId", "MemberCount"); + + b.ToTable("ComparisonResultGroups"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultGroupMemberDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsSuggestedPrimary") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("GroupId", "ResourceId") + .IsUnique(); + + b.ToTable("ComparisonResultGroupMembers"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonResultPairDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Resource1Id") + .HasColumnType("INTEGER"); + + b.Property("Resource2Id") + .HasColumnType("INTEGER"); + + b.Property("RuleScoresJson") + .HasColumnType("TEXT"); + + b.Property("TotalScore") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("Resource1Id", "Resource2Id"); + + b.HasIndex("GroupId", "Resource1Id", "Resource2Id") + .IsUnique(); + + b.ToTable("ComparisonResultPairs"); + }); + + modelBuilder.Entity("Bakabase.Modules.Comparison.Models.Db.ComparisonRuleDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BothNullBehavior") + .HasColumnType("INTEGER"); + + b.Property("IsVeto") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("INTEGER"); + + b.Property("Normalize") + .HasColumnType("INTEGER"); + + b.Property("OneNullBehavior") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .HasColumnType("TEXT"); + + b.Property("PlanId") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("PropertyPool") + .HasColumnType("INTEGER"); + + b.Property("PropertyValueScope") + .HasColumnType("INTEGER"); + + b.Property("VetoThreshold") + .HasColumnType("REAL"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlanId"); + + b.HasIndex("PlanId", "Order"); + + b.ToTable("ComparisonRules"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TypeId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TypeId"); + + b.ToTable("DataCards"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardPropertyValueDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CardId") + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("PropertyId", "Value"); + + b.HasIndex("CardId", "PropertyId", "Scope") + .IsUnique(); + + b.ToTable("DataCardPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Modules.DataCard.Abstractions.Models.Db.DataCardTypeDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayTemplate") + .HasColumnType("TEXT"); + + b.Property("IdentityPropertyIds") + .HasColumnType("TEXT"); + + b.Property("MatchRules") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NameTemplate") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PropertyIds") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataCardTypes"); + }); + + modelBuilder.Entity("Bakabase.Modules.HealthScore.Models.Db.HealthScoreProfileDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaseScore") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("MembershipFilterJson") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RulesJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HealthScoreProfiles"); + }); + + modelBuilder.Entity("Bakabase.Modules.HealthScore.Models.Db.ResourceHealthScoreDbModel", b => + { + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("EvaluatedAt") + .HasColumnType("TEXT"); + + b.Property("MatchedRulesJson") + .HasColumnType("TEXT"); + + b.Property("ProfileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("TEXT"); + + b.HasKey("ResourceId", "ProfileId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ProfileId", "ProfileHash"); + + b.ToTable("ResourceHealthScores"); + }); + + modelBuilder.Entity("Bakabase.Modules.Notification.Abstractions.Models.Db.NotificationDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PayloadJson") + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ReadAt"); + + b.HasIndex("Source"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Bakabase.Modules.Property.Abstractions.Models.Db.CustomPropertyDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Options") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("CustomProperties"); + }); + + modelBuilder.Entity("Bakabase.Modules.Property.Abstractions.Models.Db.CustomPropertyValueDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PropertyId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("INTEGER"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PropertyId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("ResourceId", "PropertyId", "Scope") + .IsUnique(); + + b.ToTable("CustomPropertyValues"); + }); + + modelBuilder.Entity("Bakabase.Modules.Subscription.Abstractions.Models.Db.SubscriptionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IntervalMinutes") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastChangeAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("TargetJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Kind"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("Bakabase.Modules.Subscription.Abstractions.Models.Db.SubscriptionSnapshotDbModel", b => + { + b.Property("SubscriptionId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("SubscriptionId"); + + b.ToTable("SubscriptionSnapshots"); + }); + + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TypeId") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TypeId"); + + b.ToTable("TextEntries"); + }); + + modelBuilder.Entity("Bakabase.Modules.Text.Abstractions.Models.Db.TextType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Shape") + .HasColumnType("INTEGER"); + + b.Property("WellKnown") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("WellKnown"); + + b.ToTable("TextTypes"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowActivityDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OnItemError") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowActivities"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowDefinitionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggerFilterJson") + .HasColumnType("TEXT"); + + b.Property("TriggerKind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TriggerKind"); + + b.ToTable("WorkflowDefinitions"); + }); + + modelBuilder.Entity("Bakabase.Modules.Workflow.Abstractions.Models.Db.WorkflowRunDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("FailedItemCount") + .HasColumnType("INTEGER"); + + b.Property("InputCount") + .HasColumnType("INTEGER"); + + b.Property("OutputCount") + .HasColumnType("INTEGER"); + + b.Property("PayloadJson") + .HasColumnType("TEXT"); + + b.Property("PayloadSummary") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StepStatsJson") + .HasColumnType("TEXT"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.HasIndex("Status"); + + b.HasIndex("WorkflowDefinitionId"); + + b.ToTable("WorkflowRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.cs new file mode 100644 index 000000000..401509009 --- /dev/null +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/20260826075024_AddTextTypeIndexes.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bakabase.InsideWorld.Business.Migrations +{ + /// + public partial class AddTextTypeIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_TextTypes_Name", + table: "TextTypes", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_TextTypes_WellKnown", + table: "TextTypes", + column: "WellKnown"); + + migrationBuilder.CreateIndex( + name: "IX_TextEntries_TypeId", + table: "TextEntries", + column: "TypeId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_TextTypes_Name", + table: "TextTypes"); + + migrationBuilder.DropIndex( + name: "IX_TextTypes_WellKnown", + table: "TextTypes"); + + migrationBuilder.DropIndex( + name: "IX_TextEntries_TypeId", + table: "TextEntries"); + } + } +} diff --git a/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs b/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs index 8d1472bd6..53c36bc6f 100644 --- a/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs +++ b/src/legacy/Bakabase.InsideWorld.Business/Migrations/InsideWorldDbContextModelSnapshot.cs @@ -799,29 +799,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SourceMetadataMappings"); }); - modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SpecialText", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.Property("Value1") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Value2") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("SpecialTexts"); - }); - modelBuilder.Entity("Bakabase.Abstractions.Models.Db.SteamAppDbModel", b => { b.Property("Id") @@ -1066,6 +1043,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PostParserTasks"); }); + modelBuilder.Entity("Bakabase.InsideWorld.Business.Models.Db.LegacySpecialText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value1") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Value2") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpecialTexts"); + }); + modelBuilder.Entity("Bakabase.InsideWorld.Business.Models.Db.ResourceCacheDbModel", b => { b.Property("ResourceId") @@ -2131,6 +2131,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("TypeId"); + b.ToTable("TextEntries"); }); @@ -2160,6 +2162,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name"); + + b.HasIndex("WellKnown"); + b.ToTable("TextTypes"); }); diff --git a/src/legacy/Bakabase.InsideWorld.Business/Models/Db/LegacySpecialText.cs b/src/legacy/Bakabase.InsideWorld.Business/Models/Db/LegacySpecialText.cs new file mode 100644 index 000000000..738395425 --- /dev/null +++ b/src/legacy/Bakabase.InsideWorld.Business/Models/Db/LegacySpecialText.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Bakabase.InsideWorld.Business.Models.Db; + +/// +/// The retired SpecialTexts table, kept mapped so +/// Bakabase.Migrations.V240.TextSystemMigrator can copy its rows into the unified text +/// vocabulary. Nothing else reads or writes it. +/// +/// It lives here rather than in Bakabase.Abstractions so that modules — which all reference +/// abstractions — no longer see a dead model, and is a raw +/// rather than the old SpecialTextType enum: the enum's only remaining job was to be read +/// once by the migrator, and while it existed it was still emitted into the frontend's generated +/// constants.ts. +/// +[Table("SpecialTexts")] +public record LegacySpecialText +{ + [Key] public int Id { get; set; } + + [Required, MaxLength(64)] public string Value1 { set; get; } = null!; + + [MaxLength(64)] public string? Value2 { set; get; } + + /// + /// The old SpecialTextType value. Its members were carried over verbatim into + /// , so the + /// migrator maps it by numeric value. + /// + public int Type { set; get; } +} diff --git a/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs b/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs index abb024ebe..c45067b6a 100644 --- a/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs +++ b/src/legacy/Bakabase.Migrations/V240/TextSystemMigrator.cs @@ -17,10 +17,12 @@ namespace Bakabase.Migrations.V240; /// and one id space. /// /// Implementation notes: -/// - The old table and model are deliberately kept. App migrators run *after* EF schema -/// migrations, so a migration dropping SpecialTexts would delete the source before this -/// ever reads it whenever a user upgrades across the release that introduced the copy. Leaving -/// the table in place as a dormant relic costs two small columns and removes that hazard. +/// - The old table and model are deliberately kept (as +/// ). App migrators run +/// *after* EF schema migrations, so a migration dropping SpecialTexts would delete the +/// source before this ever reads it whenever a user upgrades across the release that introduced +/// the copy. Leaving the table in place as a dormant relic costs two small columns and removes +/// that hazard. /// - Re-entrant: the gate is the global AppOptions version, so a crash mid-copy means a rerun. /// Entries already present under their target type (matched on both values) are skipped, which /// also keeps this consistent with how prefabs are topped up. @@ -30,18 +32,20 @@ namespace Bakabase.Migrations.V240; public class TextSystemMigrator(IServiceProvider serviceProvider) : AbstractMigrator(serviceProvider) { /// - /// Pinned just below the build height shipping the unified text vocabulary, so every DB that - /// still holds only legacy rows runs the copy exactly once on next launch. + /// Must sit at or above the build height that ships the unified text vocabulary — a threshold + /// below it never fires, since the gate asks whether the installed version is older. Landing a + /// little high only costs a few no-op startups: the copy skips entries that already exist. /// - protected override string ApplyOnVersionEqualsOrBeforeString => "2.4.0-beta"; + protected override string ApplyOnVersionEqualsOrBeforeString => "2.4.0-beta.99"; protected override async Task MigrateAfterDbMigrationInternal(object? context) { var logger = GetRequiredService().CreateLogger(); var vocabulary = GetRequiredService(); - // Creates the builtin types (and tops up their prefabs) so legacy rows have somewhere to land. - await vocabulary.EnsureSeeds(); + // Only the type rows: prefab entries are the user's data, and the rows being copied below + // already are whichever prefabs they kept. + await vocabulary.EnsureBuiltinTypes(); var dbCtx = GetRequiredService(); var legacyRows = await dbCtx.SpecialTexts.AsNoTracking().ToListAsync(); @@ -89,6 +93,6 @@ protected override async Task MigrateAfterDbMigrationInternal(object? context) /// The new handles reuse the legacy enum's values, so this is a straight cast guarded against /// rows carrying a value no longer defined. /// - private static WellKnownTextType? MapType(SpecialTextType type) => - Enum.IsDefined(typeof(WellKnownTextType), (int) type) ? (WellKnownTextType) (int) type : null; + private static WellKnownTextType? MapType(int type) => + Enum.IsDefined(typeof(WellKnownTextType), type) ? (WellKnownTextType) type : null; } diff --git a/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs b/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs index 463b7c2b4..ded50e455 100644 --- a/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs +++ b/src/modules/Bakabase.Modules.Text/Components/TextSeedData.cs @@ -3,8 +3,13 @@ namespace Bakabase.Modules.Text.Components; /// -/// Ships the builtin types and their starter entries. Carried over verbatim from the retired -/// SpecialTextPrefabs, plus the shape each type actually uses. +/// Ships the builtin types and their starter entries. +/// +/// The type rows are an invariant — +/// recreates any that are missing on every startup. The entries are only a starting point: they are +/// written by AddPrefabEntries, which the user triggers, and deleting one is meant to stick. +/// Names here are storage-level identifiers; the UI renders builtins through their +/// WellKnownTextType handle so they can be localized. /// public static class TextSeedData { diff --git a/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs b/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs index 1f614c42d..309588b6a 100644 --- a/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs +++ b/src/modules/Bakabase.Modules.Text/Services/TextVocabularyService.cs @@ -140,45 +140,113 @@ public async Task ResolveSet(int typeId) public async Task ResolveSet(WellKnownTextType wellKnown) { - var type = await GetOrCreateBuiltin(wellKnown); + var type = await FindBuiltin(wellKnown); + if (type == null) + { + // Before startup seeding has run (or if a row was removed out of band) a builtin simply + // has nothing to say — same as the empty list the old type-filtered query returned. + // Creating it here instead would write on a read path, and consumers share a scoped + // DbContext, so two of them resolving at once would collide mid-SaveChanges. + return new TextSet {Shape = TextTypeShape.Values}; + } + var entries = await entryOrm.GetAll(e => e.TypeId == type.Id); return BuildSet(type, entries); } - public async Task GetTypeId(WellKnownTextType wellKnown) => (await GetOrCreateBuiltin(wellKnown)).Id; + public async Task GetTypeId(WellKnownTextType wellKnown) + { + var type = await FindBuiltin(wellKnown); + if (type == null) + { + throw new KeyNotFoundException( + $"Builtin text type [{wellKnown}] is missing; startup seeding should have created it."); + } - public async Task EnsureSeeds() + return type.Id; + } + + public async Task EnsureBuiltinTypes() { - var existingTypes = await typeOrm.GetAll(); - var typeByWellKnown = existingTypes.Where(t => t.WellKnown.HasValue) - .ToDictionary(t => t.WellKnown!.Value, t => t); + await MergeDuplicateBuiltins(); - foreach (var builtin in TextSeedData.BuiltinTypes) + var existing = (await typeOrm.GetAll()) + .Where(t => t.WellKnown.HasValue) + .Select(t => t.WellKnown!.Value) + .ToHashSet(); + + var missing = TextSeedData.BuiltinTypes + .Where(b => !existing.Contains(b.WellKnown)) + .Select(b => new Abstractions.Models.Db.TextType + { + Name = b.Name, + WellKnown = b.WellKnown, + Shape = b.Shape, + CreatedAt = DateTime.Now + }) + .ToList(); + + if (missing.Count > 0) + { + await typeOrm.AddRange(missing); + } + } + + /// + /// Collapses duplicate rows for a builtin onto the oldest one, moving any entries across. An + /// earlier build provisioned builtins lazily on read, which could race into two rows for the + /// same handle; repairing here rather than with a unique index keeps such a database able to + /// start at all, since schema migrations run before this could clean up after them. + /// + private async Task MergeDuplicateBuiltins() + { + var duplicateGroups = (await typeOrm.GetAll()) + .Where(t => t.WellKnown.HasValue) + .GroupBy(t => t.WellKnown!.Value) + .Where(g => g.Count() > 1) + .ToList(); + + foreach (var group in duplicateGroups) { - if (!typeByWellKnown.TryGetValue(builtin.WellKnown, out var type)) + var ordered = group.OrderBy(t => t.Id).ToList(); + var survivor = ordered[0]; + foreach (var duplicate in ordered.Skip(1)) { - type = (await typeOrm.Add(new Abstractions.Models.Db.TextType + var entries = await entryOrm.GetAll(e => e.TypeId == duplicate.Id); + foreach (var entry in entries) { - Name = builtin.Name, - WellKnown = builtin.WellKnown, - Shape = builtin.Shape, - CreatedAt = DateTime.Now - })).Data; - typeByWellKnown[builtin.WellKnown] = type; + await entryOrm.UpdateByKey(entry.Id, e => e.TypeId = survivor.Id); + } + + await typeOrm.RemoveByKey(duplicate.Id); } + } + } - if (builtin.Entries.Count == 0) + public async Task AddPrefabEntries() + { + await EnsureBuiltinTypes(); + + var typeByWellKnown = (await typeOrm.GetAll()) + .Where(t => t.WellKnown.HasValue) + // Grouped rather than keyed directly: a duplicate builtin row must degrade into + // picking one, not into throwing halfway through the user's action. + .GroupBy(t => t.WellKnown!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var builtin in TextSeedData.BuiltinTypes) + { + if (builtin.Entries.Count == 0 || !typeByWellKnown.TryGetValue(builtin.WellKnown, out var type)) { continue; } - // Top up prefabs without resurrecting entries the user deleted in an earlier run: only - // rows absent right now are added, and the comparison keys on both values so pair-shaped - // types do not collapse into their first value. - var existing = (await entryOrm.GetAll(e => e.TypeId == type.Id)) + // Only rows absent right now are added, so entries the user deleted stay deleted for + // this run; the comparison keys on both values so pair shapes do not collapse into one. + var existingEntries = (await entryOrm.GetAll(e => e.TypeId == type.Id)) .Select(e => (e.Value1, e.Value2)) .ToHashSet(); - var missing = builtin.Entries.Where(e => !existing.Contains((e.Value1, e.Value2))).ToList(); + var missing = builtin.Entries.Where(e => !existingEntries.Contains((e.Value1, e.Value2))).ToList(); if (missing.Count > 0) { await AddEntries(type.Id, missing); @@ -215,28 +283,8 @@ private static TextSet BuildSet(Abstractions.Models.Db.TextType type, return type; } - /// - /// Builtins are defined in code, so a missing row is provisioned on demand rather than thrown - /// over: consumers resolve wrapper or date-format sets during ordinary work and must not depend - /// on seeding having run first (a fresh database, or a test that never seeded). - /// - private async Task GetOrCreateBuiltin(WellKnownTextType wellKnown) - { - var type = await typeOrm.GetFirstOrDefault(t => t.WellKnown == wellKnown); - if (type != null) - { - return type; - } - - var definition = TextSeedData.BuiltinTypes.FirstOrDefault(t => t.WellKnown == wellKnown); - return (await typeOrm.Add(new Abstractions.Models.Db.TextType - { - Name = definition?.Name ?? wellKnown.ToString(), - WellKnown = wellKnown, - Shape = definition?.Shape ?? TextTypeShape.Values, - CreatedAt = DateTime.Now - })).Data; - } + private async Task FindBuiltin(WellKnownTextType wellKnown) => + await typeOrm.GetFirstOrDefault(t => t.WellKnown == wellKnown); private static void EnsureNotBuiltin(Abstractions.Models.Db.TextType type, string action) { diff --git a/src/tests/Bakabase.Tests/TextOpsTests.cs b/src/tests/Bakabase.Tests/TextOpsTests.cs index c37458573..97aecd8c8 100644 --- a/src/tests/Bakabase.Tests/TextOpsTests.cs +++ b/src/tests/Bakabase.Tests/TextOpsTests.cs @@ -25,7 +25,7 @@ public async Task Setup() _sp = await TestServiceBuilder.BuildServiceProvider(); _ops = _sp.GetRequiredService(); _vocabulary = _sp.GetRequiredService(); - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); } private async Task AddEntry(WellKnownTextType type, string value1, string? value2 = null) diff --git a/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs b/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs index 4dddf44f9..09819d4fc 100644 --- a/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs +++ b/src/tests/Bakabase.Tests/TextVocabularyServiceTests.cs @@ -59,7 +59,7 @@ public async Task EntryCount_ReflectsAddedEntries() [TestMethod] public async Task RenameAndDelete_RejectedForBuiltinTypes() { - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); var wrapperId = await _vocabulary.GetTypeId(WellKnownTextType.Wrapper); await Assert.ThrowsExceptionAsync( @@ -80,21 +80,21 @@ public async Task DeleteType_RemovesItsEntries() } [TestMethod] - public async Task EnsureSeeds_IsIdempotent() + public async Task AddPrefabEntries_IsIdempotent() { - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); var afterFirst = (await _vocabulary.GetTypes()).Sum(t => t.EntryCount); Assert.IsTrue(afterFirst > 0); - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); var afterSecond = (await _vocabulary.GetTypes()).Sum(t => t.EntryCount); Assert.AreEqual(afterFirst, afterSecond); } [TestMethod] - public async Task EnsureSeeds_DoesNotResurrectDeletedEntriesWithinTheSameRun() + public async Task AddPrefabEntries_ToppedUpOneByOne() { - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); var uselessId = await _vocabulary.GetTypeId(WellKnownTextType.Useless); var victim = (await _vocabulary.GetEntries(uselessId)).First(); @@ -103,14 +103,14 @@ public async Task EnsureSeeds_DoesNotResurrectDeletedEntriesWithinTheSameRun() // A rerun tops the entry back up — the point of this test is that it stays a top-up, i.e. // exactly one row returns rather than the whole prefab set being duplicated. - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); Assert.AreEqual(afterDelete + 1, (await _vocabulary.GetEntries(uselessId)).Count); } [TestMethod] public async Task ResolveSet_PairShape_ExposesPairs() { - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); var set = await _vocabulary.ResolveSet(WellKnownTextType.Wrapper); @@ -122,7 +122,7 @@ public async Task ResolveSet_PairShape_ExposesPairs() [TestMethod] public async Task ResolveSet_ValuesShape_ExposesNoPairsEvenWhenSecondValuesExist() { - await _vocabulary.EnsureSeeds(); + await _vocabulary.AddPrefabEntries(); // Volume prefabs carry an ordinal in the second value that no consumer reads; it must be // preserved in storage yet stay out of the resolved pairs. diff --git a/src/web/src/components/ContextProvider/BakabaseContextProvider.tsx b/src/web/src/components/ContextProvider/BakabaseContextProvider.tsx index 6d5af9fab..260fc9ec3 100644 --- a/src/web/src/components/ContextProvider/BakabaseContextProvider.tsx +++ b/src/web/src/components/ContextProvider/BakabaseContextProvider.tsx @@ -298,7 +298,18 @@ const BakabaseContextProvider: FC<{ children: ReactNode }> = ({ children }) => { algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm, }} > - + {portals.map(({ key, component }) => ( diff --git a/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx b/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx index f9cf489fc..a39b53ea8 100644 --- a/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx +++ b/src/web/src/components/EnhancerSelectorV2/components/EnhancerOptionsModal/components/TargetRow/index.tsx @@ -17,7 +17,7 @@ import TargetOptions from "./TargetOptions"; import { Autocomplete, AutocompleteItem, Button, Chip, Tooltip } from "@/components/bakaui"; import { StandardValueType, WellKnownTextType } from "@/sdk/constants"; -import { IntegrateWithSpecialTextLabel } from "@/components/SpecialText"; +import { IntegrateWithTextTypeLabel } from "@/components/TextType"; import { buildLogger } from "@/components/utils"; import { useBakabaseContext } from "@/components/ContextProvider/BakabaseContextProvider"; import PropertyMatcher from "@/components/PropertyMatcher"; @@ -38,7 +38,7 @@ interface Props { readOnly?: boolean; } -const StdValueSpecialTextIntegrationMap: { +const StdValueTextTypeIntegrationMap: { [key in StandardValueType]?: WellKnownTextType; } = { [StandardValueType.DateTime]: WellKnownTextType.DateTime, @@ -114,7 +114,7 @@ const TargetRow = (props: Props) => { ? (dt ?? t("enhancer.target.default.label")) : descriptor.name; const isDefaultTargetOfDynamic = descriptor.isDynamic && dt == undefined; - const integratedSpecialTextType = StdValueSpecialTextIntegrationMap[descriptor.valueType]; + const integratedTextType = StdValueTextTypeIntegrationMap[descriptor.valueType]; let property: IProperty | undefined; @@ -198,8 +198,8 @@ const TargetRow = (props: Props) => { name: descriptor.name, }} /> - {integratedSpecialTextType && ( - + {integratedTextType && ( + )} {descriptor.description && ( { const [copied, setCopied] = useState(false); return ( -
      - +
      + -
      +
      {title}
      - {description &&
      {description}
      } + {description && ( +
      +            {description}
      +          
      + )}
      -
      +
      } > - {t("Integrate with special text")} + {t("textType.integration.label")} ); }; -IntegrateWithSpecialTextLabel.displayName = "IntegrateWithSpecialTextLabel"; +IntegrateWithTextTypeLabel.displayName = "IntegrateWithTextTypeLabel"; -export default IntegrateWithSpecialTextLabel; +export default IntegrateWithTextTypeLabel; diff --git a/src/web/src/components/TextType/index.tsx b/src/web/src/components/TextType/index.tsx new file mode 100644 index 000000000..c15819dc8 --- /dev/null +++ b/src/web/src/components/TextType/index.tsx @@ -0,0 +1,3 @@ +import IntegrateWithTextTypeLabel from "./IntegrateWithTextTypeLabel"; + +export { IntegrateWithTextTypeLabel }; diff --git a/src/web/src/components/routesMenuConfig.ts b/src/web/src/components/routesMenuConfig.ts index e20cac619..d868c8c3c 100644 --- a/src/web/src/components/routesMenuConfig.ts +++ b/src/web/src/components/routesMenuConfig.ts @@ -178,7 +178,7 @@ export const routesMenuConfig: RouteMenuItem[] = [ menu: true, }, { - name: "menu.specialText", + name: "menu.text", path: "/text", component: TextPage, icon: AiOutlineFieldString, diff --git a/src/web/src/locales/cn/common.json b/src/web/src/locales/cn/common.json index 5d4eb4db7..57652c18d 100644 --- a/src/web/src/locales/cn/common.json +++ b/src/web/src/locales/cn/common.json @@ -195,7 +195,7 @@ "menu.extensionGroup": "文件类型分组", "menu.cache": "缓存", "menu.alias": "别名", - "menu.specialText": "文本处理", + "menu.text": "文本处理", "menu.playHistory": "播放历史", "menu.bulkModification": "批量修改", "menu.comparison": "去重对比", diff --git a/src/web/src/locales/cn/components/enhancer.json b/src/web/src/locales/cn/components/enhancer.json index ca25675a3..3dd36c114 100644 --- a/src/web/src/locales/cn/components/enhancer.json +++ b/src/web/src/locales/cn/components/enhancer.json @@ -19,8 +19,8 @@ "enhancer.options.manualScope.confirm": "我已了解", "enhancer.options.pretreat.label": "预处理属性值", "enhancer.options.pretreat.description": "如果勾选,属性值将在增强前进行预处理。", - "enhancer.options.pretreat.tip": "你可以在数据菜单下的特殊文本页面配置需要预处理的特殊文本。", - "enhancer.options.pretreat.action": "点击查看特殊文本", + "enhancer.options.pretreat.tip": "你可以在数据菜单下的文本处理页面配置用于预处理的文本。", + "enhancer.options.pretreat.action": "前往文本处理", "enhancer.options.configure.title": "配置增强器", "enhancer.options.runAfter.label": "在以下增强器之后运行", "enhancer.options.runAfter.description": "你可以通过自定义增强器的执行顺序来进行链式增强。", diff --git a/src/web/src/locales/cn/pages/comparison.json b/src/web/src/locales/cn/pages/comparison.json index 51bf38733..0374215b3 100644 --- a/src/web/src/locales/cn/pages/comparison.json +++ b/src/web/src/locales/cn/pages/comparison.json @@ -14,11 +14,11 @@ "comparison.action.addRule": "添加规则", "comparison.action.editRule": "编辑规则", "comparison.action.selectProperty": "选择属性", - "comparison.action.configureSpecialText": "配置特殊文本规则", + "comparison.action.configureText": "配置文本规则", "comparison.action.hideGroup": "隐藏此分组", "comparison.action.unhideGroup": "取消隐藏此分组", "comparison.action.viewNetworkGraph": "查看关系图", - "comparison.confirm.navigateToSpecialText": "您即将离开当前页面并跳转到特殊文本配置页面。任何未保存的更改将会丢失。是否继续?", + "comparison.confirm.navigateToText": "您即将离开当前页面并跳转到文本处理页面。任何未保存的更改将会丢失。是否继续?", "comparison.confirm.delete": "确定要删除方案 \"{{name}}\" 吗?", "comparison.confirm.clearResults": "确定要清除 \"{{name}}\" 的所有结果吗?", "comparison.success.duplicated": "方案复制成功", diff --git a/src/web/src/locales/cn/pages/text.json b/src/web/src/locales/cn/pages/text.json index 893ad42e9..53efa0fe7 100644 --- a/src/web/src/locales/cn/pages/text.json +++ b/src/web/src/locales/cn/pages/text.json @@ -24,7 +24,7 @@ "text.label.pretreated": "处理后", "text.label.pretreatmentTest": "预处理测试", "text.label.rightWrapper": "右包裹符号", - "text.label.shape": "形态", + "text.label.shape": "每一条长什么样", "text.label.sourceText": "源文本", "text.label.text": "文本", "text.label.texts": "文本", @@ -35,9 +35,12 @@ "text.placeholder.rightWrapper": "输入右包裹符号", "text.placeholder.sourceText": "输入源文本", "text.placeholder.text": "输入文本", - "text.shape.delimiterPair": "定界符对", - "text.shape.mappingPair": "映射(A 转 B)", - "text.shape.values": "值集合", + "text.shape.delimiterPair": "成对括号", + "text.shape.delimiterPair.hint": "每一条是一对开头和结尾符号,例如 [ 和 ]", + "text.shape.mappingPair": "文本替换", + "text.shape.mappingPair.hint": "每一条把一段文本换成另一段,例如 ( 换成 (", + "text.shape.values": "单个文本", + "text.shape.values.hint": "每一条就是一段文本,例如一个字幕组名", "text.typeDescription.dateTime": "日期时间解析模板,用于从文本中提取日期和时间", "text.typeDescription.language": "如果文本被[包裹符号]包围,将被解析为[特定语言]", "text.typeDescription.standardization": "分析时将[文本1]视为[文本2]", @@ -49,5 +52,11 @@ "text.usedIn.exhentaiEnhancerAnalysis": "Exhentai 增强器分析", "text.usedIn.parsingOrConvertingPropertyValue": "解析或转换属性值", "text.usedIn.resourceDisplayNameTemplate": "资源显示名称模板", - "text.usedIn.textPretreatment": "文本预处理" + "text.usedIn.textPretreatment": "文本预处理", + "textType.integration.action": "前往文本处理", + "textType.integration.dateTime.tip": "将按照已配置的格式把值转换为日期时间。", + "textType.integration.default.tip": "将使用已配置的文本处理该值。", + "textType.integration.label": "使用文本处理", + "textType.integration.leaving.message": "即将离开当前页面,确定继续吗?", + "textType.integration.leaving.title": "离开当前页面" } diff --git a/src/web/src/locales/en/common.json b/src/web/src/locales/en/common.json index 148e19eb7..f8c353387 100644 --- a/src/web/src/locales/en/common.json +++ b/src/web/src/locales/en/common.json @@ -195,7 +195,7 @@ "menu.extensionGroup": "File Type Groups", "menu.cache": "Cache", "menu.alias": "Alias", - "menu.specialText": "Text Processing", + "menu.text": "Text Processing", "menu.playHistory": "Play History", "menu.bulkModification": "Bulk Modification", "menu.comparison": "Duplicate Check", diff --git a/src/web/src/locales/en/components/enhancer.json b/src/web/src/locales/en/components/enhancer.json index e1b72aba4..b9ecf0609 100644 --- a/src/web/src/locales/en/components/enhancer.json +++ b/src/web/src/locales/en/components/enhancer.json @@ -19,8 +19,8 @@ "enhancer.options.manualScope.confirm": "I've understood", "enhancer.options.pretreat.label": "Pretreat property value", "enhancer.options.pretreat.description": "If checked, the property value will be pretreated before enhancement.", - "enhancer.options.pretreat.tip": "You can configure the special texts to be pretreated in special text page under data menu.", - "enhancer.options.pretreat.action": "Click to check special texts", + "enhancer.options.pretreat.tip": "You can configure the texts used for pretreatment on the text processing page under the data menu.", + "enhancer.options.pretreat.action": "Go to text processing", "enhancer.options.configure.title": "Configure enhancer", "enhancer.options.runAfter.label": "Run after enhancers", "enhancer.options.runAfter.description": "You can perform chained enhancement by customizing the execution order of the enhancers.", diff --git a/src/web/src/locales/en/pages/comparison.json b/src/web/src/locales/en/pages/comparison.json index 24561214b..e54b63b3d 100644 --- a/src/web/src/locales/en/pages/comparison.json +++ b/src/web/src/locales/en/pages/comparison.json @@ -14,11 +14,11 @@ "comparison.action.addRule": "Add Rule", "comparison.action.editRule": "Edit Rule", "comparison.action.selectProperty": "Select Property", - "comparison.action.configureSpecialText": "Configure special text rules", + "comparison.action.configureText": "Configure text rules", "comparison.action.hideGroup": "Hide this group", "comparison.action.unhideGroup": "Unhide this group", "comparison.action.viewNetworkGraph": "View relationship graph", - "comparison.confirm.navigateToSpecialText": "You are about to leave this page and navigate to the Special Text configuration page. Any unsaved changes will be lost. Do you want to continue?", + "comparison.confirm.navigateToText": "You are about to leave this page and navigate to the text processing page. Any unsaved changes will be lost. Do you want to continue?", "comparison.confirm.delete": "Are you sure you want to delete the plan \"{{name}}\"?", "comparison.confirm.clearResults": "Are you sure you want to clear all results for \"{{name}}\"?", "comparison.success.duplicated": "Plan duplicated successfully", diff --git a/src/web/src/locales/en/pages/text.json b/src/web/src/locales/en/pages/text.json index 7e3b1d32e..d7737ba86 100644 --- a/src/web/src/locales/en/pages/text.json +++ b/src/web/src/locales/en/pages/text.json @@ -24,7 +24,7 @@ "text.label.pretreated": "Pretreated", "text.label.pretreatmentTest": "Pretreatment test", "text.label.rightWrapper": "Right wrapper", - "text.label.shape": "Shape", + "text.label.shape": "What each item looks like", "text.label.sourceText": "Source text", "text.label.text": "Text", "text.label.texts": "Texts", @@ -35,9 +35,12 @@ "text.placeholder.rightWrapper": "Enter right wrapper", "text.placeholder.sourceText": "Enter source text", "text.placeholder.text": "Enter text", - "text.shape.delimiterPair": "Delimiter pair", - "text.shape.mappingPair": "Mapping (A to B)", - "text.shape.values": "Values", + "text.shape.delimiterPair": "Bracket pair", + "text.shape.delimiterPair.hint": "Each item is an opening and closing mark, e.g. [ and ]", + "text.shape.mappingPair": "Replacement", + "text.shape.mappingPair.hint": "Each item turns one text into another, e.g. ( into (", + "text.shape.values": "Single text", + "text.shape.values.hint": "Each item is one piece of text, e.g. a release group name", "text.typeDescription.dateTime": "Date and time parsing template, used to extract dates and times from text", "text.typeDescription.language": "Text will be parsed as [specific language] if it surrounded by [wrappers]", "text.typeDescription.standardization": "Treat [text1] as [text2] during analyzation", @@ -49,5 +52,11 @@ "text.usedIn.exhentaiEnhancerAnalysis": "Exhentai enhancer analysis", "text.usedIn.parsingOrConvertingPropertyValue": "Parsing or converting property value", "text.usedIn.resourceDisplayNameTemplate": "Resource display name template", - "text.usedIn.textPretreatment": "Text pretreatment" + "text.usedIn.textPretreatment": "Text pretreatment", + "textType.integration.action": "Go to text processing", + "textType.integration.dateTime.tip": "Values will be converted into date & time using the configured formats.", + "textType.integration.default.tip": "Values will be processed with the configured texts.", + "textType.integration.label": "Uses text processing", + "textType.integration.leaving.message": "You are about to leave this page. Continue?", + "textType.integration.leaving.title": "Leaving current page" } diff --git a/src/web/src/pages/comparison/components/RuleConfigPanel.tsx b/src/web/src/pages/comparison/components/RuleConfigPanel.tsx index 520a1f189..2b705ae40 100644 --- a/src/web/src/pages/comparison/components/RuleConfigPanel.tsx +++ b/src/web/src/pages/comparison/components/RuleConfigPanel.tsx @@ -244,10 +244,10 @@ const RuleConfigPanel = ({ const hasError = (field: string) => !!getError(field); const navigate = useNavigate(); - const handleNavigateToSpecialText = () => { + const handleNavigateToText = () => { createPortal(Modal, { title: t("Confirm"), - children: t("comparison.confirm.navigateToSpecialText"), + children: t("comparison.confirm.navigateToText"), footer: { actions: ["ok", "cancel"], }, @@ -452,9 +452,9 @@ const RuleConfigPanel = ({ - {t("comparison.action.configureSpecialText")} + {t("comparison.action.configureText")}
      diff --git a/src/web/src/pages/text/index.tsx b/src/web/src/pages/text/index.tsx index 00d244df5..fd9ba134e 100644 --- a/src/web/src/pages/text/index.tsx +++ b/src/web/src/pages/text/index.tsx @@ -202,6 +202,7 @@ const TextPage = () => { defaultSelectedKeys={[String(TextTypeShape.Values)]} dataSource={Object.keys(shapeLabels).map((k) => ({ label: t(shapeLabels[parseInt(k, 10) as TextTypeShape]), + description: t(`${shapeLabels[parseInt(k, 10) as TextTypeShape]}.hint`), value: k, }))} label={t("text.label.shape")} @@ -292,7 +293,15 @@ const TextPage = () => { )} - {description} + + {description} + {type.wellKnown == undefined && ( + <> + {description ? " · " : ""} + {t(shapeLabels[type.shape])} + + )} +
      @@ -371,7 +380,7 @@ const TextPage = () => { size={"sm"} variant={"light"} onClick={() => { - BApi.text.ensureTextSeeds().then((a) => { + BApi.text.addTextPrefabEntries().then((a) => { if (!a.code) { loadData(); } diff --git a/src/web/src/sdk/Api.ts b/src/web/src/sdk/Api.ts index 0b62b9246..56488e980 100644 --- a/src/web/src/sdk/Api.ts +++ b/src/web/src/sdk/Api.ts @@ -21753,24 +21753,24 @@ export class Api extends HttpClient + addTextPrefabEntries: (params: RequestParams = {}) => this.request({ - path: `/text/seeds`, + path: `/text/prefabs`, method: "POST", format: "json", ...params, }), /** - * @description Build URL for ensureTextSeeds - * @name ensureTextSeedsUrl + * @description Build URL for addTextPrefabEntries + * @name addTextPrefabEntriesUrl */ - ensureTextSeedsUrl: () => { + addTextPrefabEntriesUrl: () => { const baseUrl = this.baseUrl || ""; - let path = `/text/seeds`; + let path = `/text/prefabs`; return baseUrl + path; }, diff --git a/src/web/src/sdk/BApi2.d.ts b/src/web/src/sdk/BApi2.d.ts index 75609be82..0b1c3a2d1 100644 --- a/src/web/src/sdk/BApi2.d.ts +++ b/src/web/src/sdk/BApi2.d.ts @@ -6000,7 +6000,7 @@ export interface paths { patch?: never; trace?: never; }; - "/text/seeds": { + "/text/prefabs": { parameters: { query?: never; header?: never; @@ -6009,7 +6009,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["EnsureTextSeeds"]; + post: operations["AddTextPrefabEntries"]; delete?: never; options?: never; head?: never; @@ -25212,7 +25212,7 @@ export interface operations { }; }; }; - EnsureTextSeeds: { + AddTextPrefabEntries: { parameters: { query?: never; header?: never; diff --git a/src/web/src/sdk/constants.ts b/src/web/src/sdk/constants.ts index 6871f753d..ed96e1ceb 100644 --- a/src/web/src/sdk/constants.ts +++ b/src/web/src/sdk/constants.ts @@ -2206,36 +2206,6 @@ export const SearchOperationLabel: Record = { [SearchOperation.NotMatches]: 'NotMatches' }; -export enum SpecialTextType { - Useless = 1, - Wrapper = 3, - Standardization = 4, - Volume = 6, - Trim = 7, - DateTime = 8, - Language = 9 -} - -export const specialTextTypes = [ - { label: 'Useless', value: SpecialTextType.Useless }, - { label: 'Wrapper', value: SpecialTextType.Wrapper }, - { label: 'Standardization', value: SpecialTextType.Standardization }, - { label: 'Volume', value: SpecialTextType.Volume }, - { label: 'Trim', value: SpecialTextType.Trim }, - { label: 'DateTime', value: SpecialTextType.DateTime }, - { label: 'Language', value: SpecialTextType.Language } -] as const; - -export const SpecialTextTypeLabel: Record = { - [SpecialTextType.Useless]: 'Useless', - [SpecialTextType.Wrapper]: 'Wrapper', - [SpecialTextType.Standardization]: 'Standardization', - [SpecialTextType.Volume]: 'Volume', - [SpecialTextType.Trim]: 'Trim', - [SpecialTextType.DateTime]: 'DateTime', - [SpecialTextType.Language]: 'Language' -}; - export enum StandardValueType { String = 1, ListString = 2, From 16555f53ae20d5981de0a173b7fe988c7d0ecdf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:01:08 +0000 Subject: [PATCH 24/46] fix(text): disable the pretreatment run button on empty input Running the pretreatment test with nothing entered round-trips to the server only to render an unchanged empty string. Trimmed, so whitespace alone does not enable it either. --- src/web/src/pages/text/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/src/pages/text/index.tsx b/src/web/src/pages/text/index.tsx index fd9ba134e..b96b564a6 100644 --- a/src/web/src/pages/text/index.tsx +++ b/src/web/src/pages/text/index.tsx @@ -405,6 +405,7 @@ const TextPage = () => {