From 748bf94d6fce39191254da380d75105006d0446e Mon Sep 17 00:00:00 2001 From: Zain Date: Sat, 14 Feb 2026 12:37:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AD=A3=E5=88=99?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E8=A7=84=E5=88=99=E5=92=8C=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增正则替换规则(RegexRule),支持捕获组、自定义flags和包含扩展名选项 - 为平台配置添加maxConcurrent字段,支持批量操作并发控制 - 重构BatchExecutor实现,使用工作池模式限制最大并发数 - 在UI配置面板中添加正则规则表单及实时验证 - 更新多语言资源文件和README文档 - 添加相关单元测试确保功能稳定性 --- .gitignore | 5 +- README.md | 28 +++- src/adapters/base/adapter.interface.ts | 1 + src/content/components/config-panel.ts | 152 ++++++++++++++++++ src/content/components/file-selector-panel.ts | 2 + src/content/components/rename-preview.ts | 4 + src/core/crash-recovery.ts | 4 +- src/core/executor.ts | 97 ++++++++--- src/locales/en.json | 10 ++ src/locales/zh_CN.json | 10 ++ src/locales/zh_TW.json | 10 ++ src/rules/regex.ts | 93 +++++++++++ src/rules/rule-factory.ts | 4 + src/shared/version.ts | 2 +- src/types/platform.ts | 2 + src/types/rule.ts | 27 +++- tests/unit/adapters/base-adapter.test.ts | 3 + tests/unit/executor.test.ts | 28 ++++ tests/unit/rules.test.ts | 65 ++++++++ 19 files changed, 513 insertions(+), 34 deletions(-) create mode 100644 src/rules/regex.ts diff --git a/.gitignore b/.gitignore index 5b201d7..113d29a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ coverage/ *.tmp .cache/ .serena/ -.claude/ \ No newline at end of file +.claude/ +findings.md +task_plan.md +progress.md diff --git a/README.md b/README.md index 2c44760..58fe256 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,26 @@ } ``` -#### 2. 前缀规则 (Prefix) +#### 2. 正则替换规则 (Regex) +使用正则表达式批量替换文件名(默认不处理扩展名),支持捕获组与 `$1` 等替换语法。 + +**使用场景**: +- 交换两段内容:`001-hello` → `hello-001` +- 清理特定模式:删除 `【1080p】`、`(字幕组)` 等 + +**配置参数**: +```typescript +{ + pattern: string; // 正则表达式(不包含前后 /) + replace: string; // 替换表达式 + caseSensitive: boolean; // 是否区分大小写(false 会启用 i) + global: boolean; // 是否全局替换(true 会启用 g) + flags?: string; // 自定义 flags(高级,可选,建议填 m/s/u 等;g/i 由上面开关控制) + includeExtension?: boolean; // 是否包含扩展名(允许修改后缀) +} +``` + +#### 3. 前缀规则 (Prefix) 为所有文件添加统一前缀,可自定义分隔符。 **使用场景**: @@ -70,7 +89,7 @@ } ``` -#### 3. 后缀规则 (Suffix) +#### 4. 后缀规则 (Suffix) 为文件名(扩展名之前)添加后缀标记。 **使用场景**: @@ -85,7 +104,7 @@ } ``` -#### 4. 编号规则 (Numbering) +#### 5. 编号规则 (Numbering) 自动为文件添加序号,支持自定义格式。 **使用场景**: @@ -103,7 +122,7 @@ } ``` -#### 5. 清理规则 (Sanitize) +#### 6. 清理规则 (Sanitize) 清除文件名中的非法字符或特定字符集。 **使用场景**: @@ -122,6 +141,7 @@ #### 批量处理系统 - **速率控制**:800ms 间隔执行,防止 API 限流 +- **最大并发**:默认 3(按平台可调),避免请求堆积导致的失败/风控 - **重试机制**:指数退避算法,自动重试失败操作 - **崩溃恢复**:异常中断后可继续未完成的任务 - **幂等性保证**:避免重复执行相同操作 diff --git a/src/adapters/base/adapter.interface.ts b/src/adapters/base/adapter.interface.ts index 9f0187f..8ae220c 100644 --- a/src/adapters/base/adapter.interface.ts +++ b/src/adapters/base/adapter.interface.ts @@ -19,6 +19,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { this.config = { platform: 'quark', requestInterval: 800, + maxConcurrent: 3, maxRetries: 3, timeout: 30000, ...config, diff --git a/src/content/components/config-panel.ts b/src/content/components/config-panel.ts index a5efab1..3591abf 100644 --- a/src/content/components/config-panel.ts +++ b/src/content/components/config-panel.ts @@ -105,18 +105,32 @@ export class ConfigPanel extends LitElement { global: true, }; + @state() + private regexValidationError: string | null = null; + /** * Handle rule type change * @private */ private handleRuleChange(type: RuleType): void { this.selectedRuleType = type; + this.regexValidationError = null; // Reset params based on rule type switch (type) { case 'replace': this.ruleParams = { search: '', replace: '', caseSensitive: false, global: true }; break; + case 'regex': + this.ruleParams = { + pattern: '', + replace: '', + caseSensitive: false, + global: true, + flags: '', + includeExtension: false, + }; + break; case 'prefix': this.ruleParams = { prefix: '', separator: '' }; break; @@ -137,6 +151,7 @@ export class ConfigPanel extends LitElement { break; } + this.validateRegexIfNeeded(); this.emitConfigChange(); } @@ -150,9 +165,64 @@ export class ConfigPanel extends LitElement { [key]: value, }; + this.validateRegexIfNeeded(); this.emitConfigChange(); } + private validateRegexIfNeeded(): void { + if (this.selectedRuleType !== 'regex') { + this.regexValidationError = null; + return; + } + + const pattern = String(this.ruleParams.pattern || ''); + if (!pattern) { + this.regexValidationError = null; + return; + } + + const flags = this.getRegexFlags(this.ruleParams); + + try { + // eslint-disable-next-line no-new + new RegExp(pattern, flags); + this.regexValidationError = null; + } catch (error) { + const errorObj = error instanceof Error ? error : new Error(String(error)); + this.regexValidationError = errorObj.message; + } + } + + private getRegexFlags(params: Record): string { + const global = Boolean(params.global); + const caseSensitive = Boolean(params.caseSensitive); + const base = (global ? 'g' : '') + (caseSensitive ? '' : 'i'); + + // 自定义 flags(高级,可选):仅允许追加其它 flags,g/i 由开关控制 + const raw = String(params.flags || ''); + const cleaned = raw.replace(/[^a-z]/gi, '').toLowerCase(); + const extraSet = new Set(); + for (const ch of cleaned) { + if (ch === 'g' || ch === 'i') continue; + extraSet.add(ch); + } + + // 与规则实现保持一致的稳定顺序 + const preferredOrder = ['m', 's', 'u', 'y', 'd', 'v']; + const extras: string[] = []; + for (const ch of preferredOrder) { + if (extraSet.has(ch)) { + extras.push(ch); + extraSet.delete(ch); + } + } + for (const ch of extraSet) { + extras.push(ch); + } + + return base + extras.join(''); + } + /** * Emit config change event * @private @@ -382,6 +452,7 @@ export class ConfigPanel extends LitElement { private renderRuleSelector() { const rules: { type: RuleType; label: string }[] = [ { type: 'replace', label: I18nService.t('rule_replace') }, + { type: 'regex', label: I18nService.t('rule_regex') }, { type: 'prefix', label: I18nService.t('rule_prefix') }, { type: 'suffix', label: I18nService.t('rule_suffix') }, { type: 'numbering', label: I18nService.t('rule_numbering') }, @@ -477,6 +548,83 @@ export class ConfigPanel extends LitElement { `; + case 'regex': { + const flags = this.getRegexFlags(this.ruleParams); + return html` +
+ + this.updateParam('pattern', (e.target as HTMLInputElement).value)} + placeholder="${I18nService.t('param_regex_pattern_placeholder')}" + /> + ${this.regexValidationError + ? html`
+ ${I18nService.t('param_regex_invalid')}: ${this.regexValidationError} +
` + : html`
${I18nService.t('param_regex_flags_hint', [flags || '(none)'])}
`} +
+ +
+ + this.updateParam('replace', (e.target as HTMLInputElement).value)} + placeholder="${I18nService.t('param_replace_placeholder')}" + /> +
+ +
+ +
+ +
+ +
+ +
+ + this.updateParam('flags', (e.target as HTMLInputElement).value)} + placeholder="${I18nService.t('param_regex_flags_placeholder')}" + /> +
${I18nService.t('param_regex_flags_extra_hint')}
+
+ +
+ +
+ `; + } + case 'prefix': return html`
@@ -909,6 +1057,10 @@ export class ConfigPanel extends LitElement { line-height: 1.5; } + .hint-text-error { + color: var(--cdr-danger, #ff4d4f); + } + .warning-message { padding: 8px 12px; background: var(--cdr-warning-bg, #fff7e6); diff --git a/src/content/components/file-selector-panel.ts b/src/content/components/file-selector-panel.ts index ae250d0..2757da2 100644 --- a/src/content/components/file-selector-panel.ts +++ b/src/content/components/file-selector-panel.ts @@ -481,6 +481,7 @@ export class FileSelectorPanel extends LitElement { this.adapter, { requestInterval: this.adapter.getConfig().requestInterval, + maxConcurrent: this.adapter.getConfig().maxConcurrent, skipUnchanged: true, onProgress: (progress) => { this.handleProgress(progress); @@ -694,6 +695,7 @@ export class FileSelectorPanel extends LitElement { const executor = new BatchExecutor(retryFiles, this.ruleConfig, this.adapter, { requestInterval: this.adapter.getConfig().requestInterval, + maxConcurrent: this.adapter.getConfig().maxConcurrent, tasks, onProgress: (progress) => { this.handleProgress(progress); diff --git a/src/content/components/rename-preview.ts b/src/content/components/rename-preview.ts index bcc214b..9e4a7b0 100644 --- a/src/content/components/rename-preview.ts +++ b/src/content/components/rename-preview.ts @@ -399,6 +399,10 @@ export class RenamePreview extends LitElement { // Replace 规则要求 search 非空 return typeof params.search === 'string' && params.search.length > 0; + case 'regex': + // Regex 规则要求 pattern 非空 + return typeof params.pattern === 'string' && params.pattern.length > 0; + case 'prefix': // Prefix 规则要求 prefix 非空 return typeof params.prefix === 'string' && params.prefix.length > 0; diff --git a/src/core/crash-recovery.ts b/src/core/crash-recovery.ts index 11e6257..c01cc03 100644 --- a/src/core/crash-recovery.ts +++ b/src/core/crash-recovery.ts @@ -125,8 +125,10 @@ export class CrashRecoveryManager { }); // 创建新的executor继续执行 + const { requestInterval, maxConcurrent } = adapter.getConfig(); const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, { - requestInterval: 800, + requestInterval: requestInterval ?? 800, + maxConcurrent, onProgress: (progress) => { // 转发进度事件 onProgress?.(progress); diff --git a/src/core/executor.ts b/src/core/executor.ts index fb73651..5497948 100644 --- a/src/core/executor.ts +++ b/src/core/executor.ts @@ -10,6 +10,8 @@ import { RuleFactory } from '../rules/rule-factory'; export interface BatchExecutorOptions { /** 请求间隔(毫秒) */ requestInterval: number; + /** 最大并发请求数(默认从平台配置读取,兜底为 3) */ + maxConcurrent?: number; /** 是否跳过无需变更的任务(默认不跳过,保持与平台行为一致) */ skipUnchanged?: boolean; /** 自定义任务列表(可选,用于重试等场景,优先级高于 rule 生成的任务) */ @@ -38,7 +40,7 @@ export enum ExecutorState { * * 核心功能: * - 800ms请求间隔控制 - * - 不限制并发数的并发执行策略 + * - 最大并发控制(默认 3,可配置) * - 进度事件系统 * - 支持暂停/恢复/取消功能 * @@ -68,6 +70,8 @@ export class BatchExecutor { private pausePromise: Promise | null = null; private pauseResolver: (() => void) | null = null; private abortController: AbortController | null = null; + private rateLimitChain: Promise = Promise.resolve(); + private lastRequestStartAt = 0; private isCancelled(): boolean { return this.state === ExecutorState.CANCELLED; @@ -104,6 +108,8 @@ export class BatchExecutor { this.abortController = new AbortController(); this.results = { success: [], failed: [] }; this.tasks = []; + this.rateLimitChain = Promise.resolve(); + this.lastRequestStartAt = 0; try { // 准备所有重命名任务 @@ -119,13 +125,38 @@ export class BatchExecutor { return this.results; } - // 并发执行,但控制请求间隔 - const promises = tasks.map((task, i) => - this.delayedRename(task, i * this.options.requestInterval) - ); + // 并发执行(限制最大并发 + 全局请求间隔) + const maxConcurrent = this.getMaxConcurrent(tasks.length); + let cursor = 0; - // 等待所有任务完成 - await Promise.allSettled(promises); + const runWorker = async () => { + for (;;) { + const task = tasks[cursor++]; + if (!task) return; + + // 取消/暂停检查 + if (this.isCancelled()) return; + if (this.state === ExecutorState.PAUSED && this.pausePromise) { + await this.pausePromise; + } + if (this.isCancelled()) return; + + // 全局节流:确保相邻请求的 start 间隔 >= requestInterval + await this.waitForRequestSlot(); + + // 再次检查(避免在等待 slot 期间被暂停/取消) + if (this.isCancelled()) return; + if (this.state === ExecutorState.PAUSED && this.pausePromise) { + await this.pausePromise; + } + if (this.isCancelled()) return; + + await this.processTask(task); + } + }; + + const workers = Array.from({ length: maxConcurrent }, () => runWorker()); + await Promise.allSettled(workers); // 保持 CANCELLED 状态,避免被覆盖为 COMPLETED if (!this.isCancelled()) { @@ -226,25 +257,7 @@ export class BatchExecutor { * 延迟执行重命名 * @private */ - private async delayedRename(task: Task, delay: number): Promise { - // 等待延迟 - await sleep(delay); - - // 检查是否已取消 - if (this.isCancelled()) { - return; - } - - // 检查是否暂停 - if (this.state === ExecutorState.PAUSED && this.pausePromise) { - await this.pausePromise; - } - - // 再次检查是否已取消(可能发生在暂停期间) - if (this.isCancelled()) { - return; - } - + private async processTask(task: Task): Promise { try { const result = await this.adapter.renameFile(task.file.id, task.newName); @@ -278,6 +291,38 @@ export class BatchExecutor { } } + private getMaxConcurrent(taskCount: number): number { + const fallback = this.adapter.getConfig().maxConcurrent ?? 3; + const raw = this.options.maxConcurrent ?? fallback; + const n = Math.max(1, Math.trunc(raw)); + return Math.min(n, Math.max(1, taskCount)); + } + + private async waitForRequestSlot(): Promise { + const interval = this.options.requestInterval; + if (!interval || interval <= 0) { + return; + } + + const doWait = async () => { + const now = Date.now(); + + if (this.lastRequestStartAt > 0) { + const delta = now - this.lastRequestStartAt; + const delay = interval - delta; + if (delay > 0) { + await sleep(delay); + } + } + + this.lastRequestStartAt = Date.now(); + }; + + const chained = this.rateLimitChain.then(doWait, doWait); + this.rateLimitChain = chained; + await chained; + } + /** * 发射进度事件 * @private diff --git a/src/locales/en.json b/src/locales/en.json index 7ece030..87e3e9a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,8 @@ "preview_section_title": "Preview (First 3 Files)", "rule_replace": "Replace Text", "rule_replace_desc": "Replace characters in filename", + "rule_regex": "Regex Replace", + "rule_regex_desc": "Replace filename using regular expression", "rule_prefix": "Add Prefix", "rule_prefix_desc": "Add text before filename", "rule_suffix": "Add Suffix", @@ -45,6 +47,14 @@ "rule_sanitize_desc": "Remove illegal characters", "param_search_text": "Search Text", "param_search_placeholder": "Text to replace", + "param_regex_pattern": "Regex Pattern", + "param_regex_pattern_placeholder": "e.g. ^old_|(\\d+)", + "param_regex_invalid": "Invalid regex", + "param_regex_flags_hint": "Current flags: $1", + "param_regex_flags": "Custom Flags (Advanced)", + "param_regex_flags_placeholder": "e.g. msu", + "param_regex_flags_extra_hint": "Tip: g/i are controlled by the toggles above. Prefer entering m/s/u/y/d/v here.", + "param_regex_include_extension": "Include extension (allow changing suffix)", "param_replace_with": "Replace With", "param_replace_placeholder": "New text", "param_case_sensitive": "Case Sensitive", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 9df84ba..8dce0d5 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -35,6 +35,8 @@ "preview_section_title": "预览(前3个文件)", "rule_replace": "字符串替换", "rule_replace_desc": "替换文件名中的字符", + "rule_regex": "正则替换", + "rule_regex_desc": "使用正则表达式替换文件名", "rule_prefix": "添加前缀", "rule_prefix_desc": "在文件名前添加文本", "rule_suffix": "添加后缀", @@ -45,6 +47,14 @@ "rule_sanitize_desc": "移除非法字符", "param_search_text": "搜索文本", "param_search_placeholder": "要替换的文本", + "param_regex_pattern": "正则表达式", + "param_regex_pattern_placeholder": "例如: ^old_|(\\d+)", + "param_regex_invalid": "正则无效", + "param_regex_flags_hint": "当前 flags: $1", + "param_regex_flags": "自定义 flags(高级,可选)", + "param_regex_flags_placeholder": "例如: msu", + "param_regex_flags_extra_hint": "提示:g/i 由“替换所有/大小写敏感”开关控制,这里建议只填 m/s/u/y/d/v。", + "param_regex_include_extension": "包含扩展名(允许修改后缀)", "param_replace_with": "替换为", "param_replace_placeholder": "新的文本", "param_case_sensitive": "大小写敏感", diff --git a/src/locales/zh_TW.json b/src/locales/zh_TW.json index e5504f0..f3c1b93 100644 --- a/src/locales/zh_TW.json +++ b/src/locales/zh_TW.json @@ -35,6 +35,8 @@ "preview_section_title": "預覽(前3個檔案)", "rule_replace": "字串替換", "rule_replace_desc": "替換檔案名稱中的字元", + "rule_regex": "正則替換", + "rule_regex_desc": "使用正則表達式替換檔案名稱", "rule_prefix": "新增前綴", "rule_prefix_desc": "在檔案名稱前新增文字", "rule_suffix": "新增後綴", @@ -45,6 +47,14 @@ "rule_sanitize_desc": "移除非法字元", "param_search_text": "搜尋文字", "param_search_placeholder": "要替換的文字", + "param_regex_pattern": "正則表達式", + "param_regex_pattern_placeholder": "例如: ^old_|(\\d+)", + "param_regex_invalid": "正則無效", + "param_regex_flags_hint": "目前 flags: $1", + "param_regex_flags": "自訂 flags(進階,可選)", + "param_regex_flags_placeholder": "例如: msu", + "param_regex_flags_extra_hint": "提示:g/i 由「替換所有/區分大小寫」開關控制,這裡建議只填 m/s/u/y/d/v。", + "param_regex_include_extension": "包含副檔名(允許修改後綴)", "param_replace_with": "替換為", "param_replace_placeholder": "新的文字", "param_case_sensitive": "區分大小寫", diff --git a/src/rules/regex.ts b/src/rules/regex.ts new file mode 100644 index 0000000..f399ba4 --- /dev/null +++ b/src/rules/regex.ts @@ -0,0 +1,93 @@ +import { BaseRule } from './base-rule'; +import { RegexRuleParams } from '../types/rule'; + +/** + * 正则替换规则 + * 支持使用正则表达式对“文件名(不含扩展名)”进行替换。 + * + * 注意:为了避免误伤扩展名,这里默认不处理扩展名部分。 + */ +export class RegexRule extends BaseRule { + protected config!: RegexRuleParams; + + private getFlags(): string { + const { caseSensitive = false, global = false, flags = '' } = this.config; + + const base = (global ? 'g' : '') + (caseSensitive ? '' : 'i'); + + // Keep only letters, lower-cased; `g/i` are controlled by toggles. + const cleaned = String(flags).replace(/[^a-z]/gi, '').toLowerCase(); + const extraSet = new Set(); + for (const ch of cleaned) { + if (ch === 'g' || ch === 'i') continue; + extraSet.add(ch); + } + + // Stable ordering for common flags to reduce user confusion. + const preferredOrder = ['m', 's', 'u', 'y', 'd', 'v']; + const extras: string[] = []; + for (const ch of preferredOrder) { + if (extraSet.has(ch)) { + extras.push(ch); + extraSet.delete(ch); + } + } + // Append any remaining flags in first-seen order. + for (const ch of extraSet) { + extras.push(ch); + } + + return base + extras.join(''); + } + + execute(fileName: string): string { + const { name, ext } = this.parseFileName(fileName); + const { pattern, replace, includeExtension = false } = this.config; + + const re = new RegExp(pattern, this.getFlags()); + + if (includeExtension) { + return fileName.replace(re, replace); + } + + const newName = name.replace(re, replace); + return newName + ext; + } + + validate(config: any): boolean { + if (typeof config !== 'object' || config === null) { + return false; + } + + if (typeof config.pattern !== 'string' || config.pattern.length === 0) { + return false; + } + + if (typeof config.replace !== 'string') { + return false; + } + + if ('flags' in config && typeof config.flags !== 'string') { + return false; + } + + if ('includeExtension' in config && typeof config.includeExtension !== 'boolean') { + return false; + } + + const base = (config.global ? 'g' : '') + (config.caseSensitive ? '' : 'i'); + const cleaned = String(config.flags ?? '').replace(/[^a-z]/gi, '').toLowerCase(); + const extras = Array.from(new Set(cleaned.split(''))).filter((ch) => ch !== 'g' && ch !== 'i'); + const flags = base + extras.join(''); + + try { + // Ensure pattern/flags are valid. + // eslint-disable-next-line no-new + new RegExp(config.pattern, flags); + } catch { + return false; + } + + return true; + } +} diff --git a/src/rules/rule-factory.ts b/src/rules/rule-factory.ts index d0428d4..bf0ab9e 100644 --- a/src/rules/rule-factory.ts +++ b/src/rules/rule-factory.ts @@ -1,5 +1,6 @@ import { RuleConfig, RuleExecutor } from '../types/rule'; import { ReplaceRule } from './replace'; +import { RegexRule } from './regex'; import { NumberingRule } from './numbering'; import { PrefixRule } from './prefix'; import { SuffixRule } from './suffix'; @@ -20,6 +21,9 @@ export class RuleFactory { case 'replace': return new ReplaceRule(config.params as any); + case 'regex': + return new RegexRule(config.params as any); + case 'prefix': return new PrefixRule(config.params as any); diff --git a/src/shared/version.ts b/src/shared/version.ts index 1d162e0..4779d63 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -10,7 +10,7 @@ * 1. Edit version in package.json * 2. Run: npm run sync:version (or automatically via prebuild/predev) * - * Generated at: 2025-12-30T09:25:09.857Z + * Generated at: 2026-02-14T04:29:55.417Z */ /** diff --git a/src/types/platform.ts b/src/types/platform.ts index 898dae6..7be9039 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -120,6 +120,8 @@ export interface PlatformConfig { platform: PlatformName; /** API请求间隔(毫秒) */ requestInterval: number; + /** 最大并发请求数(用于批量重命名等操作) */ + maxConcurrent?: number; /** 最大重试次数 */ maxRetries: number; /** 请求超时时间(毫秒) */ diff --git a/src/types/rule.ts b/src/types/rule.ts index 149e997..d5afd24 100644 --- a/src/types/rule.ts +++ b/src/types/rule.ts @@ -1,7 +1,7 @@ /** * 重命名规则类型 */ -export type RuleType = 'replace' | 'prefix' | 'suffix' | 'numbering' | 'sanitize'; +export type RuleType = 'replace' | 'regex' | 'prefix' | 'suffix' | 'numbering' | 'sanitize'; /** * 规则配置接口 @@ -27,6 +27,31 @@ export interface ReplaceRuleParams { global?: boolean; } +/** + * 正则替换规则参数 + */ +export interface RegexRuleParams { + /** 正则表达式(不包含前后 /) */ + pattern: string; + /** 替换表达式 */ + replace: string; + /** 是否大小写敏感 */ + caseSensitive?: boolean; + /** 是否全局替换 */ + global?: boolean; + /** + * 自定义 flags(高级,可选) + * - `g/i` 由 `global/caseSensitive` 控制,建议这里输入其它 flags(如 `m/s/u`) + */ + flags?: string; + /** + * 是否在匹配时包含扩展名(允许修改后缀) + * - false(默认):只对文件名主体(不含扩展名)做替换 + * - true:对完整文件名(含扩展名)做替换 + */ + includeExtension?: boolean; +} + /** * 前缀规则参数 */ diff --git a/tests/unit/adapters/base-adapter.test.ts b/tests/unit/adapters/base-adapter.test.ts index 9cb7f8d..a34b645 100644 --- a/tests/unit/adapters/base-adapter.test.ts +++ b/tests/unit/adapters/base-adapter.test.ts @@ -69,6 +69,7 @@ describe('BasePlatformAdapter', () => { expect(config.platform).toBe('quark'); expect(config.requestInterval).toBe(800); + expect(config.maxConcurrent).toBe(3); expect(config.maxRetries).toBe(3); expect(config.timeout).toBe(30000); }); @@ -83,6 +84,7 @@ describe('BasePlatformAdapter', () => { expect(config.platform).toBe('quark'); expect(config.requestInterval).toBe(1000); + expect(config.maxConcurrent).toBe(3); // 保持默认值 expect(config.maxRetries).toBe(5); expect(config.timeout).toBe(30000); // 保持默认值 }); @@ -91,6 +93,7 @@ describe('BasePlatformAdapter', () => { const customConfig = { platform: 'aliyun' as PlatformName, requestInterval: 1500, + maxConcurrent: 5, maxRetries: 10, timeout: 60000, }; diff --git a/tests/unit/executor.test.ts b/tests/unit/executor.test.ts index bf0197e..48d8968 100644 --- a/tests/unit/executor.test.ts +++ b/tests/unit/executor.test.ts @@ -206,6 +206,34 @@ describe('BatchExecutor', () => { expect(results.success.length + results.failed.length).toBe(files.length); }); + + it('应该限制最大并发数', async () => { + // 让单次请求更慢,便于观测并发峰值 + adapter.renameDelay = 200; + + let inFlight = 0; + let maxInFlight = 0; + + const originalRename = adapter.renameFile.bind(adapter); + adapter.renameFile = async (fileId: string, newName: string) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + return await originalRename(fileId, newName); + } finally { + inFlight--; + } + }; + + const executor = new BatchExecutor(files, mockRule, adapter, { + requestInterval: 0, + maxConcurrent: 2, + }); + + await executor.execute(); + + expect(maxInFlight).toBeLessThanOrEqual(2); + }); }); describe('pause/resume', () => { diff --git a/tests/unit/rules.test.ts b/tests/unit/rules.test.ts index 9dccd54..01e6af5 100644 --- a/tests/unit/rules.test.ts +++ b/tests/unit/rules.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ReplaceRule } from '../../src/rules/replace'; +import { RegexRule } from '../../src/rules/regex'; import { NumberingRule } from '../../src/rules/numbering'; import { RuleFactory } from '../../src/rules/rule-factory'; @@ -82,6 +83,58 @@ describe('NumberingRule', () => { }); }); +describe('RegexRule', () => { + it('should replace using regex pattern', () => { + const rule = new RegexRule({ + pattern: '^old_', + replace: 'new_', + caseSensitive: false, + global: false, + }); + + const result = rule.execute('old_file.txt', 0, 1); + expect(result).toBe('new_file.txt'); + }); + + it('should support capture groups', () => { + const rule = new RegexRule({ + pattern: '^(\\d+)-(.*)$', + replace: '$2-$1', + caseSensitive: false, + global: false, + }); + + const result = rule.execute('001-hello.txt', 0, 1); + expect(result).toBe('hello-001.txt'); + }); + + it('should ignore g/i in custom flags and respect toggles', () => { + const rule = new RegexRule({ + pattern: 'a', + replace: 'b', + caseSensitive: false, + global: false, + flags: 'g', // should NOT force global replace + }); + + const result = rule.execute('aaa.txt', 0, 1); + expect(result).toBe('baa.txt'); + }); + + it('should allow updating extension when includeExtension is true', () => { + const rule = new RegexRule({ + pattern: '\\.jpeg$', + replace: '.jpg', + caseSensitive: false, + global: false, + includeExtension: true, + }); + + const result = rule.execute('photo.jpeg', 0, 1); + expect(result).toBe('photo.jpg'); + }); +}); + describe('RuleFactory', () => { it('should create replace rule', () => { const rule = RuleFactory.create({ @@ -95,6 +148,18 @@ describe('RuleFactory', () => { expect(rule).toBeInstanceOf(ReplaceRule); }); + it('should create regex rule', () => { + const rule = RuleFactory.create({ + type: 'regex', + params: { + pattern: 'a+', + replace: 'b', + }, + }); + + expect(rule).toBeInstanceOf(RegexRule); + }); + it('should create numbering rule', () => { const rule = RuleFactory.create({ type: 'numbering',