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 } }