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 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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
#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