feat: 优化云盘工作区与批量文件工具

This commit is contained in:
2026-07-16 12:37:26 +08:00
parent 3cfc1617c5
commit fc22f63db4
9 changed files with 1615 additions and 385 deletions
+16
View File
@@ -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
View File
@@ -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")
+51
View File
@@ -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))
}
}
+374
View File
@@ -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 }
}
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -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
}
}
+1 -2
View File
@@ -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() }
}
+27
View File
@@ -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 }
+167
View File
@@ -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)
}
}
}
}