scope batch rename conflicts by parent directory
This commit is contained in:
@@ -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()}';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user