diff --git a/lib/models/batch_rename.dart b/lib/models/batch_rename.dart index 301ddfa..e8ecacf 100644 --- a/lib/models/batch_rename.dart +++ b/lib/models/batch_rename.dart @@ -4,6 +4,9 @@ enum BatchRenameRuleKind { remove, replace, regex, prefix, suffix } enum BatchRenameItemType { all, files, folders } +/// How a rename rule handles a destination that is already occupied. +enum BatchRenameConflictStrategy { reject, appendIndex } + class BatchRenameRule { final String id; final bool enabled; @@ -86,6 +89,8 @@ List buildRenamePreviews( List files, List rules, { required bool preserveExtension, + BatchRenameConflictStrategy conflictStrategy = + BatchRenameConflictStrategy.reject, }) { final values = []; for (final file in files) { @@ -116,37 +121,73 @@ List buildRenamePreviews( ); } } - final proposed = {}; final existing = >{}; for (final file in files) { final key = _destinationKey(file.cloudPath, file.name); (existing[key] ??= {}).add(file.id); } + final reserved = >{ + for (final entry in existing.entries) entry.key: {...entry.value}, + }; + final proposedCounts = {}; for (final item in values.where((item) => item.applicable)) { final key = _destinationKey(item.file.cloudPath, item.newName); - proposed[key] = (proposed[key] ?? 0) + 1; + proposedCounts[key] = (proposedCounts[key] ?? 0) + 1; } - return values.map((item) { - if (!item.applicable) return item; - final key = _destinationKey(item.file.cloudPath, item.newName); - if (proposed[key] != null && proposed[key]! > 1) { - return BatchRenamePreview( - file: item.file, - newName: item.newName, - error: '规则产生同名项目', - ); + final result = []; + for (final item in values) { + if (!item.applicable) { + result.add(item); + continue; } - if (existing[key]?.any((id) => id != item.file.id) ?? false) { - return BatchRenamePreview( - file: item.file, - newName: item.newName, - error: '目标名称已存在', + var targetName = item.newName; + var targetKey = _destinationKey(item.file.cloudPath, targetName); + if (conflictStrategy == BatchRenameConflictStrategy.reject && + proposedCounts[targetKey]! > 1) { + result.add( + BatchRenamePreview( + file: item.file, + newName: item.newName, + error: '规则产生同名项目', + ), ); + continue; } - return item; - }).toList(); + bool conflicts() => + reserved[targetKey]?.any((id) => id != item.file.id) ?? false; + if (conflicts() && + conflictStrategy == BatchRenameConflictStrategy.appendIndex) { + var index = 2; + do { + targetName = _appendIndex(item.newName, index++); + targetKey = _destinationKey(item.file.cloudPath, targetName); + } while (conflicts()); + } + if (conflicts()) { + result.add( + BatchRenamePreview( + file: item.file, + newName: item.newName, + error: '规则产生同名项目或目标名称已存在', + ), + ); + continue; + } + (reserved[targetKey] ??= {}).add(item.file.id); + result.add(BatchRenamePreview(file: item.file, newName: targetName)); + } + return result; } +String _appendIndex(String name, int index) { + final dot = name.lastIndexOf('.'); + if (dot > 0) { + return '${name.substring(0, dot)} ($index)${name.substring(dot)}'; + } + return '$name ($index)'; +} + +/// Collision scope is a single cloud directory, never the whole drive. String _destinationKey(String path, String name) { final separator = path.lastIndexOf('/'); final parent = separator < 0 ? '' : path.substring(0, separator); diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index 203dc8a..172f7bf 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -741,6 +741,8 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { int _total = 0; String _status = ''; BatchRenameItemType _itemType = BatchRenameItemType.all; + BatchRenameConflictStrategy _conflictStrategy = + BatchRenameConflictStrategy.reject; final _selectedIDs = {}; late List _candidates; late String? _sourceID; @@ -753,11 +755,17 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { void initState() { super.initState(); final state = ref.read(fileProvider); - _candidates = state.clipboard ?? state.files; _sourceID = state.folderPath.isEmpty ? null : state.folderPath.last.id; _sourceLabel = state.folderPath.isEmpty ? '云盘根目录' : state.folderPath.map((folder) => folder.name).join(' / '); + _candidates = (state.clipboard ?? state.files) + .map( + (file) => file.copyWith( + cloudPath: _fullCloudPath(file.cloudPath, fallbackName: file.name), + ), + ) + .toList(); _rules = [ const BatchRenameRule(id: 'rule-0', kind: BatchRenameRuleKind.replace), ]; @@ -786,6 +794,17 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { }); } + String _fullCloudPath(String path, {required String fallbackName}) { + final value = path.trim().isEmpty ? fallbackName : path.trim(); + if (value.startsWith('/')) return value; + final source = _sourceLabel == '云盘根目录' + ? '' + : _sourceLabel.split(' / ').join('/'); + if (source.isEmpty) return '/$value'; + if (value == source || value.startsWith('$source/')) return '/$value'; + return '/$source/$value'; + } + Future _pickSource() async { final selection = await showShadDialog<_BatchRenameFolderSelection>( context: context, @@ -820,7 +839,9 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { final path = node.path.isEmpty ? child.name : '${node.path}/${child.name}'; - final candidate = child.copyWith(cloudPath: path); + final candidate = child.copyWith( + cloudPath: _fullCloudPath(path, fallbackName: child.name), + ); result.add(candidate); if (_recursive && candidate.isDirectory && @@ -941,6 +962,7 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { files, _rules, preserveExtension: _preserveExtension, + conflictStrategy: _conflictStrategy, ); final filterText = _filter.text.trim().toLowerCase(); final previews = allPreviews.where((preview) { @@ -989,6 +1011,27 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { }, ), ), + SizedBox( + width: 152, + child: ShadSelect( + key: ValueKey(_conflictStrategy), + initialValue: _conflictStrategy, + selectedOptionBuilder: (_, value) => + Text(_conflictStrategyLabel(value)), + options: [ + for (final value in BatchRenameConflictStrategy.values) + ShadOption( + value: value, + child: Text(_conflictStrategyLabel(value)), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() => _conflictStrategy = value); + } + }, + ), + ), SizedBox( width: 220, child: ShadInput( @@ -1206,6 +1249,12 @@ String _itemTypeLabel(BatchRenameItemType value) => switch (value) { BatchRenameItemType.folders => '仅文件夹', }; +String _conflictStrategyLabel(BatchRenameConflictStrategy value) => + switch (value) { + BatchRenameConflictStrategy.reject => '同名:阻止执行', + BatchRenameConflictStrategy.appendIndex => '同名:自动编号', + }; + String _ruleKindLabel(BatchRenameRuleKind value) => switch (value) { BatchRenameRuleKind.remove => '删除字符', BatchRenameRuleKind.replace => '查找替换', diff --git a/test/batch_rename_test.dart b/test/batch_rename_test.dart new file mode 100644 index 0000000..869f3aa --- /dev/null +++ b/test/batch_rename_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:guangya_flutter/models/batch_rename.dart'; +import 'package:guangya_flutter/models/cloud_file.dart'; + +CloudFile _file(String id, String name, {String folder = '/媒体'}) => CloudFile( + id: id, + name: name, + isDirectory: false, + cloudPath: '$folder/$name', +); + +void main() { + const collapseNames = BatchRenameRule( + id: 'collapse', + kind: BatchRenameRuleKind.regex, + pattern: r'\d+', + ); + + test('rejects rule-generated names that collide in one folder', () { + final previews = buildRenamePreviews( + [_file('1', 'Episode 01.mkv'), _file('2', 'Episode 02.mkv')], + [collapseNames], + preserveExtension: true, + ); + + expect(previews.every((item) => item.error != null), isTrue); + expect(previews.first.error, contains('同名')); + }); + + test('adds stable indexes for rule-generated duplicate names', () { + final previews = buildRenamePreviews( + [_file('1', 'Episode 01.mkv'), _file('2', 'Episode 02.mkv')], + [collapseNames], + preserveExtension: true, + conflictStrategy: BatchRenameConflictStrategy.appendIndex, + ); + + expect(previews.map((item) => item.newName), [ + 'Episode .mkv', + 'Episode (2).mkv', + ]); + expect(previews.every((item) => item.applicable), isTrue); + }); + + test('allows the same generated name in separate cloud directories', () { + final previews = buildRenamePreviews( + [ + _file('1', 'Episode 01.mkv', folder: '/电视剧/第一季'), + _file('2', 'Episode 02.mkv', folder: '/电视剧/第二季'), + ], + [collapseNames], + preserveExtension: true, + ); + + expect(previews.map((item) => item.newName), [ + 'Episode .mkv', + 'Episode .mkv', + ]); + expect(previews.every((item) => item.applicable), isTrue); + }); +}