feat: 优化云盘工作区与批量文件工具
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; };
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; };
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; };
|
||||
8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */; };
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; };
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; };
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -27,6 +31,10 @@
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuangyaAPI.swift; sourceTree = "<group>"; };
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8C0E0B452C40000100A10001 /* 光鸭.svg */ = {isa = PBXFileReference; lastKnownFileType = image.svg; path = "光鸭.svg"; sourceTree = "<group>"; };
|
||||
8C0E0B462C40000100A10001 /* FileMetadataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadataCache.swift; sourceTree = "<group>"; };
|
||||
8C0E0B472C40000100A10001 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShell.swift; sourceTree = "<group>"; };
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRenameTool.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -61,9 +69,13 @@
|
||||
children = (
|
||||
8C0E0B362C40000100A10001 /* guangya_macApp.swift */,
|
||||
8C0E0B372C40000100A10001 /* ContentView.swift */,
|
||||
8C0E0B472C40000100A10001 /* AppTheme.swift */,
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */,
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */,
|
||||
8C0E0B422C40000100A10001 /* Models.swift */,
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */,
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */,
|
||||
8C0E0B462C40000100A10001 /* FileMetadataCache.swift */,
|
||||
8C0E0B452C40000100A10001 /* 光鸭.svg */,
|
||||
8C0E0B382C40000100A10001 /* Assets.xcassets */,
|
||||
8C0E0B392C40000100A10001 /* Preview Content */,
|
||||
@@ -155,6 +167,10 @@
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */,
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */,
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */,
|
||||
8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */,
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */,
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */,
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
+196
-44
@@ -24,6 +24,7 @@ final class AppModel: ObservableObject {
|
||||
@Published var similarFolders: [CloudFile] = []
|
||||
@Published var detail: JSONValue?
|
||||
@Published var detailFileID: String?
|
||||
@Published var detailErrorMessage = ""
|
||||
@Published var lastActionMessage = ""
|
||||
@Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? ""
|
||||
@Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? ""
|
||||
@@ -31,6 +32,8 @@ final class AppModel: ObservableObject {
|
||||
@Published var tmdbWorkflow = TMDBWorkflowConfig.load()
|
||||
@Published var tmdbJobs: [TMDBJob] = []
|
||||
@Published var tmdbTarget: CloudFile?
|
||||
@Published var batchRenameTarget: CloudFile?
|
||||
@Published var batchRenameSelection: [CloudFile] = []
|
||||
@Published var isRunningTMDBJob = false
|
||||
@Published var isAnalyzing = false
|
||||
@Published var activeScanRequest: ScanRequest?
|
||||
@@ -40,6 +43,10 @@ final class AppModel: ObservableObject {
|
||||
@Published var isLoadingScanPicker = false
|
||||
@Published var scanProgress = ScanProgress()
|
||||
@Published var isScanning = false
|
||||
@Published var isDeletingScanResults = false
|
||||
@Published var scanDeletionProgress = ScanDeletionProgress()
|
||||
@Published var isBatchRenaming = false
|
||||
@Published var batchRenameProgress = BatchRenameProgress()
|
||||
@Published var isBusy = false
|
||||
@Published var statusMessage = ""
|
||||
@Published var errorMessage = ""
|
||||
@@ -59,9 +66,11 @@ final class AppModel: ObservableObject {
|
||||
private var listResponseTask: Task<JSONValue, Error>?
|
||||
private var activeListRequestID = UUID()
|
||||
private var detailEnrichmentGeneration = UUID()
|
||||
private var scanPickerRequestID = UUID()
|
||||
private var detailCache: [String: (value: JSONValue, expiresAt: Date)] = [:]
|
||||
private var detailRequestTasks: [String: Task<JSONValue, Error>] = [:]
|
||||
private let detailRequestGate = DetailRequestGate(limit: 4)
|
||||
private let metadataCache = FileMetadataCache.shared
|
||||
private var isHandlingAuthorizationExpiry = false
|
||||
private var scanTask: Task<Void, Never>?
|
||||
|
||||
@@ -72,6 +81,7 @@ final class AppModel: ObservableObject {
|
||||
api = GuangyaAPI(accessToken: access, refreshToken: refresh)
|
||||
if !access.isEmpty || refresh != nil {
|
||||
isSignedIn = true
|
||||
isLoadingFiles = true
|
||||
Task { await loadAccount() }
|
||||
}
|
||||
}
|
||||
@@ -138,12 +148,12 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func loadVisibleItemDetails(_ file: CloudFile) {
|
||||
guard file.size == nil || file.gcid == nil || file.modifiedAt.isEmpty else { return }
|
||||
guard needsMetadata(for: file) else { return }
|
||||
guard !detailLoadingIDs.contains(file.id) else { return }
|
||||
detailLoadingIDs.insert(file.id)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let detail = try? await self.cachedDetail(for: file.id)
|
||||
let detail = try? await self.cachedDetail(for: file)
|
||||
guard !Task.isCancelled, let index = self.files.firstIndex(where: { $0.id == file.id }) else { self.detailLoadingIDs.remove(file.id); return }
|
||||
if let size = detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) { self.files[index].size = size }
|
||||
else if self.files[index].isDirectory { self.files[index].size = 0 }
|
||||
@@ -318,6 +328,54 @@ final class AppModel: ObservableObject {
|
||||
await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() }
|
||||
}
|
||||
|
||||
func batchRenameCandidates(parentID: String?, rootName: String, recursive: Bool) async throws -> [CloudFile] {
|
||||
var queue: [(id: String?, path: [String])] = [(parentID, parentID == nil ? [] : [rootName])]
|
||||
var result: [CloudFile] = []
|
||||
while !queue.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
let location = queue.removeFirst()
|
||||
var children = try await allFiles(parentID: location.id)
|
||||
for index in children.indices {
|
||||
children[index].cloudPath = "/" + (location.path + [children[index].name]).joined(separator: "/")
|
||||
}
|
||||
result += children
|
||||
if recursive {
|
||||
queue += children.filter(\.isDirectory).map { ($0.id, location.path + [$0.name]) }
|
||||
}
|
||||
}
|
||||
return Array(Dictionary(grouping: result, by: \.id).compactMap { $0.value.first })
|
||||
.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }
|
||||
}
|
||||
|
||||
func applyBatchRenames(_ changes: [BatchRenameChange]) async -> BatchRenameExecutionResult {
|
||||
guard !changes.isEmpty, !isBatchRenaming else { return BatchRenameExecutionResult(succeeded: 0, failed: 0) }
|
||||
isBatchRenaming = true
|
||||
batchRenameProgress = BatchRenameProgress(completed: 0, total: changes.count)
|
||||
defer { isBatchRenaming = false }
|
||||
var succeeded = 0
|
||||
var failures: [String] = []
|
||||
for change in changes {
|
||||
batchRenameProgress.currentName = change.file.name
|
||||
do {
|
||||
_ = try await api.fsRename(fileID: change.file.id, newName: change.newName)
|
||||
succeeded += 1
|
||||
} catch {
|
||||
failures.append("\(change.file.name):\(error.localizedDescription)")
|
||||
if isAuthorizationExpiry(error) {
|
||||
handleAuthorizationExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
batchRenameProgress.completed += 1
|
||||
}
|
||||
if isSignedIn { await loadFiles(force: true) }
|
||||
let failed = changes.count - succeeded
|
||||
lastActionMessage = failures.isEmpty
|
||||
? "已完成 \(succeeded) 项重命名"
|
||||
: "已完成 \(succeeded) 项,失败 \(failed) 项\n" + failures.prefix(5).joined(separator: "\n")
|
||||
return BatchRenameExecutionResult(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
func copy(_ file: CloudFile, to parentID: String?) async {
|
||||
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||||
}
|
||||
@@ -340,19 +398,25 @@ final class AppModel: ObservableObject {
|
||||
// its resource ID so a slower previous selection can never overwrite it.
|
||||
detailFileID = file.id
|
||||
detail = nil
|
||||
detailErrorMessage = ""
|
||||
detailLoadingIDs.insert(file.id)
|
||||
defer { detailLoadingIDs.remove(file.id) }
|
||||
do {
|
||||
let value = try await cachedDetail(for: file.id)
|
||||
let value = try await cachedDetail(for: file)
|
||||
guard !Task.isCancelled, detailFileID == file.id else { return }
|
||||
detail = value
|
||||
} catch is CancellationError { }
|
||||
catch {
|
||||
guard detailFileID == file.id else { return }
|
||||
detail = nil; lastActionMessage = "详情请求失败:\(error.localizedDescription)"
|
||||
detail = nil
|
||||
detailErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func details(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
|
||||
try await cachedDetail(for: file, forceRefresh: forceRefresh)
|
||||
}
|
||||
|
||||
func share(_ file: CloudFile) async {
|
||||
await perform("正在创建分享…") { [self] in
|
||||
let result = try await api.shareCreate(fileIDs: [file.id], title: file.name)
|
||||
@@ -382,49 +446,92 @@ final class AppModel: ObservableObject {
|
||||
scanTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.isScanning = false }
|
||||
do { self.scanResult = try await self.runScan(request) }
|
||||
catch is CancellationError { self.scanProgress.phase = "扫描已取消" }
|
||||
catch { self.errorMessage = error.localizedDescription }
|
||||
do {
|
||||
self.scanResult = try await self.runScan(request)
|
||||
self.scanProgress.phase = "扫描完成"
|
||||
}
|
||||
catch is CancellationError {
|
||||
self.scanResult = ScanResult(
|
||||
request: request,
|
||||
items: self.liveScanItems,
|
||||
foldersScanned: self.scanProgress.foldersVisited,
|
||||
filesScanned: self.scanProgress.filesVisited,
|
||||
skippedFolders: 0
|
||||
)
|
||||
self.scanProgress.phase = "扫描已取消,已保留当前结果"
|
||||
}
|
||||
catch {
|
||||
self.scanProgress.phase = "扫描失败"
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelScan() { scanTask?.cancel() }
|
||||
|
||||
func loadScanPickerFolders(parentID: String? = nil) async {
|
||||
guard !isLoadingScanPicker else { return }
|
||||
let requestID = UUID()
|
||||
scanPickerRequestID = requestID
|
||||
isLoadingScanPicker = true
|
||||
defer { isLoadingScanPicker = false }
|
||||
do { scanPickerFolders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory) }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
scanPickerFolders = []
|
||||
defer { if scanPickerRequestID == requestID { isLoadingScanPicker = false } }
|
||||
do {
|
||||
let folders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory)
|
||||
guard scanPickerRequestID == requestID else { return }
|
||||
scanPickerFolders = folders
|
||||
} catch {
|
||||
guard scanPickerRequestID == requestID else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deleteScannedEmptyFolders(_ ids: Set<String>) async {
|
||||
guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty else { return }
|
||||
guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
|
||||
let targets = result.emptyFolders.reversed().filter { ids.contains($0.id) }
|
||||
isDeletingScanResults = true
|
||||
scanDeletionProgress = ScanDeletionProgress(phase: "正在复核空文件夹…", completed: 0, total: targets.count)
|
||||
defer { isDeletingScanResults = false }
|
||||
await perform("正在复核并删除空文件夹…") { [self] in
|
||||
var deleted = 0; var skipped = 0
|
||||
for folder in result.emptyFolders.reversed() where ids.contains(folder.id) {
|
||||
var deletedIDs = Set<String>(); var skipped = 0
|
||||
for folder in targets {
|
||||
scanDeletionProgress.phase = "正在复核 \(folder.name)"
|
||||
let children = try await allFiles(parentID: folder.id)
|
||||
guard children.isEmpty else { skipped += 1; continue }
|
||||
actionLoadingIDs.insert(folder.id)
|
||||
defer { actionLoadingIDs.remove(folder.id) }
|
||||
_ = try await api.fsDelete(fileIDs: [folder.id])
|
||||
deleted += 1
|
||||
if children.isEmpty {
|
||||
scanDeletionProgress.phase = "正在删除 \(folder.name)"
|
||||
actionLoadingIDs.insert(folder.id)
|
||||
do {
|
||||
defer { actionLoadingIDs.remove(folder.id) }
|
||||
_ = try await api.fsDelete(fileIDs: [folder.id])
|
||||
}
|
||||
deletedIDs.insert(folder.id)
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
scanDeletionProgress.completed += 1
|
||||
}
|
||||
scanResult = nil
|
||||
files.removeAll { ids.contains($0.id) }
|
||||
lastActionMessage = skipped == 0 ? "已删除 \(deleted) 个空文件夹" : "已删除 \(deleted) 个,跳过 \(skipped) 个已变更文件夹"
|
||||
let remaining = result.emptyFolders.filter { !deletedIDs.contains($0.id) }
|
||||
scanResult = ScanResult(request: result.request, items: remaining.map(ScanItem.emptyFolder), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders + skipped)
|
||||
files.removeAll { deletedIDs.contains($0.id) }
|
||||
scanDeletionProgress.phase = "删除完成"
|
||||
lastActionMessage = skipped == 0 ? "已删除 \(deletedIDs.count) 个空文件夹" : "已删除 \(deletedIDs.count) 个,跳过 \(skipped) 个已变更文件夹"
|
||||
}
|
||||
}
|
||||
|
||||
func deleteScannedFiles(_ ids: Set<String>) async {
|
||||
guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty else { return }
|
||||
guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
|
||||
isDeletingScanResults = true
|
||||
scanDeletionProgress = ScanDeletionProgress(phase: "正在删除已选项目…", completed: 0, total: ids.count)
|
||||
defer { isDeletingScanResults = false }
|
||||
await perform("正在删除已选文件…") { [self] in
|
||||
var deleted = 0
|
||||
for id in ids {
|
||||
actionLoadingIDs.insert(id)
|
||||
defer { actionLoadingIDs.remove(id) }
|
||||
_ = try await api.fsDelete(fileIDs: [id])
|
||||
do {
|
||||
defer { actionLoadingIDs.remove(id) }
|
||||
_ = try await api.fsDelete(fileIDs: [id])
|
||||
}
|
||||
deleted += 1
|
||||
scanDeletionProgress.completed = deleted
|
||||
}
|
||||
files.removeAll { ids.contains($0.id) }
|
||||
switch result.request.kind {
|
||||
@@ -444,6 +551,7 @@ final class AppModel: ObservableObject {
|
||||
scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.similar), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders)
|
||||
case .emptyFolders: break
|
||||
}
|
||||
scanDeletionProgress.phase = "删除完成"
|
||||
lastActionMessage = "已删除 \(deleted) 项"
|
||||
}
|
||||
}
|
||||
@@ -451,7 +559,8 @@ final class AppModel: ObservableObject {
|
||||
private func runScan(_ request: ScanRequest) async throws -> ScanResult {
|
||||
let root: String? = request.scope == .wholeDrive ? nil : request.rootID
|
||||
let recursive = request.scope != .currentFolder
|
||||
let snapshot = try await scanTree(parentID: root, recursive: recursive)
|
||||
let rootPath = root == nil ? [] : request.rootName.split(separator: "/").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
let snapshot = try await scanTree(parentID: root, recursive: recursive, pathComponents: rootPath)
|
||||
switch request.kind {
|
||||
case .emptyFolders:
|
||||
let candidates = snapshot.folders.filter { $0.isEmptyAfterCandidates }
|
||||
@@ -463,9 +572,13 @@ final class AppModel: ObservableObject {
|
||||
if let minimum = request.excludeFilesSmallerThan { candidates = candidates.filter { ($0.size ?? 0) >= minimum } }
|
||||
let detailed = await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in
|
||||
for file in candidates {
|
||||
group.addTask { [api] in
|
||||
group.addTask { [api, metadataCache] in
|
||||
var copy = file
|
||||
if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) { copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"]) }
|
||||
if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) {
|
||||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"])
|
||||
if let size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) { copy.size = size }
|
||||
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
}
|
||||
@@ -474,7 +587,9 @@ final class AppModel: ObservableObject {
|
||||
var buckets: [String: [CloudFile]] = [:]
|
||||
for file in detailed { if let gcid = file.gcid, !gcid.isEmpty { buckets[gcid, default: []].append(file) } }
|
||||
var groups: [DuplicateGroup] = []
|
||||
for (gcid, files) in buckets where files.count > 1 { groups.append(DuplicateGroup(id: gcid, files: files)) }
|
||||
for (gcid, files) in buckets where files.count > 1 {
|
||||
groups.append(DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }))
|
||||
}
|
||||
groups.sort { $0.files.count > $1.files.count }
|
||||
return ScanResult(request: request, items: groups.map(ScanItem.duplicate), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0)
|
||||
case .similarFolders:
|
||||
@@ -484,12 +599,18 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] }
|
||||
private func scanTree(parentID: String?, recursive: Bool) async throws -> ScanTree {
|
||||
private func scanTree(parentID: String?, recursive: Bool, pathComponents: [String]) async throws -> ScanTree {
|
||||
try Task.checkCancellation()
|
||||
let children = try await allFiles(parentID: parentID)
|
||||
var children = try await allFiles(parentID: parentID)
|
||||
for index in children.indices {
|
||||
children[index].cloudPath = "/" + (pathComponents + [children[index].name]).joined(separator: "/")
|
||||
}
|
||||
// A directory whose content request is empty is immediately publishable.
|
||||
// This keeps the scan result live while deeper recursion continues.
|
||||
if let parentID, children.isEmpty, let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) {
|
||||
if activeScanRequest?.kind == .emptyFolders,
|
||||
let parentID,
|
||||
children.isEmpty,
|
||||
let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) {
|
||||
let item = ScanItem.emptyFolder(parent)
|
||||
if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) }
|
||||
}
|
||||
@@ -498,7 +619,7 @@ final class AppModel: ObservableObject {
|
||||
var all = children; var candidates: [(CloudFile, Bool)] = []
|
||||
for folder in children where folder.isDirectory {
|
||||
if recursive {
|
||||
let subtree = try await scanTree(parentID: folder.id, recursive: true)
|
||||
let subtree = try await scanTree(parentID: folder.id, recursive: true, pathComponents: pathComponents + [folder.name])
|
||||
all += subtree.allFiles
|
||||
candidates += subtree.folders
|
||||
let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates }
|
||||
@@ -537,7 +658,8 @@ final class AppModel: ObservableObject {
|
||||
// Let SwiftUI process pending input/drawing between very large pages.
|
||||
await Task.yield()
|
||||
}
|
||||
return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
|
||||
let unique = Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
|
||||
return await hydrateFromMetadataCache(unique)
|
||||
}
|
||||
|
||||
func scanDuplicates(excludingSmallFiles: Bool = false) async {
|
||||
@@ -548,11 +670,13 @@ final class AppModel: ObservableObject {
|
||||
let api = self.api
|
||||
let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in
|
||||
for file in candidates {
|
||||
group.addTask {
|
||||
group.addTask { [metadataCache] in
|
||||
guard file.gcid == nil else { return file }
|
||||
guard let detail = try? await api.fsDetail(fileID: file.id) else { return file }
|
||||
var copy = file
|
||||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
||||
copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"])
|
||||
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
@@ -603,7 +727,8 @@ final class AppModel: ObservableObject {
|
||||
defer { isRunningTMDBJob = false }
|
||||
do {
|
||||
let allowed = Set(extensionsText.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })
|
||||
let snapshot = try await scanTree(parentID: sourceID, recursive: recursive)
|
||||
let sourcePath = sourceID == nil ? [] : [tmdbTarget?.name ?? "所选文件夹"]
|
||||
let snapshot = try await scanTree(parentID: sourceID, recursive: recursive, pathComponents: sourcePath)
|
||||
let minimum = Int64(max(0, minimumSizeMB)) * 1024 * 1024
|
||||
let candidates = snapshot.allFiles.filter { file in
|
||||
guard !file.isDirectory, (file.size ?? 0) >= minimum else { return false }
|
||||
@@ -663,7 +788,6 @@ final class AppModel: ObservableObject {
|
||||
for index in tmdbJobs.indices where tmdbJobs[index].state == .matched && tmdbJobs[index].isApproved {
|
||||
guard let match = tmdbJobs[index].match else { continue }
|
||||
do {
|
||||
let year = String(match.releaseDate.prefix(4))
|
||||
let baseFolder = renderTMDBTemplate(tmdbWorkflow.folderTemplate, match: match, fallback: match.title)
|
||||
// Every season is an independent library root: its own TMDB ID,
|
||||
// NFO, artwork, cast and episodes all live in the same folder.
|
||||
@@ -857,6 +981,7 @@ final class AppModel: ObservableObject {
|
||||
folderPath = []
|
||||
detail = nil
|
||||
detailFileID = nil
|
||||
detailErrorMessage = ""
|
||||
scanResult = nil
|
||||
liveScanItems = []
|
||||
statusMessage = ""
|
||||
@@ -865,10 +990,13 @@ final class AppModel: ObservableObject {
|
||||
private func completeLogin() {
|
||||
persistTokens()
|
||||
isSignedIn = true
|
||||
isLoadingFiles = true
|
||||
Task { await loadAccount() }
|
||||
}
|
||||
|
||||
private func loadAccount() async {
|
||||
isLoadingFiles = true
|
||||
defer { isLoadingFiles = false }
|
||||
do {
|
||||
try await api.prepareSession()
|
||||
} catch {
|
||||
@@ -876,8 +1004,6 @@ final class AppModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
persistTokens()
|
||||
isLoadingFiles = true
|
||||
defer { isLoadingFiles = false }
|
||||
async let userResult: JSONValue? = try? await api.userInfo()
|
||||
async let fileResult: JSONValue? = try? await filesResponse()
|
||||
if let result = await userResult {
|
||||
@@ -886,7 +1012,7 @@ final class AppModel: ObservableObject {
|
||||
statusMessage = "登录状态已恢复"
|
||||
}
|
||||
if let result = await fileResult {
|
||||
files = extractFiles(from: result)
|
||||
files = await hydrateFromMetadataCache(extractFiles(from: result))
|
||||
updatePaging(from: result)
|
||||
// Render the directory response immediately. Per-file detail calls are
|
||||
// expensive and must never block or saturate the homepage on launch.
|
||||
@@ -910,7 +1036,7 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
let result = try await task.value
|
||||
guard !Task.isCancelled, activeListRequestID == requestID else { return }
|
||||
files = extractFiles(from: result)
|
||||
files = await hydrateFromMetadataCache(extractFiles(from: result))
|
||||
updatePaging(from: result)
|
||||
// Render the directory response immediately. Per-file detail calls are
|
||||
// expensive and must never block or saturate the homepage on launch.
|
||||
@@ -1023,7 +1149,9 @@ final class AppModel: ObservableObject {
|
||||
detailEnrichmentTask?.cancel()
|
||||
detailEnrichmentGeneration = UUID()
|
||||
let generation = detailEnrichmentGeneration
|
||||
let page = force ? files : files.filter { $0.size == nil || $0.gcid == nil || $0.modifiedAt.isEmpty }
|
||||
let page = files.filter { file in
|
||||
file.isDirectory ? (force || needsMetadata(for: file)) : needsMetadata(for: file)
|
||||
}
|
||||
guard !page.isEmpty else {
|
||||
isLoadingFolderSizes = false
|
||||
detailLoadingIDs = []
|
||||
@@ -1042,6 +1170,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
private func enrichPageDetails(_ page: [CloudFile], generation: UUID) async {
|
||||
let api = self.api
|
||||
let metadataCache = self.metadataCache
|
||||
let concurrencyLimit = 6
|
||||
var iterator = page.makeIterator()
|
||||
await withTaskGroup(of: (String, Int64?, String?, Int64?, Int?, Int?).self) { group in
|
||||
@@ -1049,6 +1178,7 @@ final class AppModel: ObservableObject {
|
||||
guard let file = iterator.next() else { return }
|
||||
group.addTask {
|
||||
let detail = try? await api.fsDetail(fileID: file.id)
|
||||
if let detail { await metadataCache.save(fileID: file.id, isDirectory: file.isDirectory, detail: detail) }
|
||||
return (
|
||||
file.id,
|
||||
detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]),
|
||||
@@ -1083,16 +1213,25 @@ final class AppModel: ObservableObject {
|
||||
isLoadingFolderSizes = false
|
||||
}
|
||||
|
||||
private func cachedDetail(for fileID: String, forceRefresh: Bool = false) async throws -> JSONValue {
|
||||
private func cachedDetail(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
|
||||
let fileID = file.id
|
||||
if !forceRefresh, let cached = detailCache[fileID], cached.expiresAt > Date() { return cached.value }
|
||||
if !forceRefresh, let cached = await metadataCache.metadata(for: fileID), cached.isUsable {
|
||||
let value = cached.detailValue
|
||||
detailCache[fileID] = (value, Date().addingTimeInterval(300))
|
||||
return value
|
||||
}
|
||||
if let task = detailRequestTasks[fileID] { return try await task.value }
|
||||
let api = self.api
|
||||
let gate = detailRequestGate
|
||||
let metadataCache = self.metadataCache
|
||||
let task = Task<JSONValue, Error> {
|
||||
await gate.acquire()
|
||||
defer { Task { await gate.release() } }
|
||||
try Task.checkCancellation()
|
||||
return try await api.fsDetail(fileID: fileID)
|
||||
let value = try await api.fsDetail(fileID: fileID)
|
||||
await metadataCache.save(fileID: fileID, isDirectory: file.isDirectory, detail: value)
|
||||
return value
|
||||
}
|
||||
detailRequestTasks[fileID] = task
|
||||
defer { detailRequestTasks[fileID] = nil }
|
||||
@@ -1102,6 +1241,19 @@ final class AppModel: ObservableObject {
|
||||
return value
|
||||
}
|
||||
|
||||
private func hydrateFromMetadataCache(_ source: [CloudFile]) async -> [CloudFile] {
|
||||
await metadataCache.save(files: source)
|
||||
let cached = await metadataCache.metadata(for: source.map(\.id))
|
||||
return source.map { file in cached[file.id]?.applying(to: file) ?? file }
|
||||
}
|
||||
|
||||
private func needsMetadata(for file: CloudFile) -> Bool {
|
||||
if file.isDirectory {
|
||||
return file.subDirectoryCount == nil || file.subFileCount == nil
|
||||
}
|
||||
return file.gcid?.isEmpty != false
|
||||
}
|
||||
|
||||
private static func formatDate(_ epoch: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppTheme {
|
||||
static let orange = Color(red: 1.0, green: 0.45, blue: 0.12)
|
||||
static let orangeDeep = Color(red: 0.86, green: 0.20, blue: 0.04)
|
||||
}
|
||||
|
||||
struct AppBackdrop: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: colorScheme == .dark
|
||||
? [Color(red: 0.07, green: 0.09, blue: 0.10), Color(red: 0.09, green: 0.13, blue: 0.13), Color(red: 0.15, green: 0.10, blue: 0.08)]
|
||||
: [Color(red: 0.93, green: 0.95, blue: 0.96), Color(red: 0.89, green: 0.94, blue: 0.93), Color(red: 0.97, green: 0.92, blue: 0.87)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(colorScheme == .dark ? Color.orange.opacity(0.05) : Color.orange.opacity(0.08))
|
||||
.frame(height: 150)
|
||||
.mask(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private struct SoftCard: ViewModifier {
|
||||
let requestedRadius: CGFloat
|
||||
private var radius: CGFloat { min(requestedRadius, 8) }
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
.overlay { RoundedRectangle(cornerRadius: radius, style: .continuous).stroke(.white.opacity(0.26), lineWidth: 1) }
|
||||
} else {
|
||||
content
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
.overlay { RoundedRectangle(cornerRadius: radius, style: .continuous).stroke(.white.opacity(0.30), lineWidth: 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func softCard(radius: CGFloat = 8) -> some View {
|
||||
modifier(SoftCard(requestedRadius: radius))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private enum BatchRenameItemType: String, CaseIterable, Identifiable {
|
||||
case all = "全部"
|
||||
case files = "仅文件"
|
||||
case folders = "仅文件夹"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private enum BatchRenameRuleKind: String, CaseIterable, Identifiable {
|
||||
case remove = "删除字符"
|
||||
case replace = "查找替换"
|
||||
case regex = "正则替换"
|
||||
case prefix = "添加前缀"
|
||||
case suffix = "添加后缀"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct BatchRenameRule: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var isEnabled: Bool
|
||||
var kind: BatchRenameRuleKind
|
||||
var pattern: String
|
||||
var replacement: String
|
||||
var ignoresCase: Bool
|
||||
|
||||
init(id: UUID = UUID(), isEnabled: Bool = true, kind: BatchRenameRuleKind = .remove, pattern: String = "", replacement: String = "", ignoresCase: Bool = false) {
|
||||
self.id = id
|
||||
self.isEnabled = isEnabled
|
||||
self.kind = kind
|
||||
self.pattern = pattern
|
||||
self.replacement = replacement
|
||||
self.ignoresCase = ignoresCase
|
||||
}
|
||||
|
||||
func applying(to value: String) throws -> String {
|
||||
guard isEnabled else { return value }
|
||||
switch kind {
|
||||
case .remove:
|
||||
guard !pattern.isEmpty else { return value }
|
||||
return value.replacingOccurrences(of: pattern, with: "", options: ignoresCase ? [.caseInsensitive] : [])
|
||||
case .replace:
|
||||
guard !pattern.isEmpty else { return value }
|
||||
return value.replacingOccurrences(of: pattern, with: replacement, options: ignoresCase ? [.caseInsensitive] : [])
|
||||
case .regex:
|
||||
guard !pattern.isEmpty else { return value }
|
||||
let expression = try NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : [])
|
||||
return expression.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: replacement)
|
||||
case .prefix:
|
||||
return pattern + value
|
||||
case .suffix:
|
||||
return value + pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BatchRenamePreview: Identifiable {
|
||||
let file: CloudFile
|
||||
let newName: String
|
||||
var error: String?
|
||||
var id: String { file.id }
|
||||
var hasChanged: Bool { file.name != newName }
|
||||
}
|
||||
|
||||
struct BatchRenameToolPage: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var sourceID: String?
|
||||
@State private var sourceName = "云盘根目录"
|
||||
@State private var usesExplicitSelection = false
|
||||
@State private var recursive = false
|
||||
@State private var itemType: BatchRenameItemType = .all
|
||||
@State private var preserveExtension = true
|
||||
@State private var filterText = ""
|
||||
@State private var rules = [BatchRenameRule()]
|
||||
@State private var candidates: [CloudFile] = []
|
||||
@State private var selectedIDs: Set<String> = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadError = ""
|
||||
@State private var showFolderPicker = false
|
||||
@State private var showConfirmation = false
|
||||
|
||||
private var previews: [BatchRenamePreview] {
|
||||
var values = candidates.filter { file in
|
||||
let matchesType = itemType == .all || (itemType == .files && !file.isDirectory) || (itemType == .folders && file.isDirectory)
|
||||
let matchesFilter = filterText.isEmpty || file.name.localizedCaseInsensitiveContains(filterText)
|
||||
return matchesType && matchesFilter
|
||||
}.map(makePreview)
|
||||
|
||||
let proposedGroups = Dictionary(grouping: values.filter { $0.hasChanged && $0.error == nil }) {
|
||||
destinationKey(path: $0.file.cloudPath, name: $0.newName)
|
||||
}
|
||||
let duplicateKeys = Set(proposedGroups.filter { $0.value.count > 1 }.map(\.key))
|
||||
let existing = Dictionary(grouping: candidates) { destinationKey(path: $0.cloudPath, name: $0.name) }
|
||||
|
||||
for index in values.indices where values[index].hasChanged && values[index].error == nil {
|
||||
let key = destinationKey(path: values[index].file.cloudPath, name: values[index].newName)
|
||||
if duplicateKeys.contains(key) {
|
||||
values[index].error = "规则产生了同名项目"
|
||||
} else if existing[key]?.contains(where: { $0.id != values[index].file.id }) == true {
|
||||
values[index].error = "目标名称已存在"
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
private var applicablePreviews: [BatchRenamePreview] {
|
||||
previews.filter { $0.hasChanged && $0.error == nil }
|
||||
}
|
||||
|
||||
private var selectedChanges: [BatchRenameChange] {
|
||||
applicablePreviews.filter { selectedIDs.contains($0.id) }.map { BatchRenameChange(file: $0.file, newName: $0.newName) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
sourceSection
|
||||
rulesSection
|
||||
previewSection
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.sheet(isPresented: $showFolderPicker) {
|
||||
ScanFolderPickerSheet(model: model, title: "选择重命名目录", selectionLabel: "选择此目录") { id, name in
|
||||
model.batchRenameSelection = []
|
||||
model.batchRenameTarget = nil
|
||||
usesExplicitSelection = false
|
||||
sourceID = id
|
||||
sourceName = name
|
||||
candidates = []
|
||||
selectedIDs = []
|
||||
}
|
||||
}
|
||||
.confirmationDialog("确认批量重命名?", isPresented: $showConfirmation) {
|
||||
Button("应用 \(selectedChanges.count) 项重命名") {
|
||||
let changes = selectedChanges
|
||||
Task {
|
||||
_ = await model.applyBatchRenames(changes)
|
||||
if usesExplicitSelection {
|
||||
model.batchRenameSelection = []
|
||||
candidates = []
|
||||
selectedIDs = []
|
||||
sourceName = "已完成所选项目"
|
||||
} else {
|
||||
await loadCandidates()
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) { }
|
||||
} message: {
|
||||
Text("名称修改会直接同步到云盘。执行前请再次核对预览。")
|
||||
}
|
||||
.task {
|
||||
syncTarget()
|
||||
if !usesExplicitSelection { await loadCandidates() }
|
||||
}
|
||||
.onChange(of: model.batchRenameTarget?.id) { _, _ in
|
||||
syncTarget()
|
||||
if !usesExplicitSelection { Task { await loadCandidates() } }
|
||||
}
|
||||
.onChange(of: model.batchRenameSelection.map(\.id)) { _, _ in
|
||||
syncTarget()
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceSection: some View {
|
||||
ToolPageCard(icon: "folder.badge.gearshape", tint: .orange, title: "数据源", detail: sourceName) {
|
||||
HStack(spacing: 10) {
|
||||
Picker("类型", selection: $itemType) {
|
||||
ForEach(BatchRenameItemType.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.frame(width: 150)
|
||||
TextField("结果内过滤", text: $filterText).textFieldStyle(.roundedBorder)
|
||||
Toggle("保留扩展名", isOn: $preserveExtension).fixedSize()
|
||||
Toggle("包含子文件夹", isOn: $recursive).fixedSize().disabled(usesExplicitSelection)
|
||||
Button { showFolderPicker = true } label: { Label("选择目录", systemImage: "folder") }
|
||||
.buttonStyle(.bordered)
|
||||
Button {
|
||||
if usesExplicitSelection { syncTarget() }
|
||||
else { Task { await loadCandidates() } }
|
||||
} label: {
|
||||
if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("重新读取目录")
|
||||
.disabled(isLoading || model.isBatchRenaming)
|
||||
}
|
||||
if !loadError.isEmpty {
|
||||
Label(loadError, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rulesSection: some View {
|
||||
ToolPageCard(icon: "list.number", tint: .blue, title: "规则链", detail: "按从上到下的顺序应用 \(rules.count) 条规则") {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(Array(rules.enumerated()), id: \.element.id) { index, rule in
|
||||
ruleRow(index: index, rule: rule)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button { rules.append(BatchRenameRule(kind: .replace)) } label: { Label("添加规则", systemImage: "plus") }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("清空规则") { rules = [BatchRenameRule()] }.disabled(rules.count == 1 && rules[0].pattern.isEmpty && rules[0].isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
ToolPageCard(icon: "rectangle.and.pencil.and.ellipsis", tint: .green, title: "预览", detail: previewSummary) {
|
||||
if isLoading {
|
||||
VStack(spacing: 12) { ProgressView(); Text("正在读取目录内容…").foregroundStyle(.secondary) }
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 42)
|
||||
} else if candidates.isEmpty {
|
||||
ContentUnavailableView("目录中没有项目", systemImage: "folder", description: Text("选择目录或重新读取数据源。"))
|
||||
.frame(minHeight: 180)
|
||||
} else {
|
||||
HStack {
|
||||
Button("全选可应用项") { selectedIDs = Set(applicablePreviews.map(\.id)) }.buttonStyle(.bordered)
|
||||
Button("全不选") { selectedIDs.removeAll() }.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Text("已选 \(selectedChanges.count) 项").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Button {
|
||||
showConfirmation = true
|
||||
} label: {
|
||||
HStack(spacing: 7) {
|
||||
if model.isBatchRenaming { ProgressView().controlSize(.small) }
|
||||
Label(model.isBatchRenaming ? "正在应用 \(model.batchRenameProgress.completed)/\(model.batchRenameProgress.total)" : "应用重命名", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent).tint(.orange)
|
||||
.disabled(selectedChanges.isEmpty || model.isBatchRenaming)
|
||||
}
|
||||
Divider()
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(previews) { preview in previewRow(preview) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var previewSummary: String {
|
||||
let invalid = previews.filter { $0.error != nil }.count
|
||||
return "共 \(previews.count) 项,可重命名 \(applicablePreviews.count) 项" + (invalid > 0 ? ",\(invalid) 项冲突" : "")
|
||||
}
|
||||
|
||||
private func ruleRow(index: Int, rule: BatchRenameRule) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("启用", isOn: $rules[index].isEnabled).labelsHidden().help("启用或停用此规则")
|
||||
Picker("规则", selection: $rules[index].kind) {
|
||||
ForEach(BatchRenameRuleKind.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.labelsHidden().frame(width: 135)
|
||||
TextField(patternPlaceholder(for: rule.kind), text: $rules[index].pattern).textFieldStyle(.roundedBorder)
|
||||
if rule.kind == .replace || rule.kind == .regex {
|
||||
Image(systemName: "arrow.right").foregroundStyle(.tertiary)
|
||||
TextField("替换为", text: $rules[index].replacement).textFieldStyle(.roundedBorder)
|
||||
}
|
||||
if rule.kind == .remove || rule.kind == .replace || rule.kind == .regex {
|
||||
Toggle("忽略大小写", isOn: $rules[index].ignoresCase).fixedSize()
|
||||
}
|
||||
Button { moveRule(index, offset: -1) } label: { Image(systemName: "arrow.up") }
|
||||
.buttonStyle(.plain).disabled(index == 0).help("上移")
|
||||
Button { moveRule(index, offset: 1) } label: { Image(systemName: "arrow.down") }
|
||||
.buttonStyle(.plain).disabled(index == rules.count - 1).help("下移")
|
||||
Button(role: .destructive) { removeRule(index) } label: { Image(systemName: "trash") }
|
||||
.buttonStyle(.plain).help("删除规则")
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
|
||||
private func previewRow(_ preview: BatchRenamePreview) -> some View {
|
||||
let canSelect = preview.hasChanged && preview.error == nil
|
||||
return HStack(spacing: 10) {
|
||||
Toggle("", isOn: Binding(
|
||||
get: { selectedIDs.contains(preview.id) },
|
||||
set: { if $0 && canSelect { selectedIDs.insert(preview.id) } else { selectedIDs.remove(preview.id) } }
|
||||
))
|
||||
.labelsHidden().disabled(!canSelect)
|
||||
Image(systemName: preview.file.icon).foregroundStyle(preview.file.isDirectory ? .orange : .accentColor).frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(preview.file.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Text(preview.newName).font(.callout.weight(.medium)).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if let error = preview.error {
|
||||
Label(error, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red)
|
||||
} else if preview.hasChanged {
|
||||
Text("将修改").font(.caption.weight(.semibold)).foregroundStyle(.green)
|
||||
} else {
|
||||
Text("无变化").font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
|
||||
private func makePreview(_ file: CloudFile) -> BatchRenamePreview {
|
||||
let extensionText = (!file.isDirectory && preserveExtension) ? (file.name as NSString).pathExtension : ""
|
||||
let originalBase = extensionText.isEmpty ? file.name : (file.name as NSString).deletingPathExtension
|
||||
do {
|
||||
let renamedBase = try rules.reduce(originalBase) { try $1.applying(to: $0) }
|
||||
let newName = extensionText.isEmpty ? renamedBase : renamedBase + "." + extensionText
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return BatchRenamePreview(file: file, newName: newName, error: "名称不能为空") }
|
||||
if newName.contains("/") || newName.contains("\0") { return BatchRenamePreview(file: file, newName: newName, error: "名称包含非法字符") }
|
||||
return BatchRenamePreview(file: file, newName: newName, error: nil)
|
||||
} catch {
|
||||
return BatchRenamePreview(file: file, newName: file.name, error: "正则表达式无效")
|
||||
}
|
||||
}
|
||||
|
||||
private func destinationKey(path: String, name: String) -> String {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
return parent.lowercased() + "/" + name.lowercased()
|
||||
}
|
||||
|
||||
private func patternPlaceholder(for kind: BatchRenameRuleKind) -> String {
|
||||
switch kind {
|
||||
case .remove: return "要删除的字符"
|
||||
case .replace: return "查找内容"
|
||||
case .regex: return "正则表达式,例如 \\s+"
|
||||
case .prefix: return "前缀"
|
||||
case .suffix: return "后缀"
|
||||
}
|
||||
}
|
||||
|
||||
private func moveRule(_ index: Int, offset: Int) {
|
||||
let destination = index + offset
|
||||
guard rules.indices.contains(index), rules.indices.contains(destination) else { return }
|
||||
rules.swapAt(index, destination)
|
||||
}
|
||||
|
||||
private func removeRule(_ index: Int) {
|
||||
guard rules.indices.contains(index) else { return }
|
||||
rules.remove(at: index)
|
||||
if rules.isEmpty { rules = [BatchRenameRule()] }
|
||||
}
|
||||
|
||||
private func syncTarget() {
|
||||
if !model.batchRenameSelection.isEmpty {
|
||||
usesExplicitSelection = true
|
||||
candidates = model.batchRenameSelection
|
||||
selectedIDs = Set(candidates.map(\.id))
|
||||
sourceID = nil
|
||||
sourceName = "已选择 \(candidates.count) 项"
|
||||
} else if let target = model.batchRenameTarget, target.isDirectory {
|
||||
usesExplicitSelection = false
|
||||
sourceID = target.id
|
||||
sourceName = target.name
|
||||
} else {
|
||||
usesExplicitSelection = false
|
||||
sourceID = model.currentScanRootID
|
||||
sourceName = model.scanScopeLocationName
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadCandidates() async {
|
||||
guard !isLoading, !usesExplicitSelection else { return }
|
||||
isLoading = true
|
||||
loadError = ""
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
candidates = try await model.batchRenameCandidates(parentID: sourceID, rootName: sourceName, recursive: recursive)
|
||||
selectedIDs = Set(candidates.map(\.id))
|
||||
} catch is CancellationError { }
|
||||
catch { loadError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
+518
-339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
struct CachedFileMetadata: Sendable {
|
||||
let fileID: String
|
||||
let isDirectory: Bool
|
||||
let gcid: String?
|
||||
let size: Int64?
|
||||
let modifiedEpoch: Int64?
|
||||
let subDirectoryCount: Int?
|
||||
let subFileCount: Int?
|
||||
|
||||
var isUsable: Bool {
|
||||
isDirectory ? (size != nil || subDirectoryCount != nil || subFileCount != nil) : !(gcid?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func applying(to file: CloudFile) -> CloudFile {
|
||||
var result = file
|
||||
if let gcid, !gcid.isEmpty, result.gcid?.isEmpty != false { result.gcid = gcid }
|
||||
if let size, result.size == nil || (result.isDirectory && result.size == 0) { result.size = size }
|
||||
if let modifiedEpoch, result.modifiedAt.isEmpty { result.modifiedAt = Self.formatDate(modifiedEpoch) }
|
||||
if let subDirectoryCount, result.subDirectoryCount == nil { result.subDirectoryCount = subDirectoryCount }
|
||||
if let subFileCount, result.subFileCount == nil { result.subFileCount = subFileCount }
|
||||
return result
|
||||
}
|
||||
|
||||
var detailValue: JSONValue {
|
||||
var fileInfo: [String: JSONValue] = ["fileId": .string(fileID)]
|
||||
if let gcid { fileInfo["gcid"] = .string(gcid) }
|
||||
if let modifiedEpoch { fileInfo["utime"] = .number(Double(modifiedEpoch)) }
|
||||
var sizeInfo: [String: JSONValue] = [:]
|
||||
if let size { sizeInfo["size"] = .number(Double(size)) }
|
||||
if let subDirectoryCount { sizeInfo["subDirCount"] = .number(Double(subDirectoryCount)) }
|
||||
if let subFileCount { sizeInfo["subFileCount"] = .number(Double(subFileCount)) }
|
||||
return .object(["data": .object(["fileInfo": .object(fileInfo), "sizeInfo": .object(sizeInfo)]), "cache": .bool(true)])
|
||||
}
|
||||
|
||||
private static func formatDate(_ epoch: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch)))
|
||||
}
|
||||
}
|
||||
|
||||
actor FileMetadataCache {
|
||||
static let shared = FileMetadataCache()
|
||||
|
||||
private var database: OpaquePointer?
|
||||
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
private init() {
|
||||
guard let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
|
||||
let directory = baseURL.appendingPathComponent("com.example.guangya-mac", isDirectory: true)
|
||||
do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) }
|
||||
catch { return }
|
||||
let url = directory.appendingPathComponent("file-metadata.sqlite3")
|
||||
guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
||||
let database else { self.database = nil; return }
|
||||
Self.execute(database, "PRAGMA journal_mode=WAL;")
|
||||
Self.execute(database, "PRAGMA synchronous=NORMAL;")
|
||||
Self.execute(database, """
|
||||
CREATE TABLE IF NOT EXISTS file_index (
|
||||
file_id TEXT PRIMARY KEY NOT NULL,
|
||||
is_directory INTEGER NOT NULL DEFAULT 0,
|
||||
gcid TEXT,
|
||||
modified_epoch INTEGER,
|
||||
directory_size INTEGER,
|
||||
sub_directory_count INTEGER,
|
||||
sub_file_count INTEGER,
|
||||
cached_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
Self.execute(database, """
|
||||
CREATE TABLE IF NOT EXISTS gcid_details (
|
||||
gcid TEXT PRIMARY KEY NOT NULL,
|
||||
size INTEGER,
|
||||
cached_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_file_index_gcid ON file_index(gcid);")
|
||||
Self.migrateLegacyTable(database)
|
||||
}
|
||||
|
||||
deinit { if let database { sqlite3_close(database) } }
|
||||
|
||||
func metadata(for fileIDs: [String]) -> [String: CachedFileMetadata] {
|
||||
guard let database, !fileIDs.isEmpty else { return [:] }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
SELECT i.file_id, i.is_directory, i.gcid,
|
||||
CASE WHEN i.is_directory = 1 THEN i.directory_size ELSE g.size END,
|
||||
i.modified_epoch, i.sub_directory_count, i.sub_file_count
|
||||
FROM file_index AS i
|
||||
LEFT JOIN gcid_details AS g ON g.gcid = i.gcid
|
||||
WHERE i.file_id = ?1;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [:] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var result: [String: CachedFileMetadata] = [:]
|
||||
for fileID in fileIDs {
|
||||
sqlite3_reset(statement)
|
||||
sqlite3_clear_bindings(statement)
|
||||
sqlite3_bind_text(statement, 1, fileID, -1, transient)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else { continue }
|
||||
let value = Self.read(statement)
|
||||
result[value.fileID] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func metadata(for fileID: String) -> CachedFileMetadata? {
|
||||
metadata(for: [fileID])[fileID]
|
||||
}
|
||||
|
||||
func save(fileID: String, isDirectory: Bool, detail: JSONValue) {
|
||||
let metadata = CachedFileMetadata(
|
||||
fileID: fileID,
|
||||
isDirectory: isDirectory,
|
||||
gcid: detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]),
|
||||
size: detail.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]),
|
||||
modifiedEpoch: detail.firstInt64Deep(["utime", "ctime"]),
|
||||
subDirectoryCount: detail.firstIntDeep(["subDirCount"]),
|
||||
subFileCount: detail.firstIntDeep(["subFileCount"])
|
||||
)
|
||||
save(metadata)
|
||||
}
|
||||
|
||||
func save(files: [CloudFile]) {
|
||||
for file in files {
|
||||
let hasGCID = !(file.gcid?.isEmpty ?? true)
|
||||
let hasDirectoryCounts = file.subDirectoryCount != nil || file.subFileCount != nil
|
||||
guard (!file.isDirectory && hasGCID) || (file.isDirectory && hasDirectoryCounts) else { continue }
|
||||
save(CachedFileMetadata(
|
||||
fileID: file.id,
|
||||
isDirectory: file.isDirectory,
|
||||
gcid: file.gcid,
|
||||
size: file.size,
|
||||
modifiedEpoch: nil,
|
||||
subDirectoryCount: file.subDirectoryCount,
|
||||
subFileCount: file.subFileCount
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ metadata: CachedFileMetadata) {
|
||||
guard let database else { return }
|
||||
let existingGCID = metadata.isDirectory ? nil : self.metadata(for: metadata.fileID)?.gcid
|
||||
let effectiveGCID = metadata.gcid?.isEmpty == false ? metadata.gcid : existingGCID
|
||||
var indexStatement: OpaquePointer?
|
||||
let sql = """
|
||||
INSERT INTO file_index
|
||||
(file_id, is_directory, gcid, modified_epoch, directory_size, sub_directory_count, sub_file_count, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(file_id) DO UPDATE SET
|
||||
is_directory = excluded.is_directory,
|
||||
gcid = COALESCE(excluded.gcid, file_index.gcid),
|
||||
modified_epoch = COALESCE(excluded.modified_epoch, file_index.modified_epoch),
|
||||
directory_size = COALESCE(excluded.directory_size, file_index.directory_size),
|
||||
sub_directory_count = COALESCE(excluded.sub_directory_count, file_index.sub_directory_count),
|
||||
sub_file_count = COALESCE(excluded.sub_file_count, file_index.sub_file_count),
|
||||
cached_at = excluded.cached_at;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &indexStatement, nil) == SQLITE_OK, let indexStatement else { return }
|
||||
sqlite3_bind_text(indexStatement, 1, metadata.fileID, -1, transient)
|
||||
sqlite3_bind_int(indexStatement, 2, metadata.isDirectory ? 1 : 0)
|
||||
Self.bind(effectiveGCID, to: indexStatement, at: 3, transient: transient)
|
||||
Self.bind(metadata.modifiedEpoch, to: indexStatement, at: 4)
|
||||
Self.bind(metadata.isDirectory ? metadata.size : nil, to: indexStatement, at: 5)
|
||||
Self.bind(metadata.subDirectoryCount.map(Int64.init), to: indexStatement, at: 6)
|
||||
Self.bind(metadata.subFileCount.map(Int64.init), to: indexStatement, at: 7)
|
||||
sqlite3_bind_double(indexStatement, 8, Date().timeIntervalSince1970)
|
||||
sqlite3_step(indexStatement)
|
||||
sqlite3_finalize(indexStatement)
|
||||
|
||||
guard !metadata.isDirectory, let effectiveGCID, !effectiveGCID.isEmpty else { return }
|
||||
var detailStatement: OpaquePointer?
|
||||
let detailSQL = """
|
||||
INSERT INTO gcid_details (gcid, size, cached_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(gcid) DO UPDATE SET
|
||||
size = COALESCE(excluded.size, gcid_details.size),
|
||||
cached_at = excluded.cached_at;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, detailSQL, -1, &detailStatement, nil) == SQLITE_OK, let detailStatement else { return }
|
||||
defer { sqlite3_finalize(detailStatement) }
|
||||
sqlite3_bind_text(detailStatement, 1, effectiveGCID, -1, transient)
|
||||
Self.bind(metadata.size, to: detailStatement, at: 2)
|
||||
sqlite3_bind_double(detailStatement, 3, Date().timeIntervalSince1970)
|
||||
sqlite3_step(detailStatement)
|
||||
}
|
||||
|
||||
private static func read(_ statement: OpaquePointer) -> CachedFileMetadata {
|
||||
CachedFileMetadata(
|
||||
fileID: String(cString: sqlite3_column_text(statement, 0)),
|
||||
isDirectory: sqlite3_column_int(statement, 1) != 0,
|
||||
gcid: text(statement, column: 2),
|
||||
size: integer(statement, column: 3),
|
||||
modifiedEpoch: integer(statement, column: 4),
|
||||
subDirectoryCount: integer(statement, column: 5).map(Int.init),
|
||||
subFileCount: integer(statement, column: 6).map(Int.init)
|
||||
)
|
||||
}
|
||||
|
||||
private static func text(_ statement: OpaquePointer, column: Int32) -> String? {
|
||||
guard sqlite3_column_type(statement, column) != SQLITE_NULL, let value = sqlite3_column_text(statement, column) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
private static func integer(_ statement: OpaquePointer, column: Int32) -> Int64? {
|
||||
sqlite3_column_type(statement, column) == SQLITE_NULL ? nil : sqlite3_column_int64(statement, column)
|
||||
}
|
||||
|
||||
private static func bind(_ value: String?, to statement: OpaquePointer, at index: Int32, transient: sqlite3_destructor_type) {
|
||||
if let value { sqlite3_bind_text(statement, index, value, -1, transient) }
|
||||
else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
|
||||
private static func bind(_ value: Int64?, to statement: OpaquePointer, at index: Int32) {
|
||||
if let value { sqlite3_bind_int64(statement, index, value) }
|
||||
else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func execute(_ database: OpaquePointer, _ sql: String) -> Bool {
|
||||
sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK
|
||||
}
|
||||
|
||||
private static func migrateLegacyTable(_ database: OpaquePointer) {
|
||||
guard tableExists(database, name: "file_metadata") else { return }
|
||||
execute(database, "BEGIN IMMEDIATE TRANSACTION;")
|
||||
let mapped = execute(database, """
|
||||
INSERT OR REPLACE INTO file_index
|
||||
(file_id, is_directory, gcid, modified_epoch, directory_size, sub_directory_count, sub_file_count, cached_at)
|
||||
SELECT file_id, is_directory, gcid, modified_epoch,
|
||||
CASE WHEN is_directory = 1 THEN size ELSE NULL END,
|
||||
sub_directory_count, sub_file_count, cached_at
|
||||
FROM file_metadata;
|
||||
""")
|
||||
let detailed = execute(database, """
|
||||
INSERT OR REPLACE INTO gcid_details (gcid, size, cached_at)
|
||||
SELECT gcid, size, cached_at
|
||||
FROM file_metadata
|
||||
WHERE is_directory = 0 AND gcid IS NOT NULL AND gcid != '';
|
||||
""")
|
||||
guard mapped, detailed else {
|
||||
execute(database, "ROLLBACK;")
|
||||
return
|
||||
}
|
||||
guard execute(database, "DROP TABLE file_metadata;") else {
|
||||
execute(database, "ROLLBACK;")
|
||||
return
|
||||
}
|
||||
execute(database, "COMMIT;")
|
||||
}
|
||||
|
||||
private static func tableExists(_ database: OpaquePointer, name: String) -> Bool {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1;", -1, &statement, nil) == SQLITE_OK,
|
||||
let statement else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, name, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
return sqlite3_step(statement) == SQLITE_ROW
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
func fsDetail(fileID: String) async throws -> JSONValue {
|
||||
// Build the payload explicitly so the 19-digit resource ID can never pass
|
||||
// through the Any/NSNumber conversion path and lose its JSON string type.
|
||||
try await apiRequest("/userres/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)]))
|
||||
try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)]))
|
||||
}
|
||||
|
||||
func vodDownloadURL(fileID: String, gcid: String) async throws -> JSONValue {
|
||||
@@ -517,4 +517,3 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -145,8 +145,11 @@ struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
var subDirectoryCount: Int?
|
||||
var subFileCount: Int?
|
||||
var modifiedAt: String
|
||||
var cloudPath: String
|
||||
let fileType: Int
|
||||
|
||||
var pathDepth: Int { cloudPath.split(separator: "/").count }
|
||||
|
||||
var icon: String {
|
||||
if isDirectory { return "folder.fill" }
|
||||
switch fileType {
|
||||
@@ -195,6 +198,7 @@ struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
// Keep it as zero rather than “unknown”, so it sorts and renders correctly.
|
||||
if isDirectory, size == nil { size = 0 }
|
||||
modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? ""
|
||||
cloudPath = json.firstString(["location", "path", "fullPath"]) ?? name
|
||||
fileType = type
|
||||
}
|
||||
}
|
||||
@@ -373,6 +377,29 @@ struct ScanProgress: Sendable {
|
||||
var filesVisited = 0
|
||||
}
|
||||
|
||||
struct ScanDeletionProgress: Sendable {
|
||||
var phase = ""
|
||||
var completed = 0
|
||||
var total = 0
|
||||
}
|
||||
|
||||
struct BatchRenameChange: Identifiable, Sendable {
|
||||
let file: CloudFile
|
||||
let newName: String
|
||||
var id: String { file.id }
|
||||
}
|
||||
|
||||
struct BatchRenameProgress: Sendable {
|
||||
var currentName = ""
|
||||
var completed = 0
|
||||
var total = 0
|
||||
}
|
||||
|
||||
struct BatchRenameExecutionResult: Sendable {
|
||||
let succeeded: Int
|
||||
let failed: Int
|
||||
}
|
||||
|
||||
enum FileSort: String, CaseIterable, Identifiable, Sendable {
|
||||
case name, size, modifiedAt, createdAt, type
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
case scan = "文件扫描与清理"
|
||||
case rename = "批量重命名"
|
||||
case tmdb = "TMDB 整理"
|
||||
case settings = "工作区设置"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .scan: return "magnifyingglass.circle.fill"
|
||||
case .rename: return "character.cursor.ibeam"
|
||||
case .tmdb: return "film.stack"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .scan: return "空文件夹、重复文件与相似文件夹统一扫描"
|
||||
case .rename: return "按字符、正则和组合规则批量修改名称"
|
||||
case .tmdb: return "配置并执行媒体识别整理"
|
||||
case .settings: return "代理和本机工具配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var selectedFileIDs: Set<String> = []
|
||||
@State private var renameFile: CloudFile?
|
||||
@State private var isCreatingFolder = false
|
||||
@State private var isImporting = false
|
||||
@State private var isShowingSettings = false
|
||||
@State private var isShowingDuplicates = false
|
||||
@State private var isInspectorPresented = false
|
||||
@State private var selectedTool: WorkspaceTool?
|
||||
@State private var folderName = ""
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@State private var previewFile: CloudFile?
|
||||
@State private var globalSearchText = ""
|
||||
|
||||
private var selectedFile: CloudFile? {
|
||||
selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackdrop()
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
WorkspaceSidebar(model: model, selectedTool: $selectedTool, columnVisibility: $columnVisibility)
|
||||
.navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 280)
|
||||
} detail: {
|
||||
if let selectedTool {
|
||||
ToolWorkspacePage(model: model, tool: selectedTool)
|
||||
} else {
|
||||
FilesBrowser(
|
||||
model: model,
|
||||
selectedFileIDs: $selectedFileIDs,
|
||||
globalSearchText: $globalSearchText,
|
||||
renameFile: $renameFile,
|
||||
isCreatingFolder: $isCreatingFolder,
|
||||
isImporting: $isImporting,
|
||||
isShowingSettings: $isShowingSettings,
|
||||
isShowingDuplicates: $isShowingDuplicates,
|
||||
onPreview: { previewFile = $0 },
|
||||
onOpenTool: { selectedTool = $0 },
|
||||
onShowInspector: { isInspectorPresented = true }
|
||||
)
|
||||
.frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity)
|
||||
.inspector(isPresented: $isInspectorPresented) {
|
||||
FileInspector(
|
||||
model: model,
|
||||
file: selectedFile,
|
||||
onPreview: { previewFile = $0 },
|
||||
onClose: { isInspectorPresented = false }
|
||||
)
|
||||
.inspectorColumnWidth(min: 280, ideal: 320, max: 420)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
AppTitleToolbar(model: model, selectedTool: selectedTool, searchText: $globalSearchText)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if selectedTool == nil {
|
||||
Button { isInspectorPresented.toggle() } label: {
|
||||
Image(systemName: "sidebar.trailing")
|
||||
}
|
||||
.help(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器")
|
||||
.accessibilityLabel(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器")
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
.disabled(selectedFile == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1040, minHeight: 700)
|
||||
.sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } }
|
||||
.sheet(isPresented: $isCreatingFolder) {
|
||||
CreateFolderSheet(name: $folderName) { name in
|
||||
isCreatingFolder = false
|
||||
folderName = ""
|
||||
Task { await model.createFolder(name: name) }
|
||||
} onCancel: { isCreatingFolder = false; folderName = "" }
|
||||
}
|
||||
.sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) }
|
||||
.sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) }
|
||||
.sheet(item: $previewFile) { file in FilePreviewSheet(model: model, file: file) }
|
||||
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in
|
||||
switch result {
|
||||
case .success(let urls): if let url = urls.first { Task { await model.upload(url: url) } }
|
||||
case .failure(let error): model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) {
|
||||
Button("确定", role: .cancel) { model.lastActionMessage = "" }
|
||||
} message: { Text(model.lastActionMessage) }
|
||||
.task {
|
||||
if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() }
|
||||
}
|
||||
.onChange(of: selectedTool) { _, tool in
|
||||
if tool != nil { isInspectorPresented = false }
|
||||
}
|
||||
.onChange(of: model.section) { _, _ in
|
||||
selectedFileIDs = []
|
||||
selectedTool = nil
|
||||
isInspectorPresented = false
|
||||
globalSearchText = ""
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.onChange(of: model.files.map(\.id)) { _, ids in
|
||||
selectedFileIDs.formIntersection(Set(ids))
|
||||
if selectedFileIDs.isEmpty { isInspectorPresented = false }
|
||||
}
|
||||
.onChange(of: model.folderPath) { _, _ in
|
||||
selectedFileIDs = []
|
||||
isInspectorPresented = false
|
||||
globalSearchText = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppTitleToolbar: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let selectedTool: WorkspaceTool?
|
||||
@Binding var searchText: String
|
||||
|
||||
private var supportsSearch: Bool {
|
||||
selectedTool == nil && [.files, .photos, .videos, .audio, .documents].contains(model.section)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Label(selectedTool?.rawValue ?? model.section.rawValue, systemImage: selectedTool?.icon ?? model.section.icon)
|
||||
.font(.headline.weight(.semibold))
|
||||
if supportsSearch {
|
||||
TextField("搜索当前列表", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user