diff --git a/plugins/crazy-zxx.yigengming/LICENSE b/plugins/crazy-zxx.yigengming/LICENSE new file mode 100644 index 0000000..af30efb --- /dev/null +++ b/plugins/crazy-zxx.yigengming/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Batch Rename Studio contributors + +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/crazy-zxx.yigengming/README.md b/plugins/crazy-zxx.yigengming/README.md new file mode 100644 index 0000000..79bcf5b --- /dev/null +++ b/plugins/crazy-zxx.yigengming/README.md @@ -0,0 +1,134 @@ +# 易更名 + +带中文窗口的文件和文件夹批量创建、重命名插件。支持顺序组合规则、实时预览、冲突检查、撤销与中断恢复,以及跟随系统/手动浅色或深色主题。 + +## 安装与启动 + +在 Win顺的插件设置中从社区 Release 安装 `crazy-zxx.yigengming-1.3.0.wsplugin` 并启用“易更名”。开发时将本目录放到 `%LOCALAPPDATA%\WinShun\plugins\crazy-zxx.yigengming\`,启用开发者模式。 + +- `br `、`rename `、`易更名 `、`批量重命名 `:打开重命名窗口,关键字后可附带文件或文件夹路径。 +- `bc `、`批量创建 `:打开创建窗口,关键字后可附带已有目标目录。 +- 窗口右上角“主题”可选跟随系统、浅色、深色;选择自动保存。 + +运行环境为 Windows 10/11、Windows PowerShell 5.1、.NET Framework 4.8,均不需要额外下载。单批最多 10000 项、最多 100 条规则。遵循传统 Windows 路径长度限制;跳过符号链接/联接点,带重解析点的 OneDrive 路径可能受限。 + +## 重命名操作 + +1. 拖入多个文件/文件夹,或点击“添加文件”“添加文件夹”。 +2. 要处理目录内的项目,点击“导入目录内容”;可选递归子目录,以及仅导入文件、仅导入文件夹或两者都导入。 +3. 使用自然名称排序、路径排序或 ↑ / ↓ 调整顺序。序号按照预览列表顺序生成,整批统一计数。 +4. 在右侧添加规则,可添加任意数量的同类规则。选择规则编辑参数,用复选框启用/停用,用 ↑ / ↓ 调整规则顺序。 +5. 检查新名称和状态;冲突、非法名称或源文件变化都会阻止执行。将鼠标移到行上可以查看完整路径或错误原因。 +6. 点击“执行重命名”,核对项目数量后确认。 + +文件夹可以与其子文件夹、子文件同时加入,插件会计算最终父子路径。可以只改大小写,也可以互换名称或循环交换三个以上的名称。 + +![重命名窗口](docs/preview.png) + +## 规则与组合 + +每条规则接收上一条规则的输出,按列表从上到下执行。每条规则都可以单独指定作用范围: + +- **主名称**:默认值。文件保留最后一个点后的扩展名;文件夹处理整个名称。 +- **完整名称**:包含文件扩展名。 +- **扩展名**:不包含点。对文件夹跳过;结果为空时移除扩展名。 + +`.gitignore` 这样的单点开头名称视为没有扩展名;`archive.tar.gz` 的最后扩展名是 `gz`。 + +| 规则 | 支持的参数与行为 | +|---|---| +| 序号 | 起始值、正/负/零步长、补零位数、前缀/后缀/指定位置/替换、分隔符 | +| 大小写 | 全大写、全小写、英文单词首字母大写、大小写反转 | +| 文字替换 | 普通文字全量替换,可忽略大小写;替换内容中的 `$1` 按普通文字处理 | +| 插入 | 前缀、后缀、指定位置或替换现有内容 | +| 随机字符 | 自定义字符集、1–128 位、前缀/后缀/指定位置/替换、分隔符 | +| 截取 / 删除 | 指定起点和长度,保留或删除该片段 | +| 正则匹配 | .NET 正则替换或提取;支持 `$1`、`${组名}`、`$0`,可忽略大小写 | +| 名称模板 | 当前名称、原始名称、原扩展名、原父目录、序号、日期 | +| 去除首尾空白 | 删除规则作用范围的首尾空白 | + +位置从 **0** 开始,负数从末尾倒数。超出范围的起点会截到边界,截取长度会截到可用长度。位置以 .NET Unicode 文本元素计数,避免拆开普通 emoji 的 UTF-16 代理对。序号和随机规则的“分隔符”用于前缀与后缀模式。 + +正则未匹配时保留原内容。每次正则匹配有 200 毫秒超时保护,复杂表达式会报错而不是无限等待;编辑规则时会取消过期的预览任务。 + +随机值由本次窗口的随机种子、原路径和规则位置决定,常规预览刷新不会重新抽取;点击“更多 → 重新生成随机值”才会更换种子。执行使用已生成的目标名称。 + +### 示例:整理照片 + +将 `IMG_20261001.JPG` 变为 `旅行_20261001_001.jpg`: + +1. 文字替换:主名称中查找 `IMG_`,替换为 `旅行_`。 +2. 大小写:仅扩展名,全部小写。 +3. 序号:主名称、后缀、起始 1、步长 1、3 位、分隔符 `_`。 + +`presets/照片整理.json` 可以直接通过“载入规则”使用。另附文件夹编号、随机后缀、正则日期整理、空白清理方案。载入会替换整个规则列表;保存会将完整规则顺序和参数写入 JSON。 + +### 模板变量 + +| 变量 | 含义 | +|---|---| +| `{name}` | 当前规则输入内容;在创建名称模板中为输入模板本身 | +| `{original}` | 原始主名称,不含原扩展名 | +| `{ext}` | 原始扩展名,不含点 | +| `{parent}` | 原始父目录名称 | +| `{n}` / `{n:000}` | 从 1 开始的全局序号,可设置数字格式 | +| `{date:yyyyMMdd}` | 日期;使用 .NET 日期格式,时间固定为本次窗口打开时的本地时间 | + +名称模板规则的作用范围默认是“主名称”,所以通常不要再手动拼接 `.{ext}`。如果需要自己生成包括扩展名的完整名称,改选“完整名称”。 + +## 批量创建 + +切换到“批量创建”,选择**现有目标目录**、文件或文件夹,并选择输入方式: + +- **模板 + 数量**:例如 `项目_{n:000}.txt`、数量 20,生成 `项目_001.txt` 至 `项目_020.txt`。 +- **按行创建**:每行一个名称,忽略空白行;每行也支持序号和日期模板变量。 + +右侧规则同样作用于生成的名称,可继续组合替换、序号、大小写、随机字符等。创建的文件为空文件;给空文件添加 `.docx` 等后缀不会生成对应文档的内部格式。不会通过名称创建子路径,名称中的 `\`、`/` 会报错。 + +![创建窗口](docs/create-preview.png) + +## 撤销、恢复与数据 + +- “撤销最近操作”可撤销最近一批已完成操作,支持跨窗口、跨启动保存;连续点击可逐批撤销更早的操作。 +- 重命名的撤销使用文件系统标识检查项目是否被替换,不会覆盖后来在原位置新建的项目。同一文件在原位置修改内容后仍可撤销名称。 +- 撤销创建时,整批项目必须仍然存在、标识一致且内容为空;有内容的文件或文件夹会导致拒绝自动删除。不会递归删除目录。 +- 重命名先将源项目移到同目录下的私有暂存名称,再移到最终名称,以处理名称互换、大小写和父子目录。失败时按相反顺序回滚。 +- 每次文件移动前记录并刷盘追加日志;大批次不会在每次移动时重写整个批次快照。完成后保存完整事务快照。 +- 若 WinShun 被退出、程序被结束或掉电,重新打开窗口后点击“更多 → 恢复中断操作”。未恢复的事务会阻止新操作。遇到外部占用或文件替换时会保留日志并显示原因,供手动核对。 +- 存储设备故障或文件被其他程序改动时,回滚可能需要人工处理;恢复日志会保留已知原路径、目标路径、暂存路径和文件标识。创建暂存项与第一次记录其标识之间若恰好中断,会要求人工核对该暂存项。 + +宿主运行时使用 `initialize.params.dataDir` 作为数据目录。独立运行时默认使用: + +```text +%LOCALAPPDATA%\WinShun\plugins\crazy-zxx.yigengming.data\ + operations.lock + theme.txt + transactions\<事务ID>.json + transactions\<事务ID>.jsonl +``` + +JSON 保存事务快照,JSONL 保存追加的恢复事件。不要单独删除某个事务的 JSONL。历史记录只在该数据目录保留,移除数据目录后无法跨启动撤销。窗口中的“更多 → 导出预览 CSV”可另行保存原路径与目标路径清单。 + +## 运行实现与权限 + +入口是 UTF-8 BOM 的 `main.ps1`,只使用 Windows 自带组件。本目录不包含 EXE/DLL、预编译缓存、下载器或混淆代码。启动时通过 .NET CodeDOM/CSharpCodeProvider 调用系统 C# 编译器,将可审查的 `src/*.cs` 编译成临时托管类库,在当前 PowerShell 进程的独立 AppDomain 中加载;临时文件在退出后清理。AppDomain 使用 `runtime.config` 保留 .NET 4.8 的高 DPI 配置。编译器会短暂启动子进程;工作窗口随后在同一常驻 PowerShell 进程的 STA 线程运行。 + +| 权限 | 用途 | +|---|---| +| `ui` | 显示 WinForms 工作窗口、系统文件选择器和确认提示 | +| `files:read` | 读取用户选择的项目、目录内容、规则方案和恢复日志;读取 Windows 应用主题设置 | +| `files:write` | 执行用户确认的重命名/创建/撤销/恢复,保存规则、主题、事务日志和用户选择的 CSV 导出 | +| `clipboard:read`、`clipboard:write` | Windows 标准文本编辑控件的复制和粘贴;不主动扫描剪贴板 | +| `processes` | 仅本地 CodeDOM 编译所需的系统 C# 编译器子进程;不下载或启动外部工具,也不为窗口另开进程 | + +不申请 `network`,不进行任何联网请求,不发送文件名、路径或内容。使用自己的 JSON-RPC 实现,未依赖或导入 WinShun PowerShell SDK。源代码采用 MIT,保留原版权声明。 + +## 独立运行与验证 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\main.ps1 -Ui +python tests/protocol_test.py +powershell -NoProfile -ExecutionPolicy Bypass -File .\main.ps1 -Validate -Screenshot .\docs\create-preview.png +``` + +Python 3 仅用于协议测试,不是运行依赖。`-Validate` 编译测试源码,并在相同 AppDomain/DPI 配置下执行规则、真实临时文件和实际控件检查。测试不会批量修改用户文件。仓库规范检查在仓库根目录执行 `python tools/check.py`。详细结果见 [验证记录](docs/TEST-REPORT.md)。 diff --git a/plugins/crazy-zxx.yigengming/app.ico b/plugins/crazy-zxx.yigengming/app.ico new file mode 100644 index 0000000..d0d122a Binary files /dev/null and b/plugins/crazy-zxx.yigengming/app.ico differ diff --git a/plugins/crazy-zxx.yigengming/docs/TEST-REPORT.md b/plugins/crazy-zxx.yigengming/docs/TEST-REPORT.md new file mode 100644 index 0000000..54b90de --- /dev/null +++ b/plugins/crazy-zxx.yigengming/docs/TEST-REPORT.md @@ -0,0 +1,36 @@ +# 易更名验证记录 + +验证日期:2026-10-24 + +## 已完成 + +- `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\main.ps1 -Validate -Screenshot .\docs\create-preview.png` + - 66 项核心规则、文件操作、撤销、恢复和持久化测试通过。 + - 46 项 WinForms 界面测试通过。 + - 覆盖 PerMonitorV2 DPI、144 DPI 字体、浅色/深色/跟随系统主题、主题切换、按钮圆角、窗口缩窄时工具栏左移、规则组合、创建文件夹和冲突检查。 +- `python tests/protocol_test.py` + - Windows PowerShell 5.1 入口、UTF-8 JSON-RPC、`initialize`、`query`、`cancel`、`settingsChanged`、未知方法错误和 `shutdown` 通过。 + - 50 次查询通过;测试启动耗时约 0.6–0.8 秒,低于 manifest 的 10 秒限制。 + - 使用 512 MB Windows Job 限额,并按 manifest 允许本地编译器子进程。 +- `python tests/windows_job.py --allow-processes powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\main.ps1 -Validate` + - 在 512 MB Job 限额下,66 项核心测试和 46 项 UI 测试通过。 +- `python tools/check.py --changed crazy-zxx.yigengming --base origin/main` + - 0 个错误、0 条警告。 + +## 作者提交时的宿主验证说明(2026-10-24) + +已下载并校验 Win顺 `v0.13.2` 官方便携版(SHA-256:`a8635855fcd13dfd204005d80bee463031e450f537780fe0407b2aab3e4da3da`),并使用独立 `--profile` 目录尝试启动。当前隔离环境中宿主窗口未稳定完成插件搜索交互,因此没有勾选“在 Win顺 里试过了”。提交内容保留了可直接放入 `%LOCALAPPDATA%\\WinShun\\plugins\\` 的开发目录和协议测试,供维护者在实际 Win顺环境中复核。 + +以上为作者提交时的历史记录;后续维护者验证如下。 + +## 维护者补测(2026-10-26) + +- 原始 PowerShell 入口的 50 次协议查询通过;在声明的 512 MB Job 限额下,66 项核心测试和 46 项 UI 测试通过。 +- 使用 Win顺项目现有 `wsbench --plugin ... --query "br " --runs 3`,原始 PowerShell 插件通过真实宿主启动和查询:冷启动约 2.4 秒,常驻往返约 0.1 毫秒;进程为普通中完整性令牌,并处于 Job 中。 +- 按钮操作验收使用临时 WinForms 测试宿主:直接编译本插件原样 `src/*.cs`,只增加入口包装,复用 `runtime.config`,由真实 `WinShunPluginHost` 启动,并以 `initialize.dataDir` 指定独立临时数据目录。该可执行文件仅用于维护者测试,不随插件分发。 +- 在上述同源码 GUI 中添加序号规则,预览并确认重命名一个包含两份测试文件的目录,然后确认撤销;核对原名称及内部文件内容恢复。 +- 在独立目标目录中按模板和组合规则创建两份空文件;核对文件名、数量和大小。创建后再次预览能显示已有目标冲突。结束时无未完成事务。 +- 打包使用干净 Git 快照,检查 `.wsplugin` 的 manifest、文件路径、禁止文件类型和 SHA256 与目录索引一致。 + +PowerShell 入口、宿主查询与同源码 GUI 操作分别验证。尚未对用户生产配置下的启动器键盘交互、所有文件选择方式及网络盘做完整验收。 + diff --git a/plugins/crazy-zxx.yigengming/docs/create-preview-dark.png b/plugins/crazy-zxx.yigengming/docs/create-preview-dark.png new file mode 100644 index 0000000..96b4a31 Binary files /dev/null and b/plugins/crazy-zxx.yigengming/docs/create-preview-dark.png differ diff --git a/plugins/crazy-zxx.yigengming/docs/create-preview.png b/plugins/crazy-zxx.yigengming/docs/create-preview.png new file mode 100644 index 0000000..256ab8e Binary files /dev/null and b/plugins/crazy-zxx.yigengming/docs/create-preview.png differ diff --git a/plugins/crazy-zxx.yigengming/docs/preview-dark.png b/plugins/crazy-zxx.yigengming/docs/preview-dark.png new file mode 100644 index 0000000..5c17caa Binary files /dev/null and b/plugins/crazy-zxx.yigengming/docs/preview-dark.png differ diff --git a/plugins/crazy-zxx.yigengming/docs/preview.png b/plugins/crazy-zxx.yigengming/docs/preview.png new file mode 100644 index 0000000..62843d9 Binary files /dev/null and b/plugins/crazy-zxx.yigengming/docs/preview.png differ diff --git a/plugins/crazy-zxx.yigengming/icon.png b/plugins/crazy-zxx.yigengming/icon.png new file mode 100644 index 0000000..d44369a Binary files /dev/null and b/plugins/crazy-zxx.yigengming/icon.png differ diff --git a/plugins/crazy-zxx.yigengming/main.ps1 b/plugins/crazy-zxx.yigengming/main.ps1 new file mode 100644 index 0000000..d9beb26 --- /dev/null +++ b/plugins/crazy-zxx.yigengming/main.ps1 @@ -0,0 +1,73 @@ +param( + [switch]$Ui, + [switch]$Validate, + [string]$Screenshot, + [string]$RenderDemo, + [ValidateSet('System','Light','Dark')][string]$Theme = 'System' +) +$ErrorActionPreference = 'Stop' +[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false) +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$errorWriter = New-Object System.IO.StreamWriter([Console]::OpenStandardError(), [Console]::OutputEncoding) +$errorWriter.AutoFlush = $true +[Console]::SetError($errorWriter) +$OutputEncoding = [Console]::OutputEncoding +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ('WinShun-YiGengMing-' + [Guid]::NewGuid().ToString('N')) +$domain = $null +$provider = $null +$result = 1 +try { + [System.IO.Directory]::CreateDirectory($temporary) | Out-Null + Add-Type -AssemblyName Microsoft.CSharp + $provider = New-Object Microsoft.CSharp.CSharpCodeProvider + $parameters = New-Object System.CodeDom.Compiler.CompilerParameters + $parameters.GenerateExecutable = $false + $parameters.GenerateInMemory = $false + $parameters.OutputAssembly = Join-Path $temporary 'YiGengMing.Runtime.dll' + $parameters.TempFiles = New-Object System.CodeDom.Compiler.TempFileCollection($temporary, $false) + $parameters.CompilerOptions = '/optimize+ /codepage:65001 /utf8output' + foreach ($reference in @('System.dll','System.Core.dll','System.Drawing.dll','System.Windows.Forms.dll','System.Web.Extensions.dll')) { + $parameters.ReferencedAssemblies.Add($reference) | Out-Null + } + $sources = @(Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'src') -Filter '*.cs' -File | ForEach-Object FullName) + if ($Validate) { + $sources += Join-Path $PSScriptRoot 'tests\CoreTests.cs' + $sources += Join-Path $PSScriptRoot 'tests\UiTests.cs' + } + $compiled = $provider.CompileAssemblyFromFile($parameters, [string[]]$sources) + if ($compiled.Errors.HasErrors) { + foreach ($entry in $compiled.Errors) { [Console]::Error.WriteLine($entry.ToString()) } + throw '易更名源码编译失败;请确认已安装 .NET Framework 4.8。' + } + $setup = New-Object System.AppDomainSetup + $setup.ApplicationBase = $temporary + $setup.ShadowCopyFiles = 'true' + $setup.CachePath = Join-Path $temporary 'cache' + $setup.ConfigurationFile = Join-Path $PSScriptRoot 'runtime.config' + $setup.TargetFrameworkName = '.NETFramework,Version=v4.8' + $domain = [AppDomain]::CreateDomain('YiGengMing', $null, $setup) + # Load our just-compiled bytes rather than using LoadFrom with a sandbox-virtualized + # temp path. This needs no remote-source trust or changes to Windows security settings. + $assemblyBytes = [System.IO.File]::ReadAllBytes($parameters.OutputAssembly) + $assembly = [System.Reflection.Assembly]::Load($assemblyBytes) + $hostObject = $domain.CreateInstanceAndUnwrap($assembly.FullName, 'WinShun.BatchRenamer.CommunityHost') + $hostObject.Initialize($PSScriptRoot) + if ($Validate) { + $result = $hostObject.Validate($Screenshot) + } + else { + [string[]]$arguments = @('--serve') + if ($Ui) { $arguments = @('--ui') } + elseif ($RenderDemo) { $arguments = @('--render-demo', [System.IO.Path]::GetFullPath($RenderDemo), $Theme) } + $result = $hostObject.Run($arguments) + } +} +catch { [Console]::Error.WriteLine($_.Exception.ToString()) } +finally { + if ($domain) { [AppDomain]::Unload($domain) } + if ($provider) { $provider.Dispose() } + # Only this invocation's random compiler directory is removed, never user-selected paths. + try { if ([System.IO.Directory]::Exists($temporary)) { [System.IO.Directory]::Delete($temporary, $true) } } + catch { [Console]::Error.WriteLine('无法清理临时编译目录:' + $temporary) } +} +exit $result diff --git a/plugins/crazy-zxx.yigengming/plugin.json b/plugins/crazy-zxx.yigengming/plugin.json new file mode 100644 index 0000000..3962e6d --- /dev/null +++ b/plugins/crazy-zxx.yigengming/plugin.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://raw.githubusercontent.com/LingCore/WinShun-plugins/main/schema/plugin.schema.json", + "manifest": 1, + "id": "crazy-zxx.yigengming", + "name": { + "zh": "易更名", + "en": "易更名" + }, + "version": "1.3.0", + "description": "带窗口界面的批量文件/文件夹创建与重命名,支持组合规则、实时预览、冲突检查和撤销。", + "author": "Xiangxin (@crazy-zxx)", + "license": "MIT", + "icon": "icon.png", + "runtime": { + "mode": "resident", + "program": "powershell", + "script": "main.ps1", + "encoding": "utf-8", + "start": "lazy", + "idleExitSec": 0, + "timeoutMs": 10000, + "memoryMB": 512, + "sandbox": "normal" + }, + "permissions": [ + "ui", + "files:read", + "files:write", + "clipboard:read", + "clipboard:write", + "processes" + ], + "commands": [ + { + "id": "rename", + "keyword": [ + "br", + "rename", + "易更名", + "批量重命名" + ], + "title": "易更名 · 批量重命名", + "description": "打开批量重命名窗口,可附带一个文件或文件夹路径。", + "hint": "留空打开窗口,或输入一个文件/文件夹路径", + "icon": "icon.png", + "actions": [ + { + "id": "open", + "title": "打开窗口", + "key": "Enter" + } + ] + }, + { + "id": "create", + "keyword": [ + "bc", + "批量创建" + ], + "title": "易更名 · 批量创建文件和文件夹", + "description": "按模板或名称清单批量创建空文件/文件夹,并应用组合规则。", + "hint": "留空打开窗口,或输入目标目录路径", + "icon": "icon.png", + "actions": [ + { + "id": "open", + "title": "打开窗口", + "key": "Enter" + } + ] + } + ], + "homepage": "https://github.com/crazy-zxx/WinShun-plugins/tree/main/plugins/crazy-zxx.yigengming" +} diff --git "a/plugins/crazy-zxx.yigengming/presets/\346\226\207\344\273\266\345\244\271\347\274\226\345\217\267.json" "b/plugins/crazy-zxx.yigengming/presets/\346\226\207\344\273\266\345\244\271\347\274\226\345\217\267.json" new file mode 100644 index 0000000..292120f --- /dev/null +++ "b/plugins/crazy-zxx.yigengming/presets/\346\226\207\344\273\266\345\244\271\347\274\226\345\217\267.json" @@ -0,0 +1,7 @@ +{ + "Version": 1, + "Rules": [ + {"Type": "Trim", "Target": "Name"}, + {"Type": "Sequence", "Target": "Name", "Mode": "Prefix", "Start": 1, "Step": 1, "Digits": 2, "Text": "_"} + ] +} diff --git "a/plugins/crazy-zxx.yigengming/presets/\346\255\243\345\210\231\346\227\245\346\234\237\346\225\264\347\220\206.json" "b/plugins/crazy-zxx.yigengming/presets/\346\255\243\345\210\231\346\227\245\346\234\237\346\225\264\347\220\206.json" new file mode 100644 index 0000000..8eae654 --- /dev/null +++ "b/plugins/crazy-zxx.yigengming/presets/\346\255\243\345\210\231\346\227\245\346\234\237\346\225\264\347\220\206.json" @@ -0,0 +1,6 @@ +{ + "Version": 1, + "Rules": [ + {"Type": "Regex", "Target": "Name", "Mode": "Replace", "Pattern": "^(?\\d{4})[-_]?(?[01]\\d)[-_]?(?[0-3]\\d)(?.*)$", "Replacement": "${year}-${month}-${day}${tail}", "IgnoreCase": false} + ] +} diff --git "a/plugins/crazy-zxx.yigengming/presets/\347\205\247\347\211\207\346\225\264\347\220\206.json" "b/plugins/crazy-zxx.yigengming/presets/\347\205\247\347\211\207\346\225\264\347\220\206.json" new file mode 100644 index 0000000..e23631b --- /dev/null +++ "b/plugins/crazy-zxx.yigengming/presets/\347\205\247\347\211\207\346\225\264\347\220\206.json" @@ -0,0 +1,8 @@ +{ + "Version": 1, + "Rules": [ + {"Type": "Replace", "Target": "Name", "Pattern": "IMG_", "Replacement": "旅行_"}, + {"Type": "Case", "Target": "Extension", "Mode": "Lower"}, + {"Type": "Sequence", "Target": "Name", "Mode": "Suffix", "Start": 1, "Step": 1, "Digits": 3, "Text": "_"} + ] +} diff --git "a/plugins/crazy-zxx.yigengming/presets/\347\251\272\347\231\275\346\270\205\347\220\206.json" "b/plugins/crazy-zxx.yigengming/presets/\347\251\272\347\231\275\346\270\205\347\220\206.json" new file mode 100644 index 0000000..9c0e81c --- /dev/null +++ "b/plugins/crazy-zxx.yigengming/presets/\347\251\272\347\231\275\346\270\205\347\220\206.json" @@ -0,0 +1,8 @@ +{ + "Version": 1, + "Rules": [ + {"Type": "Trim", "Target": "Name"}, + {"Type": "Regex", "Target": "Name", "Mode": "Replace", "Pattern": "\\s+", "Replacement": "_"}, + {"Type": "Case", "Target": "Extension", "Mode": "Lower"} + ] +} diff --git "a/plugins/crazy-zxx.yigengming/presets/\351\232\217\346\234\272\345\220\216\347\274\200.json" "b/plugins/crazy-zxx.yigengming/presets/\351\232\217\346\234\272\345\220\216\347\274\200.json" new file mode 100644 index 0000000..99ae5f3 --- /dev/null +++ "b/plugins/crazy-zxx.yigengming/presets/\351\232\217\346\234\272\345\220\216\347\274\200.json" @@ -0,0 +1,6 @@ +{ + "Version": 1, + "Rules": [ + {"Type": "Random", "Target": "Name", "Mode": "Suffix", "Length": 8, "Text": "_", "Charset": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"} + ] +} diff --git a/plugins/crazy-zxx.yigengming/runtime.config b/plugins/crazy-zxx.yigengming/runtime.config new file mode 100644 index 0000000..80eac18 --- /dev/null +++ b/plugins/crazy-zxx.yigengming/runtime.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/plugins/crazy-zxx.yigengming/src/AssemblyInfo.cs b/plugins/crazy-zxx.yigengming/src/AssemblyInfo.cs new file mode 100644 index 0000000..01982aa --- /dev/null +++ b/plugins/crazy-zxx.yigengming/src/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Reflection; +using System.Runtime.Versioning; + +[assembly: AssemblyTitle("易更名")] +[assembly: AssemblyProduct("易更名")] +[assembly: AssemblyVersion("1.3.0.0")] +[assembly: AssemblyFileVersion("1.3.0.0")] +// The compiler's CLR version alone does not opt a directly compiled EXE into modern WinForms DPI behavior. +[assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] diff --git a/plugins/crazy-zxx.yigengming/src/CommunityHost.cs b/plugins/crazy-zxx.yigengming/src/CommunityHost.cs new file mode 100644 index 0000000..68a646a --- /dev/null +++ b/plugins/crazy-zxx.yigengming/src/CommunityHost.cs @@ -0,0 +1,32 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace WinShun.BatchRenamer +{ + // The PowerShell entry loads this library in an AppDomain with our own .NET 4.8 + // configuration. No binary is shipped or downloaded, and no UI subprocess is used. + public sealed class CommunityHost : MarshalByRefObject + { + internal static string PluginDirectory; + public void Initialize(string directory) { PluginDirectory = directory; } + [DllImport("user32.dll")] static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); + internal static void UseNativeDpi() + { + try { SetThreadDpiAwarenessContext(new IntPtr(-4)); } + catch (EntryPointNotFoundException) { } + } + public int Run(string[] arguments) { return Program.Main(arguments); } + public int Validate(string screenshot) + { + UseNativeDpi(); + Assembly assembly = typeof(CommunityHost).Assembly; + Type core = assembly.GetType("CoreTests", true), ui = assembly.GetType("UiTests", true); + int result = (int)core.GetMethod("Main", BindingFlags.Public | BindingFlags.Static).Invoke(null, null); + if (result != 0) return result; + string[] args = String.IsNullOrEmpty(screenshot) ? new string[0] : new[] { screenshot }; + return (int)ui.GetMethod("Main", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { args }); + } + public override object InitializeLifetimeService() { return null; } + } +} diff --git a/plugins/crazy-zxx.yigengming/src/FileEngine.cs b/plugins/crazy-zxx.yigengming/src/FileEngine.cs new file mode 100644 index 0000000..aa22948 --- /dev/null +++ b/plugins/crazy-zxx.yigengming/src/FileEngine.cs @@ -0,0 +1,506 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using System.Threading; + +namespace WinShun.BatchRenamer +{ + public static class Fs + { + public static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + [StructLayout(LayoutKind.Sequential)] + struct FileInformation + { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation info); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool CreateDirectory(string name, IntPtr security); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, ref int information, uint size); + static string FileId(FileInformation info) { return info.Volume.ToString("X8") + ":" + info.IndexHigh.ToString("X8") + info.IndexLow.ToString("X8"); } + public static string Identity(string path) + { + using (SafeFileHandle h = CreateFile(path, 0, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (h.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法读取文件标识:" + path); + FileInformation info; + if (!GetFileInformationByHandle(h, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + // NTFS name tunneling can change creation times during name swaps. The volume/file ID remains stable. + return FileId(info); + } + } + public static string Normalize(string path) + { + string p = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path.Trim().Trim('"'))); + return p.Length > Path.GetPathRoot(p).Length ? p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) : p; + } + public static bool Exists(string path) { return File.Exists(path) || Directory.Exists(path); } + public static bool Under(string path, string parent) { return path.StartsWith(parent.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase); } + public static int Depth(string path) { return path.Count(c => c == '\\'); } + public static void Guard(string path) + { + for (string p = path; !String.IsNullOrEmpty(p); p = Path.GetDirectoryName(p)) + { + if (Exists(p) && (File.GetAttributes(p) & FileAttributes.ReparsePoint) != 0) + throw new IOException("暂不处理符号链接、联接点或其内部路径:" + p); + } + } + public static void ValidateName(string name) + { + if (String.IsNullOrWhiteSpace(name) || name == "." || name == "..") throw new ArgumentException("名称不能为空或为 . / ..。"); + if (name.Length > 255) throw new ArgumentException("名称超过 255 个 UTF-16 字符。"); + if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new ArgumentException("名称含 Windows 不允许的字符(如 \\ / : * ? \" < > |)。"); + if (name.EndsWith(" ") || name.EndsWith(".")) throw new ArgumentException("名称不能以空格或句点结尾。"); + string stem = name.Split('.')[0].TrimEnd(' ', '.').ToUpperInvariant(); + if (new[] { "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$" }.Contains(stem) || + System.Text.RegularExpressions.Regex.IsMatch(stem, @"^(COM|LPT)[1-9¹²³]$")) + throw new ArgumentException("名称属于 Windows 保留设备名。"); + } + public static void ValidateLength(string path, bool directory) + { + if (path.Length > (directory ? 247 : 259)) throw new PathTooLongException("完整路径过长,请缩短名称或选择更浅的目录。"); + } + public static void Move(string from, string to, bool directory) + { + if (Exists(to)) throw new IOException("目标已存在,未覆盖:" + to); + if (directory) Directory.Move(from, to); else File.Move(from, to); + } + public static void CreateNew(string path, bool directory) + { + if (directory) + { + // CreateDirectoryW fails atomically when an item already exists; Directory.CreateDirectory does not. + if (!CreateDirectory(path, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法创建目录:" + path); + } + else using (FileStream stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { stream.Flush(true); } + } + public static void DeleteEmptyFile(string path, string expectedIdentity) + { + // Hold an exclusive DELETE-capable handle through the empty/identity checks and deletion. + // This prevents a writer from adding content between the check and the delete. + using (SafeFileHandle handle = CreateFile(path, 0x80010000, 0, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法独占访问创建项:" + path); + FileInformation info; + if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (FileId(info) != expectedIdentity || info.SizeHigh != 0 || info.SizeLow != 0 || (info.Attributes & 0x410) != 0) + throw new IOException("创建项已改变,不能删除:" + path); + int delete = 1; + if (!SetFileInformationByHandle(handle, 4, ref delete, 4)) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法删除空文件:" + path); + } + } + } + + public static class Planner + { + public const int MaxItems = 10000; + public static OperationPlan Rename(IList sources, IList rules, string seed, DateTime date, CancellationToken cancellation = default(CancellationToken)) + { + OperationPlan plan = new OperationPlan(); + if (sources.Count > MaxItems) { plan.Error = "每批最多 10000 项。"; return plan; } + HashSet seen = new HashSet(Fs.Comparer); + foreach (SourceItem source in sources) + { + cancellation.ThrowIfCancellationRequested(); + PlanEntry e = new PlanEntry { Source = source.Path, IsDirectory = source.IsDirectory, Error = "" }; + plan.Entries.Add(e); + try + { + e.Source = Fs.Normalize(source.Path); + if (!seen.Add(e.Source)) throw new ArgumentException("源路径重复。"); + if (!Fs.Exists(e.Source)) throw new FileNotFoundException("源项目已不存在。"); + if (e.Source == Path.GetPathRoot(e.Source)) throw new ArgumentException("不能重命名磁盘根目录。"); + Fs.Guard(e.Source); + e.IsDirectory = Directory.Exists(e.Source); + e.Identity = Fs.Identity(e.Source); + e.Name = RuleEngine.Apply(Path.GetFileName(e.Source), rules, new NameContext { + OriginalName = Path.GetFileName(e.Source), Parent = Path.GetFileName(Path.GetDirectoryName(e.Source)), + Key = e.Source, Index = plan.Entries.Count - 1, IsDirectory = e.IsDirectory, Date = date, Seed = seed, Cancellation = cancellation + }); + Fs.ValidateName(e.Name); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { e.Error = ex.Message; e.Name = Path.GetFileName(e.Source); } + } + Dictionary directories = new Dictionary(Fs.Comparer); + foreach (PlanEntry e in plan.Entries.Where(x => x.IsDirectory)) if (!directories.ContainsKey(e.Source)) directories.Add(e.Source, e); + foreach (PlanEntry e in plan.Entries) + { + try + { + e.Destination = Path.Combine(FinalParent(Path.GetDirectoryName(e.Source), directories), e.Name ?? ""); + Fs.ValidateLength(e.Destination, e.IsDirectory); + } + catch (Exception ex) { e.Error = ex.Message; e.Destination = e.Source; } + } + Validate(plan, true, cancellation); + return plan; + } + static string FinalParent(string parent, Dictionary dirs) + { + if (String.IsNullOrEmpty(parent)) return parent; + PlanEntry e; + if (dirs.TryGetValue(parent, out e)) return Path.Combine(FinalParent(Path.GetDirectoryName(parent), dirs), e.Name); + string next = Path.GetDirectoryName(parent); + if (String.IsNullOrEmpty(next)) return parent; + return Path.Combine(FinalParent(next, dirs), Path.GetFileName(parent)); + } + public static OperationPlan Create(string folder, IList names, bool directory, IList rules, string seed, DateTime date, CancellationToken cancellation = default(CancellationToken)) + { + OperationPlan plan = new OperationPlan { Create = true }; + try + { + folder = Fs.Normalize(folder); + if (!Directory.Exists(folder)) throw new DirectoryNotFoundException("请选择已存在的目标目录。"); + Fs.Guard(folder); + if (names.Count > MaxItems) throw new ArgumentException("每批最多创建 10000 项。"); + } + catch (Exception ex) { plan.Error = ex.Message; return plan; } + for (int i = 0; i < names.Count; i++) + { + cancellation.ThrowIfCancellationRequested(); + PlanEntry e = new PlanEntry { Source = "", IsDirectory = directory, Error = "" }; + plan.Entries.Add(e); + try + { + string baseName = RuleEngine.Expand(names[i], names[i], new NameContext { OriginalName = names[i], Parent = Path.GetFileName(folder), Index = i, IsDirectory = directory, Date = date }); + e.Name = RuleEngine.Apply(baseName, rules, new NameContext { OriginalName = baseName, Parent = Path.GetFileName(folder), Key = i + ":" + names[i], Index = i, IsDirectory = directory, Date = date, Seed = seed, Cancellation = cancellation }); + Fs.ValidateName(e.Name); + e.Destination = Path.Combine(folder, e.Name); + Fs.ValidateLength(e.Destination, directory); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { e.Error = ex.Message; e.Destination = folder; e.Name = names[i]; } + } + Validate(plan, true, cancellation); + return plan; + } + public static void Validate(OperationPlan plan, bool retainErrors, CancellationToken cancellation = default(CancellationToken)) + { + if (!retainErrors) foreach (PlanEntry e in plan.Entries) e.Error = ""; + Dictionary source = new Dictionary(Fs.Comparer); + foreach (PlanEntry e in plan.Entries) if (!String.IsNullOrEmpty(e.Source) && !source.ContainsKey(e.Source)) source.Add(e.Source, e); + foreach (var group in plan.Entries.Where(x => !String.IsNullOrEmpty(x.Destination)).GroupBy(x => x.Destination, Fs.Comparer).Where(x => x.Count() > 1)) + foreach (PlanEntry e in group) e.Error = "本批次中有多个项目使用同一目标名称。"; + foreach (PlanEntry e in plan.Entries) + { + cancellation.ThrowIfCancellationRequested(); + if (!String.IsNullOrEmpty(e.Error)) continue; + try + { + if (!plan.Create) + { + Fs.Guard(e.Source); + if (!Fs.Exists(e.Source) || Fs.Identity(e.Source) != e.Identity) throw new IOException("源项目已删除或被替换,请刷新预览。"); + } + string physicalTarget = plan.Create ? e.Destination : Path.Combine(Path.GetDirectoryName(e.Source), e.Name); + Fs.Guard(Path.GetDirectoryName(physicalTarget)); + PlanEntry occupant; + if (Fs.Exists(physicalTarget) && !(source.TryGetValue(physicalTarget, out occupant) && + (occupant.Changed || Fs.Comparer.Equals(occupant.Source, e.Source)))) + throw new IOException("目标名称已被现有项目占用。"); + } + catch (Exception ex) { e.Error = ex.Message; } + } + } + } + + public sealed class MoveRecord + { + public string From { get; set; } + public string To { get; set; } + public bool IsDirectory { get; set; } + public string Identity { get; set; } + public bool Done { get; set; } + public bool Reversed { get; set; } + } + public sealed class Transaction + { + public string Id { get; set; } + public string Kind { get; set; } + public string State { get; set; } + public string CreatedAt { get; set; } + public string UndoOf { get; set; } + public int EventCount { get; set; } + public List Entries { get; set; } + public List Moves { get; set; } + public Transaction() { Entries = new List(); Moves = new List(); } + } + public sealed class JournalEvent + { + public int Sequence { get; set; } + public string Type { get; set; } + public int Index { get; set; } + public string Value { get; set; } + public MoveRecord Move { get; set; } + } + public sealed class FileEngine + { + readonly string dataDir; + public Action Progress; + public Action BeforeMove; // deterministic fault injection for filesystem tests + public FileEngine(string data) { dataDir = data; } + string Journal(Transaction t) { return Path.Combine(dataDir, "transactions", t.Id + ".json"); } + void Save(Transaction t) { Json.AtomicWrite(Journal(t), t); } + void Append(Transaction tx, JournalEvent entry) + { + entry.Sequence = tx.EventCount + 1; + byte[] bytes = new System.Text.UTF8Encoding(false).GetBytes(Json.Encode(entry) + "\n"); + using (FileStream stream = new FileStream(Journal(tx) + "l", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read)) + { + // Discard only a torn final record left by a process/power interruption. + long end = stream.Length; + while (end > 0) { stream.Position = end - 1; if (stream.ReadByte() == 10) break; end--; } + if (end != stream.Length) stream.SetLength(end); + stream.Position = end; stream.Write(bytes, 0, bytes.Length); stream.Flush(true); + } + tx.EventCount = entry.Sequence; + } + static void Replay(Transaction tx, string file) + { + if (!File.Exists(file)) return; + string content = File.ReadAllText(file); + string[] lines = content.Split('\n'); + for (int i = 0; i < lines.Length - 1; i++) + { + if (lines[i].Length == 0) continue; + JournalEvent entry = Json.Decode(lines[i]); + if (entry.Sequence <= tx.EventCount) continue; + if (entry.Sequence != tx.EventCount + 1) throw new IOException("恢复日志序号不连续:" + file); + switch (entry.Type) + { + case "Move": tx.Moves.Add(entry.Move); break; + case "Reverse": tx.Moves[entry.Index].Reversed = true; break; + case "PrepareCreate": tx.Entries[entry.Index].Source = entry.Value; break; + case "Created": tx.Entries[entry.Index].Identity = entry.Value; break; + default: throw new IOException("无法识别恢复日志事件:" + entry.Type); + } + tx.EventCount = entry.Sequence; + } + } + public List History() + { + string folder = Path.Combine(dataDir, "transactions"); + List result = new List(); + if (!Directory.Exists(folder)) return result; + foreach (string file in Directory.GetFiles(folder, "*.json")) + { + // Do not silently discard an unreadable recovery journal. + Transaction tx = Json.Decode(File.ReadAllText(file)); + Replay(tx, file + "l"); result.Add(tx); + } + return result.OrderByDescending(x => x.CreatedAt, StringComparer.Ordinal).ToList(); + } + public List Pending() { return History().Where(x => x.State == "Running" || x.State == "RecoveryNeeded" || x.State == "UndoRunning" || x.State == "DeletePending").ToList(); } + public Transaction LastUndoable() + { + List history = History(); + HashSet undone = new HashSet(history.Where(x => x.State == "Completed" && !String.IsNullOrEmpty(x.UndoOf)).Select(x => x.UndoOf)); + return history.FirstOrDefault(x => x.State == "Completed" && String.IsNullOrEmpty(x.UndoOf) && !undone.Contains(x.Id)); + } + FileStream AcquireLock() + { + Directory.CreateDirectory(dataDir); + try { return new FileStream(Path.Combine(dataDir, "operations.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException ex) { throw new IOException("另一个窗口正在执行文件操作,请等待完成。", ex); } + } + Transaction New(OperationPlan plan, string undoOf) + { + return new Transaction { Id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff") + "-" + Guid.NewGuid().ToString("N").Substring(0, 8), Kind = plan.Create ? "Create" : "Rename", State = "Running", CreatedAt = DateTime.UtcNow.ToString("o"), UndoOf = undoOf, Entries = plan.Entries.Where(x => plan.Create || x.Changed).ToList() }; + } + public Transaction Execute(OperationPlan plan) { using (AcquireLock()) return Execute(plan, null, false); } + Transaction Execute(OperationPlan plan, string undoOf, bool undoCreate) + { + string protectedDir = Fs.Normalize(dataDir); + foreach (PlanEntry e in plan.Entries) + foreach (string path in new[] { e.Source, e.Destination }.Where(x => !String.IsNullOrEmpty(x))) + if (Fs.Comparer.Equals(path, protectedDir) || Fs.Under(path, protectedDir) || Fs.Under(protectedDir, path)) + throw new IOException("不能操作插件自身的日志目录或其父目录:" + path); + if (Pending().Count > 0) throw new IOException("存在未完成操作,请先点击“恢复中断操作”。"); + Planner.Validate(plan, true); + if (!plan.Valid) throw new IOException("预览存在错误或文件系统已变化,请重新预览。" + Environment.NewLine + plan.Error + Environment.NewLine + String.Join(Environment.NewLine, plan.Entries.Where(x => !String.IsNullOrEmpty(x.Error)).Take(5).Select(x => x.Error))); + if (plan.ChangedCount == 0) throw new IOException("当前没有需要执行的变更。"); + Transaction tx = New(plan, undoOf); + if (undoCreate) tx.Kind = "UndoCreateStage"; + Save(tx); // A journal is durable before the first filesystem mutation. + try + { + if (plan.Create) Create(tx); else Rename(tx); + tx.State = undoCreate ? "DeletePending" : "Completed"; Save(tx); return tx; + } + catch (Exception ex) + { + try { Recover(tx); } + catch (Exception rollback) + { + tx.State = "RecoveryNeeded"; Save(tx); + throw new IOException("操作失败,回滚未能全部完成。保留了恢复日志,请先恢复中断操作。\r\n" + ex.Message + "\r\n" + rollback.Message, ex); + } + throw new IOException("操作失败,已回滚本次变更。\r\n" + ex.Message, ex); + } + } + void Create(Transaction tx) + { + int done = 0; + foreach (PlanEntry e in tx.Entries) + { + // Create into a private staging name first. A crash can be recovered using the move journal. + string temp = Temporary(Path.GetDirectoryName(e.Destination)); + e.Source = temp; Append(tx, new JournalEvent { Type = "PrepareCreate", Index = done, Value = temp }); + Fs.Guard(Path.GetDirectoryName(temp)); + Fs.CreateNew(temp, e.IsDirectory); + e.Identity = Fs.Identity(temp); Append(tx, new JournalEvent { Type = "Created", Index = done, Value = e.Identity }); + RecordMove(tx, temp, e.Destination, e.IsDirectory, e.Identity); + if (Progress != null) Progress(++done, tx.Entries.Count); + } + } + static string Temporary(string parent) + { + string temp; + do { temp = Path.Combine(parent, ".wbr-" + Guid.NewGuid().ToString("N").Substring(0, 12)); } while (Fs.Exists(temp)); + Fs.ValidateLength(temp, true); return temp; + } + void Rename(Transaction tx) + { + Dictionary current = tx.Entries.ToDictionary(x => x, x => x.Source); + int done = 0, total = tx.Entries.Count * 2; + foreach (PlanEntry e in tx.Entries.OrderByDescending(x => Fs.Depth(x.Source))) + { + string from = current[e], to = Temporary(Path.GetDirectoryName(from)); + RecordMove(tx, from, to, e.IsDirectory, e.Identity); + Update(current, from, to); + if (Progress != null) Progress(++done, total); + } + foreach (PlanEntry e in tx.Entries.OrderBy(x => Fs.Depth(x.Destination))) + { + string from = current[e]; + RecordMove(tx, from, e.Destination, e.IsDirectory, e.Identity); + Update(current, from, e.Destination); + if (Progress != null) Progress(++done, total); + } + } + static void Update(Dictionary paths, string from, string to) + { + foreach (PlanEntry e in paths.Keys.ToList()) + { + if (Fs.Comparer.Equals(paths[e], from)) paths[e] = to; + else if (Fs.Under(paths[e], from)) paths[e] = to + paths[e].Substring(from.Length); + } + } + void RecordMove(Transaction tx, string from, string to, bool directory, string identity) + { + Fs.Guard(from); Fs.Guard(Path.GetDirectoryName(to)); + if (Fs.Identity(from) != identity) throw new IOException("执行期间源文件被替换,已停止。"); + MoveRecord move = new MoveRecord { From = from, To = to, IsDirectory = directory, Identity = identity }; + Append(tx, new JournalEvent { Type = "Move", Move = move }); tx.Moves.Add(move); + if (BeforeMove != null) BeforeMove(from, to); + Fs.Move(from, to, directory); + move.Done = true; + } + public void Recover(Transaction tx) + { + foreach (MoveRecord move in tx.Moves.AsEnumerable().Reverse()) + { + if (move.Reversed) continue; + Fs.Guard(Path.GetDirectoryName(move.From)); Fs.Guard(Path.GetDirectoryName(move.To)); + bool atFrom = Fs.Exists(move.From) && Fs.Identity(move.From) == move.Identity; + bool atTo = Fs.Exists(move.To) && Fs.Identity(move.To) == move.Identity; + if (atTo && !Fs.Exists(move.From)) Fs.Move(move.To, move.From, move.IsDirectory); + else if (!atFrom) throw new IOException("恢复路径被占用或文件标识不一致,请查看日志:" + move.From + " ← " + move.To); + move.Reversed = true; Append(tx, new JournalEvent { Type = "Reverse", Index = tx.Moves.IndexOf(move) }); + } + if (tx.Kind == "Create") + { + foreach (PlanEntry e in tx.Entries) + { + if (String.IsNullOrEmpty(e.Source) || !Fs.Exists(e.Source)) continue; + if (String.IsNullOrEmpty(e.Identity)) throw new IOException("创建暂存项时发生中断,请人工核对后移走:" + e.Source); + DeleteCreated(e, e.Source); + } + } + tx.State = "RolledBack"; Save(tx); + } + static void CheckEmpty(PlanEntry e, string path) + { + Fs.Guard(path); + if (!Fs.Exists(path) || Fs.Identity(path) != e.Identity) throw new IOException("创建项已被删除或替换,不能自动撤销:" + path); + if (e.IsDirectory ? Directory.EnumerateFileSystemEntries(path).Any() : new FileInfo(path).Length != 0) + throw new IOException("创建项中已有内容,不能自动删除:" + path); + } + static void DeleteCreated(PlanEntry e, string path) + { + CheckEmpty(e, path); + if (e.IsDirectory) Directory.Delete(path, false); else Fs.DeleteEmptyFile(path, e.Identity); + } + public Transaction UndoLast() + { using (AcquireLock()) return UndoLastLocked(); } + Transaction UndoLastLocked() + { + if (Pending().Count > 0) throw new IOException("请先恢复中断操作。"); + Transaction tx = LastUndoable(); + if (tx == null) throw new IOException("没有可撤销的操作。"); + if (tx.Kind == "Create") + { + foreach (PlanEntry e in tx.Entries) CheckEmpty(e, e.Destination); + // Move the entire batch aside before deleting empty objects. This leaves a durable recovery path. + OperationPlan staging = new OperationPlan(); + foreach (PlanEntry e in tx.Entries) + { + string to = Temporary(Path.GetDirectoryName(e.Destination)); + staging.Entries.Add(new PlanEntry { Source = e.Destination, Destination = to, Name = Path.GetFileName(to), IsDirectory = e.IsDirectory, Identity = e.Identity, Error = "" }); + } + Transaction undo = Execute(staging, tx.Id, true); + // Recheck emptiness at the staged paths, so outside writers never cause recursive deletion. + try + { + foreach (PlanEntry e in undo.Entries) CheckEmpty(e, e.Destination); + } + catch { Recover(undo); throw; } + undo.Kind = "DeleteEmpty"; undo.State = "UndoRunning"; Save(undo); + foreach (PlanEntry e in undo.Entries) DeleteCreated(e, e.Destination); + undo.State = "Completed"; Save(undo); + tx.State = "Undone"; Save(tx); return tx; + } + OperationPlan reverse = new OperationPlan(); + foreach (PlanEntry e in tx.Entries) + reverse.Entries.Add(new PlanEntry { Source = e.Destination, Destination = e.Source, Name = Path.GetFileName(e.Source), IsDirectory = e.IsDirectory, Identity = e.Identity, Error = "" }); + // For hierarchical undo, destinations are absolute original paths. Collision checks still use the current parent. + Execute(reverse, tx.Id, false); + tx.State = "Undone"; Save(tx); return tx; + } + public void RecoverPending() + { using (AcquireLock()) RecoverPendingLocked(); } + void RecoverPendingLocked() + { + foreach (Transaction tx in Pending()) + { + if (tx.Kind == "DeleteEmpty" || (tx.Kind == "UndoCreateStage" && tx.State == "DeletePending")) + { + tx.Kind = "DeleteEmpty"; tx.State = "UndoRunning"; Save(tx); + foreach (PlanEntry e in tx.Entries) if (Fs.Exists(e.Destination)) DeleteCreated(e, e.Destination); + tx.State = "Completed"; Save(tx); + Transaction original = History().FirstOrDefault(x => x.Id == tx.UndoOf); + if (original != null) { original.State = "Undone"; Save(original); } + } + else Recover(tx); + } + // Complete the bookkeeping if a previous successful undo was interrupted just before marking its original. + List history = History(); + foreach (Transaction undo in history.Where(x => x.State == "Completed" && !String.IsNullOrEmpty(x.UndoOf))) + { + Transaction original = history.FirstOrDefault(x => x.Id == undo.UndoOf); + if (original != null && original.State == "Completed") { original.State = "Undone"; Save(original); } + } + } + } +} diff --git a/plugins/crazy-zxx.yigengming/src/MainForm.cs b/plugins/crazy-zxx.yigengming/src/MainForm.cs new file mode 100644 index 0000000..a8f5319 --- /dev/null +++ b/plugins/crazy-zxx.yigengming/src/MainForm.cs @@ -0,0 +1,667 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace WinShun.BatchRenamer +{ + public sealed class MainForm : Form + { + StudioPalette palette = StudioTheme.Light; + Color Ink { get { return palette.Ink; } } + Color Muted { get { return palette.Muted; } } + Color Accent { get { return palette.Accent; } } + Color Canvas { get { return palette.Canvas; } } + readonly Func readSystemTheme; + readonly ContextMenuStrip themeMenu = new ContextMenuStrip(), more = new ContextMenuStrip(); + Button themeButton; + bool themeUpdateQueued; + internal ThemePreference ThemeMode { get; private set; } + internal bool IsDarkTheme { get { return palette.Dark; } } + readonly List sources = new List(); + readonly List rules = new List(); + readonly FileEngine engine; + readonly string dataDir; + readonly Panel body = new Panel(), renameTools = new Panel(), createTools = new Panel(); + readonly Label title = new Label(), subtitle = new Label(), status = new Label(), stats = new Label(), details = new Label(); + readonly Button renameTab, createTab, execute; + readonly DataGridView grid = new StudioGrid(); + readonly Label emptyState = new Label(); + readonly TableLayoutPanel layout = new TableLayoutPanel(); + readonly SplitContainer workspaceSplit = new SplitContainer(); + readonly TableLayoutPanel ruleLayout = new TableLayoutPanel(); + readonly ToolTip tips = new ToolTip(); + FlowLayoutPanel fileActions; + readonly CheckedListBox ruleList = new CheckedListBox(); + readonly TableLayoutPanel editor = new TableLayoutPanel(); + readonly ComboBox ruleType = new StudioComboBox(), importKind = new StudioComboBox(), sort = new StudioComboBox(), createKind = new StudioComboBox(), createMode = new StudioComboBox(); + readonly TextBox folder = new TextBox(), template = new TextBox(), names = new TextBox(); + readonly NumericUpDown count = new StudioNumericUpDown(); + readonly CheckBox recursive = new CheckBox(); + readonly System.Windows.Forms.Timer debounce = new System.Windows.Forms.Timer(); + OperationPlan plan; + bool creating, busy, changingEditor; + System.Threading.CancellationTokenSource previewCancellation; + int generation; + string seed = Guid.NewGuid().ToString("N"); + string selectAfterPreview; + DateTime planDate = DateTime.Now; + public bool IsBusy { get { return busy; } } + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] static extern int StrCmpLogicalW(string a, string b); + + public MainForm(string data) : this(data, StudioTheme.ReadSystemDark) { } + internal MainForm(string data, Func systemThemeReader) + { + dataDir = data; engine = new FileEngine(data); readSystemTheme = systemThemeReader; + ThemeMode = StudioTheme.Load(data); + engine.Progress = delegate(int done, int total) + { + if ((done % 32 == 0 || done == total) && IsHandleCreated && !IsDisposed) + BeginInvoke(new Action(delegate { if (busy) status.Text = "正在处理 " + done + " / " + total + "…"; })); + }; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); AutoScaleMode = AutoScaleMode.Dpi; + Text = "易更名 · WinShun"; Size = new Size(1360, 860); MinimumSize = new Size(1060, 700); + StartPosition = FormStartPosition.CenterScreen; BackColor = Canvas; ForeColor = Ink; + Font = new Font("Microsoft YaHei UI", 10.5F, FontStyle.Regular, GraphicsUnit.Point); + string iconFile = Path.Combine(CommunityHost.PluginDirectory, "app.ico"); + if (File.Exists(iconFile)) Icon = new Icon(iconFile); + Panel main = new Panel { Dock = DockStyle.Fill, Padding = new Padding(12, 0, 12, 0) }; Controls.Add(main); + layout.Dock = DockStyle.Fill; layout.ColumnCount = 1; layout.RowCount = 4; layout.BackColor = Canvas; layout.Margin = Padding.Empty; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 52)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); + main.Controls.Add(layout); + Panel header = new Panel { Dock = DockStyle.Fill, Margin = Padding.Empty }; + title.Text = "易更名"; title.Font = new Font(Font.FontFamily, 13.5F, FontStyle.Bold, GraphicsUnit.Point); title.AutoSize = true; title.Location = new Point(6, 14); header.Controls.Add(title); + FlowLayoutPanel modes = new FlowLayoutPanel { Location = new Point(160, 10), Size = new Size(300, 34), WrapContents = false }; + renameTab = MakeButton("批量重命名", delegate { Switch(false); }, true); createTab = MakeButton("批量创建", delegate { Switch(true); }, false); + modes.Controls.Add(renameTab); modes.Controls.Add(createTab); header.Controls.Add(modes); + FlowLayoutPanel presets = new FlowLayoutPanel { Dock = DockStyle.Right, Width = 390, FlowDirection = FlowDirection.RightToLeft, Padding = new Padding(0, 10, 0, 0), WrapContents = false }; + themeMenu.Font = Font; + foreach (ThemePreference mode in Enum.GetValues(typeof(ThemePreference))) + { + ThemePreference choice = mode; + ToolStripMenuItem item = new ToolStripMenuItem(ThemeName(mode)) { Tag = mode }; + item.Click += delegate { SetThemeMode(choice); }; themeMenu.Items.Add(item); + } + themeButton = MakeButton("主题 · 跟随系统 ▾", delegate { themeMenu.Show(themeButton, new Point(0, themeButton.Height)); }, false); + presets.Controls.Add(themeButton); presets.Controls.Add(MakeButton("保存规则", SavePreset, false)); presets.Controls.Add(MakeButton("载入规则", LoadPreset, false)); header.Controls.Add(presets); + layout.Controls.Add(header, 0, 0); + Panel tools = new Panel { Dock = DockStyle.Fill, BackColor = Color.White, Tag = "theme-surface", Padding = new Padding(8, 6, 8, 6), Margin = new Padding(0, 0, 0, 8) }; + layout.Controls.Add(tools, 0, 1); + renameTools.Dock = DockStyle.Fill; createTools.Dock = DockStyle.Fill; tools.Controls.Add(renameTools); tools.Controls.Add(createTools); createTools.Visible = false; + BuildRenameTools(); BuildCreateTools(); + + body.Dock = DockStyle.Fill; body.Margin = Padding.Empty; layout.Controls.Add(body, 0, 2); + workspaceSplit.Dock = DockStyle.Fill; workspaceSplit.BackColor = Canvas; workspaceSplit.SplitterWidth = 8; workspaceSplit.FixedPanel = FixedPanel.Panel2; + workspaceSplit.Size = new Size(1320, 700); workspaceSplit.Panel1MinSize = 460; workspaceSplit.Panel2MinSize = 320; + workspaceSplit.SplitterDistance = 952; body.Controls.Add(workspaceSplit); + BuildPreview(workspaceSplit.Panel1); BuildRules(workspaceSplit.Panel2); + Panel footer = new Panel { Dock = DockStyle.Fill, Margin = Padding.Empty }; + layout.Controls.Add(footer, 0, 3); + FlowLayoutPanel actions = new FlowLayoutPanel { Dock = DockStyle.Left, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Padding = new Padding(0, 9, 0, 0), WrapContents = false }; + execute = MakeButton("执行重命名", ExecuteClick, true); execute.MinimumSize = new Size(112, 30); actions.Controls.Add(execute); + actions.Controls.Add(MakeButton("撤销最近操作", UndoClick, false)); + more.Font = Font; + more.Items.Add("导出预览 CSV", null, delegate { if (!busy) ExportPreview(); }); + more.Items.Add("重新生成随机值", null, delegate { if (!busy) { seed = Guid.NewGuid().ToString("N"); Schedule(); } }); + more.Items.Add(new ToolStripSeparator()); more.Items.Add("恢复中断操作", null, delegate { if (!busy) RecoverClick(); }); + Button moreButton = null; moreButton = MakeButton("更多 ⋯", delegate { more.Show(moreButton, new Point(0, -more.PreferredSize.Height)); }, false); actions.Controls.Add(moreButton); + footer.Controls.Add(actions); + stats.ForeColor = Muted; stats.Dock = DockStyle.Right; stats.Width = 152; stats.TextAlign = ContentAlignment.MiddleRight; footer.Controls.Add(stats); + status.Dock = DockStyle.Fill; status.ForeColor = Muted; status.AutoEllipsis = true; status.TextAlign = ContentAlignment.MiddleLeft; status.Padding = new Padding(10, 0, 8, 0); status.Text = "拖入文件,添加规则后即可预览。"; footer.Controls.Add(status); status.BringToFront(); + debounce.Interval = 280; debounce.Tick += async delegate { debounce.Stop(); await RefreshPreview(); }; + AllowDrop = true; + DragEnter += delegate(object sender, DragEventArgs e) { if (!busy && e.Data.GetDataPresent(DataFormats.FileDrop)) e.Effect = DragDropEffects.Copy; }; + DragDrop += async delegate(object sender, DragEventArgs e) { if (!busy) { Switch(false); await AddPaths((string[])e.Data.GetData(DataFormats.FileDrop)); } }; + FormClosing += delegate(object sender, FormClosingEventArgs e) { if (busy) { e.Cancel = true; MessageBox.Show(this, "操作进行中,请等待完成后关闭。", "正在处理", MessageBoxButtons.OK, MessageBoxIcon.Information); } }; + FormClosed += delegate { if (previewCancellation != null) previewCancellation.Cancel(); debounce.Dispose(); tips.Dispose(); more.Dispose(); themeMenu.Dispose(); }; + Shown += delegate + { + ApplyDensity(); + try { if (engine.Pending().Count > 0) status.Text = "检测到未完成操作,请先点击“更多 → 恢复中断操作”。"; } + catch (Exception ex) { Error(ex); } + }; + DpiChanged += delegate { BeginInvoke(new Action(ApplyDensity)); }; + RefreshRules(-1); UpdateStats(); ApplyTheme(); ResumeLayout(true); + } + Button MakeButton(string text, Action action, bool primary) + { + Button b = new StudioButton { Primary = primary, Text = text, AutoSize = true, Height = 30, MinimumSize = new Size(28, 30), Padding = new Padding(5, 0, 5, 0), Cursor = Cursors.Hand, BackColor = primary ? palette.Primary : palette.Surface, ForeColor = primary ? Color.White : Ink, Margin = new Padding(0, 0, 6, 0) }; + b.FlatAppearance.BorderColor = primary ? Accent : Color.FromArgb(218, 226, 231); b.FlatAppearance.BorderSize = primary ? 0 : 1; + b.Click += delegate { if (!busy) action(); }; return b; + } + static void Options(ComboBox box, string[] texts, int selected) + { box.DropDownStyle = ComboBoxStyle.DropDownList; box.Items.AddRange(texts); box.SelectedIndex = selected; } + void BuildRenameTools() + { + FlowLayoutPanel top = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false }; + top.Controls.Add(MakeButton("+ 文件", AddFiles, true)); + top.Controls.Add(MakeButton("+ 文件夹", AddFolder, false)); + top.Controls.Add(MakeButton("导入目录内容", ImportContents, false)); + recursive.Text = "含子目录"; recursive.AutoSize = true; recursive.Margin = new Padding(10, 5, 8, 0); top.Controls.Add(recursive); + Options(importKind, new[] { "文件和文件夹", "仅文件", "仅文件夹" }, 0); importKind.Width = 130; importKind.Margin = new Padding(0, 3, 0, 0); top.Controls.Add(importKind); + top.Controls.Add(new Label { Text = "也可直接拖入文件或文件夹", AutoSize = true, ForeColor = Muted, Margin = new Padding(18, 6, 0, 0) }); + renameTools.Controls.Add(top); + } + void BuildCreateTools() + { + TableLayoutPanel table = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 5, RowCount = 2 }; + table.Margin = Padding.Empty; table.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); table.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 70)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 94)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 125)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 125)); + createTools.Controls.Add(table); + table.Controls.Add(FieldLabel("目标目录"), 0, 0); folder.Dock = DockStyle.Fill; table.Controls.Add(folder, 1, 0); table.Controls.Add(MakeButton("选择…", PickCreateFolder, false), 2, 0); + Options(createKind, new[] { "创建文件", "创建文件夹" }, 0); createKind.Dock = DockStyle.Fill; table.Controls.Add(createKind, 3, 0); + Options(createMode, new[] { "模板 + 数量", "按行创建" }, 0); createMode.Dock = DockStyle.Fill; table.Controls.Add(createMode, 4, 0); + table.Controls.Add(FieldLabel("名称模板"), 0, 1); template.Text = "新建_{n:000}.txt"; template.Dock = DockStyle.Fill; table.Controls.Add(template, 1, 1); + names.Multiline = true; names.ScrollBars = ScrollBars.Vertical; names.Dock = DockStyle.Fill; names.Visible = false; + count.Minimum = 1; count.Maximum = Planner.MaxItems; count.Value = 10; count.Dock = DockStyle.Fill; table.Controls.Add(count, 2, 1); + Label hint = FieldLabel("{n:000} 序号 · {date:yyyyMMdd} 日期"); hint.AutoSize = false; hint.AutoEllipsis = true; hint.Dock = DockStyle.Fill; table.SetColumnSpan(hint, 2); table.Controls.Add(hint, 3, 1); + tips.SetToolTip(hint, "名称可使用 {n:000} 序号和 {date:yyyyMMdd} 日期。\r\n右侧组合规则同样适用于创建。"); + folder.TextChanged += delegate { Schedule(); }; template.TextChanged += delegate { Schedule(); }; names.TextChanged += delegate { Schedule(); }; count.ValueChanged += delegate { Schedule(); }; createKind.SelectedIndexChanged += delegate { Schedule(); }; + createMode.SelectedIndexChanged += delegate + { + if (createMode.SelectedIndex == 1) + { + table.Controls.Remove(template); table.Controls.Add(names, 1, 1); names.Visible = true; count.Enabled = false; + if (names.Text.Length == 0) names.Text = "第一项.txt\r\n第二项.txt\r\n第三项.txt"; + } + else { table.Controls.Remove(names); table.Controls.Add(template, 1, 1); template.Visible = true; count.Enabled = true; } + StudioTheme.ApplyTree(table, palette, palette.Surface); UpdateToolsHeight(); Schedule(); + }; + folder.Text = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + } + Label FieldLabel(string text) { return new Label { Text = text, AutoSize = true, ForeColor = Muted, Margin = new Padding(0, 5, 4, 3), UseCompatibleTextRendering = false }; } + void BuildPreview(Panel panel) + { + panel.BackColor = Color.White; panel.Tag = "theme-surface"; + TableLayoutPanel table = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, Margin = Padding.Empty }; + // An implicit AutoSize column retains the header's previous preferred width, + // leaving its right-docked actions outside the viewport after narrowing. + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); table.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); table.RowStyles.Add(new RowStyle(SizeType.Absolute, 29)); panel.Controls.Add(table); + Panel previewHeader = new Panel { Dock = DockStyle.Fill, Margin = Padding.Empty }; + previewHeader.Controls.Add(new Label { Text = "文件预览", Font = new Font(Font, FontStyle.Bold), Dock = DockStyle.Fill, AutoEllipsis = true, TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(10, 0, 6, 0) }); + fileActions = new FlowLayoutPanel { Dock = DockStyle.Right, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, WrapContents = false, Padding = new Padding(0, 6, 0, 0) }; + Options(sort, new[] { "名称自然排序 ↑", "名称自然排序 ↓", "完整路径排序" }, 0); sort.Width = 145; sort.Margin = new Padding(0, 2, 5, 0); fileActions.Controls.Add(sort); + fileActions.Controls.Add(MakeButton("排序", SortSources, false)); + Button up = MakeButton("↑", delegate { MoveSource(-1); }, false), down = MakeButton("↓", delegate { MoveSource(1); }, false); + tips.SetToolTip(up, "上移选中项目"); tips.SetToolTip(down, "下移选中项目"); fileActions.Controls.Add(up); fileActions.Controls.Add(down); + fileActions.Controls.Add(MakeButton("移除", RemoveSelected, false)); fileActions.Controls.Add(MakeButton("清空", delegate { sources.Clear(); Schedule(); }, false)); + previewHeader.Controls.Add(fileActions); table.Controls.Add(previewHeader, 0, 0); + grid.Dock = DockStyle.Fill; grid.BorderStyle = BorderStyle.None; grid.BackgroundColor = Color.White; grid.ReadOnly = true; grid.AllowUserToAddRows = false; grid.AllowUserToDeleteRows = false; grid.AllowUserToResizeRows = false; + grid.RowHeadersVisible = false; grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; grid.AutoGenerateColumns = false; grid.EnableHeadersVisualStyles = false; grid.ColumnHeadersHeight = 38; grid.RowTemplate.Height = 33; + grid.ColumnHeadersHeight = 32; grid.RowTemplate.Height = 30; grid.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing; + grid.Font = Font; grid.DefaultCellStyle.Font = Font; grid.ColumnHeadersDefaultCellStyle.Font = Font; + grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(239, 244, 247); grid.ColumnHeadersDefaultCellStyle.ForeColor = Ink; grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = Color.FromArgb(239, 244, 247); grid.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None; + grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(222, 242, 236); grid.DefaultCellStyle.SelectionForeColor = Ink; grid.DefaultCellStyle.Padding = new Padding(6, 0, 6, 0); + grid.DefaultCellStyle.ForeColor = Ink; grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(250, 252, 253); grid.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal; grid.GridColor = Color.FromArgb(237, 241, 245); + grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "index", HeaderText = "#", Width = 45 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "kind", HeaderText = "类型", Width = 62 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "old", HeaderText = "原名称", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, FillWeight = 100 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "new", HeaderText = "新名称", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, FillWeight = 120 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "state", HeaderText = "状态", Width = 88 }); + foreach (DataGridViewColumn column in grid.Columns) column.SortMode = DataGridViewColumnSortMode.NotSortable; + grid.SelectionChanged += delegate + { + if (grid.CurrentRow == null || plan == null || grid.CurrentRow.Index >= plan.Entries.Count) return; + PlanEntry e = plan.Entries[grid.CurrentRow.Index]; details.Text = (String.IsNullOrEmpty(e.Error) ? e.Destination : e.Error) ?? ""; + }; + table.Controls.Add(grid, 0, 1); + emptyState.Text = "将文件或文件夹拖到这里\r\n\r\n添加规则后,新名称会实时显示在右侧列。"; emptyState.Dock = DockStyle.Fill; emptyState.BackColor = Color.White; emptyState.ForeColor = Muted; emptyState.TextAlign = ContentAlignment.MiddleCenter; grid.Controls.Add(emptyState); + details.ForeColor = Muted; details.Dock = DockStyle.Fill; details.Padding = new Padding(10, 0, 8, 0); details.TextAlign = ContentAlignment.MiddleLeft; details.AutoEllipsis = true; table.Controls.Add(details, 0, 2); + } + void BuildRules(Panel panel) + { + panel.BackColor = Color.White; panel.Tag = "theme-surface"; panel.Padding = new Padding(12, 10, 12, 8); + TableLayoutPanel table = ruleLayout; table.Dock = DockStyle.Fill; table.ColumnCount = 1; table.RowCount = 6; table.Margin = Padding.Empty; + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); table.RowStyles.Add(new RowStyle(SizeType.Absolute, 37)); table.RowStyles.Add(new RowStyle(SizeType.Absolute, 96)); table.RowStyles.Add(new RowStyle(SizeType.Absolute, 35)); table.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); table.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); panel.Controls.Add(table); + table.Controls.Add(new Label { Text = "组合规则", Font = new Font(Font, FontStyle.Bold), AutoSize = true }, 0, 0); + FlowLayoutPanel add = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false }; + Options(ruleType, new[] { "序号", "大小写转换", "文字替换", "插入文字", "随机字符", "截取 / 删除", "正则匹配", "名称模板", "去除首尾空白" }, 0); ruleType.Width = 200; ruleType.Margin = new Padding(0, 2, 6, 0); add.Controls.Add(ruleType); + add.Controls.Add(MakeButton("+ 添加", delegate { AddRule(Rule.New(new[] { "Sequence", "Case", "Replace", "Insert", "Random", "Slice", "Regex", "Template", "Trim" }[ruleType.SelectedIndex])); }, true)); table.Controls.Add(add, 0, 1); + ruleList.Dock = DockStyle.Fill; ruleList.BorderStyle = BorderStyle.None; ruleList.BackColor = Color.FromArgb(247, 250, 252); ruleList.CheckOnClick = false; ruleList.IntegralHeight = false; ruleList.HorizontalScrollbar = true; + ruleList.SelectedIndexChanged += delegate { if (!changingEditor) BuildEditor(); }; + ruleList.ItemCheck += delegate(object sender, ItemCheckEventArgs e) + { if (!changingEditor && e.Index < rules.Count) { rules[e.Index].Enabled = e.NewValue == CheckState.Checked; Schedule(); } }; + table.Controls.Add(ruleList, 0, 2); + FlowLayoutPanel order = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false, Margin = Padding.Empty }; + Button up = MakeButton("↑", delegate { MoveRule(-1); }, false); order.Controls.Add(up); + Button down = MakeButton("↓", delegate { MoveRule(1); }, false); order.Controls.Add(down); + order.Controls.Add(MakeButton("移除规则", delegate { int i = ruleList.SelectedIndex; if (i >= 0) { rules.RemoveAt(i); RefreshRules(Math.Min(i, rules.Count - 1)); Schedule(); } }, false)); + table.Controls.Add(order, 0, 3); + table.Controls.Add(new Label { Text = "规则参数", Font = new Font(Font, FontStyle.Bold), AutoSize = true, Margin = new Padding(0, 4, 0, 0) }, 0, 4); + Panel scroll = new Panel { Dock = DockStyle.Fill, AutoScroll = true }; table.Controls.Add(scroll, 0, 5); + editor.Dock = DockStyle.Top; editor.AutoSize = true; editor.ColumnCount = 2; + editor.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 78)); editor.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); scroll.Controls.Add(editor); + editor.SizeChanged += delegate { foreach (Label label in editor.Controls.OfType