scope batch rename conflicts by parent directory

This commit is contained in:
ngfchl
2026-07-18 19:28:54 +08:00
parent 189334b6fa
commit 5a23f438f1
4 changed files with 101 additions and 5 deletions
+10 -5
View File
@@ -123,7 +123,7 @@ List<BatchRenamePreview> buildRenamePreviews(
}
final existing = <String, Set<String>>{};
for (final file in files) {
final key = _destinationKey(file.cloudPath, file.name);
final key = _destinationKey(file, file.name);
(existing[key] ??= <String>{}).add(file.id);
}
final reserved = <String, Set<String>>{
@@ -131,7 +131,7 @@ List<BatchRenamePreview> buildRenamePreviews(
};
final proposedCounts = <String, int>{};
for (final item in values.where((item) => item.applicable)) {
final key = _destinationKey(item.file.cloudPath, item.newName);
final key = _destinationKey(item.file, item.newName);
proposedCounts[key] = (proposedCounts[key] ?? 0) + 1;
}
final result = <BatchRenamePreview>[];
@@ -141,7 +141,7 @@ List<BatchRenamePreview> buildRenamePreviews(
continue;
}
var targetName = item.newName;
var targetKey = _destinationKey(item.file.cloudPath, targetName);
var targetKey = _destinationKey(item.file, targetName);
if (conflictStrategy == BatchRenameConflictStrategy.reject &&
proposedCounts[targetKey]! > 1) {
result.add(
@@ -160,7 +160,7 @@ List<BatchRenamePreview> buildRenamePreviews(
var index = 2;
do {
targetName = _appendIndex(item.newName, index++);
targetKey = _destinationKey(item.file.cloudPath, targetName);
targetKey = _destinationKey(item.file, targetName);
} while (conflicts());
}
if (conflicts()) {
@@ -188,7 +188,12 @@ String _appendIndex(String name, int index) {
}
/// Collision scope is a single cloud directory, never the whole drive.
String _destinationKey(String path, String name) {
String _destinationKey(CloudFile file, String name) {
final parentID = file.parentID?.trim();
if (parentID != null && parentID.isNotEmpty) {
return 'id:$parentID/${name.toLowerCase()}';
}
final path = file.cloudPath;
final separator = path.lastIndexOf('/');
final parent = separator < 0 ? '' : path.substring(0, separator);
return '${parent.toLowerCase()}/${name.toLowerCase()}';
+19
View File
@@ -35,6 +35,11 @@ class CloudFile {
final int? subFileCount;
final String modifiedAt;
final String cloudPath;
/// Stable parent directory ID from the cloud API, used for directory-scoped
/// operations even when the API omits a full display path.
final String? parentID;
final String? fullParentIDs;
final int fileType;
const CloudFile({
@@ -47,6 +52,8 @@ class CloudFile {
this.subFileCount,
this.modifiedAt = '',
this.cloudPath = '',
this.parentID,
this.fullParentIDs,
this.fileType = 0,
});
@@ -104,6 +111,8 @@ class CloudFile {
int? subFileCount,
String? modifiedAt,
String? cloudPath,
String? parentID,
String? fullParentIDs,
int? fileType,
}) {
return CloudFile(
@@ -116,6 +125,8 @@ class CloudFile {
subFileCount: subFileCount ?? this.subFileCount,
modifiedAt: modifiedAt ?? this.modifiedAt,
cloudPath: cloudPath ?? this.cloudPath,
parentID: parentID ?? this.parentID,
fullParentIDs: fullParentIDs ?? this.fullParentIDs,
fileType: fileType ?? this.fileType,
);
}
@@ -191,6 +202,12 @@ class CloudFile {
cloudPath:
_extractString(json, ['location', 'path', 'fullPath']) ??
name.toString(),
parentID: _extractString(json, ['parentId', 'parent_id', 'parentID']),
fullParentIDs: _extractString(json, [
'fullParentIds',
'fullParentIDs',
'full_parent_ids',
]),
fileType: type,
);
}
@@ -205,6 +222,8 @@ class CloudFile {
'subFileCount': subFileCount,
'updateTime': modifiedAt,
'path': cloudPath,
'parentId': parentID,
'fullParentIds': fullParentIDs,
'fileType': fileType,
};
+56
View File
@@ -770,6 +770,7 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> {
const BatchRenameRule(id: 'rule-0', kind: BatchRenameRuleKind.replace),
];
_selectedIDs.addAll(_candidates.map((file) => file.id));
Future.microtask(_enrichCandidatePaths);
}
@override
@@ -805,6 +806,61 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> {
return '/$source/$value';
}
Future<void> _enrichCandidatePaths() async {
final api = ref.read(authProvider.notifier).api;
final detailCache = <String, Future<CloudFile?>>{};
Future<CloudFile?> loadFolder(String id) =>
detailCache.putIfAbsent(id, () async {
try {
final detail = await api.fsDetail(id);
final values = _extractCandidates(detail);
return values.cast<CloudFile?>().firstWhere(
(file) => file?.id == id,
orElse: () => null,
);
} catch (_) {
return null;
}
});
Future<String?> resolvePath(CloudFile file) async {
final names = <String>[file.name];
var parentID = file.parentID?.trim();
final visited = <String>{file.id};
while (parentID != null && parentID.isNotEmpty && visited.add(parentID)) {
if (parentID == _sourceID && _sourceLabel != '云盘根目录') {
names.insertAll(0, _sourceLabel.split(' / '));
break;
}
final folder = await loadFolder(parentID);
if (folder == null) return null;
names.insert(0, folder.name);
parentID = folder.parentID?.trim();
}
return '/${names.join('/')}';
}
final paths = await Future.wait(
_candidates.map((file) async => (file, await resolvePath(file))),
);
if (!mounted) return;
setState(() {
final resolved = <String, String>{
for (final entry in paths)
if (entry.$2 != null) entry.$1.id: entry.$2!,
};
if (resolved.isEmpty) return;
_candidates = _candidates
.map(
(file) => resolved.containsKey(file.id)
? file.copyWith(cloudPath: resolved[file.id])
: file,
)
.toList();
});
}
Future<void> _pickSource() async {
final selection = await showShadDialog<_BatchRenameFolderSelection>(
context: context,
+16
View File
@@ -58,4 +58,20 @@ void main() {
]);
expect(previews.every((item) => item.applicable), isTrue);
});
test(
'uses parent IDs for collision scope when display paths are incomplete',
() {
final previews = buildRenamePreviews(
[
_file('1', 'Episode 01.mkv').copyWith(parentID: 'folder-a'),
_file('2', 'Episode 02.mkv').copyWith(parentID: 'folder-b'),
],
[collapseNames],
preserveExtension: true,
);
expect(previews.every((item) => item.applicable), isTrue);
},
);
}