266 lines
12 KiB
Swift
266 lines
12 KiB
Swift
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
|
|
}
|
|
}
|