630 lines
24 KiB
Swift
630 lines
24 KiB
Swift
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
import SQLite3
|
|
|
|
struct PersistedSourceRecord: Sendable {
|
|
let sourceID: URL
|
|
let folderURL: URL
|
|
let origin: MinecraftSourceOrigin
|
|
let accessDescriptor: SourceAccessDescriptor
|
|
let availability: SourceAvailability
|
|
let bookmarkData: Data?
|
|
let displayName: String
|
|
let rawItems: [MinecraftContentItem]
|
|
let snapshot: SourceSnapshot?
|
|
let lastScanDate: Date?
|
|
let needsRepair: Bool
|
|
}
|
|
|
|
private struct PersistedItemSnapshotPayload: Codable, Sendable {
|
|
let idPath: String
|
|
let relativePath: String
|
|
let modifiedDate: Date?
|
|
let sizeBytes: Int64?
|
|
let packUUID: String?
|
|
let packVersion: String?
|
|
|
|
nonisolated init(_ snapshot: ItemSnapshot) {
|
|
self.idPath = snapshot.id.path
|
|
self.relativePath = snapshot.relativePath
|
|
self.modifiedDate = snapshot.modifiedDate
|
|
self.sizeBytes = snapshot.sizeBytes
|
|
self.packUUID = snapshot.packUUID
|
|
self.packVersion = snapshot.packVersion
|
|
}
|
|
|
|
nonisolated var itemSnapshot: ItemSnapshot {
|
|
ItemSnapshot(
|
|
id: URL(fileURLWithPath: idPath),
|
|
relativePath: relativePath,
|
|
modifiedDate: modifiedDate,
|
|
sizeBytes: sizeBytes,
|
|
packUUID: packUUID,
|
|
packVersion: packVersion
|
|
)
|
|
}
|
|
|
|
nonisolated init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.idPath = try container.decode(String.self, forKey: .idPath)
|
|
self.relativePath = try container.decode(String.self, forKey: .relativePath)
|
|
self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
|
|
self.sizeBytes = try container.decodeIfPresent(Int64.self, forKey: .sizeBytes)
|
|
self.packUUID = try container.decodeIfPresent(String.self, forKey: .packUUID)
|
|
self.packVersion = try container.decodeIfPresent(String.self, forKey: .packVersion)
|
|
}
|
|
|
|
nonisolated func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(idPath, forKey: .idPath)
|
|
try container.encode(relativePath, forKey: .relativePath)
|
|
try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
|
|
try container.encodeIfPresent(sizeBytes, forKey: .sizeBytes)
|
|
try container.encodeIfPresent(packUUID, forKey: .packUUID)
|
|
try container.encodeIfPresent(packVersion, forKey: .packVersion)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case idPath
|
|
case relativePath
|
|
case modifiedDate
|
|
case sizeBytes
|
|
case packUUID
|
|
case packVersion
|
|
}
|
|
}
|
|
|
|
private struct PersistedCollectionSnapshotPayload: Codable, Sendable {
|
|
let folderName: String
|
|
let modifiedDate: Date?
|
|
let childDirectoryCount: Int
|
|
let fingerprint: String
|
|
|
|
nonisolated init(_ snapshot: CollectionSnapshot) {
|
|
self.folderName = snapshot.folderName
|
|
self.modifiedDate = snapshot.modifiedDate
|
|
self.childDirectoryCount = snapshot.childDirectoryCount
|
|
self.fingerprint = snapshot.fingerprint
|
|
}
|
|
|
|
nonisolated var collectionSnapshot: CollectionSnapshot {
|
|
CollectionSnapshot(
|
|
folderName: folderName,
|
|
modifiedDate: modifiedDate,
|
|
childDirectoryCount: childDirectoryCount,
|
|
fingerprint: fingerprint
|
|
)
|
|
}
|
|
|
|
nonisolated init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.folderName = try container.decode(String.self, forKey: .folderName)
|
|
self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
|
|
self.childDirectoryCount = try container.decode(Int.self, forKey: .childDirectoryCount)
|
|
self.fingerprint = try container.decode(String.self, forKey: .fingerprint)
|
|
}
|
|
|
|
nonisolated func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(folderName, forKey: .folderName)
|
|
try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
|
|
try container.encode(childDirectoryCount, forKey: .childDirectoryCount)
|
|
try container.encode(fingerprint, forKey: .fingerprint)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case folderName
|
|
case modifiedDate
|
|
case childDirectoryCount
|
|
case fingerprint
|
|
}
|
|
}
|
|
|
|
private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
|
let sourceIdentifier: String
|
|
let rootModifiedDate: Date?
|
|
let collectionSnapshots: [PersistedCollectionSnapshotPayload]
|
|
let itemSnapshots: [PersistedItemSnapshotPayload]
|
|
|
|
nonisolated init(_ snapshot: SourceSnapshot) {
|
|
self.sourceIdentifier = snapshot.sourceID.absoluteString
|
|
self.rootModifiedDate = snapshot.rootModifiedDate
|
|
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
|
|
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
|
|
}
|
|
|
|
nonisolated var sourceSnapshot: SourceSnapshot {
|
|
SourceSnapshot(
|
|
sourceID: URL(string: sourceIdentifier) ?? URL(fileURLWithPath: sourceIdentifier),
|
|
rootModifiedDate: rootModifiedDate,
|
|
collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot),
|
|
itemSnapshots: itemSnapshots.map(\.itemSnapshot)
|
|
)
|
|
}
|
|
|
|
nonisolated init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier)
|
|
self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
|
|
self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
|
|
self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
|
|
}
|
|
|
|
nonisolated func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(sourceIdentifier, forKey: .sourceIdentifier)
|
|
try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
|
|
try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
|
|
try container.encode(itemSnapshots, forKey: .itemSnapshots)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case sourceIdentifier
|
|
case rootModifiedDate
|
|
case collectionSnapshots
|
|
case itemSnapshots
|
|
}
|
|
}
|
|
|
|
actor SourcePersistenceStore {
|
|
static let shared = SourcePersistenceStore()
|
|
private static let cacheGeneration = "v2026-05-28"
|
|
|
|
private let databaseURL: URL
|
|
|
|
init(fileManager: FileManager = .default) {
|
|
let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
|
|
let directoryURL = applicationSupportURL
|
|
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
|
|
self.databaseURL = directoryURL.appendingPathComponent(
|
|
"LibraryCache-\(Self.cacheGeneration).sqlite",
|
|
isDirectory: false
|
|
)
|
|
}
|
|
|
|
init(databaseURL: URL) {
|
|
self.databaseURL = databaseURL
|
|
}
|
|
|
|
func loadSources() throws -> [PersistedSourceRecord] {
|
|
let database = try openDatabase()
|
|
defer { sqlite3_close(database) }
|
|
|
|
let sql = """
|
|
SELECT source_id, folder_path, origin_json, access_descriptor_json, availability_state,
|
|
bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
|
|
FROM source_cache
|
|
ORDER BY display_name COLLATE NOCASE ASC;
|
|
"""
|
|
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
throw databaseError(database)
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
|
|
var records: [PersistedSourceRecord] = []
|
|
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
|
guard let folderPathPointer = sqlite3_column_text(statement, 1) else {
|
|
continue
|
|
}
|
|
|
|
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
|
|
let folderPath = String(cString: folderPathPointer)
|
|
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
|
|
let originResult = decodeOrigin(statement: statement, columnIndex: 2, bookmarkData: bookmarkData)
|
|
let origin = originResult.value
|
|
let accessDescriptorResult = decodeAccessDescriptor(statement: statement, columnIndex: 3, origin: origin)
|
|
let accessDescriptor = accessDescriptorResult.value
|
|
let availability = decodeAvailability(statement: statement, columnIndex: 4)
|
|
let displayName = String(cString: sqlite3_column_text(statement, 6))
|
|
let rawItemsResult = decodeRawItems(statement: statement, columnIndex: 7)
|
|
let snapshotResult = decodeSnapshot(statement: statement, columnIndex: 8)
|
|
let rawItems = rawItemsResult.value
|
|
let snapshot = snapshotResult.value
|
|
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
|
|
? nil
|
|
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
|
|
|
|
records.append(
|
|
PersistedSourceRecord(
|
|
sourceID: sourceID,
|
|
folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL,
|
|
origin: origin,
|
|
accessDescriptor: accessDescriptor,
|
|
availability: availability,
|
|
bookmarkData: bookmarkData,
|
|
displayName: displayName,
|
|
rawItems: rawItems,
|
|
snapshot: snapshot,
|
|
lastScanDate: lastScanDate,
|
|
needsRepair: originResult.didRepair
|
|
|| accessDescriptorResult.didRepair
|
|
|| rawItemsResult.didRepair
|
|
|| snapshotResult.didRepair
|
|
)
|
|
)
|
|
}
|
|
|
|
return records
|
|
}
|
|
|
|
func save(source: MinecraftSource) throws {
|
|
let database = try openDatabase()
|
|
defer { sqlite3_close(database) }
|
|
|
|
try save(
|
|
record: PersistedSourceRecord(
|
|
sourceID: source.id,
|
|
folderURL: source.folderURL,
|
|
origin: source.origin,
|
|
accessDescriptor: source.accessDescriptor,
|
|
availability: source.availability,
|
|
bookmarkData: source.bookmarkData,
|
|
displayName: source.displayName,
|
|
rawItems: source.rawItems,
|
|
snapshot: source.snapshot,
|
|
lastScanDate: source.lastScanDate,
|
|
needsRepair: false
|
|
),
|
|
on: database
|
|
)
|
|
}
|
|
|
|
func repair(record: PersistedSourceRecord) throws {
|
|
let database = try openDatabase()
|
|
defer { sqlite3_close(database) }
|
|
|
|
try save(record: record, on: database)
|
|
}
|
|
|
|
private func save(record: PersistedSourceRecord, on database: OpaquePointer?) throws {
|
|
let sql = """
|
|
INSERT INTO source_cache (
|
|
source_id,
|
|
folder_path,
|
|
origin_json,
|
|
access_descriptor_json,
|
|
availability_state,
|
|
bookmark_data,
|
|
display_name,
|
|
raw_items_json,
|
|
snapshot_json,
|
|
last_scan_date
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(source_id) DO UPDATE SET
|
|
bookmark_data = excluded.bookmark_data,
|
|
folder_path = excluded.folder_path,
|
|
origin_json = excluded.origin_json,
|
|
access_descriptor_json = excluded.access_descriptor_json,
|
|
availability_state = excluded.availability_state,
|
|
display_name = excluded.display_name,
|
|
raw_items_json = excluded.raw_items_json,
|
|
snapshot_json = excluded.snapshot_json,
|
|
last_scan_date = excluded.last_scan_date;
|
|
"""
|
|
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
throw databaseError(database)
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
|
|
try bindText(normalizedIdentifierText(for: record.sourceID), to: statement, at: 1)
|
|
try bindText(record.folderURL.path, to: statement, at: 2)
|
|
try bindJSON(record.origin, to: statement, at: 3)
|
|
try bindJSON(record.accessDescriptor, to: statement, at: 4)
|
|
try bindText(record.availability.rawValue, to: statement, at: 5)
|
|
try bindData(record.bookmarkData, to: statement, at: 6)
|
|
try bindText(record.displayName, to: statement, at: 7)
|
|
try bindJSON(record.rawItems, to: statement, at: 8)
|
|
try bindJSON(record.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)
|
|
|
|
if let lastScanDate = record.lastScanDate {
|
|
sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
|
|
} else {
|
|
sqlite3_bind_null(statement, 10)
|
|
}
|
|
|
|
guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
throw databaseError(database)
|
|
}
|
|
}
|
|
|
|
func deleteSource(withID sourceID: URL) throws {
|
|
let database = try openDatabase()
|
|
defer { sqlite3_close(database) }
|
|
|
|
let sql = "DELETE FROM source_cache WHERE source_id = ? OR folder_path = ?;"
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
throw databaseError(database)
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
|
|
try bindText(normalizedIdentifierText(for: sourceID), to: statement, at: 1)
|
|
try bindText(sourceID.isFileURL ? sourceID.standardizedFileURL.path : sourceID.path, to: statement, at: 2)
|
|
|
|
guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
throw databaseError(database)
|
|
}
|
|
}
|
|
|
|
private func openDatabase() throws -> OpaquePointer? {
|
|
try FileManager.default.createDirectory(
|
|
at: databaseURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
|
|
var database: OpaquePointer?
|
|
guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK else {
|
|
defer { sqlite3_close(database) }
|
|
throw databaseError(database)
|
|
}
|
|
|
|
try execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS source_cache (
|
|
source_id TEXT,
|
|
folder_path TEXT PRIMARY KEY,
|
|
origin_json BLOB,
|
|
access_descriptor_json BLOB,
|
|
availability_state TEXT,
|
|
bookmark_data BLOB,
|
|
display_name TEXT NOT NULL,
|
|
raw_items_json BLOB NOT NULL,
|
|
snapshot_json BLOB,
|
|
last_scan_date REAL
|
|
);
|
|
""",
|
|
on: database
|
|
)
|
|
let existingColumns = try columns(in: "source_cache", on: database)
|
|
try addColumnIfNeeded("bookmark_data", sql: "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", existingColumns: existingColumns, on: database)
|
|
try addColumnIfNeeded("source_id", sql: "ALTER TABLE source_cache ADD COLUMN source_id TEXT;", existingColumns: existingColumns, on: database)
|
|
try addColumnIfNeeded("origin_json", sql: "ALTER TABLE source_cache ADD COLUMN origin_json BLOB;", existingColumns: existingColumns, on: database)
|
|
try addColumnIfNeeded("access_descriptor_json", sql: "ALTER TABLE source_cache ADD COLUMN access_descriptor_json BLOB;", existingColumns: existingColumns, on: database)
|
|
try addColumnIfNeeded("availability_state", sql: "ALTER TABLE source_cache ADD COLUMN availability_state TEXT;", existingColumns: existingColumns, on: database)
|
|
try execute(
|
|
"""
|
|
UPDATE source_cache
|
|
SET source_id = folder_path
|
|
WHERE source_id IS NULL OR source_id = '';
|
|
""",
|
|
on: database
|
|
)
|
|
try execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS source_cache_source_id_idx ON source_cache(source_id);",
|
|
on: database
|
|
)
|
|
|
|
return database
|
|
}
|
|
|
|
private func columns(in tableName: String, on database: OpaquePointer?) throws -> Set<String> {
|
|
let sql = "PRAGMA table_info(\(tableName));"
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
throw databaseError(database)
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
|
|
var columns = Set<String>()
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
|
if let namePointer = sqlite3_column_text(statement, 1) {
|
|
columns.insert(String(cString: namePointer))
|
|
}
|
|
}
|
|
|
|
return columns
|
|
}
|
|
|
|
private func addColumnIfNeeded(
|
|
_ columnName: String,
|
|
sql: String,
|
|
existingColumns: Set<String>,
|
|
on database: OpaquePointer?
|
|
) throws {
|
|
guard !existingColumns.contains(columnName) else {
|
|
return
|
|
}
|
|
|
|
try execute(sql, on: database)
|
|
}
|
|
|
|
private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws {
|
|
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
|
if ignoringDuplicateColumn,
|
|
let database,
|
|
String(cString: sqlite3_errmsg(database)).localizedCaseInsensitiveContains("duplicate column name") {
|
|
return
|
|
}
|
|
throw databaseError(database)
|
|
}
|
|
}
|
|
|
|
private func bindText(_ value: String, to statement: OpaquePointer?, at index: Int32) throws {
|
|
let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
guard sqlite3_bind_text(statement, index, value, -1, transientDestructor) == SQLITE_OK else {
|
|
throw persistenceError("Failed to bind text parameter.")
|
|
}
|
|
}
|
|
|
|
private func bindJSON<T: Encodable>(_ value: T, to statement: OpaquePointer?, at index: Int32) throws {
|
|
let data = try JSONEncoder().encode(value)
|
|
try bindData(data, to: statement, at: index)
|
|
}
|
|
|
|
private func bindData(_ value: Data?, to statement: OpaquePointer?, at index: Int32) throws {
|
|
guard let value else {
|
|
sqlite3_bind_null(statement, index)
|
|
return
|
|
}
|
|
|
|
let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
|
|
let result = value.withUnsafeBytes { rawBuffer in
|
|
sqlite3_bind_blob(statement, index, rawBuffer.baseAddress, Int32(value.count), transientDestructor)
|
|
}
|
|
|
|
guard result == SQLITE_OK else {
|
|
throw persistenceError("Failed to bind data parameter.")
|
|
}
|
|
}
|
|
|
|
private func decodeColumn<T: Decodable>(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? {
|
|
guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
|
|
return nil
|
|
}
|
|
|
|
return try JSONDecoder().decode(type, from: data)
|
|
}
|
|
|
|
private func decodeDataColumn(statement: OpaquePointer?, columnIndex: Int32) -> Data? {
|
|
guard sqlite3_column_type(statement, columnIndex) != SQLITE_NULL else {
|
|
return nil
|
|
}
|
|
|
|
let byteCount = Int(sqlite3_column_bytes(statement, columnIndex))
|
|
guard
|
|
byteCount > 0,
|
|
let bytes = sqlite3_column_blob(statement, columnIndex)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return Data(bytes: bytes, count: byteCount)
|
|
}
|
|
|
|
private func decodeOrigin(
|
|
statement: OpaquePointer?,
|
|
columnIndex: Int32,
|
|
bookmarkData: Data?
|
|
) -> (value: MinecraftSourceOrigin, didRepair: Bool) {
|
|
do {
|
|
if let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: columnIndex) {
|
|
return (origin, false)
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
return (.localFolder(bookmarkData: bookmarkData), true)
|
|
}
|
|
|
|
private func decodeAccessDescriptor(
|
|
statement: OpaquePointer?,
|
|
columnIndex: Int32,
|
|
origin: MinecraftSourceOrigin
|
|
) -> (value: SourceAccessDescriptor, didRepair: Bool) {
|
|
do {
|
|
if let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: columnIndex) {
|
|
return (accessDescriptor, false)
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
return (
|
|
SourceAccessDescriptor(
|
|
accessorIdentifier: origin.defaultAccessorIdentifier,
|
|
kind: origin.kind,
|
|
refreshStrategy: origin.defaultRefreshStrategy
|
|
),
|
|
true
|
|
)
|
|
}
|
|
|
|
private func decodeRawItems(statement: OpaquePointer?, columnIndex: Int32) -> (value: [MinecraftContentItem], didRepair: Bool) {
|
|
do {
|
|
if let items = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: columnIndex) {
|
|
return (items, false)
|
|
}
|
|
|
|
return ([], false)
|
|
} catch {
|
|
guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
|
|
return ([], true)
|
|
}
|
|
|
|
let items = decodeArrayElementsLeniently(MinecraftContentItem.self, from: data)
|
|
return (items, true)
|
|
}
|
|
}
|
|
|
|
private func decodeSnapshot(statement: OpaquePointer?, columnIndex: Int32) -> (value: SourceSnapshot?, didRepair: Bool) {
|
|
do {
|
|
let payload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: columnIndex)
|
|
return (payload?.sourceSnapshot, false)
|
|
} catch {
|
|
return (nil, true)
|
|
}
|
|
}
|
|
|
|
private func decodeArrayElementsLeniently<Element: Decodable>(_ type: Element.Type, from data: Data) -> [Element] {
|
|
guard let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
|
|
return []
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
var decodedElements: [Element] = []
|
|
decodedElements.reserveCapacity(rawArray.count)
|
|
|
|
for rawElement in rawArray {
|
|
guard JSONSerialization.isValidJSONObject(rawElement) else {
|
|
continue
|
|
}
|
|
|
|
guard let elementData = try? JSONSerialization.data(withJSONObject: rawElement) else {
|
|
continue
|
|
}
|
|
|
|
guard let element = try? decoder.decode(Element.self, from: elementData) else {
|
|
continue
|
|
}
|
|
|
|
decodedElements.append(element)
|
|
}
|
|
|
|
return decodedElements
|
|
}
|
|
|
|
private func sourceID(from statement: OpaquePointer?) -> URL? {
|
|
guard let pointer = sqlite3_column_text(statement, 0) else {
|
|
return nil
|
|
}
|
|
|
|
let value = String(cString: pointer)
|
|
return URL(string: value) ?? URL(fileURLWithPath: value)
|
|
}
|
|
|
|
private func decodeAvailability(statement: OpaquePointer?, columnIndex: Int32) -> SourceAvailability {
|
|
guard
|
|
let pointer = sqlite3_column_text(statement, columnIndex),
|
|
let availability = SourceAvailability(rawValue: String(cString: pointer))
|
|
else {
|
|
return .unknown
|
|
}
|
|
|
|
return availability
|
|
}
|
|
|
|
private func normalizedIdentifierText(for sourceID: URL) -> String {
|
|
if sourceID.isFileURL {
|
|
return sourceID.standardizedFileURL.absoluteString
|
|
}
|
|
|
|
return sourceID.standardized.absoluteString
|
|
}
|
|
|
|
private func databaseError(_ database: OpaquePointer?) -> Error {
|
|
persistenceError(String(cString: sqlite3_errmsg(database)))
|
|
}
|
|
|
|
private func persistenceError(_ message: String) -> Error {
|
|
NSError(domain: "SourcePersistenceStore", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
}
|
|
}
|