diff --git a/lib/models/batch_rename.dart b/lib/models/batch_rename.dart index e8ecacf..c7d6fda 100644 --- a/lib/models/batch_rename.dart +++ b/lib/models/batch_rename.dart @@ -123,7 +123,7 @@ List buildRenamePreviews( } final existing = >{}; for (final file in files) { - final key = _destinationKey(file.cloudPath, file.name); + final key = _destinationKey(file, file.name); (existing[key] ??= {}).add(file.id); } final reserved = >{ @@ -131,7 +131,7 @@ List buildRenamePreviews( }; final proposedCounts = {}; 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 = []; @@ -141,7 +141,7 @@ List 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 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()}'; diff --git a/lib/models/cloud_file.dart b/lib/models/cloud_file.dart index 014c3d5..0b5ecdd 100644 --- a/lib/models/cloud_file.dart +++ b/lib/models/cloud_file.dart @@ -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, }; diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index 172f7bf..1501e3d 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -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 _enrichCandidatePaths() async { + final api = ref.read(authProvider.notifier).api; + final detailCache = >{}; + + Future loadFolder(String id) => + detailCache.putIfAbsent(id, () async { + try { + final detail = await api.fsDetail(id); + final values = _extractCandidates(detail); + return values.cast().firstWhere( + (file) => file?.id == id, + orElse: () => null, + ); + } catch (_) { + return null; + } + }); + + Future resolvePath(CloudFile file) async { + final names = [file.name]; + var parentID = file.parentID?.trim(); + final visited = {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 = { + 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 _pickSource() async { final selection = await showShadDialog<_BatchRenameFolderSelection>( context: context, diff --git a/test/batch_rename_test.dart b/test/batch_rename_test.dart index 869f3aa..672eb0b 100644 --- a/test/batch_rename_test.dart +++ b/test/batch_rename_test.dart @@ -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); + }, + ); }