speed improvement, dedupe on the fly, first whack at persistence
This commit is contained in:
parent
56f7ea7055
commit
516469427e
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -37,6 +37,15 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
private var scanTasks: [URL: 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 {
|
||||
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<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 {
|
||||
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<URL>] = [:]
|
||||
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<PackIdentity>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user