feat: 优化云盘工作区与批量文件工具
This commit is contained in:
+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")
|
||||
|
||||
Reference in New Issue
Block a user