handle batch rename paths and directory conflicts

This commit is contained in:
ngfchl
2026-07-18 19:08:32 +08:00
parent aa775adcd8
commit ac6323171e
3 changed files with 171 additions and 20 deletions
+59 -18
View File
@@ -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<BatchRenamePreview> buildRenamePreviews(
List<CloudFile> files,
List<BatchRenameRule> rules, {
required bool preserveExtension,
BatchRenameConflictStrategy conflictStrategy =
BatchRenameConflictStrategy.reject,
}) {
final values = <BatchRenamePreview>[];
for (final file in files) {
@@ -116,37 +121,73 @@ List<BatchRenamePreview> buildRenamePreviews(
);
}
}
final proposed = <String, int>{};
final existing = <String, Set<String>>{};
for (final file in files) {
final key = _destinationKey(file.cloudPath, file.name);
(existing[key] ??= <String>{}).add(file.id);
}
final reserved = <String, Set<String>>{
for (final entry in existing.entries) entry.key: {...entry.value},
};
final proposedCounts = <String, int>{};
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 = <BatchRenamePreview>[];
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] ??= <String>{}).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);
+51 -2
View File
@@ -741,6 +741,8 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> {
int _total = 0;
String _status = '';
BatchRenameItemType _itemType = BatchRenameItemType.all;
BatchRenameConflictStrategy _conflictStrategy =
BatchRenameConflictStrategy.reject;
final _selectedIDs = <String>{};
late List<CloudFile> _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<void> _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<BatchRenameConflictStrategy>(
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 => '查找替换',
+61
View File
@@ -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);
});
}