speed improvement, dedupe on the fly, first whack at persistence

This commit is contained in:
John Burwell 2026-05-26 00:07:56 -05:00
parent 56f7ea7055
commit 516469427e
5 changed files with 711 additions and 79 deletions

View File

@ -20,7 +20,7 @@ struct PackIdentity: Hashable, Sendable, Identifiable {
let fallbackLocationHint: String? let fallbackLocationHint: String?
let source: PackIdentitySource let source: PackIdentitySource
var id: String { nonisolated var id: String {
[ [
type.rawValue, type.rawValue,
uuid ?? normalizedFallbackName, uuid ?? normalizedFallbackName,
@ -29,11 +29,28 @@ struct PackIdentity: Hashable, Sendable, Identifiable {
].joined(separator: "::") ].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 source == .fallback
} }
init( nonisolated init(
type: MinecraftContentType, type: MinecraftContentType,
uuid: String?, uuid: String?,
version: String?, version: String?,
@ -48,11 +65,11 @@ struct PackIdentity: Hashable, Sendable, Identifiable {
self.source = self.uuid == nil ? .fallback : .manifestUUID self.source = self.uuid == nil ? .fallback : .manifestUUID
} }
private var normalizedFallbackName: String { private nonisolated var normalizedFallbackName: String {
fallbackName.lowercased() fallbackName.lowercased()
} }
static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool { nonisolated static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool {
guard lhs.type == rhs.type else { guard lhs.type == rhs.type else {
return false return false
} }
@ -67,7 +84,7 @@ struct PackIdentity: Hashable, Sendable, Identifiable {
&& lhs.fallbackLocationHint == rhs.fallbackLocationHint && lhs.fallbackLocationHint == rhs.fallbackLocationHint
} }
func hash(into hasher: inout Hasher) { nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(type) hasher.combine(type)
if let uuid { 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 id: URL
let relativePath: String let relativePath: String
let modifiedDate: Date? let modifiedDate: Date?
@ -133,7 +150,7 @@ struct ItemSnapshot: Identifiable, Hashable, Sendable {
let packVersion: String? let packVersion: String?
} }
struct CollectionSnapshot: Identifiable, Hashable, Sendable { struct CollectionSnapshot: Identifiable, Hashable, Sendable, Codable {
let folderName: String let folderName: String
let modifiedDate: Date? let modifiedDate: Date?
let childDirectoryCount: Int let childDirectoryCount: Int
@ -142,7 +159,7 @@ struct CollectionSnapshot: Identifiable, Hashable, Sendable {
var id: String { folderName } var id: String { folderName }
} }
struct SourceSnapshot: Hashable, Sendable { struct SourceSnapshot: Hashable, Sendable, Codable {
let sourceID: URL let sourceID: URL
let rootModifiedDate: Date? let rootModifiedDate: Date?
let collectionSnapshots: [CollectionSnapshot] let collectionSnapshots: [CollectionSnapshot]

View File

@ -7,7 +7,7 @@
import Foundation import Foundation
enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable {
case world = "World" case world = "World"
case behaviorPack = "Behavior Pack" case behaviorPack = "Behavior Pack"
case resourcePack = "Resource 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 referencedByWorld
case embeddedInWorld case embeddedInWorld
case foundInCollection case foundInCollection
} }
struct ContentPackReference: Identifiable, Hashable, Sendable { struct ContentPackReference: Identifiable, Hashable, Sendable, Codable {
let id: String let id: String
let name: String let name: String
let type: MinecraftContentType 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 id: URL
let folderURL: URL let folderURL: URL
let folderName: String let folderName: String

View File

@ -37,6 +37,15 @@ final class SourceLibrary: ObservableObject {
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>? private var footerResetTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore
init(persistenceStore: SourcePersistenceStore = .shared) {
self.persistenceStore = persistenceStore
Task { [weak self] in
await self?.restorePersistedSources()
}
}
func addSource(at url: URL) -> URL { func addSource(at url: URL) -> URL {
let normalizedURL = url.standardizedFileURL let normalizedURL = url.standardizedFileURL
@ -48,6 +57,7 @@ final class SourceLibrary: ObservableObject {
sources.append(MinecraftSource(folderURL: normalizedURL)) sources.append(MinecraftSource(folderURL: normalizedURL))
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
persistSourceIfAvailable(withID: normalizedURL)
startScan(for: normalizedURL) startScan(for: normalizedURL)
return normalizedURL return normalizedURL
} }
@ -64,6 +74,7 @@ final class SourceLibrary: ObservableObject {
scanTasks[sourceID]?.cancel() scanTasks[sourceID]?.cancel()
scanTasks[sourceID] = nil scanTasks[sourceID] = nil
sources.removeAll { $0.id == sourceID } sources.removeAll { $0.id == sourceID }
deletePersistedSource(withID: sourceID)
refreshSidebarFooterState() refreshSidebarFooterState()
} }
@ -254,6 +265,10 @@ final class SourceLibrary: ObservableObject {
if let snapshot = await index.finishScan() { if let snapshot = await index.finishScan() {
applySnapshot(snapshot, to: sourceID) applySnapshot(snapshot, to: sourceID)
} }
updateSource(sourceID) { source in
source.snapshot = buildSnapshot(for: source, packMetadataByItemID: [:])
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState() refreshSidebarFooterState()
} catch { } catch {
guard !Task.isCancelled else { guard !Task.isCancelled else {
@ -453,6 +468,11 @@ final class SourceLibrary: ObservableObject {
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
} }
source.worldPackRelationships = worldRelationships 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<URL>()
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 { private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool {
contentType == .behaviorPack || contentType == .resourcePack contentType == .behaviorPack || contentType == .resourcePack
} }
@ -879,6 +1065,11 @@ private actor SourceIndexActor {
private var orderedItemIDs: [URL] = [] private var orderedItemIDs: [URL] = []
private var itemsByID: [URL: MinecraftContentItem] = [:] 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<URL>] = [:]
private var packRepresentativeItemIDByIdentityID: [String: URL] = [:]
private var indexedItemCount = 0 private var indexedItemCount = 0
private var indexedDetailCount = 0 private var indexedDetailCount = 0
private var discoveryFinished = false private var discoveryFinished = false
@ -904,6 +1095,11 @@ private actor SourceIndexActor {
if item.metadataLoaded, previous?.metadataLoaded != true { if item.metadataLoaded, previous?.metadataLoaded != true {
indexedDetailCount += 1 indexedDetailCount += 1
} }
if isLogicalPackType(item.contentType) {
refreshTrackedPackIdentity(for: item, previousItem: previous)
}
return snapshotIfNeeded() return snapshotIfNeeded()
} }
@ -949,6 +1145,13 @@ private actor SourceIndexActor {
lastPublishedAt = now lastPublishedAt = now
let rawItems = orderedItemIDs.compactMap { itemsByID[$0] } 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 let scanStatus: String
if !discoveryFinished { if !discoveryFinished {
@ -957,9 +1160,9 @@ private actor SourceIndexActor {
: "Found \(indexedItemCount) items. Loading metadata..." : "Found \(indexedItemCount) items. Loading metadata..."
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: buildRawDisplayItems(from: rawItems), displayItems: dedupedDisplayItems,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: [], logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
packInstances: [], packInstances: [],
worldPackRelationships: [], 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 { if !metadataFinished {
scanStatus = indexedItemCount == 0 scanStatus = indexedItemCount == 0
? "No Minecraft items found." ? "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 worldRelationships: [WorldPackRelationship] = []
var logicalWorlds: [LogicalWorld] = [] var logicalWorlds: [LogicalWorld] = []
for world in rawWorlds { for world in rawWorlds {
var usedPackIDs = Set<PackIdentity>() var usedPackIDsByID: [String: PackIdentity] = [:]
var unresolvedReferences: [ContentPackReference] = [] var unresolvedReferences: [ContentPackReference] = []
for reference in world.packReferences { for reference in world.packReferences {
@ -1087,10 +1231,10 @@ private actor SourceIndexActor {
fallbackName: reference.name, fallbackName: reference.name,
fallbackLocationHint: world.folderName fallbackLocationHint: world.folderName
) )
let resolvedID = logicalPacksByID[referenceIdentity]?.id let resolvedID = logicalPacksByID[referenceIdentity.canonicalKey]?.id
if let resolvedID { if let resolvedID {
usedPackIDs.insert(resolvedID) usedPackIDsByID[resolvedID.id] = resolvedID
} else { } else {
unresolvedReferences.append(reference) unresolvedReferences.append(reference)
} }
@ -1108,7 +1252,7 @@ private actor SourceIndexActor {
LogicalWorld( LogicalWorld(
id: world.id, id: world.id,
itemID: 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 unresolvedReferences: unresolvedReferences
) )
) )
@ -1194,6 +1338,43 @@ private actor SourceIndexActor {
rawItems.sorted(by: WorldScanner.sortItems) 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 { private func packMetadata(for item: MinecraftContentItem) -> PackMetadata {
let uuid = item.packUUID let uuid = item.packUUID
let version = item.packVersion let version = item.packVersion
@ -1243,4 +1424,84 @@ private actor SourceIndexActor {
packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") packItem.folderURL.path.hasPrefix(world.folderURL.path + "/")
})?.id })?.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
}
} }

View File

@ -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<T: Encodable>(_ 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<T: Decodable>(_ 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])
}
}

View File

@ -158,4 +158,64 @@ struct World_Manager_for_MinecraftTests {
#expect(enrichedWorld.packReferences.first?.version == "1.0.0") #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)
}
} }