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