From 516469427ece7e4f7321b0b0b1f1b7562b4e6017 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Tue, 26 May 2026 00:07:56 -0500 Subject: [PATCH] speed improvement, dedupe on the fly, first whack at persistence --- .../Models/LibraryIndex.swift | 35 +- .../Models/MinecraftContentItem.swift | 8 +- .../Services/SourceLibrary.swift | 393 +++++++++++++++--- .../Services/SourcePersistenceStore.swift | 294 +++++++++++++ .../World_Manager_for_MinecraftTests.swift | 60 +++ 5 files changed, 711 insertions(+), 79 deletions(-) create mode 100644 World Manager for Minecraft/Services/SourcePersistenceStore.swift diff --git a/World Manager for Minecraft/Models/LibraryIndex.swift b/World Manager for Minecraft/Models/LibraryIndex.swift index 31576e1..d99e65f 100644 --- a/World Manager for Minecraft/Models/LibraryIndex.swift +++ b/World Manager for Minecraft/Models/LibraryIndex.swift @@ -20,7 +20,7 @@ struct PackIdentity: Hashable, Sendable, Identifiable { let fallbackLocationHint: String? let source: PackIdentitySource - var id: String { + nonisolated var id: String { [ type.rawValue, uuid ?? normalizedFallbackName, @@ -29,11 +29,28 @@ struct PackIdentity: Hashable, Sendable, Identifiable { ].joined(separator: "::") } - var isSuspicious: Bool { + nonisolated var canonicalKey: String { + if let uuid { + return [ + type.rawValue, + uuid, + version ?? "" + ].joined(separator: "::") + } + + return [ + type.rawValue, + normalizedFallbackName, + version ?? "", + fallbackLocationHint ?? "" + ].joined(separator: "::") + } + + nonisolated var isSuspicious: Bool { source == .fallback } - init( + nonisolated init( type: MinecraftContentType, uuid: String?, version: String?, @@ -48,11 +65,11 @@ struct PackIdentity: Hashable, Sendable, Identifiable { self.source = self.uuid == nil ? .fallback : .manifestUUID } - private var normalizedFallbackName: String { + private nonisolated var normalizedFallbackName: String { fallbackName.lowercased() } - static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool { + nonisolated static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool { guard lhs.type == rhs.type else { return false } @@ -67,7 +84,7 @@ struct PackIdentity: Hashable, Sendable, Identifiable { && lhs.fallbackLocationHint == rhs.fallbackLocationHint } - func hash(into hasher: inout Hasher) { + nonisolated func hash(into hasher: inout Hasher) { hasher.combine(type) if let uuid { @@ -124,7 +141,7 @@ struct WorldPackRelationship: Identifiable, Hashable, Sendable { } } -struct ItemSnapshot: Identifiable, Hashable, Sendable { +struct ItemSnapshot: Identifiable, Hashable, Sendable, Codable { let id: URL let relativePath: String let modifiedDate: Date? @@ -133,7 +150,7 @@ struct ItemSnapshot: Identifiable, Hashable, Sendable { let packVersion: String? } -struct CollectionSnapshot: Identifiable, Hashable, Sendable { +struct CollectionSnapshot: Identifiable, Hashable, Sendable, Codable { let folderName: String let modifiedDate: Date? let childDirectoryCount: Int @@ -142,7 +159,7 @@ struct CollectionSnapshot: Identifiable, Hashable, Sendable { var id: String { folderName } } -struct SourceSnapshot: Hashable, Sendable { +struct SourceSnapshot: Hashable, Sendable, Codable { let sourceID: URL let rootModifiedDate: Date? let collectionSnapshots: [CollectionSnapshot] diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index e565e52..2bcc55c 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -7,7 +7,7 @@ import Foundation -enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { +enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable { case world = "World" case behaviorPack = "Behavior Pack" case resourcePack = "Resource Pack" @@ -56,13 +56,13 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { } } -enum PackSource: String, Hashable, Sendable { +enum PackSource: String, Hashable, Sendable, Codable { case referencedByWorld case embeddedInWorld case foundInCollection } -struct ContentPackReference: Identifiable, Hashable, Sendable { +struct ContentPackReference: Identifiable, Hashable, Sendable, Codable { let id: String let name: String let type: MinecraftContentType @@ -93,7 +93,7 @@ struct ContentPackReference: Identifiable, Hashable, Sendable { } } -struct MinecraftContentItem: Identifiable, Hashable, Sendable { +struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { let id: URL let folderURL: URL let folderName: String diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 0853f54..baf7192 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -37,6 +37,15 @@ final class SourceLibrary: ObservableObject { private var scanTasks: [URL: Task] = [:] private var footerResetTask: Task? + private let persistenceStore: SourcePersistenceStore + + init(persistenceStore: SourcePersistenceStore = .shared) { + self.persistenceStore = persistenceStore + + Task { [weak self] in + await self?.restorePersistedSources() + } + } func addSource(at url: URL) -> URL { let normalizedURL = url.standardizedFileURL @@ -48,6 +57,7 @@ final class SourceLibrary: ObservableObject { sources.append(MinecraftSource(folderURL: normalizedURL)) sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + persistSourceIfAvailable(withID: normalizedURL) startScan(for: normalizedURL) return normalizedURL } @@ -64,6 +74,7 @@ final class SourceLibrary: ObservableObject { scanTasks[sourceID]?.cancel() scanTasks[sourceID] = nil sources.removeAll { $0.id == sourceID } + deletePersistedSource(withID: sourceID) refreshSidebarFooterState() } @@ -254,6 +265,10 @@ final class SourceLibrary: ObservableObject { if let snapshot = await index.finishScan() { applySnapshot(snapshot, to: sourceID) } + updateSource(sourceID) { source in + source.snapshot = buildSnapshot(for: source, packMetadataByItemID: [:]) + } + persistSourceIfAvailable(withID: sourceID) refreshSidebarFooterState() } catch { guard !Task.isCancelled else { @@ -453,6 +468,11 @@ final class SourceLibrary: ObservableObject { $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending } source.worldPackRelationships = worldRelationships + source.displayItems = buildDisplayItems( + from: rawItems, + logicalPacks: logicalPacks, + rawItemsByID: rawItemsByID + ) } } @@ -642,6 +662,172 @@ final class SourceLibrary: ObservableObject { } } + private func restorePersistedSources() async { + let records: [PersistedSourceRecord] + do { + records = try await persistenceStore.loadSources() + } catch { + return + } + + for record in records { + var source = MinecraftSource(folderURL: record.folderURL) + source.displayName = record.displayName + source.rawItems = record.rawItems + source.indexedItemCount = record.rawItems.count + source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count + source.lastScanDate = record.lastScanDate + source.snapshot = record.snapshot + + sources.append(source) + rebuildNormalizedIndex(for: source.id) + + updateSource(source.id) { source in + source.displayItems = source.displayItems.sorted(by: WorldScanner.sortItems) + source.scanStatus = source.indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(source.indexedDetailCount) items." + } + } + + sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + refreshSidebarFooterState() + + for record in records { + if sourceNeedsRescan(record) { + startScan(for: record.folderURL) + } + } + } + + private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool { + guard let snapshot = record.snapshot else { + return true + } + + let fileManager = FileManager.default + let sourceURL = record.folderURL + + guard fileManager.fileExists(atPath: sourceURL.path) else { + return true + } + + let currentCollections = Dictionary(uniqueKeysWithValues: currentCollectionSnapshots(for: sourceURL).map { ($0.folderName, $0) }) + let persistedCollections = Dictionary(uniqueKeysWithValues: snapshot.collectionSnapshots.map { ($0.folderName, $0) }) + + if currentCollections.count != persistedCollections.count { + return true + } + + for (folderName, persistedCollection) in persistedCollections { + guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { + return true + } + } + + for itemSnapshot in snapshot.itemSnapshots { + let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true) + guard fileManager.fileExists(atPath: itemURL.path) else { + return true + } + + let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + if modifiedDate != itemSnapshot.modifiedDate { + return true + } + } + + return false + } + + private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { + let fileManager = FileManager.default + + return MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in + let collectionURL = sourceURL.appendingPathComponent(type.collectionFolderName, isDirectory: true) + guard fileManager.fileExists(atPath: collectionURL.path) else { + return nil + } + + let children = (try? fileManager.contentsOfDirectory( + at: collectionURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + let childDirectoryCount = children.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + }.count + let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + + return CollectionSnapshot( + folderName: type.collectionFolderName, + modifiedDate: modifiedDate, + childDirectoryCount: childDirectoryCount, + fingerprint: [ + type.collectionFolderName, + String(childDirectoryCount), + modifiedDate?.timeIntervalSince1970.formatted() ?? "nil" + ].joined(separator: "::") + ) + } + } + + private func buildDisplayItems( + from rawItems: [MinecraftContentItem], + logicalPacks: [LogicalPack], + rawItemsByID: [URL: MinecraftContentItem] + ) -> [MinecraftContentItem] { + var normalizedItemIDs = Set() + var normalizedItems: [MinecraftContentItem] = [] + + for item in rawItems where item.contentType == .world { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + for logicalPack in logicalPacks { + guard + let item = rawItemsByID[logicalPack.representativeItemID], + normalizedItemIDs.insert(item.id).inserted + else { + continue + } + + normalizedItems.append(item) + } + + for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + return normalizedItems + } + + private func persistSourceIfAvailable(withID sourceID: URL) { + guard let source = source(withID: sourceID) else { + return + } + + let persistedSource = source + Task { + try? await persistenceStore.save(source: persistedSource) + } + } + + private func deletePersistedSource(withID sourceID: URL) { + let normalizedSourceID = sourceID.standardizedFileURL + Task { + try? await persistenceStore.deleteSource(withID: normalizedSourceID) + } + } + private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { contentType == .behaviorPack || contentType == .resourcePack } @@ -879,6 +1065,11 @@ private actor SourceIndexActor { private var orderedItemIDs: [URL] = [] private var itemsByID: [URL: MinecraftContentItem] = [:] + private var packMetadataByItemID: [URL: PackMetadata] = [:] + private var packIdentityByItemID: [URL: String] = [:] + private var packIdentityValueByID: [String: PackIdentity] = [:] + private var packItemIDsByIdentityID: [String: Set] = [:] + private var packRepresentativeItemIDByIdentityID: [String: URL] = [:] private var indexedItemCount = 0 private var indexedDetailCount = 0 private var discoveryFinished = false @@ -904,6 +1095,11 @@ private actor SourceIndexActor { if item.metadataLoaded, previous?.metadataLoaded != true { indexedDetailCount += 1 } + + if isLogicalPackType(item.contentType) { + refreshTrackedPackIdentity(for: item, previousItem: previous) + } + return snapshotIfNeeded() } @@ -949,6 +1145,13 @@ private actor SourceIndexActor { lastPublishedAt = now let rawItems = orderedItemIDs.compactMap { itemsByID[$0] } + let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) }) + let logicalPacks = buildLogicalPacks(rawItemsByID: rawItemsByID) + let dedupedDisplayItems = buildDisplayItems( + from: rawItems, + logicalPacks: logicalPacks, + rawItemsByID: rawItemsByID + ) let scanStatus: String if !discoveryFinished { @@ -957,9 +1160,9 @@ private actor SourceIndexActor { : "Found \(indexedItemCount) items. Loading metadata..." return SourceIndexSnapshot( - displayItems: buildRawDisplayItems(from: rawItems), + displayItems: dedupedDisplayItems, rawItems: rawItems, - logicalPacks: [], + logicalPacks: logicalPacks, logicalWorlds: [], packInstances: [], worldPackRelationships: [], @@ -971,65 +1174,6 @@ private actor SourceIndexActor { ) } - let rawPacks = rawItems.filter { - $0.contentType == .behaviorPack || $0.contentType == .resourcePack - } - let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) }) - - let packMetadataByItemID = Dictionary(uniqueKeysWithValues: rawPacks.map { item in - (item.id, packMetadata(for: item)) - }) - - var chosenRepresentativeByIdentity: [PackIdentity: MinecraftContentItem] = [:] - var allPackItemsByIdentity: [PackIdentity: [MinecraftContentItem]] = [:] - - for item in rawPacks { - let metadata = packMetadataByItemID[item.id] ?? packMetadata(for: item) - let identity = metadata.identity - allPackItemsByIdentity[identity, default: []].append(item) - - guard let existing = chosenRepresentativeByIdentity[identity] else { - chosenRepresentativeByIdentity[identity] = item - continue - } - - if shouldPreferPackItem(item, over: existing) { - chosenRepresentativeByIdentity[identity] = item - } - } - - let logicalPacks = allPackItemsByIdentity.keys.sorted { - let lhs = chosenRepresentativeByIdentity[$0]?.displayName ?? "" - let rhs = chosenRepresentativeByIdentity[$1]?.displayName ?? "" - let nameOrder = lhs.localizedStandardCompare(rhs) - if nameOrder != .orderedSame { - return nameOrder == .orderedAscending - } - - return $0.id.localizedStandardCompare($1.id) == .orderedAscending - }.compactMap { identity -> LogicalPack? in - guard - let representativeItem = chosenRepresentativeByIdentity[identity], - let instances = allPackItemsByIdentity[identity] - else { - return nil - } - - let metadata = packMetadataByItemID[representativeItem.id] - - return LogicalPack( - id: identity, - contentType: identity.type, - displayName: representativeItem.displayName, - uuid: metadata?.uuid, - version: metadata?.version, - representativeItemID: representativeItem.id, - instanceItemIDs: instances.map(\.id).sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }, - isSuspicious: identity.isSuspicious - ) - } - - let dedupedDisplayItems = buildDisplayItems(from: rawItems, logicalPacks: logicalPacks, rawItemsByID: rawItemsByID) if !metadataFinished { scanStatus = indexedItemCount == 0 ? "No Minecraft items found." @@ -1071,12 +1215,12 @@ private actor SourceIndexActor { } } - let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id, $0) }) + let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id.canonicalKey, $0) }) var worldRelationships: [WorldPackRelationship] = [] var logicalWorlds: [LogicalWorld] = [] for world in rawWorlds { - var usedPackIDs = Set() + var usedPackIDsByID: [String: PackIdentity] = [:] var unresolvedReferences: [ContentPackReference] = [] for reference in world.packReferences { @@ -1087,10 +1231,10 @@ private actor SourceIndexActor { fallbackName: reference.name, fallbackLocationHint: world.folderName ) - let resolvedID = logicalPacksByID[referenceIdentity]?.id + let resolvedID = logicalPacksByID[referenceIdentity.canonicalKey]?.id if let resolvedID { - usedPackIDs.insert(resolvedID) + usedPackIDsByID[resolvedID.id] = resolvedID } else { unresolvedReferences.append(reference) } @@ -1108,7 +1252,7 @@ private actor SourceIndexActor { LogicalWorld( id: world.id, itemID: world.id, - usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, + usedPackIDs: usedPackIDsByID.values.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, unresolvedReferences: unresolvedReferences ) ) @@ -1194,6 +1338,43 @@ private actor SourceIndexActor { rawItems.sorted(by: WorldScanner.sortItems) } + private func buildLogicalPacks(rawItemsByID: [URL: MinecraftContentItem]) -> [LogicalPack] { + packItemIDsByIdentityID.keys.sorted { + let lhs = packRepresentativeItemIDByIdentityID[$0].flatMap { rawItemsByID[$0]?.displayName } ?? "" + let rhs = packRepresentativeItemIDByIdentityID[$1].flatMap { rawItemsByID[$0]?.displayName } ?? "" + let nameOrder = lhs.localizedStandardCompare(rhs) + if nameOrder != .orderedSame { + return nameOrder == .orderedAscending + } + + return $0.localizedStandardCompare($1) == .orderedAscending + }.compactMap { identityID in + guard + let identity = packIdentityValueByID[identityID], + let representativeItemID = packRepresentativeItemIDByIdentityID[identityID], + let representativeItem = rawItemsByID[representativeItemID], + let instanceItemIDs = packItemIDsByIdentityID[identityID] + else { + return nil + } + + let metadata = packMetadataByItemID[representativeItemID] + + return LogicalPack( + id: identity, + contentType: identity.type, + displayName: representativeItem.displayName, + uuid: metadata?.uuid, + version: metadata?.version, + representativeItemID: representativeItemID, + instanceItemIDs: instanceItemIDs.sorted { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + }, + isSuspicious: identity.isSuspicious + ) + } + } + private func packMetadata(for item: MinecraftContentItem) -> PackMetadata { let uuid = item.packUUID let version = item.packVersion @@ -1243,4 +1424,84 @@ private actor SourceIndexActor { packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") })?.id } + + private func refreshTrackedPackIdentity(for item: MinecraftContentItem, previousItem: MinecraftContentItem?) { + let previousIdentityID = packIdentityByItemID[item.id] + let newMetadata = packMetadata(for: item) + let newIdentity = newMetadata.identity + let newIdentityID = newIdentity.canonicalKey + + packMetadataByItemID[item.id] = newMetadata + packIdentityByItemID[item.id] = newIdentityID + packIdentityValueByID[newIdentityID] = newIdentity + + if let previousIdentityID, previousIdentityID != newIdentityID { + removePackItem(itemID: item.id, fromIdentityID: previousIdentityID) + } + + packItemIDsByIdentityID[newIdentityID, default: []].insert(item.id) + refreshRepresentative(forIdentityID: newIdentityID) + + if let previousItem, previousIdentityID == newIdentityID { + guard + let representativeItemID = packRepresentativeItemIDByIdentityID[newIdentityID], + let currentRepresentative = itemsByID[representativeItemID] + else { + return + } + + if shouldPreferPackItem(item, over: currentRepresentative) || representativeItemID == item.id { + refreshRepresentative(forIdentityID: newIdentityID) + } else if representativeItemID == previousItem.id { + refreshRepresentative(forIdentityID: newIdentityID) + } + } + } + + private func removePackItem(itemID: URL, fromIdentityID identityID: String) { + guard var itemIDs = packItemIDsByIdentityID[identityID] else { + return + } + + itemIDs.remove(itemID) + if itemIDs.isEmpty { + packItemIDsByIdentityID[identityID] = nil + packRepresentativeItemIDByIdentityID[identityID] = nil + packIdentityValueByID[identityID] = nil + } else { + packItemIDsByIdentityID[identityID] = itemIDs + if packRepresentativeItemIDByIdentityID[identityID] == itemID { + refreshRepresentative(forIdentityID: identityID) + } + } + } + + private func refreshRepresentative(forIdentityID identityID: String) { + guard let itemIDs = packItemIDsByIdentityID[identityID] else { + packRepresentativeItemIDByIdentityID[identityID] = nil + return + } + + let candidateIDs = itemIDs.sorted { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + } + + guard + let firstID = candidateIDs.first, + let firstItem = itemsByID[firstID] + else { + packRepresentativeItemIDByIdentityID[identityID] = nil + return + } + + let representative = candidateIDs.dropFirst().compactMap { itemsByID[$0] }.reduce(firstItem) { current, candidate in + shouldPreferPackItem(candidate, over: current) ? candidate : current + } + + packRepresentativeItemIDByIdentityID[identityID] = representative.id + } + + private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { + contentType == .behaviorPack || contentType == .resourcePack + } } diff --git a/World Manager for Minecraft/Services/SourcePersistenceStore.swift b/World Manager for Minecraft/Services/SourcePersistenceStore.swift new file mode 100644 index 0000000..950a45c --- /dev/null +++ b/World Manager for Minecraft/Services/SourcePersistenceStore.swift @@ -0,0 +1,294 @@ +// +// SourcePersistenceStore.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-25. +// + +import Foundation +import SQLite3 + +struct PersistedSourceRecord: Sendable { + let folderURL: URL + let displayName: String + let rawItems: [MinecraftContentItem] + let snapshot: SourceSnapshot? + let lastScanDate: Date? +} + +private struct PersistedItemSnapshotPayload: Codable { + let idPath: String + let relativePath: String + let modifiedDate: Date? + let sizeBytes: Int64? + let packUUID: String? + let packVersion: String? + + 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 + } + + var itemSnapshot: ItemSnapshot { + ItemSnapshot( + id: URL(fileURLWithPath: idPath), + relativePath: relativePath, + modifiedDate: modifiedDate, + sizeBytes: sizeBytes, + packUUID: packUUID, + packVersion: packVersion + ) + } +} + +private struct PersistedCollectionSnapshotPayload: Codable { + let folderName: String + let modifiedDate: Date? + let childDirectoryCount: Int + let fingerprint: String + + init(_ snapshot: CollectionSnapshot) { + self.folderName = snapshot.folderName + self.modifiedDate = snapshot.modifiedDate + self.childDirectoryCount = snapshot.childDirectoryCount + self.fingerprint = snapshot.fingerprint + } + + var collectionSnapshot: CollectionSnapshot { + CollectionSnapshot( + folderName: folderName, + modifiedDate: modifiedDate, + childDirectoryCount: childDirectoryCount, + fingerprint: fingerprint + ) + } +} + +private struct PersistedSourceSnapshotPayload: Codable { + let sourcePath: String + let rootModifiedDate: Date? + let collectionSnapshots: [PersistedCollectionSnapshotPayload] + let itemSnapshots: [PersistedItemSnapshotPayload] + + init(_ snapshot: SourceSnapshot) { + self.sourcePath = snapshot.sourceID.path + self.rootModifiedDate = snapshot.rootModifiedDate + self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init) + self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init) + } + + var sourceSnapshot: SourceSnapshot { + SourceSnapshot( + sourceID: URL(fileURLWithPath: sourcePath), + rootModifiedDate: rootModifiedDate, + collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot), + itemSnapshots: itemSnapshots.map(\.itemSnapshot) + ) + } +} + +actor SourcePersistenceStore { + static let shared = SourcePersistenceStore() + + 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.sqlite", isDirectory: false) + } + + init(databaseURL: URL) { + self.databaseURL = databaseURL + } + + func loadSources() throws -> [PersistedSourceRecord] { + let database = try openDatabase() + defer { sqlite3_close(database) } + + let sql = """ + SELECT folder_path, 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, 0) else { + continue + } + + let folderPath = String(cString: folderPathPointer) + let displayName = String(cString: sqlite3_column_text(statement, 1)) + let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 2) ?? [] + let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 3) + let snapshot = snapshotPayload?.sourceSnapshot + let lastScanDate = sqlite3_column_type(statement, 4) == SQLITE_NULL + ? nil + : Date(timeIntervalSince1970: sqlite3_column_double(statement, 4)) + + records.append( + PersistedSourceRecord( + folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL, + displayName: displayName, + rawItems: rawItems, + snapshot: snapshot, + lastScanDate: lastScanDate + ) + ) + } + + return records + } + + func save(source: MinecraftSource) throws { + let database = try openDatabase() + defer { sqlite3_close(database) } + + let sql = """ + INSERT INTO source_cache ( + folder_path, + display_name, + raw_items_json, + snapshot_json, + last_scan_date + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(folder_path) DO UPDATE SET + 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(source.folderURL.path, to: statement, at: 1) + try bindText(source.displayName, to: statement, at: 2) + try bindJSON(source.rawItems, to: statement, at: 3) + try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 4) + + if let lastScanDate = source.lastScanDate { + sqlite3_bind_double(statement, 5, lastScanDate.timeIntervalSince1970) + } else { + sqlite3_bind_null(statement, 5) + } + + 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 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(sourceID.standardizedFileURL.path, to: statement, at: 1) + + 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 ( + folder_path TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + raw_items_json BLOB NOT NULL, + snapshot_json BLOB, + last_scan_date REAL + ); + """, + on: database + ) + + return database + } + + private func execute(_ sql: String, on database: OpaquePointer?) throws { + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + 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(_ value: T, to statement: OpaquePointer?, at index: Int32) throws { + let data = try JSONEncoder().encode(value) + let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + let result = data.withUnsafeBytes { rawBuffer in + sqlite3_bind_blob(statement, index, rawBuffer.baseAddress, Int32(data.count), transientDestructor) + } + + guard result == SQLITE_OK else { + throw persistenceError("Failed to bind JSON parameter.") + } + } + + private func decodeColumn(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? { + 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 + } + + let data = Data(bytes: bytes, count: byteCount) + return try JSONDecoder().decode(type, from: data) + } + + 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]) + } +} diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index 5b74bec..d9ba3b6 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -158,4 +158,64 @@ struct World_Manager_for_MinecraftTests { #expect(enrichedWorld.packReferences.first?.version == "1.0.0") } + @Test func sourcePersistenceStoreRoundTripsCachedSource() async throws { + let fileManager = FileManager.default + let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let databaseURL = workingURL.appendingPathComponent("cache.sqlite", isDirectory: false) + let sourceURL = workingURL.appendingPathComponent("Source", isDirectory: true) + defer { try? fileManager.removeItem(at: workingURL) } + + let item = MinecraftContentItem( + folderURL: sourceURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true), + folderName: "WorldA", + contentType: .world, + collectionRootURL: sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true), + displayName: "World A", + modifiedDate: Date(timeIntervalSince1970: 100), + sizeBytes: 42, + metadataLoaded: true, + sizeLoaded: true + ) + let snapshot = SourceSnapshot( + sourceID: sourceURL, + rootModifiedDate: Date(timeIntervalSince1970: 10), + collectionSnapshots: [ + CollectionSnapshot( + folderName: MinecraftContentType.world.collectionFolderName, + modifiedDate: Date(timeIntervalSince1970: 11), + childDirectoryCount: 1, + fingerprint: "minecraftWorlds::1::11" + ) + ], + itemSnapshots: [ + ItemSnapshot( + id: item.id, + relativePath: "minecraftWorlds/WorldA", + modifiedDate: item.modifiedDate, + sizeBytes: item.sizeBytes, + packUUID: nil, + packVersion: nil + ) + ] + ) + + var source = MinecraftSource(folderURL: sourceURL) + source.displayName = "Source" + source.rawItems = [item] + source.snapshot = snapshot + source.lastScanDate = Date(timeIntervalSince1970: 200) + + let store = SourcePersistenceStore(databaseURL: databaseURL) + try await store.save(source: source) + + let restored = try await store.loadSources() + + #expect(restored.count == 1) + #expect(restored.first?.folderURL == sourceURL.standardizedFileURL) + #expect(restored.first?.displayName == "Source") + #expect(restored.first?.rawItems == [item]) + #expect(restored.first?.snapshot == snapshot) + #expect(restored.first?.lastScanDate == source.lastScanDate) + } + }