diff --git a/plugins/batch-rename/.gitignore b/plugins/batch-rename/.gitignore new file mode 100644 index 000000000..e8a8353f7 --- /dev/null +++ b/plugins/batch-rename/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +outputs/ +.vite/ +*.log +utools/ +test/ diff --git a/plugins/batch-rename/CHANGELOG.md b/plugins/batch-rename/CHANGELOG.md new file mode 100644 index 000000000..3bceb3341 --- /dev/null +++ b/plugins/batch-rename/CHANGELOG.md @@ -0,0 +1,14 @@ +# 更新日志 + +## 1.0.0 - 2026-09-25 + +### 新增 + +- 提供查找替换、自动编号、插入内容和手动编辑四种批量重命名方式,并支持文件夹扫描、排序和实时预览。 +- 支持基于 ZTools 已配置模型生成智能重命名候选;用户确认预览后才执行改名。 +- 执行前检查名称冲突和非法名称,支持关联改名及最近一次操作撤销。 +- 支持读取文件时间、大小、图片尺寸和拍摄时间等信息用于预览名称。 + +### 安全 + +- 改名遇到冲突或失败时保留源文件,不覆盖无关目标文件;支持部分成功结果提示。 diff --git a/plugins/batch-rename/LICENSE b/plugins/batch-rename/LICENSE new file mode 100644 index 000000000..ad3dacb8b --- /dev/null +++ b/plugins/batch-rename/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 yyxxd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/batch-rename/README.md b/plugins/batch-rename/README.md new file mode 100644 index 000000000..e14853702 --- /dev/null +++ b/plugins/batch-rename/README.md @@ -0,0 +1,49 @@ +# 文件智能重命名 + +面向 Windows / ZTools 的文件批量重命名插件。支持规则化重命名,也可以使用 ZTools 中已配置的 AI 模型生成候选名称;所有候选都可先预览,再执行。 + +## 功能 + +- **查找替换**:普通替换、位置替换、多规则串联,并可选择是否处理扩展名。 +- **自动编号**:支持数字、中文数字和英文字母序号,可设置前后缀、起始值和位数。 +- **插入内容**:插入文本、序号、文件时间、大小、图片尺寸和拍摄时间。 +- **智能重命名**:根据自然语言要求生成候选名称;需要先在 ZTools 配置可用模型。 +- **手动编辑**:逐项编辑,或粘贴电子表格中的多行文件名。 +- 支持从资源管理器文件上下文导入、递归扫描文件夹、排序、实时预览、冲突预检和最近一次操作撤销。 + +## 界面截图 + +| 查找替换 | 智能重命名 | +| --- | --- | +| ![查找替换](docs/ui-evidence/v1.0.0-find-replace.png) | ![智能重命名](docs/ui-evidence/v1.0.0-smart-rename.png) | + +其他界面:[自动编号](docs/ui-evidence/v1.0.0-numbering.png) · [插入内容](docs/ui-evidence/v1.0.0-insert.png) · [手动编辑](docs/ui-evidence/v1.0.0-manual-edit.png) + +## 安装 + +- **插件中心**:在 ZTools 插件市场搜索“文件智能重命名”。市场审核通过后即可安装。 +- **本地安装**:从 [GitHub Releases](https://github.com/yyxxd/ztools-batch-rename/releases) 下载 ZIP,在 ZTools 插件管理中选择“本地安装”。 + +## 本地开发与打包 + +需要 Node.js 和 pnpm。克隆仓库后运行: + +```powershell +pnpm install +pnpm dev +pnpm build +pnpm test +.\scripts\package-probe.ps1 +``` + +打包脚本会在 `outputs/` 生成可在 ZTools 中本地安装的 ZIP。也可在 ZTools 插件管理中加载本地开发版本。 + +## 支持范围 + +- 目标平台:Windows(`win32`)与 ZTools。 +- 智能重命名依赖 ZTools 已配置的模型;每次最多处理 500 个文件。 +- 其他操作系统及未经实测的宿主版本暂不声明支持。 + +## 许可 + +[MIT License](LICENSE) diff --git "a/plugins/batch-rename/ZTools_\346\211\271\351\207\217\351\207\215\345\221\275\345\220\215\346\217\222\344\273\266_PRD_V1.0.md" "b/plugins/batch-rename/ZTools_\346\211\271\351\207\217\351\207\215\345\221\275\345\220\215\346\217\222\344\273\266_PRD_V1.0.md" new file mode 100644 index 000000000..b9842665a --- /dev/null +++ "b/plugins/batch-rename/ZTools_\346\211\271\351\207\217\351\207\215\345\221\275\345\220\215\346\217\222\344\273\266_PRD_V1.0.md" @@ -0,0 +1,1886 @@ +# ZTools「批量重命名」插件 PRD + +> 文档版本:V1.0 +> 状态:可进入开发 +> 日期:2026-09-25 +> 目标平台:ZTools +> 首版范围:除「智能重命名」外,其余核心功能全部实现 + +--- + +## 1. 项目背景 + +ZTools 当前已有重命名类能力,但现有方案在「从资源管理器当前选中文件直接进入批量重命名」这一高频场景中,操作步骤偏多。 + +本项目目标是参考 uTools 官方「批量重命名」插件的优秀使用体验,在 ZTools 中重新实现一套更适合高频文件整理的批量重命名插件。 + +本项目不是机械复制原插件界面,而是: + +1. 保留原插件已经验证过的高效工作流; +2. 第一版完整实现除「智能重命名」外的核心功能; +3. 在不增加用户操作步骤的前提下,对安全性、错误提示、冲突检测等能力进行优化; +4. 尽量使用 ZTools 原生插件能力,与 ZTools 的超级面板 / 文件上下文入口自然集成。 + +--- + +## 2. 产品目标 + +### 2.1 核心目标 + +用户在 Windows 资源管理器 / macOS Finder 中选中文件后,应能够: + +```text +选中文件 +→ 呼出 ZTools 文件上下文 / 超级面板 +→ 点击「批量重命名」 +→ 插件直接打开 +→ 当前选中的文件已经自动加载 +→ 设置规则 +→ 实时预览 +→ 一键执行 +``` + +整个流程中,不应要求用户再次手动选择已经选中的文件。 + +### 2.2 高频功能目标 + +V1 最高优先级功能: + +1. 查找替换 +2. 自动编号 + +同时完整实现: + +3. 插入内容 +4. 手动编辑 +5. 文件导入 / 文件夹递归读取 +6. 排序 +7. 实时预览 +8. 冲突预检 +9. 执行重命名 +10. 单级撤销 + +### 2.3 优化原则 + +> 能比原版做得更好,就做得更好;但不能因为“更安全”而增加明显操作步骤。 + +允许优化: + +- 预览阶段提前发现重名冲突; +- 预览阶段发现非法文件名; +- 失败原因直接显示在文件列表; +- 正常文件继续执行,错误文件自动跳过; +- 支持文件名交换 / 循环重命名; +- 执行前后二次校验; +- 更明确的成功 / 失败汇总。 + +不做: + +- 为安全性增加多余确认弹窗; +- 强制用户先修复所有错误才能执行; +- 为小概率场景设计复杂工作流。 + +--- + +## 3. V1 产品范围 + +### 3.1 V1 必做 + +#### 功能模块 + +- 查找替换 +- 自动编号 +- 插入内容 +- 手动编辑 + +#### 公共能力 + +- ZTools 普通指令入口 +- ZTools FilesCmd / 文件上下文入口 +- 自动接收当前选中文件 +- 多选添加文件 +- 读取文件夹 +- 递归读取子目录 +- 单个移除文件 +- 清空文件列表 +- 8 种排序方式 +- 原文件名 / 新文件名实时预览 +- 状态标识 +- 冲突检测 +- 非法名称检测 +- 执行结果汇总 +- 单级撤销 +- 部分失败容错 +- 深色 / 浅色主题适配 + +### 3.2 V1 明确不做 + +- 智能重命名 / AI 重命名 +- 多级撤销历史 +- 跨 ZTools 重启保存撤销记录 +- 长期重命名历史 +- 用户偏好记忆 + - 不记忆上次 Tab + - 不记忆上次排序 + - 不记忆上次规则 +- 规则收藏 +- WebDAV +- 自定义 JavaScript 规则 +- Excel 文件导入 / 导出 +- 专门的跨目录编排功能 +- 云端同步 +- 遥测 / 统计上报 + +> 注:如果 FilesCmd 或递归目录天然带入来自多个目录的文件,插件可以正常处理;但 V1 不为“跨文件夹批处理”额外设计复杂 UI。 + +--- + +# 4. 用户入口与会话行为 + +## 4.1 入口 A:文件上下文 / 超级面板 + +### 场景 + +用户在资源管理器中选中一批文件。 + +### 流程 + +```text +资源管理器选中文件 +→ 呼出 ZTools 文件上下文 / 超级面板 +→ 点击「批量重命名」 +→ ZTools 通过 files 类型 feature 启动插件 +→ onPluginEnter 接收文件参数 +→ 直接加载选中文件 +``` + +### 行为要求 + +当插件通过文件入口收到一批新的文件时: + +1. 当前文件列表直接替换为新文件; +2. 清除上一任务的单级 Undo; +3. 默认进入「查找替换」; +4. 文件按默认排序显示; +5. 不弹出“是否替换当前任务”确认框。 + +该入口被视为“明确开始新任务”。 + +--- + +## 4.2 入口 B:普通打开插件 + +触发关键词建议: + +- 批量重命名 +- 重命名 + +普通打开时: + +- 若插件当前进程内仍保留上一批文件,则继续显示当前任务; +- 不主动读取资源管理器中的当前选中内容,除非 ZTools 宿主在此次启动参数中明确提供文件; +- 不覆盖当前任务; +- 不清空 Undo。 + +> 开发前必须实测: +> Windows Explorer 选中文件后,通过 ZTools 主面板快捷键直接搜索「批量重命名」,ZTools 是否会以 files 上下文进入插件。 +> 如果宿主已经自动提供文件,则按入口 A 处理。 + +--- + +# 5. 页面整体结构 + +## 5.1 顶部功能 Tab + +V1 默认顺序: + +```text +查找替换 | 自动编号 | 插入内容 | 手动编辑 +``` + +原因: + +- 「查找替换」和「自动编号」是最高频功能; +- 提高访问效率; +- 不再保留「智能重命名」Tab。 + +切换 Tab 时: + +- 文件列表保持; +- 当前排序保持; +- 未执行的规则预览立即切换为当前模块的结果; +- 不将不同 Tab 的规则叠加。 + +> V1 中,每次只使用一个功能模块生成本轮目标文件名。 +> 「查找替换」模块内部允许多条规则串行组合。 + +--- + +## 5.2 中间:文件列表 + +每一行: + +```text +[文件图标] 当前文件名 → [状态] 目标文件名 [移除] +``` + +建议左右各占约 45%,中间状态 / 箭头占少量空间。 + +### 状态 + +#### 绿色 + +目标文件名与当前文件名一致。 + +含义: + +- 当前规则没有改变该文件; +- 本轮执行时跳过该文件。 + +#### 橙色 + +目标文件名合法,且会发生变化。 + +#### 红色 + +当前文件存在错误,不能执行。 + +例如: + +- 目标名与同目录另一个目标名重复; +- 目标名已被外部文件占用; +- 文件名为空; +- 文件名包含当前平台非法字符; +- 源文件已经不存在; +- 元数据规则所需信息不存在; +- 其他可预判错误。 + +红色项必须直接显示可理解的错误原因。 + +示例: + +```text +❌ 与「视频03.mp4」目标名称重复 +❌ 目标文件已存在 +❌ 文件名包含非法字符:: +❌ 无法读取照片拍摄时间 +``` + +--- + +# 6. 文件导入与列表管理 + +## 6.1 底部左侧按钮 + +保持原插件成熟布局: + +1. 读取文件夹 +2. 添加文件 +3. 清空 + +### 读取文件夹 + +- 打开目录选择器; +- 读取目标文件夹中的文件; +- 默认递归读取所有子目录; +- 不将文件夹本身作为重命名对象; +- 遇到无权限目录时不中断全部扫描,记录并提示。 + +### 添加文件 + +- 支持多选; +- 新文件追加到当前文件列表; +- 对完全相同的绝对路径去重。 + +### 清空 + +- 清空当前文件列表; +- 清空当前预览; +- 清空当前 Undo。 + +--- + +## 6.2 单文件移除 + +每行右侧提供 `×`。 + +移除: + +- 仅从当前任务中删除; +- 不删除磁盘文件; +- 实时重新计算自动编号与预览。 + +--- + +# 7. 排序 + +支持以下 8 种排序方式: + +1. 按名称升序 +2. 按名称降序 +3. 按大小升序 +4. 按大小降序 +5. 按修改时间升序 +6. 按修改时间降序 +7. 按创建时间升序 +8. 按创建时间降序 + +默认: + +```text +按名称升序 +``` + +排序变化后: + +- 文件显示顺序立即更新; +- 自动编号立即重新计算; +- 插入序号立即重新计算; +- 文件目标名称实时刷新。 + +--- + +# 8. 功能一:查找替换 + +## 8.1 普通查找替换 + +核心输入: + +```text +查找 [__________] → 替换成 [__________] +``` + +支持: + +- 替换指定文本; +- 替换内容为空 = 删除匹配文本。 + +默认: + +- 仅处理主文件名; +- 不处理扩展名。 + +提供复选框: + +```text +□ 替换文件扩展名 +``` + +勾选后: + +- 查找范围包括完整文件名与扩展名。 + +--- + +## 8.2 特殊查找方式 + +查找框右侧提供菜单: + +1. 前 N 位 +2. 后 N 位 +3. 从第 X 位开始的 N 位 +4. 某字符之后的内容 +5. 某字符之前的内容 +6. 某字符之后的 N 位 +7. 某字符之前的 N 位 + +### 位置规则 + +- 所有人类位置输入使用 1-based; +- 默认作用于不含扩展名的主文件名; +- 勾选「替换文件扩展名」后作用于完整文件名。 + +--- + +## 8.3 多条替换规则 + +提供: + +```text ++ 添加替换规则 +``` + +支持多条规则。 + +执行顺序: + +```text +规则 1 +↓ +规则 2 基于规则 1 的结果继续计算 +↓ +规则 3 基于规则 2 的结果继续计算 +... +``` + +已确认原版行为:串行执行。 + +例如: + +```text +原名:AAA + +规则1:A → B +规则2:B → C + +最终结果:CCC +``` + +### 规则操作 + +V1 必须支持: + +- 新增 +- 删除 +- 按界面从上到下串行执行 + +若实现成本低,可支持拖动排序;不是 V1 验收阻塞项。 + +--- + +# 9. 功能二:自动编号 + +## 9.1 参数 + +界面组成: + +```text +[序号前面字符] + +[开始序号] +[序号类型] +[固定位数] + +[序号后面字符] +``` + +输出结构: + +```text +前缀 + 序号 + 后缀 + 原扩展名 +``` + +例: + +```text +前缀:视频_ +开始:1 +类型:阿拉伯数字 +固定位数:3 +后缀:_成片 + +→ 视频_001_成片.mp4 +→ 视频_002_成片.mp4 +``` + +--- + +## 9.2 序号类型 + +必须支持: + +1. 阿拉伯数字 +2. 中文小写数字 +3. 中文大写数字 +4. 英文小写字母 +5. 英文大写字母 + +建议规则: + +### 阿拉伯数字 + +```text +1, 2, 3... +``` + +固定位数: + +```text +001, 002, 003... +``` + +### 中文小写 + +```text +一、二、三……十、十一…… +``` + +### 中文大写 + +```text +壹、贰、叁……拾、拾壹…… +``` + +### 英文小写 + +```text +a, b, c ... z, aa, ab... +``` + +### 英文大写 + +```text +A, B, C ... Z, AA, AB... +``` + +### 固定位数 + +V1 中: + +- 对阿拉伯数字启用; +- 其他类型禁用 / 隐藏该输入框; +- 避免产生含义不清的“补零字母 / 中文数字”。 + +--- + +## 9.3 编号顺序 + +编号顺序严格基于当前文件排序。 + +任何排序变化均立即改变编号预览。 + +--- + +# 10. 功能三:插入内容 + +## 10.1 内容类型 + +三个子选项: + +```text +文本 | 序号 | 文件信息 +``` + +## 10.2 插入位置 + +三个选项: + +```text +开头 | 指定位置 | 末尾 +``` + +说明: + +- 开头:主文件名最前方; +- 末尾:扩展名前; +- 指定位置:以主文件名为基准,1-based 位置插入。 + +默认不修改扩展名。 + +--- + +## 10.3 插入固定文本 + +输入: + +```text +固定文本 +``` + +示例: + +```text +原名:视频.mp4 +文本:最终版_ +位置:开头 + +→ 最终版_视频.mp4 +``` + +--- + +## 10.4 插入序号 + +参数沿用自动编号能力: + +- 序号前面字符 +- 开始序号 +- 序号类型 +- 固定位数 +- 序号后面字符 + +区别: + +自动编号: + +```text +直接使用序号内容替换整个主文件名 +``` + +插入序号: + +```text +在原文件名指定位置插入序号内容 +``` + +例: + +```text +送男朋友1.mp4 +送男朋友2.mp4 +送男朋友3.mp4 +``` + +--- + +## 10.5 插入文件信息 + +V1 支持: + +1. 文件创建时间 +2. 文件修改时间 +3. 文件大小 +4. 图片尺寸(宽×高) +5. 照片拍摄时间 + +允许增加: + +```text +[文件信息前面字符] + 文件信息 + [文件信息后面字符] +``` + +### 默认格式建议 + +为保证文件名安全: + +#### 创建 / 修改 / 拍摄时间 + +默认: + +```text +YYYY-MM-DD_HH-mm-ss +``` + +#### 文件大小 + +使用易读格式: + +```text +12KB +4.6MB +1.2GB +``` + +#### 图片尺寸 + +```text +1920x1080 +``` + +### 元数据缺失 + +例如普通 PNG 没有 EXIF 拍摄时间: + +- 对应文件标红; +- 显示「无法读取照片拍摄时间」; +- 执行时自动跳过该文件; +- 不阻止其他文件执行。 + +--- + +# 11. 功能四:手动编辑 + +界面: + +```text +当前文件名 新文件名 +A.mp4 [____________] +B.mp4 [____________] +C.mp4 [____________] +``` + +支持: + +1. 单个编辑目标文件名; +2. 粘贴多行文本; +3. 多行文本按当前列表顺序逐行覆盖。 + +提示: + +```text +粘贴多行文本(如 Excel 复制的一列),将自动向下批量替换 +``` + +## 11.1 多行粘贴规则 + +例如当前有 4 个文件: + +粘贴: + +```text +甲 +乙 +丙 +丁 +``` + +对应: + +```text +第1行 → 第1个文件 +第2行 → 第2个文件 +... +``` + +### 行数少于文件数 + +- 只覆盖对应的前 N 个文件; +- 剩余文件保持当前名称。 + +### 行数多于文件数 + +- 超出行数忽略; +- 给出轻量提示,不弹阻塞确认框。 + +### 空行 + +- 空行视为非法目标名; +- 对应项标红; +- 不自动跳过行位移。 + +--- + +# 12. 实时预览与预检 + +所有规则变化必须实时更新目标文件名。 + +## 12.1 预检顺序 + +每次目标名称计算后,对每一项执行: + +1. 是否为空; +2. 是否包含平台非法字符; +3. 是否属于 Windows 保留名称; +4. 同目录目标名是否重复; +5. 目标路径是否被“本次任务之外”的现存文件占用; +6. 源文件是否仍存在; +7. 元数据依赖是否满足。 + +--- + +## 12.2 同批次文件互换 + +以下情况不应被错误判定为“外部占用”: + +```text +A.txt → B.txt +B.txt → A.txt +``` + +以及: + +```text +A → B +B → C +C → A +``` + +因为目标文件虽然当前存在,但它同时属于本批次的 source 集合,并会被移动。 + +这类情况必须允许执行。 + +--- + +# 13. 执行重命名 + +## 13.1 执行按钮 + +底部右侧: + +```text +▶ 执行重命名 +``` + +即使存在红色错误项,也保持可点击。 + +点击后: + +```text +绿色无变化项 → 跳过 +橙色合法项 → 执行 +红色错误项 → 自动跳过 +``` + +--- + +## 13.2 执行前二次校验 + +预览只是前置检查。 + +真正执行前必须重新检查: + +- 源文件是否仍存在; +- 目标是否突然被其他程序占用; +- 文件权限; +- 父目录是否存在; +- 目标名称是否合法。 + +文件系统状态可能在预览后变化,因此执行阶段以实际文件系统状态为最终依据。 + +--- + +## 13.3 两阶段安全重命名 + +禁止简单使用: + +```text +按列表依次 source → target +``` + +因为会导致文件交换失败。 + +必须使用临时名: + +### 阶段一 + +```text +A.txt → .ztools-rename-{uuid}-1.tmp +B.txt → .ztools-rename-{uuid}-2.tmp +``` + +### 阶段二 + +```text +temp-1 → B.txt +temp-2 → A.txt +``` + +临时名称要求: + +- 同目录; +- 保证唯一; +- 用户正常不会产生冲突; +- 失败时尽可能恢复; +- 不改变文件内容。 + +--- + +## 13.4 部分失败策略 + +执行采用 best-effort。 + +例如 100 个文件: + +```text +合法:98 +错误:2 +``` + +点击执行后: + +```text +成功 98 +失败 2 +``` + +不能因为 2 个错误阻塞其余 98 个文件。 + +--- + +## 13.5 执行结果 + +成功后顶部 / 右上角显示 Toast: + +```text +执行成功:共 98 个 +``` + +存在失败: + +```text +重命名完成:成功 98 个,失败 2 个 +``` + +文件列表: + +- 成功文件重新基准化; +- 左侧当前文件名更新为新名称; +- 右侧与左侧一致; +- 状态变绿; +- 失败项保留原当前名称; +- 失败项继续标红并显示具体原因。 + +--- + +# 14. 单级撤销 + +## 14.1 基本规则 + +只支持撤销最近一次“执行重命名”。 + +例如: + +```text +原始名称 → A +A → B +``` + +撤销一次: + +```text +B → A +``` + +不能继续: + +```text +A → 原始名称 +``` + +不做多级 Undo。 + +--- + +## 14.2 生命周期 + +撤销记录: + +- 仅保存在当前 ZTools / 插件进程内; +- 关闭插件窗口但 ZTools 没有完全退出:保留; +- 完全退出 ZTools 并重新启动:清除; +- 不写数据库; +- 不持久化。 + +--- + +## 14.3 新任务规则 + +若 FilesCmd / 超级面板传入一批新文件: + +```text +清除上一任务 Undo +``` + +若仅普通再次打开插件: + +```text +保留当前任务和 Undo +``` + +--- + +## 14.4 撤销执行方式 + +逐文件 best-effort。 + +例如有 4 个文件: + +- 3 个仍保持执行后的状态; +- 1 个被用户在资源管理器手动改名。 + +点击撤销: + +```text +成功恢复 3 个 +失败 1 个 +``` + +Toast: + +```text +撤销完成:成功 3 个,失败 1 个 +``` + +失败项显示原因。 + +--- + +## 14.5 撤销冲突 + +如果撤销目标名称已经被其他文件占用: + +- 该文件撤销失败; +- 不覆盖现有文件; +- 其他文件继续撤销; +- 最后汇总成功 / 失败数量。 + +--- + +# 15. 底部操作栏 + +建议布局: + +```text +[读取文件夹] [添加文件] [清空] + [排序 ▼] [已选 N 个文件] + [撤销重命名] [执行重命名] +``` + +## 15.1 撤销按钮状态 + +无 Undo: + +- 隐藏或禁用。 + +有 Undo: + +- 显示高亮。 + +通过新文件上下文任务进入: + +- 立即清除 Undo。 + +--- + +# 16. 错误与异常处理 + +## 16.1 同一任务内部目标名重复 + +例如: + +```text +A.txt → C.txt +B.txt → C.txt +``` + +预览阶段直接标红。 + +两项都不执行。 + +## 16.2 外部文件占用 + +目录已经存在: + +```text +C.txt +``` + +计划: + +```text +A.txt → C.txt +``` + +预览: + +```text +❌ 目标文件已存在 +``` + +执行时自动跳过。 + +## 16.3 源文件被删除 + +预览后文件被删除: + +```text +❌ 源文件不存在 +``` + +其他文件继续执行。 + +## 16.4 文件无权限 + +捕获系统错误。 + +列表显示: + +```text +❌ 无权限重命名 +``` + +## 16.5 Windows 非法名称 + +预检: + +- `<` +- `>` +- `:` +- `"` +- `/` +- `\` +- `|` +- `?` +- `*` + +以及保留名称: + +- CON +- PRN +- AUX +- NUL +- COM1 ~ COM9 +- LPT1 ~ LPT9 + +同时处理: + +- 结尾空格 +- 结尾句点 + +macOS / Linux 根据平台规则校验。 + +--- + +# 17. 状态模型 + +建议内部维护: + +```ts +type FileItem = { + id: string + sourcePath: string + directory: string + currentName: string + baseName: string + extension: string + + size: number + createdAt?: number + modifiedAt?: number + + targetName: string + targetPath: string + + status: 'unchanged' | 'ready' | 'error' + errorCode?: string + errorMessage?: string +} +``` + +当前任务: + +```ts +type RenameSession = { + files: FileItem[] + sortMode: SortMode + activeModule: + | 'replace' + | 'number' + | 'insert' + | 'manual' + + replaceRules: ReplaceRule[] + numberRule: NumberRule + insertRule: InsertRule + manualNames: Record +} +``` + +Undo: + +```ts +type RenameTransaction = { + id: string + createdAt: number + entries: Array<{ + beforePath: string + afterPath: string + }> +} +``` + +只保存: + +```ts +lastTransaction: RenameTransaction | null +``` + +--- + +# 18. 技术实现建议 + +## 18.1 插件形态 + +使用: + +```text +Vue 3 + TypeScript + Vite ++ +ZTools UI Plugin ++ +preload.js ++ +Node.js 文件系统 API +``` + +理由: + +- 页面有多个 Tab; +- 大量表单; +- 文件列表; +- 实时预览; +- 错误状态; +- 批处理结果。 + +不适合 Preload Only。 + +--- + +## 18.2 plugin.json + +至少声明: + +### 普通关键词 + +```text +批量重命名 +重命名 +``` + +### FilesCmd + +使用: + +```text +type: files +label: 批量重命名 +minLength: 1 +maxLength: 10000 +``` + +不限制扩展名。 + +V1 主要处理文件。 + +--- + +## 18.3 onPluginEnter + +UI 插件通过: + +```js +ztools.onPluginEnter(({ payload, type, code }) => { + ... +}) +``` + +接收启动参数。 + +开发中不要假定 payload 的文件形态。 + +第一阶段必须打印真实参数,确认: + +- Windows Explorer 多选文件; +- 单文件; +- FilesCmd; +- 超级面板; +- 普通主面板。 + +--- + +## 18.4 preload.js 职责 + +所有真正文件系统操作放到 preload 层。 + +包括: + +- stat +- 文件 / 目录存在检测 +- 元数据读取 +- 文件夹递归扫描 +- rename +- Undo +- 图片尺寸读取 +- EXIF 拍摄时间读取 +- 最终二次校验 + +前端禁止直接暴露完整 `fs`。 + +仅暴露最小业务 API,例如: + +```ts +window.renameService.scanDirectory(...) +window.renameService.readMetadata(...) +window.renameService.validatePlan(...) +window.renameService.executePlan(...) +window.renameService.undo(...) +``` + +--- + +## 18.5 Rename Engine + +建议拆成: + +```text +Rule Engine + ↓ +Rename Plan + ↓ +Validator + ↓ +Preview + ↓ +Executor + ↓ +Transaction +``` + +### Rule Engine + +纯函数。 + +输入: + +```text +文件 + 当前规则 + 当前排序 +``` + +输出: + +```text +目标文件名 +``` + +不操作文件系统。 + +### Validator + +检查: + +- 名称合法性 +- 重名 +- 外部占用 +- 元数据错误 +- 源文件状态 + +### Executor + +仅负责执行已确认可执行项。 + +--- + +# 19. 性能要求 + +## 19.1 目标 + +典型场景: + +```text +1 ~ 500 个文件 +``` + +应保证: + +- 输入规则时实时预览; +- 页面无明显卡顿; +- 排序响应快速。 + +压力场景: + +```text +1000 ~ 10000 个文件 +``` + +要求: + +- 不崩溃; +- 不阻塞主线程数秒; +- 文件扫描 / 元数据读取应分批或异步; +- 超大列表建议使用虚拟列表。 + +### 注意 + +图片尺寸 / EXIF 不应在每次普通规则输入时重新读取。 + +应: + +```text +首次需要时读取 +→ 缓存到当前会话内 +``` + +不做跨重启持久缓存。 + +--- + +# 20. UI / UX 原则 + +## 20.1 视觉 + +不要 1:1 复制 uTools。 + +采用: + +```text +功能逻辑参考 uTools +视觉语言跟随 ZTools +``` + +优先: + +- ZTools 主题变量 +- ZTools-UI / ZTools 风格控件 +- 深色 / 浅色模式 +- 清晰状态色 + +## 20.2 不增加操作负担 + +禁止出现: + +```text +发现 1 个冲突,无法执行,请先全部处理 +``` + +应为: + +```text +2 个文件有问题 +↓ +仍然允许点击执行 +↓ +自动处理其余 98 个 +``` + +--- + +# 21. 开发前必须完成的 Probe + +这是正式功能开发前的第一项任务。 + +创建最小 ZTools 测试插件,仅做参数打印。 + +测试: + +## Probe A + +```text +Explorer 选 4 个文件 +→ ZTools 超级面板 / 文件上下文 +→ 批量重命名 +``` + +确认: + +- `code` +- `type` +- `payload` +- 文件路径数据结构 + +## Probe B + +```text +Explorer 选 4 个文件 +→ Alt+Z 呼出 ZTools +→ 搜索批量重命名 +→ Enter +``` + +确认: + +- 是否自动进入 files 上下文; +- 是否带入当前选中 4 个文件。 + +## Probe C + +```text +无任何文件选择 +→ 普通搜索进入插件 +``` + +确认正常启动。 + +## Probe D + +插件已有一批文件及 Undo: + +```text +超级面板传入另一批文件 +``` + +确认: + +- 新文件替换当前列表; +- Undo 清除。 + +Probe 结果写入: + +```text +docs/ztools-entry-probe.md +``` + +确认入口行为后再开发主体。 + +--- + +# 22. 验收测试 + +## 22.1 查找替换 + +### TC-01 普通替换 + +```text +abc_test.mp4 +abc_demo.mp4 + +查找 abc +替换 new +``` + +期望: + +```text +new_test.mp4 +new_demo.mp4 +``` + +### TC-02 删除文字 + +```text +查找 abc_ +替换为空 +``` + +期望删除。 + +### TC-03 多规则串行 + +规则: + +```text +A → B +B → C +``` + +输入: + +```text +AAA.txt +``` + +期望: + +```text +CCC.txt +``` + +### TC-04 不修改扩展名 + +默认规则不应错误修改 `.mp4`。 + +### TC-05 修改扩展名 + +勾选后允许扩展名参与规则。 + +--- + +## 22.2 自动编号 + +### TC-06 阿拉伯数字 + +4 个文件。 + +期望: + +```text +1.ext +2.ext +3.ext +4.ext +``` + +### TC-07 固定位数 + +位数 3: + +```text +001.ext +002.ext +``` + +### TC-08 前后字符 + +```text +测试001成片.ext +``` + +### TC-09 排序联动 + +更改名称升 / 降序后,编号对应关系同步变化。 + +--- + +## 22.3 插入内容 + +### TC-10 固定文本前缀 + +### TC-11 固定文本末尾 + +必须插在扩展名前。 + +### TC-12 指定位置 + +验证 1-based。 + +### TC-13 插入序号 + +验证排序联动。 + +### TC-14 文件修改时间 + +使用安全格式。 + +### TC-15 图片尺寸 + +正确产生: + +```text +1920x1080 +``` + +### TC-16 EXIF 不存在 + +标红但其他文件仍可执行。 + +--- + +## 22.4 手动编辑 + +### TC-17 单行编辑 + +### TC-18 Excel 多行粘贴 + +### TC-19 少于文件数 + +只覆盖前 N 条。 + +### TC-20 多于文件数 + +多余行忽略。 + +### TC-21 空行 + +对应项标红。 + +--- + +## 22.5 冲突 + +### TC-22 同批次重名 + +```text +A → C +B → C +``` + +预览标红。 + +### TC-23 外部文件占用 + +目标目录已经存在 C。 + +预览标红。 + +### TC-24 交换文件名 + +```text +A → B +B → A +``` + +必须执行成功。 + +### TC-25 三文件循环 + +```text +A → B +B → C +C → A +``` + +必须执行成功。 + +--- + +## 22.6 部分失败 + +### TC-26 4 个文件 1 个错误 + +期望: + +```text +成功 3 +失败 1 +``` + +不能整体回滚。 + +--- + +## 22.7 Undo + +### TC-27 基础撤销 + +```text +苹果 → 测试1 +``` + +撤销: + +```text +测试1 → 苹果 +``` + +### TC-28 仅一次 + +```text +原名 → A +A → B +``` + +撤销: + +```text +B → A +``` + +撤销按钮随后失效。 + +### TC-29 插件窗口关闭 + +ZTools 未完全退出。 + +重新打开: + +Undo 仍存在。 + +### TC-30 ZTools 完全退出 + +重启: + +Undo 不存在。 + +### TC-31 Undo 中一个文件被人工改名 + +其余正常撤销。 + +汇总: + +```text +成功 3 +失败 1 +``` + +### TC-32 Undo 目标名被占用 + +该项失败。 + +其他正常撤销。 + +--- + +## 22.8 新任务 + +### TC-33 FilesCmd 传入新文件 + +上一批文件替换。 + +Undo 清除。 + +--- + +# 23. 开发完成定义(Definition of Done) + +V1 可以交付的条件: + +- [ ] ZTools FilesCmd 文件入口真实验证通过 +- [ ] 选中文件可直接进入插件 +- [ ] 查找替换完成 +- [ ] 7 种位置匹配完成 +- [ ] 多规则串行完成 +- [ ] 自动编号完成 +- [ ] 5 种序号类型完成 +- [ ] 插入固定文本完成 +- [ ] 插入序号完成 +- [ ] 5 种文件信息完成 +- [ ] 手动编辑完成 +- [ ] Excel 多行粘贴完成 +- [ ] 8 种排序完成 +- [ ] 文件夹递归读取完成 +- [ ] 实时预览完成 +- [ ] 预览冲突检测完成 +- [ ] 非法名称检测完成 +- [ ] 交换 / 循环重命名完成 +- [ ] 部分失败执行完成 +- [ ] 成功 / 失败结果清晰 +- [ ] 单级 Undo 完成 +- [ ] Undo 部分失败完成 +- [ ] 新任务清空 Undo +- [ ] 完全退出 ZTools 后不持久化 Undo +- [ ] 深浅色主题正常 +- [ ] 1000 文件规模基本流畅 +- [ ] 构建后的 dist 插件可直接安装测试 +- [ ] 核心验收用例通过 + +--- + +# 24. 建议开发顺序 + +## Phase 0:入口 Probe + +先确认 ZTools 文件上下文参数。 + +不要先做 UI。 + +## Phase 1:核心数据模型 + +- FileItem +- RenameSession +- Rule Engine +- Preview + +## Phase 2:最高频功能 + +1. 查找替换 +2. 自动编号 + +完成后即可内部试用。 + +## Phase 3:执行引擎 + +- Validator +- 两阶段 Rename +- Result +- Undo + +## Phase 4:剩余功能 + +- 插入内容 +- 文件信息 +- 手动编辑 + +## Phase 5:UI 完善 + +- ZTools 风格 +- 深浅主题 +- 状态提示 +- 长列表优化 + +## Phase 6:完整验收 + +执行本文 TC-01 ~ TC-33。 + +--- + +# 25. 技术依据 + +本 PRD 的 ZTools 接入方案基于官方开发资料: + +1. ZTools《快速开始》 + - 插件形态为 Web 前端 + Node.js 本地能力 + - 支持 Vue / React + +2. ZTools《AI 插件开发指南》 + - UI 插件使用 `ztools.onPluginEnter` + - 文件处理插件使用 `files` 类型指令 + - FilesCmd 支持 `minLength` / `maxLength` + - 文件读写应放在 preload.js + - 文件类指令默认最多可匹配 10000 个文件 + +3. ZTools《插件 API 文档》 + - 支持文件选择对话框 + - 支持插件生命周期与入口参数 + - 支持系统能力调用 + +4. ZTools Native API + - 支持 Windows / macOS 获取当前选中的文件、文本、图片 + - Windows 使用 UI Automation,并可回退剪贴板方案 + - 支持 Explorer / Finder 中选中的文件 + +开发阶段以当时安装的 ZTools 最新稳定版实际行为为最终准则。 + +--- + +# 26. 最终产品一句话定义 + +> 一个专注“少一步操作”的 ZTools 批量重命名插件:在资源管理器选中文件后直接进入重命名,快速完成查找替换、自动编号、插入内容和手动编辑,并通过实时预览、冲突预检、部分失败容错和单级撤销,比原版更安全,但不更麻烦。 diff --git a/plugins/batch-rename/docs/phase-1-acceptance.md b/plugins/batch-rename/docs/phase-1-acceptance.md new file mode 100644 index 000000000..e84cf28b6 --- /dev/null +++ b/plugins/batch-rename/docs/phase-1-acceptance.md @@ -0,0 +1,22 @@ +# Phase 1 验收记录 + +## 当前状态 + +- 自动测试:6 项通过;类型检查、构建和安装包入口检查通过。 +- ZTools 宿主入口:2026-09-25 用户反馈以下验收步骤全部通过。Phase 1 验收完成。 +- 本阶段只读取文件信息和展示名称,不执行重命名。 + +## 安装 + +在 ZTools 插件管理页使用“本地安装”,选择 `outputs/batch-rename-phase1-0.1.0.zip`。本阶段插件名称为“批量重命名”,与此前的“批量重命名入口 Probe”是两个独立安装项;测试时请选择新插件。 + +## 宿主验收步骤 + +请使用临时测试文件,并在测试前记下其原始名称。 + +1. 在 Explorer 选中 4 个文件,经 ZTools 文件上下文打开“批量重命名”。预期:列表恰好显示 4 个文件,按名称升序排列,当前名称和目标名称一致。 +2. 保持 Explorer 选择,经 `Alt+Z` 搜索“批量重命名”并进入。预期:原有列表保持,不重复添加文件。 +3. 在 Explorer 选中另一批文件,再经文件上下文进入。预期:列表替换为新文件,不包含上一批文件。 +4. 点击单行 `×`、再点击“清空”。预期:数量和列表即时更新;Explorer 中的文件仍存在,文件名与测试前一致。 + +验收结果:用户确认以上步骤均无问题,测试通过。 diff --git a/plugins/batch-rename/docs/ui-evidence/default.png b/plugins/batch-rename/docs/ui-evidence/default.png new file mode 100644 index 000000000..38c379b63 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/default.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/partial.png b/plugins/batch-rename/docs/ui-evidence/partial.png new file mode 100644 index 000000000..11129fd7e Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/partial.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/success.png b/plugins/batch-rename/docs/ui-evidence/success.png new file mode 100644 index 000000000..960e3ae70 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/success.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/v1.0.0-find-replace.png b/plugins/batch-rename/docs/ui-evidence/v1.0.0-find-replace.png new file mode 100644 index 000000000..49fc9c5de Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/v1.0.0-find-replace.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/v1.0.0-insert.png b/plugins/batch-rename/docs/ui-evidence/v1.0.0-insert.png new file mode 100644 index 000000000..496584d45 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/v1.0.0-insert.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/v1.0.0-manual-edit.png b/plugins/batch-rename/docs/ui-evidence/v1.0.0-manual-edit.png new file mode 100644 index 000000000..b43be3366 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/v1.0.0-manual-edit.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/v1.0.0-numbering.png b/plugins/batch-rename/docs/ui-evidence/v1.0.0-numbering.png new file mode 100644 index 000000000..f05f21911 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/v1.0.0-numbering.png differ diff --git a/plugins/batch-rename/docs/ui-evidence/v1.0.0-smart-rename.png b/plugins/batch-rename/docs/ui-evidence/v1.0.0-smart-rename.png new file mode 100644 index 000000000..87e191dc0 Binary files /dev/null and b/plugins/batch-rename/docs/ui-evidence/v1.0.0-smart-rename.png differ diff --git a/plugins/batch-rename/docs/v1-acceptance.md b/plugins/batch-rename/docs/v1-acceptance.md new file mode 100644 index 000000000..9a95c25b1 --- /dev/null +++ b/plugins/batch-rename/docs/v1-acceptance.md @@ -0,0 +1,95 @@ +# V1 验收记录 + +## 当前状态 + +- 发布准备版本:`1.0.0`;`package.json` 与 `plugin.json` 版本已同步,插件声明仅支持 Windows(`win32`)。 +- 构建:2026-09-25 运行 `pnpm build` 通过,包含 `vue-tsc --noEmit` 和 Vite production build。 +- 自动测试:2026-09-25 运行 `pnpm test`,11 个测试文件、71 项测试通过。 +- ZIP:已生成 `outputs/file-smart-rename-v1.0.0.zip`(1,810,862 字节);包含 7 个文件且清单与 `dist` 完全一致,SHA-256 与入口检查见下方。 +- UI:已收到用户提供的 `1.0.0` 五个功能页截图:[查找替换](ui-evidence/v1.0.0-find-replace.png)、[自动编号](ui-evidence/v1.0.0-numbering.png)、[智能重命名](ui-evidence/v1.0.0-smart-rename.png)、[插入内容](ui-evidence/v1.0.0-insert.png)、[手动编辑](ui-evidence/v1.0.0-manual-edit.png)。截图展示各功能页空列表状态,可用于 PR 界面展示。 +- ZTools 宿主实测:用户于 2026-09-25 确认验收通过。下面的逐项矩阵尚未收到每个 TC 的单独结果,因此保留“待逐项记录”;这不改变用户对整体验收通过的确认。 + +## 自动化测试与宿主验收矩阵 + +“自动证据”记录仓库当前测试覆盖,不代替 ZTools 实际交互验收。用户已确认整体验收通过;如需保留逐项审计记录,可按 [PRD 验收用例 TC-01~TC-33](../ZTools_批量重命名插件_PRD_V1.0.md) 补充每项日期、结果及证据路径。 + +| 用例 | 自动证据与覆盖状态 | ZTools 宿主实测 | +|---|---|---| +| TC-01 普通替换 | `src/core/replace.test.ts`,通过 | 通过* | +| TC-02 删除文字 | `src/core/replace.test.ts`,通过 | 通过* | +| TC-03 多规则串行 | `src/core/replace.test.ts`,通过 | 通过* | +| TC-04 不修改扩展名 | `src/core/replace.test.ts`,通过 | 通过* | +| TC-05 修改扩展名 | `src/core/replace.test.ts`,通过 | 通过* | +| TC-06 阿拉伯数字 | `src/core/number.test.ts`,序号生成通过 | 通过* | +| TC-07 固定位数 | `src/core/number.test.ts`,补零通过 | 通过* | +| TC-08 前后字符 | `src/core/number.test.ts`,前缀/后缀/扩展名拼接通过 | 通过* | +| TC-09 排序联动 | `src/core/sort.test.ts` 覆盖 8 种排序;未用界面集成测试验证编号预览联动 | 通过* | +| TC-10 固定文本前缀 | `src/core/insert.test.ts`,通过 | 通过* | +| TC-11 固定文本末尾 | `src/core/insert.test.ts`,扩展名前插入通过 | 通过* | +| TC-12 指定位置 | `src/core/insert.test.ts`,1-based 位置边界通过 | 通过* | +| TC-13 插入序号 | `src/core/insert.test.ts`,按输入排序索引编号通过 | 通过* | +| TC-14 文件修改时间 | `src/core/insert.test.ts`,安全时间格式通过 | 通过* | +| TC-15 图片尺寸 | `src/core/insert.test.ts` 和 `src/core/metadata.test.ts`,尺寸格式和 PNG 读取通过 | 通过* | +| TC-16 EXIF 不存在 | `src/core/insert.test.ts` 和 `src/core/metadata.test.ts`,缺失元数据逐项报错通过 | 通过* | +| TC-17 单行编辑 | `src/core/manual.test.ts`,稳定 ID 编辑值通过 | 通过* | +| TC-18 Excel 多行粘贴 | `src/core/manual.test.ts`,按传入排序和 Windows 换行处理通过 | 通过* | +| TC-19 少于文件数 | `src/core/manual.test.ts`,未覆盖项保留原值通过 | 通过* | +| TC-20 多于文件数 | `src/core/manual.test.ts`,忽略并计数多余行通过 | 通过* | +| TC-21 空行 | `src/core/manual.test.ts`,空行位置保留通过;UI 错误展示未做组件测试 | 通过* | +| TC-22 同批次重名 | `src/core/engine.test.ts`,两个重复目标均标错 | 通过* | +| TC-23 外部文件占用 | `src/core/engine.test.ts`,占用目标不覆盖且其他项继续执行通过 | 通过* | +| TC-24 交换文件名 | `src/core/engine.test.ts`,两文件交换及撤销、内容完整性通过 | 通过* | +| TC-25 三文件循环 | `src/core/engine.test.ts`,三文件循环执行和恢复内容通过 | 通过* | +| TC-26 4 个文件 1 个错误 | `src/core/engine.test.ts`,3 成功 / 1 失败且不覆盖外部文件 | 通过* | +| TC-27 基础撤销 | `src/core/engine.test.ts`,循环任务撤销并核对原内容通过 | 通过* | +| TC-28 仅一次 | `src/core/engine.test.ts`,连续执行两次只撤销第二次 | 通过* | +| TC-29 插件窗口关闭 | 无 ZTools 生命周期自动化测试 | 通过* | +| TC-30 ZTools 完全退出 | 无 ZTools 生命周期自动化测试 | 通过* | +| TC-31 Undo 中一个文件被人工改名 | `src/core/engine.test.ts`,4 文件中 3 成功 / 1 失败 | 通过* | +| TC-32 Undo 目标名被占用 | `src/core/engine.test.ts`,占用项失败,另一项成功,外部文件不被覆盖 | 通过* | +| TC-33 FilesCmd 传入新文件 | `src/core/session.test.ts` 覆盖文件路径提取、新任务默认状态;运行中的 Undo 清除集成行为未由自动测试证明 | 通过* | + +> * 用例状态根据用户于 2026-09-25 的整体验收通过反馈记录;未收到逐用例明细,附件截图仅展示各功能页空状态。 + +## ZIP 安装检查 + +填写本次发布包信息并逐项确认: + +- 构建版本:`1.0.0` +- ZIP 路径:`outputs/file-smart-rename-v1.0.0.zip` +- ZIP 大小:1,810,862 字节 +- SHA-256:`B14179998BC458ECACD2148A0BBDC28DCF84CB8A377505FE22A95AF2A9E7B521` +- ZIP 根目录清单与 `dist` 完全一致,共 7 个文件:`plugin.json`、`index.html`、`logo.png`、`preload/services.js`、`assets/index-tox1SWC9.js`、`assets/index-tt30pCFg.css`、`assets/logo-B6eAezgC.png`。 +- `plugin.json` 的 `main`、`preload`、`logo` 路径在 ZIP 内均存在。 +- `preload/services.js` 已包含扫描、元数据与改名引擎代码;安装运行不依赖项目开发目录或额外 `preload/*.js` 文件。 +- 本地安装及启动结果:用户反馈 1.0.0 已验收通过;具体日期和宿主版本未提供。 + +## ZTools 宿主记录 + +建议先在专用临时目录准备内容各不相同的 `A.txt`、`B.txt`、`C.txt`,以及一张带拍摄时间的 JPEG 和一张不带 EXIF 的 PNG。不要用正式文件试验。按以下顺序完成一次集中验收,并在上表逐项填写结果: + +1. 安装本页记录的 ZIP;从 Explorer 选中 3 个文件通过文件上下文打开,确认自动加载。普通搜索重入应保留列表;选择另一批文件通过文件上下文打开应替换列表。 +2. 依次操作查找替换的普通与位置规则、多条串行规则,确认扩展名开关;操作 5 种编号和 8 种排序,确认编号随排序变化。 +3. 依次试插入文本、序号、创建/修改时间、大小、尺寸和拍摄时间;缺失 EXIF 的 PNG 应只在对应规则下显示错误。手动编辑试单行、少行、多行和含空行粘贴。 +4. 试添加文件、递归扫描文件夹、单行移除和清空;核对被移除及清空的磁盘文件仍存在。 +5. 用手动编辑令 `A.txt → B.txt`、`B.txt → A.txt`,执行后按文件内容确认交换,再撤销;随后用 3 个文件验证循环改名。 +6. 准备一个不在任务中的占用文件,验证它不被覆盖且其他正常文件继续改名;再验证撤销目标被占用时只跳过受影响项。 +7. 在自动编号、插入内容和手动编辑页分别执行,检查执行后仍停留在原 Tab,右上角 Toast 可见,成功项显示真实新名称且不会立即再次应用旧规则;部分失败行能看到原因。智能重命名页使用 ZTools 已配置模型生成预览,核对候选名称后执行,并验证撤销;未配置模型时应提示且不能生成预览。 +8. 检查搜索仅筛选可见行,并提示执行仍针对全部文件;执行后清除筛选以显示完整结果。关闭插件窗口并重开,检查 Undo;完全退出并重启 ZTools,再检查 Undo 已清除。分别检查浅色、深色主题及较大列表的响应。 +9. 检查正式版 Tab 顺序;在“插入内容”的文本、序号、文件信息与三个插入位置之间切换,并查看“自动编号”。规则面板应无纵向滚动条,选项栏位置稳定,序号字段完整显示。 + +用户已反馈整体验收通过;如需完整审计轨迹,可补记各用例的实际结果、日期和失败复现步骤。 + +- 日期 / 操作系统 / ZTools 版本:2026-09-25 / 用户未提供操作系统版本与 ZTools 版本 +- 安装包版本及 SHA-256:1.0.0 / `B14179998BC458ECACD2148A0BBDC28DCF84CB8A377505FE22A95AF2A9E7B521` +- 文件入口、普通入口、任务保留与替换:通过* +- 四个模块、预览状态和错误提示:通过* +- 添加文件、递归文件夹扫描、清空:通过* +- 预检、交换 / 循环、部分失败及撤销:通过* +- 关闭窗口和完全退出后的撤销生命周期:通过* +- 深色 / 浅色主题与大列表响应:通过* +- 执行后留在当前 Tab、结果 Toast、智能重命名模型调用与执行及搜索范围说明:通过* +- Tab 顺序、插入内容切换时的定位与规则面板无纵向滚动条:通过* +- 结果、复现步骤、截图或日志路径:__________ + +上述用户验收反馈针对安装包 `1.0.0`;自动测试结果、截图和宿主逐项记录分别标注,避免互相替代。 diff --git a/plugins/batch-rename/docs/v1-user-guide.md b/plugins/batch-rename/docs/v1-user-guide.md new file mode 100644 index 000000000..41d6f141b --- /dev/null +++ b/plugins/batch-rename/docs/v1-user-guide.md @@ -0,0 +1,41 @@ +# 文件智能重命名 V1 使用说明 + +## 安装与启动 + +1. 在项目根目录运行 `pnpm install`,然后运行 `pnpm build`。 +2. 运行 `.\scripts\package-probe.ps1`。默认生成 `outputs/file-smart-rename-v<版本号>.zip`;也可以传入 `-OutputPath ` 指定位置。 +3. 在 ZTools 插件管理中选择“本地安装”,安装生成的 ZIP。 +4. 插件也可从 ZTools 普通搜索入口打开。普通入口不读取 Explorer 当前选择;插件进程仍保留任务时会继续显示该任务。 + +## 导入文件 + +- **文件上下文入口:** 在 Explorer 选中文件,通过 ZTools 文件上下文打开“文件智能重命名”。收到新文件任务后,列表会替换为所选文件,默认进入“查找替换”并按名称升序排列,同时清除旧任务的撤销记录。 +- **添加文件:** 点击“添加文件”并多选文件。新文件追加到列表,重复绝对路径会去重。取消文件选择不会清空已有任务。 +- **扫描文件夹:** 点击“扫描文件夹”并选择目录。插件递归扫描普通文件,不把文件夹加入改名列表;扫描遇到错误时会保留可读取文件并提示错误。最多处理 10,000 个文件。 +- **移除和清空:** 每行的“移除”只移除该项。点击“清空”会清除文件、预览和当前撤销记录。 + +## 编辑目标名称 + +列表显示当前文件名、目标文件名和状态。状态包括“无变化”“待改名”“错误”和执行后的“已完成”;错误行会显示原因,点击错误标签可查看完整原因。修改规则、排序或编辑值后,目标名称会重新预览,并检查目标名冲突。顶部搜索框只筛选可见文件,执行仍针对当前任务中的全部文件。 + +四个可执行功能模块分别保存自己的规则: + +- **查找替换:** 可添加多条规则并按顺序应用。支持普通替换、前 N 位、后 N 位、指定区间、定位字符前后及定位字符前后的 N 位。默认不处理扩展名;勾选后才将扩展名纳入替换。 +- **自动编号:** 可设置前缀、开始序号、序号类型、阿拉伯数字固定位数和后缀。支持阿拉伯数字、中文大小写数字和英文字母大小写。固定位数只对阿拉伯数字生效。序号根据当前显示顺序计算,原扩展名保留。 +- **插入内容:** 可插入固定文本、序号或文件信息,并选择文件名开头、指定位置或扩展名前。文件信息包括创建时间、修改时间、文件大小、图片尺寸和拍摄时间;缺少所需元数据的文件会显示错误。 +- **手动编辑:** 可逐项填写目标名,也可从表格或电子表格复制多行后粘贴。粘贴按当前排序顺序填写;多余行忽略,少于文件数时其余项保留原值,空白行对应的目标名会标错。 +- **智能重命名:** 在 ZTools 设置中配置 AI 模型后,选择模型、填写重命名要求并点击“生成预览”。插件仅发送文件名、大小和时间元数据,由 ZTools 调用已配置的模型;检查候选名称后再点击“执行重命名”。每次最多处理 500 个文件,内部按每批 50 个请求;模型返回无效结果时不会执行改名。模型配置变更后可点击“刷新模型”。 + +## 排序与执行 + +排序菜单提供名称、大小、修改时间和创建时间的升序、降序。排序会更新显示顺序及编号预览;相同值保持原相对顺序,缺失的数值排在末尾。 + +点击“执行重命名”后,插件会再次检查文件和目标名称。非法名称、同目录目标重名、已被外部文件占用的目标会被拒绝。互换或循环改名会作为关联操作安全执行。互不相关的文件逐项处理;部分失败时,其余文件仍可继续。执行完成后停留在当前功能页,右上角提示成功和失败数量;成功项更新为实际文件名,失败项显示原因。修改规则或切换功能页后,才会从新的文件名生成下一轮预览。 + +## 撤销与限制 + +- “撤销”只恢复最近一次执行中成功改名的文件,逐项检查文件是否仍是原文件,并且不会覆盖已被占用的名称。 +- 撤销记录只存在于当前 ZTools / 插件进程内:关闭插件窗口但 ZTools 未退出时应保留;完全退出 ZTools 后清除。它不支持多级撤销,也不写入数据库。 +- 通过文件上下文进入的新任务会清除上一任务的撤销记录。普通入口重入会保留当前任务。 +- V1 面向 Windows / ZTools。其他操作系统和宿主版本未经实测的行为,请参见 [V1 验收记录](v1-acceptance.md),不得据此视为已验收。 +- 所在文件系统若不支持安全建立硬链接,相关改名会报错并保留源文件。 diff --git a/plugins/batch-rename/docs/ztools-entry-probe.md b/plugins/batch-rename/docs/ztools-entry-probe.md new file mode 100644 index 000000000..906b254c8 --- /dev/null +++ b/plugins/batch-rename/docs/ztools-entry-probe.md @@ -0,0 +1,168 @@ +# ZTools 入口 Probe 记录 + +## 结论状态 + +Phase 0 的 A~D 入口验证已完成。ZTools 3.2.0 的文件上下文入口会传入选中文件;普通主面板搜索不会自动携带 Explorer 当前选择。 + +## 测试基线 + +- 测试日期:2026-09-25 +- 操作系统:Windows +- ZTools:ZToolsCenter 3.2.0 +- 运行时:Windows x64、Node.js 24.15.0、Electron 41.4.0 +- 插件 ID:`batch-rename-probe` +- 分支:`chore/phase-0-entry-probe` +- Probe 原则:仅记录入口参数和内存会话变化,不读取或修改被选文件内容 + +## 已收到的实测报告 + +### 普通入口(Probe C) + +- 报告时间:2026-09-25 02:32:09 UTC(北京时间 10:32:09) +- 安装模式:`isDev: false`,说明运行的是本地安装包 +- `onPluginEnter`:已触发 1 次 +- `code`:`batch-rename`(普通关键词入口) +- `type`、`payload`:报告中未出现 +- 提取到的绝对路径:0 条 +- 会话:当前文件列表为空,模拟 Undo 不存在 +- 触发场景:用户确认未选中文件,经普通搜索进入插件 + +结论:Probe C 的普通启动通过,且本次没有自动带入文件;尚不能验证重入时是否保留已有任务与 Undo。 + +### 文件入口(Probe A) + +- 报告时间:2026-09-25 02:36:58 UTC(北京时间 10:36:58) +- 入口:Explorer 选中 4 个文件后,经 ZTools 文件上下文进入 +- `onPluginEnter`:再次触发,事件序号为 3 +- `code`:`batch-rename-files` +- `type`:`files` +- `payload`:长度为 4 的数组;每项包含 `isFile: true`、`isDirectory: false`、`name`、`path` +- `path`:Windows 绝对文件路径;此处省略用户文件名,不在文档中保存原始路径 +- 提取路径:4 条;`session.currentFiles` 同步变为这 4 条路径 +- 执行的会话动作:替换当前文件列表,并把模拟 Undo 设为 `false` +- 在本次进入前记录到 `plugin-out` 且 `isKill: false`;再次进入时旧事件仍在,说明该次退出没有丢失 Probe 页面状态 + +结论:选中文件可通过 FilesCmd 一次传入 4 个文件;入口识别应以 `code: batch-rename-files` 为准,并从 `payload[].path` 读取文件路径。因为测试前模拟 Undo 已为 `false`,此报告不能证明清除 Undo 的实际效果。 + +### 选中文件后普通搜索(Probe B) + +- `plugin-enter` 事件时间:2026-09-25 02:38:34 UTC(北京时间 10:38:34) +- 触发场景:Explorer 仍选中文件,经 `Alt+Z` 普通搜索“批量重命名”进入 +- `code`:`batch-rename` +- `type`:`text` +- `payload`:空字符串 +- 提取路径:0 条 +- 会话:`currentFiles` 仍是 Probe A 已载入的 4 条路径,模拟 Undo 为 `false` + +结论:当前 ZTools 3.2.0 中,经主面板普通搜索进入不会自动携带 Explorer 当前选择;普通入口保留已有文件列表。此结果不能证明已有模拟 Undo 会保留,因为进入前该值为 `false`。 + +### 新文件上下文任务(Probe D) + +- `plugin-enter` 事件时间:2026-09-25 02:41:20 UTC(北京时间 10:41:20) +- 操作:按测试步骤先创建模拟 Undo,再从 Explorer 选取与 Probe A 不同的 4 个文件,通过文件上下文进入 +- `code`:`batch-rename-files` +- `type`:`files` +- `payload`:4 个文件对象;文件名和完整路径不写入文档 +- 会话:`currentFiles` 变为新选中的 4 条路径,不含 Probe A 的文件;`hasSimulatedUndo: false` +- 旧事件仍在;本次进入前记录到 `plugin-out` 且 `isKill: false` + +结论:文件入口再次触发时,插件沿用原有页面状态并替换当前任务;按本次操作步骤,之前创建的模拟 Undo 被清除。导出报告只显示最终 Undo 状态,没有记录点击按钮后的中间状态;正式实现还需为 Undo 生命周期编写集成测试。 + +### 对 V1 实现的约束 + +1. 用 `code: batch-rename-files` 识别文件上下文任务;将 `payload` 作为文件对象数组处理,从每项的 `path` 读取绝对路径。 +2. 普通搜索入口使用 `code: batch-rename`。即使 Explorer 当前选中了文件,ZTools 也可能给出 `type: text`、空 `payload`;此时保留当前任务,不自行读取 Explorer 选择。 +3. 文件入口重入视为新任务:替换文件列表、重置默认 Tab 和排序、清除最近一次 Undo。 +4. `onPluginOut(false)` 后页面状态在本次测试中保留;完全退出 ZTools 后 Undo 生命周期未在本 Probe 中实测,正式实现不可依赖持久化状态。 + +## 安装与运行 + +### 本地安装(ZIP) + +1. 执行 `pnpm install`。 +2. 执行 `pnpm build`。 +3. 执行 `./scripts/package-probe.ps1 -OutputPath <目标 ZIP 绝对路径>`。ZIP 根目录必须直接包含 `plugin.json`、`index.html`、`logo.png` 和 `preload/services.js`,不能多一层 `dist/`。 +4. 在 ZTools 插件管理页面选择“本地安装”,选中生成的 `.zip` 文件。 + +当前可安装包:`D:\Ai_Project\quick-batch-rename\outputs\batch-rename-probe-0.0.1.zip`。 + +### 开发项目入口(与本地安装不同) + +1. 执行 `pnpm dev`。 +2. 在 ZTools 的“导入开发插件”入口选择项目根目录的 `plugin.json`;此入口的文件选择器接受 JSON。 +3. ZTools 将读取 `development.main` 并连接本地 Vite 服务。 + +ZTools 3.2.0 的“本地安装”文件选择器只接受 `.zpx` / `.zip`。官方开发文档把 `dist` 描述为构建后的插件内容目录,但实际安装需要把它封装为上述文件格式。 + +依据: + +- [ZTools AI 插件开发指南](https://ztoolscenter.github.io/ZTools-doc/ai-plugin-guide.html):`dist` 是构建后的插件应用目录。 +- [ZTools 插件安装实现](https://github.com/ZToolsCenter/ZTools/blob/main/src/main/api/renderer/pluginInstaller.ts):本地安装文件选择器只接受 `.zpx` / `.zip`,ZIP 根目录读取 `plugin.json`。 +- [ZTools 开发项目导入实现](https://github.com/ZToolsCenter/ZTools/blob/main/src/main/api/renderer/pluginDevProjects.ts):开发项目入口选择 `plugin.json`。 + +## Probe A:Explorer 文件上下文 + +步骤:Explorer 选中 4 个测试文件,通过 ZTools 文件上下文进入“批量重命名”。 + +- 状态:通过 +- `code`:`batch-rename-files` +- `type`:`files` +- `payload`:4 个文件对象组成的数组 +- 路径结构:`payload[].path` 为 Windows 绝对路径 +- 是否一次收到全部 4 个文件:是 + +## Probe B:Explorer 选中文件后普通搜索 + +步骤:Explorer 选中 4 个测试文件,按 `Alt+Z`,搜索“批量重命名”并回车。 + +- 状态:通过 +- 是否进入 files 指令:否,进入普通关键词指令 +- 是否携带当前选中的 4 个文件:否;已有文件列表继续显示 +- `code`:`batch-rename` +- `type`:`text` +- `payload`:空字符串 + +## Probe C:无文件选择时普通搜索 + +步骤:取消文件选择,按 `Alt+Z`,搜索“批量重命名”并回车。 + +- 状态:普通启动通过;已有 Undo 保留行为未单独实测 +- 是否正常启动:是;`onPluginEnter` 收到 `code: batch-rename` +- 是否保留当前任务:Probe B 已验证普通入口保留已有文件列表 +- `code`:`batch-rename` +- `type` / `payload`:导出报告中未出现 +- 提取路径:0 条 + +## Probe D:新文件上下文替换当前任务 + +步骤:先进入插件并点击“创建模拟 Undo”,再从 Explorer 选择另一批文件,通过文件上下文进入插件。 + +- 状态:通过 +- 是否保留原有页面状态:是,旧事件仍在 +- 是否触发新的 `onPluginEnter`:是,`code: batch-rename-files` +- 当前文件是否替换:是,旧 4 个文件换成新 4 个文件 +- 模拟 Undo 是否清除:是,按用户操作步骤,进入前设为“存在”,进入后报告为 `false` + +## 补充生命周期检查 + +- 普通隐藏后再次打开是否保留页面内存:是,旧事件在后续报告中仍可见 +- `onPluginOut(false)` 是否触发:是,在 Probe C 退出后记录到事件序号 2 +- 完全结束插件时是否收到 `onPluginOut(true)`:待记录 +- 仅修改文件名大小写时,Windows 文件系统行为:待后续执行引擎 Probe + +## Phase 0 完成门槛 + +- [x] 确认目标客户端和版本 +- [x] 建立 Git 仓库与独立开发分支 +- [x] 创建最小 UI Probe 插件 +- [x] 分离普通指令与 FilesCmd 的 feature code +- [x] 展示原始 `code`、`type`、`payload` +- [x] 展示可提取的绝对路径 +- [x] 实现当前任务与单级 Undo 的模拟状态及展示 +- [x] 通过宿主重入验证模拟 Undo 的清理行为 +- [x] Probe C:无文件选择时,普通搜索可启动插件 +- [x] Probe A:Explorer 选中的 4 个文件通过 FilesCmd 一次传入 +- [x] Probe B:Explorer 选中文件后普通搜索不自动携带选择,已有文件列表保留 +- [x] Probe D:新文件上下文任务替换旧列表并清除模拟 Undo +- [x] 完成 Probe A~D 宿主实测 +- [x] 把实测结论写回本文档 diff --git a/plugins/batch-rename/index.html b/plugins/batch-rename/index.html new file mode 100644 index 000000000..aa055e086 --- /dev/null +++ b/plugins/batch-rename/index.html @@ -0,0 +1,12 @@ + + + + + + 文件智能重命名 + + +
+ + + diff --git a/plugins/batch-rename/logo.png b/plugins/batch-rename/logo.png new file mode 100644 index 000000000..91e31115e Binary files /dev/null and b/plugins/batch-rename/logo.png differ diff --git a/plugins/batch-rename/package.json b/plugins/batch-rename/package.json new file mode 100644 index 000000000..4a2e9ada2 --- /dev/null +++ b/plugins/batch-rename/package.json @@ -0,0 +1,28 @@ +{ + "name": "ztools-batch-rename", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "build": "pnpm run typecheck && vite build" + }, + "dependencies": { + "exifr": "^7.1.3", + "image-size": "^2.0.4", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "esbuild": "^0.28.2", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vitest": "^3.2.7", + "vue-tsc": "^2.0.0" + }, + "packageManager": "pnpm@11.21.0" +} diff --git a/plugins/batch-rename/plugin.json b/plugins/batch-rename/plugin.json new file mode 100644 index 000000000..9113d810f --- /dev/null +++ b/plugins/batch-rename/plugin.json @@ -0,0 +1,37 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "batch-rename", + "title": "文件智能重命名", + "description": "从文件上下文批量重命名,支持 AI 预览、规则预览、执行和单级撤销", + "author": "yyxxd", + "version": "1.0.0", + "platform": ["win32"], + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "features": [ + { + "code": "batch-rename", + "explain": "打开文件智能重命名", + "icon": "logo.png", + "cmds": ["文件智能重命名", "批量重命名", "重命名"] + }, + { + "code": "batch-rename-files", + "explain": "读取当前选中的文件", + "icon": "logo.png", + "cmds": [ + { + "type": "files", + "label": "文件智能重命名", + "fileType": "file", + "minLength": 1, + "maxLength": 10000 + } + ] + } + ] +} diff --git a/plugins/batch-rename/pnpm-lock.yaml b/plugins/batch-rename/pnpm-lock.yaml new file mode 100644 index 000000000..c8c551d5b --- /dev/null +++ b/plugins/batch-rename/pnpm-lock.yaml @@ -0,0 +1,1622 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + exifr: + specifier: ^7.1.3 + version: 7.1.3 + image-size: + specifier: ^2.0.4 + version: 2.0.4 + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.6 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3(@types/node@24.13.6))(vue@3.5.43(typescript@5.9.3)) + '@ztools-center/ztools-api-types': + specifier: ^1.0.1 + version: 1.0.3 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + typescript: + specifier: ^5.3.0 + version: 5.9.3 + vite: + specifier: ^6.0.11 + version: 6.4.3(@types/node@24.13.6) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@24.13.6) + vue-tsc: + specifier: ^2.0.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.6': + resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@ztools-center/ztools-api-types@1.0.3': + resolution: {integrity: sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + image-size@2.0.4: + resolution: {integrity: sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==} + engines: {node: '>=18'} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.6': + dependencies: + undici-types: 7.18.2 + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@24.13.6))(vue@3.5.43(typescript@5.9.3))': + dependencies: + vite: 6.4.3(@types/node@24.13.6) + vue: 3.5.43(typescript@5.9.3) + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@6.4.3(@types/node@24.13.6))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@24.13.6) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.43 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@ztools-center/ztools-api-types@1.0.3': {} + + alien-signals@1.0.13: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + entities@7.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + exifr@7.1.3: {} + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + image-size@2.0.4: {} + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite-node@3.2.4(@types/node@24.13.6): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@24.13.6) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@24.13.6): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.6 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@24.13.6): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@6.4.3(@types/node@24.13.6)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@24.13.6) + vite-node: 3.2.4(@types/node@24.13.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.6 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-uri@3.2.0: {} + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15(typescript@5.9.3) + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/plugins/batch-rename/pnpm-workspace.yaml b/plugins/batch-rename/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/plugins/batch-rename/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/plugins/batch-rename/preload/engine.js b/plugins/batch-rename/preload/engine.js new file mode 100644 index 000000000..7a1d3e98f --- /dev/null +++ b/plugins/batch-rename/preload/engine.js @@ -0,0 +1,249 @@ +const fs = require('node:fs/promises') +const path = require('node:path') +const { randomUUID } = require('node:crypto') + +let lastTransaction = null +let busy = false + +function pathKey(value) { + return process.platform === 'win32' ? value.replaceAll('/', '\\').toLowerCase() : value +} + +function nameError(name) { + if (typeof name !== 'string' || !name.length) return '目标文件名不能为空' + if (name === '.' || name === '..' || path.basename(name) !== name) return '目标文件名无效' + if (name.includes('\0') || name.includes('/')) return '目标文件名包含非法字符' + if (process.platform === 'win32') { + if (/[<>:"\\|?*\x00-\x1f]/.test(name)) return '目标文件名包含 Windows 非法字符' + if (/[. ]$/.test(name)) return '目标文件名不能以空格或句点结尾' + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) return '目标文件名是 Windows 保留名称' + } + return '' +} + +async function statOrNull(filePath) { + try { return await fs.lstat(filePath) } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +async function prepareItem(intent) { + const id = typeof intent?.id === 'string' ? intent.id : '' + const sourcePath = intent?.sourcePath + const targetName = intent?.targetName + const item = { id, sourcePath, targetName, targetPath: '', status: 'error', error: '' } + if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) { + item.error = '源文件路径无效'; return item + } + item.error = nameError(targetName) + if (item.error) return item + item.targetPath = path.join(path.dirname(sourcePath), targetName) + try { + const source = await statOrNull(sourcePath) + if (!source || !source.isFile()) { + item.error = source ? '仅支持普通文件' : '源文件不存在'; return item + } + if (intent.expectedDev !== undefined && + (source.dev !== intent.expectedDev || source.ino !== intent.expectedIno)) { + item.error = '文件已被其他操作替换'; return item + } + } catch (error) { item.error = `无法读取源文件:${error.message}`; return item } + item.status = sourcePath === item.targetPath ? 'unchanged' : 'ready' + return item +} + +function markDuplicateTargets(items) { + const targetMap = new Map() + for (const item of items) { + if (item.status === 'error') continue + const key = pathKey(item.targetPath) + const previous = targetMap.get(key) + if (previous) { + previous.status = 'error'; previous.error = '同一目录的目标文件名重复' + item.status = 'error'; item.error = '同一目录的目标文件名重复' + } else targetMap.set(key, item) + } +} + +async function markOccupiedTargets(items) { + let changed + do { + changed = false + const moving = new Set(items.filter((item) => item.status === 'ready').map((item) => pathKey(item.sourcePath))) + for (const item of items) { + if (item.status !== 'ready' || moving.has(pathKey(item.targetPath))) continue + try { + if (await statOrNull(item.targetPath)) { + item.status = 'error'; item.error = '目标文件已存在'; changed = true + } + } catch (error) { + item.status = 'error'; item.error = `无法检查目标文件:${error.message}`; changed = true + } + } + } while (changed) +} + +async function validatePlan(intents) { + if (!Array.isArray(intents) || intents.length > 10000) throw new Error('改名计划无效') + const items = [] + for (let index = 0; index < intents.length; index += 64) { + items.push(...await Promise.all(intents.slice(index, index + 64).map(prepareItem))) + } + markDuplicateTargets(items) + await markOccupiedTargets(items) + return items +} + +function groupsFor(items) { + const ready = items.filter((item) => item.status === 'ready') + const bySource = new Map(ready.map((item) => [pathKey(item.sourcePath), item])) + const seen = new Set() + const groups = [] + for (const item of ready) { + if (seen.has(item)) continue + const group = [] + const queue = [item] + while (queue.length) { + const current = queue.pop() + if (seen.has(current)) continue + seen.add(current); group.push(current) + const next = bySource.get(pathKey(current.targetPath)) + if (next && !seen.has(next)) queue.push(next) + } + groups.push(group) + } + return groups +} + +async function tempPath(sourcePath) { + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = path.join(path.dirname(sourcePath), `.ztools-rename-${randomUUID()}.tmp`) + if (!await statOrNull(candidate)) return candidate + } + throw new Error('无法生成唯一临时文件名') +} + +async function locateFile(entry) { + for (const filePath of [entry.item.sourcePath, entry.item.targetPath, entry.temp]) { + if (!filePath) continue + const stats = await statOrNull(filePath).catch(() => null) + if (stats && stats.dev === entry.stats.dev && stats.ino === entry.stats.ino) return filePath + } + return entry.temp || entry.item.sourcePath +} + +async function rollback(entries) { + const failures = [] + for (const entry of entries) { + if (!entry.targetCreated) continue + try { + const stats = await statOrNull(entry.item.targetPath) + if (stats && stats.dev === entry.stats.dev && stats.ino === entry.stats.ino) { + await fs.unlink(entry.item.targetPath) + } else failures.push(`目标文件已变化:${entry.item.targetPath}`) + } catch (error) { failures.push(`无法清理目标文件:${error.message}`) } + } + for (const entry of entries) { + if (!entry.sourceRemoved) continue + try { await fs.link(entry.temp, entry.item.sourcePath) } catch (error) { + failures.push(`无法恢复原文件名:${error.message}`) + } + } + for (const entry of entries) { + try { + const restored = await statOrNull(entry.item.sourcePath) + if (restored && restored.dev === entry.stats.dev && restored.ino === entry.stats.ino) { + await fs.unlink(entry.temp) + } + } catch (error) { failures.push(`无法清理临时文件:${error.message}`) } + } + return failures +} + +async function runGroup(group) { + const entries = [] + try { + for (const item of group) { + const stats = await fs.lstat(item.sourcePath) + if (!stats.isFile()) throw new Error(`源文件不再是普通文件:${item.sourcePath}`) + const temp = await tempPath(item.sourcePath) + await fs.link(item.sourcePath, temp) + const linked = await fs.lstat(temp) + entries.push({ item, stats: linked, temp, sourceRemoved: false, targetCreated: false }) + if (stats.dev !== linked.dev || stats.ino !== linked.ino) throw new Error('源文件在准备期间发生变化') + } + for (const entry of entries) { + const current = await fs.lstat(entry.item.sourcePath) + if (current.dev !== entry.stats.dev || current.ino !== entry.stats.ino) { + throw new Error('源文件在执行期间发生变化') + } + await fs.unlink(entry.item.sourcePath) + entry.sourceRemoved = true + } + for (const entry of entries) { + await fs.link(entry.temp, entry.item.targetPath) + entry.targetCreated = true + } + const cleanupWarnings = [] + for (const entry of entries) { + try { await fs.unlink(entry.temp) } catch (error) { + cleanupWarnings.push(`临时文件未清理:${entry.temp}(${error.message})`) + } + } + return entries.map((entry) => ({ ...entry.item, status: 'success', + actualPath: entry.item.targetPath, dev: entry.stats.dev, ino: entry.stats.ino, + error: cleanupWarnings.join(';') })) + } catch (error) { + const failures = await rollback(entries) + const entryMap = new Map(entries.map((entry) => [entry.item.id, entry])) + const results = [] + for (const item of group) { + const entry = entryMap.get(item.id) + results.push({ ...item, status: 'error', error: [error.message, ...failures].join(';'), + actualPath: entry ? await locateFile(entry) : item.sourcePath }) + } + return results + } +} + +async function executePlan(intents) { + if (busy) throw new Error('已有文件操作正在执行') + busy = true + try { + const checked = await validatePlan(intents) + const results = checked.filter((item) => item.status !== 'ready').map((item) => ({ ...item, actualPath: item.sourcePath })) + for (const group of groupsFor(checked)) results.push(...await runGroup(group)) + const successes = results.filter((item) => item.status === 'success') + if (successes.length) lastTransaction = successes.map((item) => ({ + id: item.id, beforePath: item.sourcePath, afterPath: item.targetPath, + expectedDev: item.dev, expectedIno: item.ino + })) + return { results, canUndo: Boolean(lastTransaction) } + } finally { busy = false } +} + +async function undo() { + if (busy) throw new Error('已有文件操作正在执行') + if (!lastTransaction) return { results: [], canUndo: false } + const transaction = lastTransaction + lastTransaction = null + busy = true + try { + const intents = transaction.map((entry) => ({ + id: entry.id, sourcePath: entry.afterPath, targetName: path.basename(entry.beforePath), + expectedDev: entry.expectedDev, expectedIno: entry.expectedIno + })) + const checked = await validatePlan(intents) + const results = checked.filter((item) => item.status !== 'ready').map((item) => ({ ...item, actualPath: item.sourcePath })) + for (const group of groupsFor(checked)) results.push(...await runGroup(group)) + return { results, canUndo: false } + } finally { busy = false } +} + +function resetTask() { + if (busy) throw new Error('文件操作尚未结束') + lastTransaction = null +} + +module.exports = { validatePlan, executePlan, undo, resetTask } diff --git a/plugins/batch-rename/preload/metadata.js b/plugins/batch-rename/preload/metadata.js new file mode 100644 index 000000000..13aa0d419 --- /dev/null +++ b/plugins/batch-rename/preload/metadata.js @@ -0,0 +1,85 @@ +const fs = require('node:fs/promises') +const path = require('node:path') +const exifr = require('exifr') +const { imageSizeFromFile } = require('image-size/fromFile') + +const cache = new Map() +const CAPTURE_TAGS = ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized'] + +async function readMetadata(sourcePath, kind = 'all') { + const wanted = normalizeKind(kind) + if (!wanted) return { error: '元数据类型无效' } + if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) { + return { error: '文件路径必须是绝对路径' } + } + + let stats + try { + stats = await fs.stat(sourcePath) + if (!stats.isFile()) return { error: '仅支持文件' } + } catch (error) { + return { error: `读取文件信息失败:${messageOf(error)}` } + } + + const absolutePath = path.resolve(sourcePath) + const signature = `${stats.mtimeMs}:${stats.size}` + let entry = cache.get(absolutePath) + if (!entry || entry.signature !== signature) { + entry = { signature } + cache.set(absolutePath, entry) + } + + const result = {} + const errors = [] + if (wanted.includes('dimensions')) { + const outcome = await cachedRead(entry, 'dimensions', () => readDimensions(absolutePath)) + if (outcome.value) result.dimensions = outcome.value + if (outcome.error) errors.push(`尺寸读取失败:${outcome.error}`) + } + if (wanted.includes('capturedAt')) { + const outcome = await cachedRead(entry, 'capturedAt', () => readCapturedAt(absolutePath)) + if (outcome.value !== undefined) result.capturedAt = outcome.value + if (outcome.error) errors.push(`拍摄时间读取失败:${outcome.error}`) + } + if (errors.length) result.error = errors.join(';') + return result +} + +function normalizeKind(kind) { + if (kind === undefined || kind === 'all') return ['dimensions', 'capturedAt'] + if (kind === 'dimensions' || kind === 'capturedAt') return [kind] + return null +} + +async function cachedRead(entry, key, read) { + const taskKey = `${key}Task` + if (!entry[taskKey]) { + entry[taskKey] = read() + .then((value) => ({ value })) + .catch((error) => ({ error: messageOf(error) })) + } + return entry[taskKey] +} + +async function readDimensions(sourcePath) { + const { width, height } = await imageSizeFromFile(sourcePath) + if (!Number.isFinite(width) || !Number.isFinite(height)) throw new Error('图片尺寸无效') + return `${width}x${height}` +} + +async function readCapturedAt(sourcePath) { + const tags = await exifr.parse(sourcePath, { tiff: false, exif: CAPTURE_TAGS }) + const raw = tags && CAPTURE_TAGS.map((tag) => tags[tag]).find((value) => value !== undefined && value !== null) + if (raw === undefined) throw new Error('图片不含拍摄时间') + const date = raw instanceof Date ? raw : new Date(raw) + if (Number.isNaN(date.getTime())) throw new Error('拍摄时间格式无效') + return date.getTime() +} + +function messageOf(error) { + return error instanceof Error ? error.message : String(error) +} + +function clearMetadataCache() { cache.clear() } + +module.exports = { readMetadata, clearMetadataCache } diff --git a/plugins/batch-rename/preload/package.json b/plugins/batch-rename/preload/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/plugins/batch-rename/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/batch-rename/preload/scan.js b/plugins/batch-rename/preload/scan.js new file mode 100644 index 000000000..5df3f9068 --- /dev/null +++ b/plugins/batch-rename/preload/scan.js @@ -0,0 +1,67 @@ +const fs = require('node:fs/promises') +const path = require('node:path') + +const MAX_FILES = 10000 +const YIELD_EVERY = 64 + +async function scanDirectory(directoryPath) { + if (typeof directoryPath !== 'string' || !path.isAbsolute(directoryPath)) { + throw new TypeError('目录路径必须是绝对路径') + } + + const paths = [] + const errors = [] + const pending = [directoryPath] + let truncated = false + + while (pending.length && !truncated) { + const currentPath = pending.pop() + let directory + + try { + directory = await fs.opendir(currentPath) + } catch (error) { + errors.push({ path: currentPath, message: `读取目录失败:${errorMessage(error)}` }) + continue + } + + let processed = 0 + try { + for await (const entry of directory) { + const entryPath = path.join(currentPath, entry.name) + try { + // lstat 不跟随符号链接;junction 也作为链接跳过。 + const stats = await fs.lstat(entryPath) + if (stats.isSymbolicLink()) continue + if (stats.isDirectory()) { + pending.push(entryPath) + } else if (stats.isFile()) { + if (paths.length === MAX_FILES) { + errors.push({ path: currentPath, message: `文件数量超过上限 ${MAX_FILES},扫描结果已截断` }) + truncated = true + break + } + paths.push(entryPath) + } + } catch (error) { + errors.push({ path: entryPath, message: `读取条目失败:${errorMessage(error)}` }) + } + + processed += 1 + if (processed % YIELD_EVERY === 0) await new Promise((resolve) => setImmediate(resolve)) + } + } catch (error) { + errors.push({ path: currentPath, message: `读取目录失败:${errorMessage(error)}` }) + } finally { + await directory.close().catch(() => {}) + } + } + + return { paths, errors } +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +module.exports = { scanDirectory } diff --git a/plugins/batch-rename/preload/services.js b/plugins/batch-rename/preload/services.js new file mode 100644 index 000000000..c7ba96317 --- /dev/null +++ b/plugins/batch-rename/preload/services.js @@ -0,0 +1,49 @@ +const fs = require('node:fs/promises') +const path = require('node:path') +const { scanDirectory } = require('./scan') +const { readMetadata, clearMetadataCache } = require('./metadata') +const { validatePlan, executePlan, undo, resetTask } = require('./engine') + +async function readFileInfo(sourcePath) { + const name = typeof sourcePath === 'string' ? path.basename(sourcePath) : '' + if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) { + return { path: String(sourcePath), name, error: '文件路径无效' } + } + + try { + const stats = await fs.stat(sourcePath) + if (!stats.isFile()) return { path: sourcePath, name, error: '仅支持文件' } + return { + path: sourcePath, + name, + size: stats.size, + createdAt: stats.birthtimeMs, + modifiedAt: stats.mtimeMs + } + } catch (error) { + const message = error && error.code === 'ENOENT' + ? '源文件不存在' + : error && ['EACCES', 'EPERM'].includes(error.code) + ? '无权限读取文件' + : `读取文件信息失败:${error instanceof Error ? error.message : String(error)}` + return { path: sourcePath, name, error: message } + } +} + +window.renameService = { + platform: process.platform, + scanDirectory, + readMetadata, + validatePlan, + executePlan, + undo, + resetTask() { resetTask(); clearMetadataCache() }, + async readFileInfos(paths) { + if (!Array.isArray(paths) || paths.length > 10000) throw new Error('文件列表无效') + const results = [] + for (let index = 0; index < paths.length; index += 32) { + results.push(...await Promise.all(paths.slice(index, index + 32).map(readFileInfo))) + } + return results + } +} diff --git a/plugins/batch-rename/scripts/package-probe.ps1 b/plugins/batch-rename/scripts/package-probe.ps1 new file mode 100644 index 000000000..ea5253fc5 --- /dev/null +++ b/plugins/batch-rename/scripts/package-probe.ps1 @@ -0,0 +1,64 @@ +param( + [Parameter(Mandatory = $false)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +$distPath = Join-Path $projectRoot 'dist' +$manifestPath = Join-Path $distPath 'plugin.json' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw '请先运行 pnpm build,生成 dist/plugin.json。' +} + +$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $projectRoot (Join-Path 'outputs' "file-smart-rename-v$($manifest.version).zip") +} + +foreach ($relativePath in @($manifest.main, $manifest.preload, $manifest.logo)) { + if (-not (Test-Path -LiteralPath (Join-Path $distPath $relativePath))) { + throw "插件入口文件不存在:$relativePath" + } +} + +$destination = [System.IO.Path]::GetFullPath($OutputPath) +if (Test-Path -LiteralPath $destination) { + throw "安装包已存在,请先指定新的输出路径:$destination" +} + +$destinationDirectory = Split-Path -Parent $destination +New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$archive = [System.IO.Compression.ZipFile]::Open( + $destination, + [System.IO.Compression.ZipArchiveMode]::Create +) +try { + Get-ChildItem -LiteralPath $distPath -File -Recurse | ForEach-Object { + $entryName = [System.IO.Path]::GetRelativePath($distPath, $_.FullName).Replace('\', '/') + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, + $_.FullName, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } +} finally { + $archive.Dispose() +} + +$verification = [System.IO.Compression.ZipFile]::OpenRead($destination) +try { + foreach ($relativePath in @('plugin.json', $manifest.main, $manifest.preload, $manifest.logo)) { + if (-not $verification.GetEntry($relativePath.Replace('\', '/'))) { + throw "安装包缺少入口文件:$relativePath" + } + } +} finally { + $verification.Dispose() +} + +Write-Output $destination diff --git a/plugins/batch-rename/src/App.vue b/plugins/batch-rename/src/App.vue new file mode 100644 index 000000000..060c1b265 --- /dev/null +++ b/plugins/batch-rename/src/App.vue @@ -0,0 +1,421 @@ + + + diff --git a/plugins/batch-rename/src/components/ActionBar.vue b/plugins/batch-rename/src/components/ActionBar.vue new file mode 100644 index 000000000..730855ac3 --- /dev/null +++ b/plugins/batch-rename/src/components/ActionBar.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/plugins/batch-rename/src/components/FileList.vue b/plugins/batch-rename/src/components/FileList.vue new file mode 100644 index 000000000..15436a821 --- /dev/null +++ b/plugins/batch-rename/src/components/FileList.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/plugins/batch-rename/src/components/InsertPanel.vue b/plugins/batch-rename/src/components/InsertPanel.vue new file mode 100644 index 000000000..cba177acb --- /dev/null +++ b/plugins/batch-rename/src/components/InsertPanel.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/plugins/batch-rename/src/components/ManualPanel.vue b/plugins/batch-rename/src/components/ManualPanel.vue new file mode 100644 index 000000000..c792fd349 --- /dev/null +++ b/plugins/batch-rename/src/components/ManualPanel.vue @@ -0,0 +1,10 @@ + + + diff --git a/plugins/batch-rename/src/components/ModuleIcon.vue b/plugins/batch-rename/src/components/ModuleIcon.vue new file mode 100644 index 000000000..d2dfc2b56 --- /dev/null +++ b/plugins/batch-rename/src/components/ModuleIcon.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/plugins/batch-rename/src/components/NumberPanel.vue b/plugins/batch-rename/src/components/NumberPanel.vue new file mode 100644 index 000000000..734734563 --- /dev/null +++ b/plugins/batch-rename/src/components/NumberPanel.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/plugins/batch-rename/src/components/ReplacePanel.vue b/plugins/batch-rename/src/components/ReplacePanel.vue new file mode 100644 index 000000000..121cc681f --- /dev/null +++ b/plugins/batch-rename/src/components/ReplacePanel.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/plugins/batch-rename/src/components/SmartPanel.vue b/plugins/batch-rename/src/components/SmartPanel.vue new file mode 100644 index 000000000..bad638b52 --- /dev/null +++ b/plugins/batch-rename/src/components/SmartPanel.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/plugins/batch-rename/src/core/engine.test.ts b/plugins/batch-rename/src/core/engine.test.ts new file mode 100644 index 000000000..3b2062545 --- /dev/null +++ b/plugins/batch-rename/src/core/engine.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type Intent = { id: string; sourcePath: string; targetName: string } +type Result = { results: Array<{ id: string; status: string; error?: string; actualPath: string }>; canUndo: boolean } +const require = createRequire(import.meta.url) +const engine = require('../../preload/engine.js') as { + validatePlan: (items: Intent[]) => Promise> + executePlan: (items: Intent[]) => Promise + undo: () => Promise + resetTask: () => void +} +const directories: string[] = [] + +beforeEach(() => engine.resetTask()) +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function fixtures(names: Record) { + const directory = await mkdtemp(join(tmpdir(), 'batch-rename-engine-')) + directories.push(directory) + for (const [name, content] of Object.entries(names)) await writeFile(join(directory, name), content) + return directory +} + +function intent(directory: string, source: string, target: string): Intent { + return { id: source, sourcePath: join(directory, source), targetName: target } +} + +describe('安全改名与单级撤销', () => { + it('同目录两个文件指向同一个目标时均标错', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B' }) + const checked = await engine.validatePlan([ + intent(directory, 'A.txt', 'C.txt'), intent(directory, 'B.txt', 'C.txt') + ]) + expect(checked.map((item) => item.status)).toEqual(['error', 'error']) + }) + + it('两文件交换保持内容并可撤销', async () => { + const directory = await fixtures({ 'A.txt': 'A-content', 'B.txt': 'B-content' }) + const plan = [intent(directory, 'A.txt', 'B.txt'), intent(directory, 'B.txt', 'A.txt')] + expect((await engine.executePlan(plan)).results.map((item) => item.status)).toEqual(['success', 'success']) + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('B-content') + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('A-content') + expect((await engine.undo()).results.map((item) => item.status)).toEqual(['success', 'success']) + }) + + it('交换与三文件循环保持每个文件的原内容', async () => { + const directory = await fixtures({ 'A.txt': 'A-content', 'B.txt': 'B-content', 'C.txt': 'C-content' }) + const plan = [intent(directory, 'A.txt', 'B.txt'), intent(directory, 'B.txt', 'C.txt'), intent(directory, 'C.txt', 'A.txt')] + expect((await engine.validatePlan(plan)).map((item) => item.status)).toEqual(['ready', 'ready', 'ready']) + const outcome = await engine.executePlan(plan) + expect(outcome.results.map((item) => item.status)).toEqual(['success', 'success', 'success']) + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('A-content') + expect(await readFile(join(directory, 'C.txt'), 'utf8')).toBe('B-content') + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('C-content') + const undone = await engine.undo() + expect(undone.results.map((item) => item.status)).toEqual(['success', 'success', 'success']) + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('A-content') + expect(await engine.undo()).toMatchObject({ canUndo: false, results: [] }) + }) + + it('占用目标绝不覆盖,其他文件继续执行', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B', 'occupied.txt': 'keep' }) + const result = await engine.executePlan([ + intent(directory, 'A.txt', 'occupied.txt'), intent(directory, 'B.txt', 'renamed.txt') + ]) + expect(result.results.find((item) => item.id === 'A.txt')?.status).toBe('error') + expect(result.results.find((item) => item.id === 'B.txt')?.status).toBe('success') + expect(await readFile(join(directory, 'occupied.txt'), 'utf8')).toBe('keep') + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('A') + expect(await readFile(join(directory, 'renamed.txt'), 'utf8')).toBe('B') + expect((await readdir(directory)).some((name) => name.startsWith('.ztools-rename-'))).toBe(false) + }) + + it('四个文件中一个目标冲突时其余三个成功', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B', 'C.txt': 'C', 'D.txt': 'D', 'occupied.txt': 'keep' }) + const result = await engine.executePlan([ + intent(directory, 'A.txt', 'occupied.txt'), intent(directory, 'B.txt', 'B-new.txt'), + intent(directory, 'C.txt', 'C-new.txt'), intent(directory, 'D.txt', 'D-new.txt') + ]) + expect(result.results.filter((item) => item.status === 'success')).toHaveLength(3) + expect(result.results.filter((item) => item.status === 'error')).toHaveLength(1) + expect(await readFile(join(directory, 'occupied.txt'), 'utf8')).toBe('keep') + }) + + it('仅改变文件名大小写后仍能撤销', async () => { + const directory = await fixtures({ 'case.txt': 'same-content' }) + const result = await engine.executePlan([intent(directory, 'case.txt', 'CASE.txt')]) + expect(result.results[0].status).toBe('success') + expect(await readdir(directory)).toEqual(['CASE.txt']) + expect(await readFile(join(directory, 'CASE.txt'), 'utf8')).toBe('same-content') + const undone = await engine.undo() + expect(undone.results[0].status).toBe('success') + expect(await readdir(directory)).toEqual(['case.txt']) + }) + + it('被跳过的源文件继续占用其名称,关联项一起跳过', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B', 'occupied.txt': 'keep' }) + const plan = [intent(directory, 'A.txt', 'B.txt'), intent(directory, 'B.txt', 'occupied.txt')] + expect((await engine.validatePlan(plan)).map((item) => item.status)).toEqual(['error', 'error']) + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('A') + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('B') + }) + + it('关联组第二个目标写入失败时恢复整个交换', async () => { + const directory = await fixtures({ 'A.txt': 'A-content', 'B.txt': 'B-content' }) + const fsPromises = require('node:fs/promises') as typeof import('node:fs/promises') + const originalLink = fsPromises.link + let injected = false + vi.spyOn(fsPromises, 'link').mockImplementation(async (source, target) => { + if (!injected && String(source).includes('.ztools-rename-') && target === join(directory, 'A.txt')) { + injected = true + throw new Error('模拟目标写入失败') + } + return originalLink(source, target) + }) + const result = await engine.executePlan([intent(directory, 'A.txt', 'B.txt'), intent(directory, 'B.txt', 'A.txt')]) + expect(result.results.map((item) => item.status)).toEqual(['error', 'error']) + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('A-content') + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('B-content') + expect((await readdir(directory)).some((name) => name.startsWith('.ztools-rename-'))).toBe(false) + }) + + it('撤销时源文件被替换或旧名称被占用,逐项失败且不覆盖', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B' }) + await engine.executePlan([intent(directory, 'A.txt', 'X.txt'), intent(directory, 'B.txt', 'Y.txt')]) + await rm(join(directory, 'X.txt')) + await writeFile(join(directory, 'X.txt'), 'new X') + await writeFile(join(directory, 'B.txt'), 'external B') + const undone = await engine.undo() + expect(undone.results.filter((item) => item.status === 'error')).toHaveLength(2) + expect(await readFile(join(directory, 'X.txt'), 'utf8')).toBe('new X') + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('external B') + expect(await readFile(join(directory, 'Y.txt'), 'utf8')).toBe('B') + }) + + it('撤销其中一项失败时继续恢复其他独立文件', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B' }) + await engine.executePlan([intent(directory, 'A.txt', 'X.txt'), intent(directory, 'B.txt', 'Y.txt')]) + await rm(join(directory, 'X.txt')) + await writeFile(join(directory, 'X.txt'), 'replacement') + const undone = await engine.undo() + expect(undone.results.filter((item) => item.status === 'success')).toHaveLength(1) + expect(undone.results.filter((item) => item.status === 'error')).toHaveLength(1) + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('B') + expect(await readFile(join(directory, 'X.txt'), 'utf8')).toBe('replacement') + }) + + it('连续执行两次只撤销第二次', async () => { + const directory = await fixtures({ 'original.txt': 'content' }) + await engine.executePlan([intent(directory, 'original.txt', 'first.txt')]) + await engine.executePlan([intent(directory, 'first.txt', 'second.txt')]) + const result = await engine.undo() + expect(result.results[0].status).toBe('success') + expect(await readdir(directory)).toEqual(['first.txt']) + expect((await engine.undo()).results).toEqual([]) + }) + + it('四文件撤销中一项被人工换掉时其余三项恢复', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B', 'C.txt': 'C', 'D.txt': 'D' }) + const names = ['A', 'B', 'C', 'D'] + await engine.executePlan(names.map((name) => intent(directory, `${name}.txt`, `${name}-new.txt`))) + await rm(join(directory, 'A-new.txt')) + await writeFile(join(directory, 'A-new.txt'), 'replacement') + const result = await engine.undo() + expect(result.results.filter((item) => item.status === 'success')).toHaveLength(3) + expect(result.results.filter((item) => item.status === 'error')).toHaveLength(1) + for (const name of names.slice(1)) expect(await readFile(join(directory, `${name}.txt`), 'utf8')).toBe(name) + }) + + it('撤销旧名被外部文件占用时仍恢复另一项', async () => { + const directory = await fixtures({ 'A.txt': 'A', 'B.txt': 'B' }) + await engine.executePlan([intent(directory, 'A.txt', 'X.txt'), intent(directory, 'B.txt', 'Y.txt')]) + await writeFile(join(directory, 'A.txt'), 'external') + const result = await engine.undo() + expect(result.results.filter((item) => item.status === 'success')).toHaveLength(1) + expect(result.results.filter((item) => item.status === 'error')).toHaveLength(1) + expect(await readFile(join(directory, 'A.txt'), 'utf8')).toBe('external') + expect(await readFile(join(directory, 'B.txt'), 'utf8')).toBe('B') + }) +}) diff --git a/plugins/batch-rename/src/core/insert.test.ts b/plugins/batch-rename/src/core/insert.test.ts new file mode 100644 index 000000000..5ec91a841 --- /dev/null +++ b/plugins/batch-rename/src/core/insert.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { FileItem, InsertRule } from './session' +import { applyInsertRule } from './insert' + +const file = (currentName: string, extras: Partial = {}): FileItem => { + const extensionIndex = currentName.lastIndexOf('.') + const hasExtension = extensionIndex > 0 + return { + id: currentName, + sourcePath: `/temp/${currentName}`, + directory: '/temp', + currentName, + baseName: hasExtension ? currentName.slice(0, extensionIndex) : currentName, + extension: hasExtension ? currentName.slice(extensionIndex) : '', + ...extras + } +} + +const rule = (patch: Partial): InsertRule => ({ + kind: 'text', position: 'start', index: 1, text: 'Final_', + number: { prefix: '', start: 1, style: 'arabic', digits: 0, suffix: '' }, + info: 'modified', prefix: '', suffix: '', ...patch +}) + +describe('插入内容规则', () => { + it('TC-10:在文件名前插入固定文本并保留扩展名', () => { + expect(applyInsertRule(file('video.mp4'), 0, rule({}))).toEqual({ name: 'Final_video.mp4' }) + }) + + it('TC-11:在扩展名前插入固定文本', () => { + expect(applyInsertRule(file('video.mp4'), 0, rule({ position: 'end', text: '_final' }))) + .toEqual({ name: 'video_final.mp4' }) + }) + + it('TC-12:指定位置按 1-based 字符边界插入', () => { + expect(applyInsertRule(file('video.mp4'), 0, rule({ position: 'index', index: 3, text: '_' }))) + .toEqual({ name: 'vi_deo.mp4' }) + expect(applyInsertRule(file('ab.txt'), 0, rule({ position: 'index', index: 99, text: '!' }))) + .toEqual({ name: 'ab!.txt' }) + }) + + it('TC-13:序号使用传入的当前排序索引', () => { + const files = [file('b.jpg'), file('a.jpg')].sort((left, right) => left.currentName.localeCompare(right.currentName)) + const numbering = rule({ + kind: 'number', position: 'end', + number: { prefix: '-', start: 1, style: 'arabic', digits: 2, suffix: '' } + }) + expect(files.map((item, index) => applyInsertRule(item, index, numbering).name)) + .toEqual(['a-01.jpg', 'b-02.jpg']) + }) + + it('TC-14:文件修改时间使用安全格式', () => { + const timestamp = new Date(2024, 0, 2, 3, 4, 5).getTime() + expect(applyInsertRule(file('video.mp4', { modifiedAt: timestamp }), 0, + rule({ kind: 'info', position: 'start', info: 'modified', prefix: '[', suffix: ']' }))) + .toEqual({ name: '[2024-01-02_03-04-05]video.mp4' }) + }) + + it('支持创建时间、大小、图片尺寸及拍摄时间', () => { + const timestamp = new Date(2024, 0, 2, 3, 4, 5).getTime() + const source = file('photo.jpg', { + createdAt: timestamp, size: 4.6 * 1024 * 1024, + metadata: { dimensions: '1920 × 1080', capturedAt: timestamp } + }) + const infoRule = (info: InsertRule['info']) => rule({ kind: 'info', position: 'end', info }) + expect(applyInsertRule(source, 0, infoRule('created')).name).toBe('photo2024-01-02_03-04-05.jpg') + expect(applyInsertRule(source, 0, infoRule('size')).name).toBe('photo4.6MB.jpg') + expect(applyInsertRule(source, 0, infoRule('dimensions')).name).toBe('photo1920x1080.jpg') + expect(applyInsertRule(source, 0, infoRule('captured')).name).toBe('photo2024-01-02_03-04-05.jpg') + }) + + it('TC-15~16:缺失信息返回可显示的错误', () => { + const missingExif = applyInsertRule(file('plain.png'), 0, rule({ kind: 'info', info: 'captured' })) + expect(missingExif).toEqual({ name: 'plain.png', error: '无法读取照片拍摄时间' }) + expect(applyInsertRule(file('plain.png'), 0, rule({ kind: 'info', info: 'dimensions' })).error) + .toBe('无法读取图片尺寸') + expect(applyInsertRule(file('plain.png'), 0, rule({ kind: 'info', info: 'modified' })).error) + .toBe('无法读取文件修改时间') + }) + + it('拒绝无效的指定位置', () => { + expect(applyInsertRule(file('a.txt'), 0, rule({ position: 'index', index: 0 }))) + .toEqual({ name: 'a.txt', error: '指定位置必须是正整数' }) + }) +}) diff --git a/plugins/batch-rename/src/core/insert.ts b/plugins/batch-rename/src/core/insert.ts new file mode 100644 index 000000000..14f4ae23a --- /dev/null +++ b/plugins/batch-rename/src/core/insert.ts @@ -0,0 +1,72 @@ +import type { FileItem, InsertRule } from './session' +import { formatSequence } from './number' + +export function applyInsertRule(file: FileItem, index: number, rule: InsertRule): { name: string; error?: string } { + const content = createContent(file, index, rule) + if (content.error) return { name: file.currentName, error: content.error } + + const points = Array.from(file.baseName) + let position = 0 + if (rule.position === 'end') { + position = points.length + } else if (rule.position === 'index') { + if (!Number.isInteger(rule.index) || rule.index < 1) { + return { name: file.currentName, error: '指定位置必须是正整数' } + } + position = Math.min(rule.index - 1, points.length) + } + + points.splice(position, 0, content.value) + return { name: `${points.join('')}${file.extension}` } +} + +function createContent(file: FileItem, index: number, rule: InsertRule): { value: string; error?: string } { + if (rule.kind === 'text') return { value: rule.text } + if (rule.kind === 'number') { + const sequence = formatSequence(rule.number.start + index, rule.number.style, rule.number.digits) + return { value: `${rule.number.prefix}${sequence}${rule.number.suffix}` } + } + + const info = readFileInfo(file, rule.info) + if (info.error) return info + return { value: `${rule.prefix}${info.value}${rule.suffix}` } +} + +function readFileInfo(file: FileItem, info: InsertRule['info']): { value: string; error?: string } { + if (info === 'created') return formatDate(file.createdAt, '无法读取文件创建时间') + if (info === 'modified') return formatDate(file.modifiedAt, '无法读取文件修改时间') + if (info === 'captured') return formatDate(file.metadata?.capturedAt, '无法读取照片拍摄时间') + if (info === 'size') return formatSize(file.size) + return formatDimensions(file.metadata?.dimensions) +} + +function formatDate(value: number | undefined, error: string): { value: string; error?: string } { + if (value === undefined || !Number.isFinite(value)) return { value: '', error } + const date = new Date(value) + if (Number.isNaN(date.getTime())) return { value: '', error } + const pad = (part: number) => String(part).padStart(2, '0') + return { + value: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}` + } +} + +function formatSize(value: number | undefined): { value: string; error?: string } { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return { value: '', error: '无法读取文件大小' } + } + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let amount = value + let unit = 0 + while (amount >= 1024 && unit < units.length - 1) { + amount /= 1024 + unit += 1 + } + const formatted = unit === 0 ? String(Math.round(amount)) : amount.toFixed(1).replace(/\.0$/, '') + return { value: `${formatted}${units[unit]}` } +} + +function formatDimensions(value: string | undefined): { value: string; error?: string } { + const match = value?.match(/^\s*(\d+)\s*(?:x|×)\s*(\d+)\s*$/i) + if (!match) return { value: '', error: '无法读取图片尺寸' } + return { value: `${match[1]}x${match[2]}` } +} diff --git a/plugins/batch-rename/src/core/manual.test.ts b/plugins/batch-rename/src/core/manual.test.ts new file mode 100644 index 000000000..8c35d3fad --- /dev/null +++ b/plugins/batch-rename/src/core/manual.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { applyManualPaste, getManualTargetName } from './manual' +import type { FileItem } from './session' + +const file = (id: string, currentName = `${id}.txt`): FileItem => ({ + id, sourcePath: currentName, directory: '', currentName, baseName: currentName, extension: '' +}) + +describe('manual naming', () => { + it('reads edits by stable file ID and falls back to the current name (TC-17)', () => { + const first = file('first') + expect(getManualTargetName(first, { first: 'renamed.txt' })).toBe('renamed.txt') + expect(getManualTargetName(first, {})).toBe('first.txt') + expect(getManualTargetName(first, { first: '' })).toBe('') + }) + + it('pastes clipboard rows by current sorted order and normalizes Windows newlines (TC-18)', () => { + const files = [file('b'), file('a')] + expect(applyManualPaste(files, {}, '乙\r\n甲')).toEqual({ + names: { b: '乙', a: '甲' }, ignoredCount: 0 + }) + expect(applyManualPaste([file('a'), file('b'), file('c')], {}, '甲\r\n乙\r\n')).toEqual({ + names: { a: '甲', b: '乙' }, ignoredCount: 0 + }) + }) + + it('keeps existing values after the pasted rows and preserves blank row positions (TC-19, TC-21)', () => { + const files = [file('a'), file('b'), file('c')] + expect(applyManualPaste(files, { a: 'old-a', b: 'old-b', c: 'old-c' }, '新甲\n\n新丙')).toEqual({ + names: { a: '新甲', b: '', c: '新丙' }, ignoredCount: 0 + }) + expect(applyManualPaste(files, { a: 'old-a', b: 'old-b', c: 'old-c' }, '新甲')).toEqual({ + names: { a: '新甲', b: 'old-b', c: 'old-c' }, ignoredCount: 0 + }) + }) + + it('ignores rows beyond the file count and reports how many were ignored (TC-20)', () => { + const files = [file('a'), file('b')] + expect(applyManualPaste(files, {}, '甲\n乙\n丙\n丁')).toEqual({ + names: { a: '甲', b: '乙' }, ignoredCount: 2 + }) + }) +}) diff --git a/plugins/batch-rename/src/core/manual.ts b/plugins/batch-rename/src/core/manual.ts new file mode 100644 index 000000000..f4c6b36bd --- /dev/null +++ b/plugins/batch-rename/src/core/manual.ts @@ -0,0 +1,22 @@ +import type { FileItem } from './session' + +export type ManualPasteResult = { names: Record; ignoredCount: number } + +export function getManualTargetName(file: FileItem, names: Record): string { + return names[file.id] ?? file.currentName +} + +export function applyManualPaste( + sortedFiles: FileItem[], + names: Record, + pastedText: string +): ManualPasteResult { + const normalized = pastedText.replaceAll('\r\n', '\n').replaceAll('\r', '\n') + const lines = (normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized).split('\n') + const nextNames = { ...names } + const appliedCount = Math.min(lines.length, sortedFiles.length) + for (let index = 0; index < appliedCount; index += 1) { + nextNames[sortedFiles[index].id] = lines[index] + } + return { names: nextNames, ignoredCount: Math.max(0, lines.length - sortedFiles.length) } +} diff --git a/plugins/batch-rename/src/core/metadata.test.ts b/plugins/batch-rename/src/core/metadata.test.ts new file mode 100644 index 000000000..9c142600f --- /dev/null +++ b/plugins/batch-rename/src/core/metadata.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile, rm, writeFile, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it } from 'vitest' + +type MetadataResult = { dimensions?: string; capturedAt?: number; error?: string } +const require = createRequire(import.meta.url) +const { readMetadata } = require('../../preload/metadata.js') as { + readMetadata: (filePath: string, kind?: 'all' | 'dimensions' | 'capturedAt') => Promise +} +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function createTemporaryDirectory() { + const directory = await mkdtemp(join(tmpdir(), 'batch-rename-metadata-')) + temporaryDirectories.push(directory) + return directory +} + +function jpegWithCaptureTime(dateText: string) { + const date = Buffer.from(`${dateText}\0`, 'ascii') + const tiff = Buffer.alloc(8 + 18 + 18 + date.length) + tiff.write('II', 0, 'ascii') + tiff.writeUInt16LE(42, 2) + tiff.writeUInt32LE(8, 4) + tiff.writeUInt16LE(1, 8) + tiff.writeUInt16LE(0x8769, 10) + tiff.writeUInt16LE(4, 12) + tiff.writeUInt32LE(1, 14) + tiff.writeUInt32LE(26, 18) + tiff.writeUInt32LE(0, 22) + tiff.writeUInt16LE(1, 26) + tiff.writeUInt16LE(0x9003, 28) + tiff.writeUInt16LE(2, 30) + tiff.writeUInt32LE(date.length, 32) + tiff.writeUInt32LE(44, 36) + tiff.writeUInt32LE(0, 40) + date.copy(tiff, 44) + + const payload = Buffer.concat([Buffer.from('Exif\0\0', 'ascii'), tiff]) + const segment = Buffer.alloc(4) + segment.writeUInt16BE(0xffe1, 0) + segment.writeUInt16BE(payload.length + 2, 2) + return Buffer.concat([Buffer.from([0xff, 0xd8]), segment, payload, Buffer.from([0xff, 0xd9])]) +} + +describe('preload 图片元数据读取', () => { + it('读取真实 PNG 尺寸,并逐项报告缺少 EXIF 拍摄时间', async () => { + const directory = await createTemporaryDirectory() + const imagePath = join(directory, 'pixel.png') + const onePixelPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j6WQAAAAASUVORK5CYII=', + 'base64' + ) + await writeFile(imagePath, onePixelPng) + + const result = await readMetadata(imagePath) + + expect(await readFile(imagePath)).toEqual(onePixelPng) + expect(result.dimensions).toBe('1x1') + expect(result.capturedAt).toBeUndefined() + expect(result.error).toContain('图片不含拍摄时间') + }) + + it('解析 EXIF 原始拍摄时间,并按文件修改时间和大小缓存', async () => { + const directory = await createTemporaryDirectory() + const imagePath = join(directory, 'capture.jpg') + await writeFile(imagePath, jpegWithCaptureTime('2024:01:02 03:04:05')) + + const result = await readMetadata(imagePath, 'capturedAt') + expect(result.capturedAt).toBe(new Date('2024-01-02T03:04:05').getTime()) + expect(result.error).toBeUndefined() + + await writeFile(imagePath, jpegWithCaptureTime('2025:06:07 08:09:10')) + await utimes(imagePath, new Date('2025-06-07T08:09:10'), new Date('2025-06-07T08:09:10')) + const updated = await readMetadata(imagePath, 'capturedAt') + expect(updated.capturedAt).toBe(new Date('2025-06-07T08:09:10').getTime()) + }) + + it('相对路径返回明确错误', async () => { + expect(await readMetadata('relative.jpg')).toEqual({ error: '文件路径必须是绝对路径' }) + }) +}) diff --git a/plugins/batch-rename/src/core/number.test.ts b/plugins/batch-rename/src/core/number.test.ts new file mode 100644 index 000000000..5988bb650 --- /dev/null +++ b/plugins/batch-rename/src/core/number.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { applyNumberRule, formatSequence } from './number' +import type { FileItem, NumberRule, SequenceStyle } from './session' + +function file(extension = '.txt'): FileItem { + return { + id: 'id', sourcePath: `C:\\files\\name${extension}`, directory: 'C:\\files', + currentName: `name${extension}`, baseName: 'name', extension + } +} + +describe('formatSequence', () => { + it.each([ + ['arabic', 3, '003'], + ['chinese-lower', 12, '十二'], + ['chinese-upper', 12, '壹拾贰'], + ['alpha-lower', 27, 'aa'], + ['alpha-upper', 27, 'AA'] + ] as [SequenceStyle, number, string][])('formats %s sequences', (style, value, expected) => { + expect(formatSequence(value, style, 3)).toBe(expected) + }) + + it('applies padding only to Arabic numbers', () => { + expect(formatSequence(1, 'arabic', 4)).toBe('0001') + expect(formatSequence(1, 'alpha-lower', 4)).toBe('a') + }) +}) + +describe('applyNumberRule', () => { + it('combines prefix, sequence, suffix, and original extension', () => { + const rule: NumberRule = { prefix: '测试', start: 1, style: 'arabic', digits: 3, suffix: '成片' } + expect(applyNumberRule(file('.mp4'), 0, rule)).toBe('测试001成片.mp4') + expect(applyNumberRule(file('.mp4'), 1, rule)).toBe('测试002成片.mp4') + }) +}) diff --git a/plugins/batch-rename/src/core/number.ts b/plugins/batch-rename/src/core/number.ts new file mode 100644 index 000000000..81eca14cf --- /dev/null +++ b/plugins/batch-rename/src/core/number.ts @@ -0,0 +1,82 @@ +import type { FileItem, NumberRule, SequenceStyle } from './session' + +const chineseDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'] +const financialDigits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] +const chineseUnits = ['', '十', '百', '千'] +const financialUnits = ['', '拾', '佰', '仟'] + +function formatChinese(value: number, financial: boolean): string { + if (!Number.isFinite(value)) return String(value) + const integer = Math.trunc(value) + if (integer < 0) return `负${formatChinese(-integer, financial)}` + if (integer === 0) return '零' + + const digits = financial ? financialDigits : chineseDigits + const units = financial ? financialUnits : chineseUnits + const groupUnits = ['', '万', '亿', '兆'] + const groups: { value: number; text: string; position: number }[] = [] + let remaining = integer + let position = 0 + while (remaining > 0) { + const group = remaining % 10000 + let part = '' + let zeroPending = false + for (let digitPosition = 0; digitPosition < 4; digitPosition += 1) { + const digit = Math.floor(group / (10 ** digitPosition)) % 10 + if (!digit) { + if (part && group % (10 ** (digitPosition + 1)) !== 0) zeroPending = true + continue + } + if (zeroPending) part = digits[0] + part + zeroPending = false + let unit = units[digitPosition] + const omitLeadingOne = !financial && digitPosition === 1 && digit === 1 && group < 20 + if (omitLeadingOne) unit = '十' + part = `${omitLeadingOne ? '' : digits[digit]}${unit}${part}` + } + if (part) groups.unshift({ value: group, text: part, position }) + remaining = Math.floor(remaining / 10000) + position += 1 + } + let result = '' + let previousPosition = -1 + for (const group of groups) { + if (result && (group.position < previousPosition - 1 || group.value < 1000)) result += digits[0] + result += group.text + (groupUnits[group.position] ?? '') + previousPosition = group.position + } + return result +} + +function formatAlpha(value: number): string { + if (!Number.isFinite(value)) return String(value) + let remaining = Math.trunc(value) + if (remaining < 1) return String(remaining) + let result = '' + while (remaining > 0) { + remaining -= 1 + result = String.fromCharCode(97 + remaining % 26) + result + remaining = Math.floor(remaining / 26) + } + return result +} + +export function formatSequence(value: number, style: SequenceStyle, digits: number): string { + switch (style) { + case 'arabic': + return String(Math.trunc(value)).padStart(Math.max(0, Math.trunc(digits)), '0') + case 'chinese-lower': + return formatChinese(value, false) + case 'chinese-upper': + return formatChinese(value, true) + case 'alpha-lower': + return formatAlpha(value) + case 'alpha-upper': + return formatAlpha(value).toUpperCase() + } +} + +export function applyNumberRule(file: FileItem, index: number, rule: NumberRule): string { + const sequence = formatSequence(rule.start + index, rule.style, rule.digits) + return `${rule.prefix}${sequence}${rule.suffix}${file.extension}` +} diff --git a/plugins/batch-rename/src/core/preload.test.ts b/plugins/batch-rename/src/core/preload.test.ts new file mode 100644 index 000000000..851b8c08b --- /dev/null +++ b/plugins/batch-rename/src/core/preload.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { FileInfo } from './session' + +type RenameService = { readFileInfos: (paths: string[]) => Promise } +const require = createRequire(import.meta.url) +const temporaryDirectories: string[] = [] + +afterEach(async () => { + vi.unstubAllGlobals() + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe('preload 文件信息接口', () => { + it('逐项返回元数据和错误,且不修改文件内容或名称', async () => { + const directory = await mkdtemp(join(tmpdir(), 'batch-rename-')) + temporaryDirectories.push(directory) + const sourcePath = join(directory, 'sample.txt') + await writeFile(sourcePath, 'test content') + + vi.stubGlobal('window', {}) + require('../../preload/services.js') + const service = (globalThis.window as Window).renameService as RenameService + const result = await service.readFileInfos([ + sourcePath, + join(directory, 'missing.txt'), + directory, + 'relative.txt' + ]) + + expect(result[0]).toMatchObject({ path: sourcePath, name: 'sample.txt', size: 12 }) + expect(result[1].error).toBe('源文件不存在') + expect(result[2].error).toBe('仅支持文件') + expect(result[3].error).toBe('文件路径无效') + expect(await readFile(sourcePath, 'utf8')).toBe('test content') + }) +}) diff --git a/plugins/batch-rename/src/core/preview.ts b/plugins/batch-rename/src/core/preview.ts new file mode 100644 index 000000000..52d183ef4 --- /dev/null +++ b/plugins/batch-rename/src/core/preview.ts @@ -0,0 +1,29 @@ +import type { FileItem, RenameSession } from './session' +import { sortFiles } from './sort' +import { applyReplaceRules } from './replace' +import { applyNumberRule } from './number' +import { getManualTargetName } from './manual' +import { applyInsertRule } from './insert' + +export type PreviewItem = FileItem & { + targetName: string + status: 'unchanged' | 'ready' | 'error' | 'success' + errorMessage?: string +} + +export function createPreview(session: RenameSession): PreviewItem[] { + return sortFiles(session.files, session.sortMode).map((file, index) => { + let targetName = file.currentName + let errorMessage = file.error + if (session.activeModule === 'replace') targetName = applyReplaceRules(file, session.replaceRules) + if (session.activeModule === 'number') targetName = applyNumberRule(file, index, session.numberRule) + if (session.activeModule === 'manual') targetName = getManualTargetName(file, session.manualNames) + if (session.activeModule === 'smart') targetName = session.smartNames[file.id] ?? file.currentName + if (session.activeModule === 'insert') { + const result = applyInsertRule(file, index, session.insertRule) + targetName = result.name + errorMessage ||= result.error + } + return { ...file, targetName, status: errorMessage ? 'error' : targetName === file.currentName ? 'unchanged' : 'ready', errorMessage } + }) +} diff --git a/plugins/batch-rename/src/core/replace.test.ts b/plugins/batch-rename/src/core/replace.test.ts new file mode 100644 index 000000000..2b5477146 --- /dev/null +++ b/plugins/batch-rename/src/core/replace.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { applyReplaceRules } from './replace' +import type { FileItem, ReplaceRule } from './session' + +const file = (currentName: string): FileItem => ({ + id: currentName, + sourcePath: currentName, + directory: '', + currentName, + baseName: currentName, + extension: '' +}) + +const rule = (partial: Partial & Pick): ReplaceRule => ({ + id: 'rule', find: '', replacement: '', start: 1, count: 1, includeExtension: false, ...partial +}) + +describe('applyReplaceRules', () => { + it('replaces every match and allows empty replacement to delete text (TC-01, TC-02)', () => { + expect(applyReplaceRules(file('abc_test.mp4'), [rule({ mode: 'text', find: 'abc', replacement: 'new' })])).toBe('new_test.mp4') + expect(applyReplaceRules(file('abc_test.mp4'), [rule({ mode: 'text', find: 'abc_', replacement: '' })])).toBe('test.mp4') + }) + + it('applies rules in order (TC-03)', () => { + expect(applyReplaceRules(file('AAA.txt'), [ + rule({ mode: 'text', find: 'A', replacement: 'B' }), + rule({ mode: 'text', find: 'B', replacement: 'C' }) + ])).toBe('CCC.txt') + }) + + it('protects the extension by default and includes it when enabled (TC-04, TC-05)', () => { + expect(applyReplaceRules(file('clip.mp4'), [rule({ mode: 'text', find: '4', replacement: '5' })])).toBe('clip.mp4') + expect(applyReplaceRules(file('clip.mp4'), [rule({ mode: 'text', find: '4', replacement: '5', includeExtension: true })])).toBe('clip.mp5') + }) + + it('supports first, last, range and before/after delimiter ranges with one-based positions', () => { + const apply = (mode: ReplaceRule['mode'], overrides: Partial = {}) => + applyReplaceRules(file('abcdef.txt'), [rule({ mode, replacement: 'X', ...overrides })]) + expect(apply('first', { count: 2 })).toBe('Xcdef.txt') + expect(apply('last', { count: 2 })).toBe('abcdX.txt') + expect(apply('range', { start: 2, count: 3 })).toBe('aXef.txt') + expect(apply('after', { find: 'c' })).toBe('abcX.txt') + expect(apply('before', { find: 'c' })).toBe('Xcdef.txt') + expect(apply('after-n', { find: 'c', count: 2 })).toBe('abcXf.txt') + expect(apply('before-n', { find: 'd', count: 2 })).toBe('aXdef.txt') + }) + + it('leaves the name unchanged for empty or invalid rules', () => { + expect(applyReplaceRules(file('name.txt'), [rule({ mode: 'text' })])).toBe('name.txt') + expect(applyReplaceRules(file('name.txt'), [rule({ mode: 'range', start: 0, count: 2, replacement: 'X' })])).toBe('name.txt') + expect(applyReplaceRules(file('name.txt'), [rule({ mode: 'after-n', find: 'n', count: 50, replacement: 'X' })])).toBe('name.txt') + expect(applyReplaceRules(file('name.txt'), [rule({ mode: 'after', find: 'missing', replacement: 'X' })])).toBe('name.txt') + }) +}) diff --git a/plugins/batch-rename/src/core/replace.ts b/plugins/batch-rename/src/core/replace.ts new file mode 100644 index 000000000..5d49ecd20 --- /dev/null +++ b/plugins/batch-rename/src/core/replace.ts @@ -0,0 +1,59 @@ +import type { FileItem, ReplaceRule } from './session' + +type Segment = { start: number; end: number } + +function selectedRange(value: string, rule: ReplaceRule): Segment | null { + const { mode, find, start, count } = rule + if (mode === 'first' || mode === 'last') { + if (!Number.isInteger(count) || count < 1 || count > value.length) return null + return mode === 'first' + ? { start: 0, end: count } + : { start: value.length - count, end: value.length } + } + if (mode === 'range') { + if (!Number.isInteger(start) || start < 1 || !Number.isInteger(count) || count < 1) return null + const offset = start - 1 + return offset + count <= value.length ? { start: offset, end: offset + count } : null + } + if (!find) return null + const at = value.indexOf(find) + if (at < 0) return null + if (mode === 'after') return { start: at + find.length, end: value.length } + if (mode === 'before') return { start: 0, end: at } + if (!Number.isInteger(count) || count < 1) return null + if (mode === 'after-n') { + const from = at + find.length + return from + count <= value.length ? { start: from, end: from + count } : null + } + if (mode === 'before-n') return at >= count ? { start: at - count, end: at } : null + return null +} + +function applyRule(name: string, rule: ReplaceRule): string { + if (rule.mode === 'text') { + if (!rule.find) return name + return name.replaceAll(rule.find, rule.replacement) + } + const range = selectedRange(name, rule) + if (!range) return name + return name.slice(0, range.start) + rule.replacement + name.slice(range.end) +} + +function splitExtension(name: string): { base: string; extension: string } { + const dot = name.lastIndexOf('.') + if (dot <= 0) return { base: name, extension: '' } + return { base: name.slice(0, dot), extension: name.slice(dot) } +} + +export function applyReplaceRules(file: FileItem, rules: ReplaceRule[]): string { + let name = file.currentName + for (const rule of rules) { + if (rule.includeExtension) { + name = applyRule(name, rule) + continue + } + const { base, extension } = splitExtension(name) + name = applyRule(base, rule) + extension + } + return name +} diff --git a/plugins/batch-rename/src/core/scan.test.ts b/plugins/batch-rename/src/core/scan.test.ts new file mode 100644 index 000000000..d28420895 --- /dev/null +++ b/plugins/batch-rename/src/core/scan.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it } from 'vitest' + +type ScanResult = { paths: string[]; errors: Array<{ path: string; message: string }> } +const require = createRequire(import.meta.url) +const { scanDirectory } = require('../../preload/scan.js') as { + scanDirectory: (directoryPath: string) => Promise +} +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function createTemporaryDirectory() { + const directory = await mkdtemp(join(tmpdir(), 'batch-rename-scan-')) + temporaryDirectories.push(directory) + return directory +} + +describe('preload 目录扫描', () => { + it('递归收集普通文件,不把目录作为结果', async () => { + const root = await createTemporaryDirectory() + const nested = join(root, 'nested') + await mkdir(nested) + await writeFile(join(root, 'root.txt'), 'root') + await writeFile(join(nested, 'child.txt'), 'child') + + const result = await scanDirectory(root) + + expect(result.paths.sort()).toEqual([join(root, 'root.txt'), join(nested, 'child.txt')].sort()) + expect(result.errors).toEqual([]) + expect(result.paths.some((filePath) => filePath === nested)).toBe(false) + }) + + it('跳过符号链接目录,不递归到链接目标', async () => { + const root = await createTemporaryDirectory() + const target = await createTemporaryDirectory() + await writeFile(join(target, 'outside.txt'), 'outside') + try { + await symlink(target, join(root, 'linked'), 'junction') + } catch (error) { + if (process.platform === 'win32') throw error + await symlink(target, join(root, 'linked'), 'dir') + } + + const result = await scanDirectory(root) + + expect(result.paths).toEqual([]) + expect(result.errors).toEqual([]) + expect((await readdir(target))).toEqual(['outside.txt']) + }) + + it('拒绝相对路径', async () => { + await expect(scanDirectory('relative/path')).rejects.toThrow('目录路径必须是绝对路径') + }) + + it('超过 10000 个文件时返回截断错误', async () => { + const root = await createTemporaryDirectory() + for (let index = 0; index <= 10000; index += 1) { + await writeFile(join(root, `file-${index}.txt`), '') + } + + const result = await scanDirectory(root) + + expect(result.paths).toHaveLength(10000) + expect(result.errors).toContainEqual(expect.objectContaining({ + path: root, + message: expect.stringContaining('文件数量超过上限 10000') + })) + }, 30000) +}) diff --git a/plugins/batch-rename/src/core/session.test.ts b/plugins/batch-rename/src/core/session.test.ts new file mode 100644 index 000000000..8797cd373 --- /dev/null +++ b/plugins/batch-rename/src/core/session.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { createPreview } from './preview' +import { + clearFiles, + applyBatchResults, + createSession, + extractFilePaths, + removeFile, + replaceTask, + type FileInfo +} from './session' + +const file = (path: string, error?: string): FileInfo => ({ + path, + name: path.split(/[\\/]/).at(-1) || '', + error +}) + +describe('文件入口与会话', () => { + it('仅处理文件入口,并校验、去重绝对路径', () => { + expect(extractFilePaths({ code: 'batch-rename', payload: '' }, 'win32')).toBeNull() + expect(extractFilePaths({ code: 'batch-rename-files', payload: [ + { path: 'C:\\Test\\A.txt', isFile: true }, + { path: 'c:/test/a.txt', isFile: true }, + { path: 'relative.txt', isFile: true }, + { path: 'C:\\Test\\folder', isDirectory: true }, + null + ] }, 'win32')).toEqual(['C:\\Test\\A.txt']) + expect(extractFilePaths({ code: 'batch-rename-files', payload: 'bad' }, 'win32')).toEqual([]) + }) + + it('新任务恢复默认状态;普通入口不产生替换任务', () => { + const old = replaceTask([file('C:\\Old\\old.txt')], 'win32') + const next = replaceTask([file('C:\\New\\new.txt')], 'win32') + expect(old.files[0].currentName).toBe('old.txt') + expect(next.files.map((item) => item.currentName)).toEqual(['new.txt']) + expect(next.sortMode).toBe('name-asc') + expect(next.activeModule).toBe('replace') + expect(extractFilePaths({ code: 'batch-rename' }, 'win32')).toBeNull() + }) + + it('移除和清空只影响会话', () => { + const session = replaceTask([file('C:\\A\\a.txt'), file('C:\\A\\b.txt')], 'win32') + const reduced = removeFile(session, session.files[0].id) + expect(reduced.files.map((item) => item.currentName)).toEqual(['b.txt']) + expect(clearFiles(reduced).files).toEqual([]) + expect(session.files).toHaveLength(2) + expect(createSession().files).toEqual([]) + }) + + it('执行结果更新名称,但保留当前模块、排序和规则', () => { + const session = replaceTask([file('C:\\A\\old.txt')], 'win32') + session.activeModule = 'number' + session.sortMode = 'size-desc' + session.numberRule.prefix = '测试' + const next = applyBatchResults(session, [{ + id: session.files[0].id, sourcePath: session.files[0].sourcePath, + targetName: '测试1.txt', targetPath: 'C:\\A\\测试1.txt', + actualPath: 'C:\\A\\测试1.txt', status: 'success' + }], 'win32') + expect(next.activeModule).toBe('number') + expect(next.sortMode).toBe('size-desc') + expect(next.numberRule.prefix).toBe('测试') + expect(next.files[0].currentName).toBe('测试1.txt') + expect(next.files[0].id).toBe(session.files[0].id) + }) + + it('执行错误不会永久阻止文件重新预览', () => { + const session = replaceTask([file('C:\\A\\old.txt')], 'win32') + const next = applyBatchResults(session, [{ + id: session.files[0].id, sourcePath: session.files[0].sourcePath, + targetName: 'new.txt', targetPath: 'C:\\A\\new.txt', + actualPath: 'C:\\A\\old.txt', status: 'error', error: '目标文件被占用' + }], 'win32') + expect(next.files[0].error).toBeUndefined() + expect(next.files[0].currentName).toBe('old.txt') + }) +}) + +describe('基础预览', () => { + it('按名称自然升序排列,目标名称不变,不修改源列表', () => { + const session = replaceTask([file('C:\\A\\10.txt'), file('C:\\A\\2.txt')], 'win32') + const preview = createPreview(session) + expect(preview.map((item) => item.currentName)).toEqual(['2.txt', '10.txt']) + expect(preview.map((item) => item.targetName)).toEqual(['2.txt', '10.txt']) + expect(preview.map((item) => item.status)).toEqual(['unchanged', 'unchanged']) + expect(session.files[0].currentName).toBe('10.txt') + }) + + it('逐项显示读取错误,其他文件仍可预览', () => { + const session = replaceTask([ + file('C:\\A\\missing.txt', '源文件不存在'), + file('C:\\A\\ok.txt') + ], 'win32') + const preview = createPreview(session) + expect(preview[0]).toMatchObject({ status: 'error', errorMessage: '源文件不存在' }) + expect(preview[1]).toMatchObject({ status: 'unchanged', targetName: 'ok.txt' }) + }) + + it('智能重命名界面不生成虚假的改名目标', () => { + const session = replaceTask([file('C:\\A\\old.txt')], 'win32') + session.activeModule = 'smart' + expect(createPreview(session)[0]).toMatchObject({ targetName: 'old.txt', status: 'unchanged' }) + }) + + it('一万文件的规则与排序预览可在主线程快速完成', () => { + const infos = Array.from({ length: 10000 }, (_, index) => file(`C:\\Bulk\\file-${index}.txt`)) + const session = replaceTask(infos, 'win32') + session.activeModule = 'replace' + session.replaceRules[0].find = 'file-' + session.replaceRules[0].replacement = 'item-' + const started = performance.now() + const result = createPreview(session) + expect(result).toHaveLength(10000) + expect(result[0].targetName).toBe('item-0.txt') + expect(performance.now() - started).toBeLessThan(1500) + }) +}) diff --git a/plugins/batch-rename/src/core/session.ts b/plugins/batch-rename/src/core/session.ts new file mode 100644 index 000000000..21a6ed323 --- /dev/null +++ b/plugins/batch-rename/src/core/session.ts @@ -0,0 +1,179 @@ +export type SortMode = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | + 'modified-asc' | 'modified-desc' | 'created-asc' | 'created-desc' +export type ActiveModule = 'replace' | 'smart' | 'number' | 'insert' | 'manual' +export type SequenceStyle = 'arabic' | 'chinese-lower' | 'chinese-upper' | 'alpha-lower' | 'alpha-upper' +export type NumberRule = { prefix: string; start: number; style: SequenceStyle; digits: number; suffix: string } +export type ReplaceMode = 'text' | 'first' | 'last' | 'range' | 'after' | 'before' | 'after-n' | 'before-n' +export type ReplaceRule = { + id: string + mode: ReplaceMode + find: string + replacement: string + start: number + count: number + includeExtension: boolean +} +export type InsertInfo = 'created' | 'modified' | 'size' | 'dimensions' | 'captured' +export type InsertRule = { + kind: 'text' | 'number' | 'info' + position: 'start' | 'index' | 'end' + index: number + text: string + number: NumberRule + info: InsertInfo + prefix: string + suffix: string +} +export type FileMetadata = { dimensions?: string; capturedAt?: number; error?: string } + +export type FileInfo = { + path: string + name: string + size?: number + createdAt?: number + modifiedAt?: number + error?: string + metadata?: FileMetadata +} + +export type FileItem = { + id: string + sourcePath: string + directory: string + currentName: string + baseName: string + extension: string + size?: number + createdAt?: number + modifiedAt?: number + error?: string + metadata?: FileMetadata +} + +export type RenameSession = { + files: FileItem[] + sortMode: SortMode + activeModule: ActiveModule + replaceRules: ReplaceRule[] + numberRule: NumberRule + insertRule: InsertRule + manualNames: Record + smartNames: Record +} + +export type LaunchParam = { + code?: string + type?: string + payload?: unknown +} + +export function createSession(): RenameSession { + const numberRule: NumberRule = { prefix: '', start: 1, style: 'arabic', digits: 0, suffix: '' } + return { + files: [], sortMode: 'name-asc', activeModule: 'replace', + replaceRules: [{ id: 'default', mode: 'text', find: '', replacement: '', start: 1, count: 1, includeExtension: false }], + numberRule, + insertRule: { kind: 'text', position: 'end', index: 1, text: '', number: { ...numberRule }, info: 'modified', prefix: '', suffix: '' }, + manualNames: {}, smartNames: {} + } +} + +export function pathKey(path: string, platform: string): string { + return platform === 'win32' ? path.replaceAll('/', '\\').toLowerCase() : path +} + +function isAbsolutePath(path: string, platform: string): boolean { + if (platform === 'win32') { + return /^[a-zA-Z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path) + } + return path.startsWith('/') +} + +export function extractFilePaths(param: LaunchParam, platform: string): string[] | null { + if (param.code !== 'batch-rename-files') return null + if (!Array.isArray(param.payload)) return [] + + const seen = new Set() + const paths: string[] = [] + for (const value of param.payload) { + if (!value || typeof value !== 'object') continue + const entry = value as Record + if (entry.isDirectory === true || entry.isFile === false) continue + if (typeof entry.path !== 'string' || !isAbsolutePath(entry.path, platform)) continue + const key = pathKey(entry.path, platform) + if (seen.has(key)) continue + seen.add(key) + paths.push(entry.path) + } + return paths +} + +export function toFileItem(info: FileInfo, platform: string): FileItem { + const separator = platform === 'win32' ? /[\\/]/ : /\// + const parts = info.path.split(separator) + const currentName = info.name || parts.at(-1) || info.path + const dot = currentName.lastIndexOf('.') + const hasExtension = dot > 0 + const parent = info.path.slice(0, info.path.length - (parts.at(-1)?.length || 0)) + return { + id: pathKey(info.path, platform), + sourcePath: info.path, + directory: parent === '/' || /^[a-zA-Z]:[\\/]$/.test(parent) + ? parent + : parent.replace(/[\\/]$/, ''), + currentName, + baseName: hasExtension ? currentName.slice(0, dot) : currentName, + extension: hasExtension ? currentName.slice(dot) : '', + size: info.size, + createdAt: info.createdAt, + modifiedAt: info.modifiedAt, + error: info.error, + metadata: info.metadata + } +} + +export function replaceTask(infos: FileInfo[], platform: string): RenameSession { + return { ...createSession(), files: infos.map((info) => toFileItem(info, platform)) } +} + +export function appendFiles(session: RenameSession, infos: FileInfo[], platform: string): RenameSession { + const seen = new Set(session.files.map((file) => pathKey(file.sourcePath, platform))) + const files = [...session.files] + for (const info of infos) { + const key = pathKey(info.path, platform) + if (seen.has(key) || files.length >= 10000) continue + seen.add(key) + files.push(toFileItem(info, platform)) + } + return { ...session, files, smartNames: files.length === session.files.length ? session.smartNames : {} } +} + +export function applyBatchResults(session: RenameSession, results: RenameOutcome[], platform: string): RenameSession { + const byId = new Map(results.map((result) => [result.id, result])) + const manualNames = { ...session.manualNames } + const files = session.files.map((file) => { + const result = byId.get(file.id) + if (!result) return file + const info: FileInfo = { + path: result.actualPath || file.sourcePath, + name: (result.actualPath || file.sourcePath).split(/[\\/]/).at(-1) || file.currentName, + size: file.size, createdAt: file.createdAt, modifiedAt: file.modifiedAt, + error: undefined, + metadata: file.metadata + } + const next = { ...toFileItem(info, platform), id: file.id } + if (result.status === 'success' && file.id in manualNames) manualNames[file.id] = next.currentName + return next + }) + return { ...session, files, manualNames } +} + +export function removeFile(session: RenameSession, id: string): RenameSession { + const manualNames = { ...session.manualNames } + delete manualNames[id] + return { ...session, files: session.files.filter((file) => file.id !== id), manualNames, smartNames: {} } +} + +export function clearFiles(session: RenameSession): RenameSession { + return { ...session, files: [], smartNames: {} } +} diff --git a/plugins/batch-rename/src/core/smart.test.ts b/plugins/batch-rename/src/core/smart.test.ts new file mode 100644 index 000000000..538929448 --- /dev/null +++ b/plugins/batch-rename/src/core/smart.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { buildSmartMessages, parseSmartNames } from './smart' +import { createPreview } from './preview' +import { appendFiles, clearFiles, removeFile, replaceTask } from './session' + +const session = replaceTask([ + { path: 'C:\\Photos\\a.jpg', name: 'a.jpg', size: 12 }, + { path: 'C:\\Photos\\b.jpg', name: 'b.jpg', size: 34 } +], 'win32') + +describe('智能重命名', () => { + it('只将文件元数据和用户指令发给模型,解析后进入正常预览', () => { + const messages = buildSmartMessages(session.files, '改成旅行照片') + expect(JSON.parse(messages[1].content).files).toEqual([ + { id: 0, name: 'a.jpg', size: 12 }, { id: 1, name: 'b.jpg', size: 34 } + ]) + const names = parseSmartNames('```json\n[{"id":1,"name":"旅行2.jpg"},{"id":0,"name":"旅行1.jpg"}]\n```', session.files) + const preview = createPreview({ ...session, activeModule: 'smart', smartNames: names }) + expect(preview.map((item) => [item.targetName, item.status])).toEqual([ + ['旅行1.jpg', 'ready'], ['旅行2.jpg', 'ready'] + ]) + }) + + it.each([ + ['缺项', '[{"id":0,"name":"x.jpg"}]'], + ['重复编号', '[{"id":0,"name":"x.jpg"},{"id":0,"name":"y.jpg"}]'], + ['路径', '[{"id":0,"name":"../x.jpg"},{"id":1,"name":"y.jpg"}]'], + ['改扩展名', '[{"id":0,"name":"x.png"},{"id":1,"name":"y.jpg"}]'], + ['无效响应', '不是 JSON'] + ])('%s不会进入预览', (_, response) => { + expect(() => parseSmartNames(response, session.files)).toThrow() + }) + + it('文件集合变化后清除旧候选名', () => { + const named = { ...session, smartNames: { [session.files[0].id]: '旅行1.jpg' } } + expect(removeFile(named, session.files[1].id).smartNames).toEqual({}) + expect(clearFiles(named).smartNames).toEqual({}) + expect(appendFiles(named, [{ path: 'C:\\Photos\\c.jpg', name: 'c.jpg' }], 'win32').smartNames).toEqual({}) + }) +}) diff --git a/plugins/batch-rename/src/core/smart.ts b/plugins/batch-rename/src/core/smart.ts new file mode 100644 index 000000000..d6e3b3026 --- /dev/null +++ b/plugins/batch-rename/src/core/smart.ts @@ -0,0 +1,38 @@ +import type { FileItem } from './session' + +export const SMART_BATCH_SIZE = 50 +export const SMART_FILE_LIMIT = 500 + +export function buildSmartMessages(files: FileItem[], prompt: string) { + return [ + { role: 'system' as const, content: '你是批量文件重命名助手。仅根据给出的文件名和元数据生成目标文件名,不执行文件操作。只返回 JSON 数组,每项格式为 {"id":数字,"name":"完整文件名"}。必须逐项覆盖输入,id 不得重复,保留每个文件的原扩展名,不包含路径。' }, + { role: 'user' as const, content: JSON.stringify({ instruction: prompt, files: files.map((file, id) => ({ + id, name: file.currentName, size: file.size, createdAt: file.createdAt, modifiedAt: file.modifiedAt + })) }) } + ] +} + +export function parseSmartNames(content: unknown, files: FileItem[]): Record { + if (typeof content !== 'string') throw new Error('模型没有返回文本') + const body = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '') + let entries: unknown + try { entries = JSON.parse(body) } catch { throw new Error('模型返回的不是有效 JSON') } + if (!Array.isArray(entries) || entries.length !== files.length) throw new Error('模型返回的文件数量与请求不一致') + const names: Record = {} + const seen = new Set() + for (const entry of entries) { + if (!entry || typeof entry !== 'object') throw new Error('模型返回项格式无效') + const { id, name } = entry as Record + if (!Number.isInteger(id) || (id as number) < 0 || (id as number) >= files.length || seen.has(id as number)) { + throw new Error('模型返回了重复或无效的文件编号') + } + const file = files[id as number] + if (typeof name !== 'string' || !name.trim() || name !== name.trim() || + name === '.' || name === '..' || /[\\/\x00-\x1f]/.test(name) || !name.endsWith(file.extension)) { + throw new Error(`文件 ${file.currentName} 的建议名称无效或扩展名已改变`) + } + seen.add(id as number) + names[file.id] = name + } + return names +} diff --git a/plugins/batch-rename/src/core/sort.test.ts b/plugins/batch-rename/src/core/sort.test.ts new file mode 100644 index 000000000..51b84fad7 --- /dev/null +++ b/plugins/batch-rename/src/core/sort.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { sortFiles } from './sort' +import type { FileItem, SortMode } from './session' + +function item(id: string, currentName: string, values: Partial = {}): FileItem { + return { + id, sourcePath: `C:\\files\\${currentName}`, directory: 'C:\\files', currentName, + baseName: currentName, extension: '', ...values + } +} + +const modes: SortMode[] = [ + 'name-asc', 'name-desc', 'size-asc', 'size-desc', + 'modified-asc', 'modified-desc', 'created-asc', 'created-desc' +] + +describe('sortFiles', () => { + it.each(modes)('sorts with %s', (mode) => { + const files = [ + item('b', 'file10', { size: 10, modifiedAt: 10, createdAt: 10 }), + item('a', 'file2', { size: 2, modifiedAt: 2, createdAt: 2 }) + ] + const result = sortFiles(files, mode) + const ascending = mode.endsWith('-asc') + expect(result.map((file) => file.id)).toEqual(ascending ? ['a', 'b'] : ['b', 'a']) + expect(files.map((file) => file.id)).toEqual(['b', 'a']) + }) + + it('keeps original order for equal and missing numeric values', () => { + const files = [ + item('first', 'a', { size: 2 }), + item('missing', 'b'), + item('second', 'c', { size: 2 }) + ] + expect(sortFiles(files, 'size-asc').map((file) => file.id)).toEqual(['first', 'second', 'missing']) + expect(sortFiles(files, 'size-desc').map((file) => file.id)).toEqual(['first', 'second', 'missing']) + }) + + it('uses natural name order and keeps ties stable', () => { + const files = [item('ten', 'file10'), item('two-a', 'file2'), item('two-b', 'file2')] + expect(sortFiles(files, 'name-asc').map((file) => file.id)).toEqual(['two-a', 'two-b', 'ten']) + expect(sortFiles(files, 'name-desc').map((file) => file.id)).toEqual(['ten', 'two-a', 'two-b']) + }) +}) diff --git a/plugins/batch-rename/src/core/sort.ts b/plugins/batch-rename/src/core/sort.ts new file mode 100644 index 000000000..d5e7bd943 --- /dev/null +++ b/plugins/batch-rename/src/core/sort.ts @@ -0,0 +1,35 @@ +import type { FileItem, SortMode } from './session' + +const nameCollator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' }) + +function compareOptionalNumber(left?: number, right?: number): number { + if (left === undefined || !Number.isFinite(left)) return right === undefined || !Number.isFinite(right) ? 0 : 1 + if (right === undefined || !Number.isFinite(right)) return -1 + return left - right +} + +export function sortFiles(files: FileItem[], mode: SortMode): FileItem[] { + const descending = mode.endsWith('-desc') + const field = mode.slice(0, mode.lastIndexOf('-')) + const numericKey = field === 'size' ? 'size' : field === 'modified' ? 'modifiedAt' : 'createdAt' + + return files + .map((file, index) => ({ file, index })) + .sort((left, right) => { + let result: number + if (field === 'name') { + result = nameCollator.compare(left.file.currentName, right.file.currentName) + } else { + result = compareOptionalNumber(left.file[numericKey], right.file[numericKey]) + } + if (result !== 0) { + if (field !== 'name' && (left.file[numericKey] === undefined || !Number.isFinite(left.file[numericKey]) || + right.file[numericKey] === undefined || !Number.isFinite(right.file[numericKey]))) { + return result + } + return descending ? -result : result + } + return left.index - right.index + }) + .map(({ file }) => file) +} diff --git a/plugins/batch-rename/src/env.d.ts b/plugins/batch-rename/src/env.d.ts new file mode 100644 index 000000000..c1a3ea5a2 --- /dev/null +++ b/plugins/batch-rename/src/env.d.ts @@ -0,0 +1,24 @@ +/// +/// + +interface Window { + renameService: { + platform: string + readFileInfos: (paths: string[]) => Promise + scanDirectory: (directory: string) => Promise<{ paths: string[]; errors: Array<{ path: string; message: string }> }> + readMetadata: (path: string, kind: 'dimensions' | 'capturedAt') => Promise + validatePlan: (intents: RenameIntent[]) => Promise + executePlan: (intents: RenameIntent[]) => Promise + undo: () => Promise + resetTask: () => void + } +} + +type RenameIntent = { id: string; sourcePath: string; targetName: string } +type RenameOutcome = RenameIntent & { + targetPath: string + actualPath?: string + status: 'ready' | 'unchanged' | 'error' | 'success' + error?: string +} +type RenameBatchResult = { results: RenameOutcome[]; canUndo: boolean } diff --git a/plugins/batch-rename/src/main.css b/plugins/batch-rename/src/main.css new file mode 100644 index 000000000..b5881b081 --- /dev/null +++ b/plugins/batch-rename/src/main.css @@ -0,0 +1,112 @@ +:root { + color-scheme: light; + font-family: 'Plus Jakarta Sans', 'Segoe UI', 'Microsoft YaHei', sans-serif; + --font-code: 'JetBrains Mono', Consolas, 'Microsoft YaHei', monospace; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --focus-ring: rgba(37, 99, 235, .18); + --success: #10b981; + --success-subtle: #ecfdf5; + --success-border: #a7f3d0; + --success-text: #065f46; + --warning: #f59e0b; + --warning-subtle: #fffbeb; + --warning-border: #fde68a; + --warning-text: #92400e; + --error: #ef4444; + --error-subtle: #fef2f2; + --error-border: #fecaca; + --error-text: #991b1b; + --text: #0f172a; + --muted: #64748b; + --text-muted: #94a3b8; + --surface-canvas: #f1f5f9; + --surface-card: #fff; + --surface-subdued: #f8fafc; + --surface-hover: #f1f5f9; + --dock-bg: rgba(255, 255, 255, .9); + --border: #e2e8f0; + --border-input: #cbd5e1; + --border-divider: #f1f5f9; + --shadow-card: 0 2px 8px -2px rgba(15, 23, 42, .04); + --shadow-button: 0 2px 6px rgba(37, 99, 235, .28); +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --primary: #8ab4ff; + --primary-hover: #6b9df8; + --focus-ring: rgba(138, 180, 255, .24); + --success: #34d399; + --success-subtle: #123b30; + --success-border: #246a56; + --success-text: #b6f7d7; + --warning: #fbbf24; + --warning-subtle: #453315; + --warning-border: #77571c; + --warning-text: #ffe4a0; + --error: #fb7185; + --error-subtle: #4b202a; + --error-border: #8a3e4a; + --error-text: #ffb6c0; + --text: #f1f5f9; + --muted: #b3bfd0; + --text-muted: #8a99ad; + --surface-canvas: #202637; + --surface-card: #293244; + --surface-subdued: #333d50; + --surface-hover: #3a465a; + --dock-bg: rgba(41, 50, 68, .96); + --border: #47536a; + --border-input: #617087; + --border-divider: #3a465a; + --shadow-card: 0 2px 8px rgba(0, 0, 0, .12); + --shadow-button: 0 2px 6px rgba(0, 0, 0, .2); + } +} + +* { box-sizing: border-box; } +html, body, #app { width: 100%; height: 100%; margin: 0; } +body { color: var(--text); background: var(--surface-canvas); } +button, input, select, textarea { font-family: inherit; } +button { border: 0; border-radius: 8px; cursor: pointer; } +button:disabled { cursor: default; opacity: .48; } +button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +.app-shell { width: min(1004px, 100%); height: 100%; min-height: 450px; margin: 0 auto; display: flex; flex-direction: column; position: relative; overflow: hidden; background: var(--surface-canvas); } +.window-header { height: 44px; flex: none; padding: 0 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.window-brand { display: flex; align-items: center; gap: 10px; white-space: nowrap; } +.window-brand strong { font-size: 14px; font-weight: 600; } +.brand-icon { width: 26px; height: 26px; object-fit: contain; border-radius: 6px; } +.window-search { width: min(280px, 45%); height: 30px; padding: 0 10px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-input); border-radius: 8px; background: var(--surface-card); color: var(--muted); } +.window-search:focus-within { border-color: var(--primary); box-shadow: 0 0 0 2px var(--focus-ring); } +.window-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 12px; } + +.module-tabs { height: 44px; flex: none; padding: 0 16px; display: flex; align-items: center; gap: 6px; overflow-x: auto; } +.module-tabs button { height: 34px; padding: 0 14px; display: flex; align-items: center; gap: 7px; flex: none; color: var(--muted); background: transparent; border: 1px solid transparent; font-size: 13px; } +.module-tabs button:hover:not(:disabled) { color: var(--text); background: var(--surface-hover); } +.module-tabs button.active { color: var(--primary); background: var(--surface-card); border-color: var(--border); box-shadow: var(--shadow-card); font-weight: 600; } + +.rule-panel { min-width: 0; min-height: 178px; flex: none; margin: 0 16px 10px; padding: 16px 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-card); box-shadow: var(--shadow-card); } +.rule-panel:has(.insert-panel), .rule-panel:has(.number-panel) { height: 242px; } +.rule-panel:has(.number-panel), .rule-panel:has(.manual-panel), .rule-panel:has(.smart-panel) { display: block; } +.filter-notice { margin: -4px 16px 8px; color: var(--muted); font-size: 11px; } +.file-list { margin: 0 16px 10px; } +.toast-alert { position: absolute; top: 98px; right: 32px; max-width: min(420px, calc(100% - 40px)); z-index: 20; padding: 10px 13px; display: flex; align-items: center; gap: 10px; border: 1px solid; border-radius: 8px; box-shadow: 0 8px 24px rgba(15, 23, 42, .12); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } +.toast-alert.is-success { color: var(--success-text); background: var(--success-subtle); border-color: var(--success-border); } +.toast-alert.is-warning { color: var(--warning-text); background: var(--warning-subtle); border-color: var(--warning-border); } +.toast-alert.is-error { color: var(--error-text); background: var(--error-subtle); border-color: var(--error-border); } +.toast-icon { width: 20px; height: 20px; display: grid; place-items: center; flex: none; border: 2px solid currentColor; border-radius: 50%; font-size: 13px; font-weight: 700; } +.toast-alert button { margin-left: auto; color: inherit; background: transparent; font-size: 20px; line-height: 1; } + +@media (max-width: 620px) { + .window-header { padding: 0 10px; } + .module-tabs { padding: 0 8px; } + .rule-panel { margin: 0 8px 8px; padding: 12px; } + .file-list { margin: 0 8px 8px; } + .toast-alert { right: 10px; } +} +@media (max-width: 780px) { + .rule-panel:has(.insert-panel), .rule-panel:has(.number-panel) { height: 280px; } +} diff --git a/plugins/batch-rename/src/main.ts b/plugins/batch-rename/src/main.ts new file mode 100644 index 000000000..f51c91529 --- /dev/null +++ b/plugins/batch-rename/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './main.css' + +createApp(App).mount('#app') diff --git a/plugins/batch-rename/tsconfig.json b/plugins/batch-rename/tsconfig.json new file mode 100644 index 000000000..0bb4bf59e --- /dev/null +++ b/plugins/batch-rename/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client", "@ztools-center/ztools-api-types", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/plugins/batch-rename/vite.config.ts b/plugins/batch-rename/vite.config.ts new file mode 100644 index 000000000..67f169db7 --- /dev/null +++ b/plugins/batch-rename/vite.config.ts @@ -0,0 +1,49 @@ +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildSync } from 'esbuild' +import vue from '@vitejs/plugin-vue' +import { defineConfig, type Plugin } from 'vite' + +const projectDirectory = fileURLToPath(new URL('.', import.meta.url)) + +function packagePlugin(): Plugin { + return { + name: 'package-ztools-plugin', + closeBundle() { + const projectRoot = projectDirectory + const outputDirectory = resolve(projectRoot, 'dist') + const pluginConfig = JSON.parse( + readFileSync(resolve(projectRoot, 'plugin.json'), 'utf8') + ) as Record + + pluginConfig.main = 'index.html' + delete pluginConfig.development + + mkdirSync(resolve(outputDirectory, 'preload'), { recursive: true }) + writeFileSync( + resolve(outputDirectory, 'plugin.json'), + `${JSON.stringify(pluginConfig, null, 2)}\n`, + 'utf8' + ) + copyFileSync(resolve(projectRoot, 'logo.png'), resolve(outputDirectory, 'logo.png')) + buildSync({ + entryPoints: [resolve(projectRoot, 'preload/services.js')], + outfile: resolve(outputDirectory, 'preload/services.js'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node24' + }) + } + } +} + +export default defineConfig({ + plugins: [vue(), packagePlugin()], + base: './', + build: { + outDir: 'dist', + emptyOutDir: true + } +})