handle batch rename paths and directory conflicts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 => '查找替换',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user