镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 13:47:47 +08:00
feat: 新增正则替换规则和并发控制功能
- 新增正则替换规则(RegexRule),支持捕获组、自定义flags和包含扩展名选项 - 为平台配置添加maxConcurrent字段,支持批量操作并发控制 - 重构BatchExecutor实现,使用工作池模式限制最大并发数 - 在UI配置面板中添加正则规则表单及实时验证 - 更新多语言资源文件和README文档 - 添加相关单元测试确保功能稳定性
这个提交包含在:
父节点
ceeb2b81f1
当前提交
748bf94d6f
3
.gitignore
vendored
3
.gitignore
vendored
@ -36,3 +36,6 @@ coverage/
|
||||
.cache/
|
||||
.serena/
|
||||
.claude/
|
||||
findings.md
|
||||
task_plan.md
|
||||
progress.md
|
||||
|
||||
28
README.md
28
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(按平台可调),避免请求堆积导致的失败/风控
|
||||
- **重试机制**:指数退避算法,自动重试失败操作
|
||||
- **崩溃恢复**:异常中断后可继续未完成的任务
|
||||
- **幂等性保证**:避免重复执行相同操作
|
||||
|
||||
@ -19,6 +19,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
this.config = {
|
||||
platform: 'quark',
|
||||
requestInterval: 800,
|
||||
maxConcurrent: 3,
|
||||
maxRetries: 3,
|
||||
timeout: 30000,
|
||||
...config,
|
||||
|
||||
@ -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, any>): 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<string>();
|
||||
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 {
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'regex': {
|
||||
const flags = this.getRegexFlags(this.ruleParams);
|
||||
return html`
|
||||
<div class="form-group">
|
||||
<label class="form-label">${I18nService.t('param_regex_pattern')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.ruleParams.pattern || ''}
|
||||
@input=${(e: Event) => this.updateParam('pattern', (e.target as HTMLInputElement).value)}
|
||||
placeholder="${I18nService.t('param_regex_pattern_placeholder')}"
|
||||
/>
|
||||
${this.regexValidationError
|
||||
? html`<div class="hint-text hint-text-error">
|
||||
${I18nService.t('param_regex_invalid')}: ${this.regexValidationError}
|
||||
</div>`
|
||||
: html`<div class="hint-text">${I18nService.t('param_regex_flags_hint', [flags || '(none)'])}</div>`}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">${I18nService.t('param_replace_with')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.ruleParams.replace || ''}
|
||||
@input=${(e: Event) => this.updateParam('replace', (e.target as HTMLInputElement).value)}
|
||||
placeholder="${I18nService.t('param_replace_placeholder')}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.ruleParams.caseSensitive || false}
|
||||
@change=${(e: Event) => this.updateParam('caseSensitive', (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>${I18nService.t('param_case_sensitive')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.ruleParams.global ?? true}
|
||||
@change=${(e: Event) => this.updateParam('global', (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>${I18nService.t('param_replace_all')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">${I18nService.t('param_regex_flags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.ruleParams.flags || ''}
|
||||
@input=${(e: Event) => this.updateParam('flags', (e.target as HTMLInputElement).value)}
|
||||
placeholder="${I18nService.t('param_regex_flags_placeholder')}"
|
||||
/>
|
||||
<div class="hint-text">${I18nService.t('param_regex_flags_extra_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.ruleParams.includeExtension || false}
|
||||
@change=${(e: Event) => this.updateParam('includeExtension', (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>${I18nService.t('param_regex_include_extension')}</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
case 'prefix':
|
||||
return html`
|
||||
<div class="form-group">
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<void> | null = null;
|
||||
private pauseResolver: (() => void) | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
private rateLimitChain: Promise<void> = 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<void> {
|
||||
// 等待延迟
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "大小写敏感",
|
||||
|
||||
@ -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": "區分大小寫",
|
||||
|
||||
93
src/rules/regex.ts
普通文件
93
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@ -120,6 +120,8 @@ export interface PlatformConfig {
|
||||
platform: PlatformName;
|
||||
/** API请求间隔(毫秒) */
|
||||
requestInterval: number;
|
||||
/** 最大并发请求数(用于批量重命名等操作) */
|
||||
maxConcurrent?: number;
|
||||
/** 最大重试次数 */
|
||||
maxRetries: number;
|
||||
/** 请求超时时间(毫秒) */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前缀规则参数
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户