From 56f7ea70559e5124eddd1363e28cfa9e1606fecc Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 25 May 2026 23:26:58 -0500 Subject: [PATCH] concurrency --- .gitignore | 20 + World Manager for Minecraft/ContentView.swift | 157 ++- .../Models/LibraryIndex.swift | 150 +++ .../Models/MinecraftContentItem.swift | 11 +- .../Models/MinecraftSource.swift | 95 +- .../Services/SourceLibrary.swift | 1027 ++++++++++++++++- .../Services/WorldScanner.swift | 282 +++-- .../World_Manager_for_MinecraftApp.swift | 1 + .../World_Manager_for_MinecraftTests.swift | 148 ++- 9 files changed, 1742 insertions(+), 149 deletions(-) create mode 100644 .gitignore create mode 100644 World Manager for Minecraft/Models/LibraryIndex.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e519fa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +DerivedData/ + +# macOS +.DS_Store + +# Xcode user data +*.xcuserstate +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +# Swift Package Manager local state +.swiftpm/ diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 76226be..479e95e 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -36,6 +36,7 @@ struct ContentView: View { revealFooterURLAction: revealURLInFinder(_:), filters: sidebarFilters(for:) ) + .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380) } content: { ItemListColumnView( isEmpty: library.sources.isEmpty, @@ -52,11 +53,15 @@ struct ContentView: View { refreshAction: rescanCurrentSource, itemContextMenu: itemContextMenu(for:) ) + .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) } detail: { ItemDetailColumnView( item: currentSelectedItem, - behaviorPacks: currentSelectedItem.map { packReferences(for: $0, type: .behaviorPack) } ?? [], - resourcePacks: currentSelectedItem.map { packReferences(for: $0, type: .resourcePack) } ?? [], + behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], + resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], + worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], + backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [], + isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false, contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [], directoryPreviewLimit: directoryPreviewLimit, isEmpty: library.sources.isEmpty, @@ -84,6 +89,7 @@ struct ContentView: View { shareItem(item, from: anchorView) } ) + .frame(minWidth: 450) } .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { @@ -368,8 +374,54 @@ struct ContentView: View { } } - private func packReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] { - item.packReferences.filter { $0.type == type } + private func logicalPackReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] { + guard + item.contentType == .world, + let source = currentSource + else { + return [] + } + + return source.resolvedPackReferences(for: item.id, type: type) + } + + private func worldsUsingPack(for item: MinecraftContentItem) -> [MinecraftContentItem] { + guard + (item.contentType == .behaviorPack || item.contentType == .resourcePack), + let source = currentSource, + let logicalPack = source.logicalPack(forRepresentativeItemID: item.id) + else { + return [] + } + + return source.worldsUsingPack(logicalPack.id).sorted(by: sortComparator) + } + + private func backingPackInstances(for item: MinecraftContentItem) -> [MinecraftContentItem] { + guard + (item.contentType == .behaviorPack || item.contentType == .resourcePack), + let source = currentSource, + let logicalPack = source.logicalPack(forRepresentativeItemID: item.id) + else { + return [] + } + + return source + .packInstances(for: logicalPack.id) + .compactMap { source.rawItem(withID: $0.itemID) } + .sorted(by: WorldScanner.sortItems) + } + + private func isSuspiciousPack(_ item: MinecraftContentItem) -> Bool { + guard + (item.contentType == .behaviorPack || item.contentType == .resourcePack), + let source = currentSource, + let logicalPack = source.logicalPack(forRepresentativeItemID: item.id) + else { + return false + } + + return logicalPack.isSuspicious } private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] { @@ -716,7 +768,7 @@ private struct SidebarFilterRow: View { private struct SidebarSourcesSectionHeaderView: View { var body: some View { - Text("Library") + Text("Libraries") .font(.headline) .foregroundStyle(.secondary) .textCase(nil) @@ -849,6 +901,9 @@ private struct ItemDetailColumnView: View { let item: MinecraftContentItem? let behaviorPacks: [ContentPackReference] let resourcePacks: [ContentPackReference] + let worldsUsingPack: [MinecraftContentItem] + let backingPackInstances: [MinecraftContentItem] + let isSuspiciousPack: Bool let contents: [DirectoryPreviewEntry] let directoryPreviewLimit: Int let isEmpty: Bool @@ -868,6 +923,9 @@ private struct ItemDetailColumnView: View { item: item, behaviorPacks: behaviorPacks, resourcePacks: resourcePacks, + worldsUsingPack: worldsUsingPack, + backingPackInstances: backingPackInstances, + isSuspiciousPack: isSuspiciousPack, contents: contents, directoryPreviewLimit: directoryPreviewLimit ) @@ -924,7 +982,7 @@ private struct ContentRowView: View { Spacer() - if !item.metadataLoaded { + if !item.metadataLoaded || !item.sizeLoaded { ProgressView() .controlSize(.small) } @@ -934,9 +992,14 @@ private struct ContentRowView: View { } private var metadataLine: String { - let sizeText = item.sizeBytes.map { - ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) - } ?? "Size unavailable" + let sizeText: String + if let sizeBytes = item.sizeBytes { + sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } else if item.metadataLoaded { + sizeText = "Calculating size..." + } else { + sizeText = "Loading metadata..." + } let dateText = item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Date unavailable" @@ -949,6 +1012,9 @@ private struct ItemDetailView: View { let item: MinecraftContentItem let behaviorPacks: [ContentPackReference] let resourcePacks: [ContentPackReference] + let worldsUsingPack: [MinecraftContentItem] + let backingPackInstances: [MinecraftContentItem] + let isSuspiciousPack: Bool let contents: [DirectoryPreviewEntry] let directoryPreviewLimit: Int @State private var isTechnicalDetailsExpanded = false @@ -975,6 +1041,12 @@ private struct ItemDetailView: View { Text("Details") .font(.headline) + if isSuspiciousPack { + Label("Manifest UUID is missing or unreadable for this pack.", systemImage: "exclamationmark.triangle") + .font(.subheadline) + .foregroundStyle(.orange) + } + detailValueRow(title: "Size", value: sizeText) detailValueRow(title: item.displayDateLabel, value: displayDateText) @@ -1004,6 +1076,52 @@ private struct ItemDetailView: View { } } + if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !worldsUsingPack.isEmpty { + detailCard { + VStack(alignment: .leading, spacing: 14) { + Text("Used By Worlds") + .font(.headline) + + ForEach(worldsUsingPack) { world in + HStack(alignment: .top, spacing: 12) { + PackReferenceIconView(iconURL: world.iconURL) + + VStack(alignment: .leading, spacing: 2) { + Text(world.displayName) + + Text(worldUsageSecondaryText(for: world)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty { + detailCard { + VStack(alignment: .leading, spacing: 14) { + Text("Pack Instances") + .font(.headline) + + ForEach(backingPackInstances) { instance in + HStack(alignment: .top, spacing: 12) { + PackReferenceIconView(iconURL: instance.iconURL) + + VStack(alignment: .leading, spacing: 2) { + Text(instance.folderName) + + Text(packInstanceSecondaryText(for: instance)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + detailCard { DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) { VStack(alignment: .leading, spacing: 18) { @@ -1052,7 +1170,7 @@ private struct ItemDetailView: View { } } .padding(28) - .frame(maxWidth: 760, alignment: .leading) + .frame(maxWidth: 450, alignment: .leading) } } @@ -1113,7 +1231,11 @@ private struct ItemDetailView: View { } private var sizeText: String { - item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown" + if let sizeBytes = item.sizeBytes { + return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } + + return item.metadataLoaded ? "Calculating..." : "Loading..." } private var displayDateText: String { @@ -1125,6 +1247,19 @@ private struct ItemDetailView: View { .compactMap { $0 } return components.isEmpty ? nil : components.joined(separator: " • ") } + + private func worldUsageSecondaryText(for world: MinecraftContentItem) -> String { + let dateText = world.displayDate?.formatted(date: .abbreviated, time: .omitted) ?? "Date unavailable" + return "\(world.displayDateLabel) \(dateText)" + } + + private func packInstanceSecondaryText(for instance: MinecraftContentItem) -> String { + if instance.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) { + return "Embedded in world copy" + } + + return "Top-level pack folder" + } } private struct DirectoryPreviewEntry: Identifiable { diff --git a/World Manager for Minecraft/Models/LibraryIndex.swift b/World Manager for Minecraft/Models/LibraryIndex.swift new file mode 100644 index 0000000..31576e1 --- /dev/null +++ b/World Manager for Minecraft/Models/LibraryIndex.swift @@ -0,0 +1,150 @@ +// +// LibraryIndex.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-25. +// + +import Foundation + +enum PackIdentitySource: String, Hashable, Sendable { + case manifestUUID + case fallback +} + +struct PackIdentity: Hashable, Sendable, Identifiable { + let type: MinecraftContentType + let uuid: String? + let version: String? + let fallbackName: String + let fallbackLocationHint: String? + let source: PackIdentitySource + + var id: String { + [ + type.rawValue, + uuid ?? normalizedFallbackName, + version ?? "", + fallbackLocationHint ?? "" + ].joined(separator: "::") + } + + var isSuspicious: Bool { + source == .fallback + } + + init( + type: MinecraftContentType, + uuid: String?, + version: String?, + fallbackName: String, + fallbackLocationHint: String? + ) { + self.type = type + self.uuid = uuid?.lowercased() + self.version = version + self.fallbackName = fallbackName.trimmingCharacters(in: .whitespacesAndNewlines) + self.fallbackLocationHint = fallbackLocationHint + self.source = self.uuid == nil ? .fallback : .manifestUUID + } + + private var normalizedFallbackName: String { + fallbackName.lowercased() + } + + static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool { + guard lhs.type == rhs.type else { + return false + } + + if let lhsUUID = lhs.uuid, let rhsUUID = rhs.uuid { + return lhsUUID == rhsUUID && lhs.version == rhs.version + } + + return lhs.uuid == rhs.uuid + && lhs.version == rhs.version + && lhs.normalizedFallbackName == rhs.normalizedFallbackName + && lhs.fallbackLocationHint == rhs.fallbackLocationHint + } + + func hash(into hasher: inout Hasher) { + hasher.combine(type) + + if let uuid { + hasher.combine(uuid) + hasher.combine(version) + return + } + + hasher.combine(uuid) + hasher.combine(version) + hasher.combine(normalizedFallbackName) + hasher.combine(fallbackLocationHint) + } +} + +struct PackInstance: Identifiable, Hashable, Sendable { + let id: URL + let itemID: URL + let sourceID: URL + let logicalPackID: PackIdentity + let origin: PackSource + let hostWorldItemID: URL? +} + +struct LogicalPack: Identifiable, Hashable, Sendable { + let id: PackIdentity + let contentType: MinecraftContentType + let displayName: String + let uuid: String? + let version: String? + let representativeItemID: URL + let instanceItemIDs: [URL] + let isSuspicious: Bool +} + +struct LogicalWorld: Identifiable, Hashable, Sendable { + let id: URL + let itemID: URL + let usedPackIDs: [PackIdentity] + let unresolvedReferences: [ContentPackReference] +} + +struct WorldPackRelationship: Identifiable, Hashable, Sendable { + let worldItemID: URL + let logicalPackID: PackIdentity? + let reference: ContentPackReference + + var id: String { + [ + worldItemID.path, + logicalPackID?.id ?? "unresolved", + reference.id + ].joined(separator: "::") + } +} + +struct ItemSnapshot: Identifiable, Hashable, Sendable { + let id: URL + let relativePath: String + let modifiedDate: Date? + let sizeBytes: Int64? + let packUUID: String? + let packVersion: String? +} + +struct CollectionSnapshot: Identifiable, Hashable, Sendable { + let folderName: String + let modifiedDate: Date? + let childDirectoryCount: Int + let fingerprint: String + + var id: String { folderName } +} + +struct SourceSnapshot: Hashable, Sendable { + let sourceID: URL + let rootModifiedDate: Date? + let collectionSnapshots: [CollectionSnapshot] + let itemSnapshots: [ItemSnapshot] +} diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index a68d258..e565e52 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -104,8 +104,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { var lastPlayedDate: Date? var modifiedDate: Date? var sizeBytes: Int64? + var packUUID: String? + var packVersion: String? var packReferences: [ContentPackReference] var metadataLoaded: Bool + var sizeLoaded: Bool nonisolated init( folderURL: URL, @@ -117,8 +120,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { lastPlayedDate: Date? = nil, modifiedDate: Date? = nil, sizeBytes: Int64? = nil, + packUUID: String? = nil, + packVersion: String? = nil, packReferences: [ContentPackReference] = [], - metadataLoaded: Bool = false + metadataLoaded: Bool = false, + sizeLoaded: Bool = false ) { self.id = folderURL.standardizedFileURL self.folderURL = folderURL @@ -130,8 +136,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { self.lastPlayedDate = lastPlayedDate self.modifiedDate = modifiedDate self.sizeBytes = sizeBytes + self.packUUID = packUUID?.lowercased() + self.packVersion = packVersion self.packReferences = packReferences self.metadataLoaded = metadataLoaded + self.sizeLoaded = sizeLoaded } nonisolated var folderID: String { diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 61bb268..12ddd29 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -11,7 +11,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL var displayName: String - var items: [MinecraftContentItem] + var displayItems: [MinecraftContentItem] + var rawItems: [MinecraftContentItem] + var logicalPacks: [LogicalPack] + var logicalWorlds: [LogicalWorld] + var packInstances: [PackInstance] + var worldPackRelationships: [WorldPackRelationship] + var snapshot: SourceSnapshot? var isScanning: Bool var scanStatus: String var scanError: String? @@ -24,7 +30,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { self.id = normalizedURL self.folderURL = normalizedURL self.displayName = normalizedURL.lastPathComponent - self.items = [] + self.displayItems = [] + self.rawItems = [] + self.logicalPacks = [] + self.logicalWorlds = [] + self.packInstances = [] + self.worldPackRelationships = [] + self.snapshot = nil self.isScanning = false self.scanStatus = "" self.scanError = nil @@ -34,6 +46,83 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { } var itemCount: Int { - items.count + displayItems.count + } + + var items: [MinecraftContentItem] { + displayItems + } + + func rawItem(withID itemID: URL) -> MinecraftContentItem? { + rawItems.first(where: { $0.id == itemID }) + } + + func logicalPack(forRepresentativeItemID itemID: URL) -> LogicalPack? { + logicalPacks.first(where: { $0.representativeItemID == itemID }) + } + + func logicalWorld(forItemID itemID: URL) -> LogicalWorld? { + logicalWorlds.first(where: { $0.itemID == itemID }) + } + + func packInstances(for logicalPackID: PackIdentity) -> [PackInstance] { + packInstances.filter { $0.logicalPackID == logicalPackID } + } + + func worldsUsingPack(_ logicalPackID: PackIdentity) -> [MinecraftContentItem] { + worldPackRelationships + .filter { $0.logicalPackID == logicalPackID } + .compactMap { rawItem(withID: $0.worldItemID) } + .uniqued(by: \.id) + .sorted(by: WorldScanner.sortItems) + } + + func resolvedPackReferences(for worldItemID: URL, type: MinecraftContentType) -> [ContentPackReference] { + worldPackRelationships + .filter { $0.worldItemID == worldItemID && $0.reference.type == type } + .compactMap { relationship in + if let logicalPackID = relationship.logicalPackID, + let logicalPack = logicalPacks.first(where: { $0.id == logicalPackID }), + let representativeItem = rawItem(withID: logicalPack.representativeItemID) { + return ContentPackReference( + name: logicalPack.displayName, + type: logicalPack.contentType, + iconURL: representativeItem.iconURL, + uuid: logicalPack.uuid, + version: logicalPack.version, + source: relationship.reference.source + ) + } + + return relationship.reference + } + .uniqued(by: \.id) + } + + private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool { + switch item.contentType { + case .world, .behaviorPack, .resourcePack: + return false + case .skinPack, .worldTemplate: + return true + } + } +} + +private extension Array { + func uniqued(by keyPath: KeyPath) -> [Element] { + var seen = Set() + var result: [Element] = [] + + for element in self { + let key = element[keyPath: keyPath] + guard seen.insert(key).inserted else { + continue + } + + result.append(element) + } + + return result } } diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 943a16d..0853f54 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -24,6 +24,9 @@ struct SidebarFooterState { @MainActor final class SourceLibrary: ObservableObject { + private static let enrichmentWorkerCount = 4 + private static let sizeWorkerCount = 2 + @Published var sources: [MinecraftSource] = [] @Published private(set) var sidebarFooterState = SidebarFooterState( style: .idle, @@ -122,20 +125,78 @@ final class SourceLibrary: ObservableObject { } private func scanSource(withID sourceID: URL) async { + var workerTasks: [Task] = [] + var sizeWorkerTasks: [Task] = [] + defer { + workerTasks.forEach { $0.cancel() } + sizeWorkerTasks.forEach { $0.cancel() } + scanTasks[sourceID] = nil + } + + await WorldScanner.beginScanSession(for: sourceID) + updateSource(sourceID) { source in source.isScanning = true source.scanError = nil source.scanStatus = "Scanning Minecraft library..." - source.items = [] + source.displayItems = [] + source.rawItems = [] + source.logicalPacks = [] + source.logicalWorlds = [] + source.packInstances = [] + source.worldPackRelationships = [] + source.snapshot = nil source.indexedItemCount = 0 source.indexedDetailCount = 0 } refreshSidebarFooterState() do { - let enrichmentTracker = PendingEnrichmentTracker() - let applyEnrichedItem: @MainActor (MinecraftContentItem) -> Void = { [weak self] enrichedItem in - self?.handleEnrichedItem(enrichedItem, for: sourceID) + let index = SourceIndexActor(sourceID: sourceID, folderURL: sourceID) + let enrichmentQueue = EnrichmentWorkQueue() + let sizeQueue = EnrichmentWorkQueue() + workerTasks = (0.. { continuation in let discoveryTask = Task.detached(priority: .userInitiated) { @@ -162,33 +223,36 @@ final class SourceLibrary: ObservableObject { } discoveredCount += 1 - updateSource(sourceID) { source in - source.items.append(item) - source.indexedItemCount = discoveredCount - source.scanStatus = "Found \(discoveredCount) items. Loading details..." + if let snapshot = await index.addDiscoveredItem( + item, + discoveredCount: discoveredCount + ) { + applySnapshot(snapshot, to: sourceID) } refreshSidebarFooterState() - await enrichmentTracker.beginEnrichment() - let tracker = enrichmentTracker - - Task.detached(priority: .utility) { - let enrichedItem = WorldScanner.enrich(item: item) - await applyEnrichedItem(enrichedItem) - await tracker.finishEnrichment() - } + await enrichmentQueue.enqueue(item) } - await enrichmentTracker.markDiscoveryFinished() - await enrichmentTracker.waitForCompletion() + await enrichmentQueue.finish() - updateSource(sourceID) { source in - source.items.sort(by: WorldScanner.sortItems) - source.scanStatus = source.indexedItemCount == 0 - ? "No Minecraft items found." - : "Loaded \(source.indexedDetailCount) items." - source.isScanning = false - source.lastScanDate = Date() + for workerTask in workerTasks { + await workerTask.value + } + + if let snapshot = await index.markMetadataFinished() { + applySnapshot(snapshot, to: sourceID) + } + refreshSidebarFooterState() + + await sizeQueue.finish() + + for sizeWorkerTask in sizeWorkerTasks { + await sizeWorkerTask.value + } + + if let snapshot = await index.finishScan() { + applySnapshot(snapshot, to: sourceID) } refreshSidebarFooterState() } catch { @@ -203,26 +267,228 @@ final class SourceLibrary: ObservableObject { } refreshSidebarFooterState() } - - scanTasks[sourceID] = nil } private func handleEnrichedItem(_ enrichedItem: MinecraftContentItem, for sourceID: URL) { + var previousItem: MinecraftContentItem? + updateSource(sourceID) { source in - guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else { + guard let index = source.rawItems.firstIndex(where: { $0.id == enrichedItem.id }) else { return } - source.items[index] = enrichedItem + previousItem = source.rawItems[index] + source.rawItems[index] = enrichedItem source.indexedDetailCount += 1 if source.indexedDetailCount < source.indexedItemCount { source.scanStatus = "Loaded details for \(source.indexedDetailCount) of \(source.indexedItemCount) items..." } + + handleMetadataUpdate( + for: enrichedItem, + previousItem: previousItem, + in: &source, + sourceID: sourceID + ) } refreshSidebarFooterState() } + private func handleSizedItem(_ sizedItem: MinecraftContentItem, for sourceID: URL) { + updateSource(sourceID) { source in + guard let index = source.rawItems.firstIndex(where: { $0.id == sizedItem.id }) else { + return + } + + source.rawItems[index].sizeBytes = sizedItem.sizeBytes + source.rawItems[index].sizeLoaded = sizedItem.sizeLoaded + + if source.isScanning { + source.scanStatus = "Calculating sizes for \(source.rawItems.filter(\.sizeLoaded).count) of \(source.indexedItemCount) items..." + } + } + refreshSidebarFooterState() + } + + private func rebuildNormalizedIndex(for sourceID: URL) { + updateSource(sourceID) { source in + let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems) + source.rawItems = rawItems + let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) }) + + let rawPacks = rawItems.filter { + $0.contentType == .behaviorPack || $0.contentType == .resourcePack + } + let rawWorlds = rawItems.filter { $0.contentType == .world } + + let packMetadataByItemID = Dictionary(uniqueKeysWithValues: rawPacks.map { item in + (item.id, packMetadata(for: item, sourceRootURL: source.folderURL)) + }) + + var chosenRepresentativeByIdentity: [PackIdentity: MinecraftContentItem] = [:] + var allPackItemsByIdentity: [PackIdentity: [MinecraftContentItem]] = [:] + + for item in rawPacks { + let metadata = packMetadataByItemID[item.id] ?? packMetadata(for: item, sourceRootURL: source.folderURL) + 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 + ) + } + + var packInstances: [PackInstance] = [] + for logicalPack in logicalPacks { + for itemID in logicalPack.instanceItemIDs { + guard let item = rawItemsByID[itemID] else { + continue + } + + packInstances.append( + PackInstance( + id: item.id, + itemID: item.id, + sourceID: sourceID, + logicalPackID: logicalPack.id, + origin: packOrigin(for: item), + hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds) + ) + ) + } + } + + let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id, $0) }) + var worldRelationships: [WorldPackRelationship] = [] + var logicalWorlds: [LogicalWorld] = [] + + for world in rawWorlds { + var usedPackIDs = Set() + var unresolvedReferences: [ContentPackReference] = [] + + for reference in world.packReferences { + let referenceIdentity = PackIdentity( + type: reference.type, + uuid: reference.uuid, + version: reference.version, + fallbackName: reference.name, + fallbackLocationHint: world.folderName + ) + let resolvedID = logicalPacksByID[referenceIdentity]?.id + + if let resolvedID { + usedPackIDs.insert(resolvedID) + } else { + unresolvedReferences.append(reference) + } + + worldRelationships.append( + WorldPackRelationship( + worldItemID: world.id, + logicalPackID: resolvedID, + reference: reference + ) + ) + } + + logicalWorlds.append( + LogicalWorld( + id: world.id, + itemID: world.id, + usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, + unresolvedReferences: unresolvedReferences + ) + ) + } + + source.logicalPacks = logicalPacks + source.logicalWorlds = logicalWorlds.sorted { + guard + let lhs = source.rawItem(withID: $0.itemID), + let rhs = source.rawItem(withID: $1.itemID) + else { + return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + + return WorldScanner.sortItems(lhs, rhs) + } + source.packInstances = packInstances.sorted { + $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + source.worldPackRelationships = worldRelationships + } + } + + private func handleDiscoveredItem(_ item: MinecraftContentItem, in source: inout MinecraftSource, sourceID: URL) { + guard isLogicalPackType(item.contentType) else { + return + } + + let identity = packMetadata(for: item, sourceRootURL: source.folderURL).identity + refreshLogicalPack(identity: identity, in: &source, sourceID: sourceID) + } + + private func handleMetadataUpdate( + for item: MinecraftContentItem, + previousItem: MinecraftContentItem?, + in source: inout MinecraftSource, + sourceID: URL + ) { + if isLogicalPackType(item.contentType) { + let newIdentity = packMetadata(for: item, sourceRootURL: source.folderURL).identity + let previousIdentity = previousItem.map { packMetadata(for: $0, sourceRootURL: source.folderURL).identity } + + if let previousIdentity, previousIdentity != newIdentity { + refreshLogicalPack(identity: previousIdentity, in: &source, sourceID: sourceID) + } + + refreshLogicalPack(identity: newIdentity, in: &source, sourceID: sourceID) + refreshWorldRelationships(in: &source, filteringTo: item.contentType) + return + } + + if item.contentType == .world { + refreshWorldRelationship(for: item, in: &source) + } + } + private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) { guard let index = sources.firstIndex(where: { $0.id == sourceID }) else { return @@ -231,6 +497,155 @@ final class SourceLibrary: ObservableObject { mutate(&sources[index]) } + private func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { + updateSource(sourceID) { source in + source.displayItems = snapshot.displayItems + source.rawItems = snapshot.rawItems + source.logicalPacks = snapshot.logicalPacks + source.logicalWorlds = snapshot.logicalWorlds + source.packInstances = snapshot.packInstances + source.worldPackRelationships = snapshot.worldPackRelationships + source.indexedItemCount = snapshot.indexedItemCount + source.indexedDetailCount = snapshot.indexedDetailCount + source.scanStatus = snapshot.scanStatus + source.isScanning = snapshot.isScanning + source.lastScanDate = snapshot.lastScanDate + } + } + + private func refreshLogicalPack(identity: PackIdentity, in source: inout MinecraftSource, sourceID: URL) { + let matchingItems = source.rawItems.filter { item in + guard isLogicalPackType(item.contentType) else { + return false + } + + return packMetadata(for: item, sourceRootURL: source.folderURL).identity == identity + } + + source.logicalPacks.removeAll { $0.id == identity } + source.packInstances.removeAll { $0.logicalPackID == identity } + + guard !matchingItems.isEmpty else { + return + } + + let representativeItem = matchingItems.reduce(matchingItems[0]) { current, candidate in + shouldPreferPackItem(candidate, over: current) ? candidate : current + } + let representativeMetadata = packMetadata(for: representativeItem, sourceRootURL: source.folderURL) + + source.logicalPacks.append( + LogicalPack( + id: identity, + contentType: identity.type, + displayName: representativeItem.displayName, + uuid: representativeMetadata.uuid, + version: representativeMetadata.version, + representativeItemID: representativeItem.id, + instanceItemIDs: matchingItems.map(\.id).sorted { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + }, + isSuspicious: identity.isSuspicious + ) + ) + source.logicalPacks.sort { + let nameOrder = $0.displayName.localizedStandardCompare($1.displayName) + if nameOrder != .orderedSame { + return nameOrder == .orderedAscending + } + + return $0.id.id.localizedStandardCompare($1.id.id) == .orderedAscending + } + + let rawWorlds = source.rawItems.filter { $0.contentType == .world } + source.packInstances.append( + contentsOf: matchingItems.map { item in + PackInstance( + id: item.id, + itemID: item.id, + sourceID: sourceID, + logicalPackID: identity, + origin: packOrigin(for: item), + hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds) + ) + } + ) + source.packInstances.sort { + $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + } + + private func refreshWorldRelationships(in source: inout MinecraftSource, filteringTo type: MinecraftContentType? = nil) { + let worlds = source.rawItems.filter { $0.contentType == .world } + for world in worlds { + guard type == nil || world.packReferences.contains(where: { $0.type == type }) else { + continue + } + + refreshWorldRelationship(for: world, in: &source) + } + } + + private func refreshWorldRelationship(for world: MinecraftContentItem, in source: inout MinecraftSource) { + source.worldPackRelationships.removeAll { $0.worldItemID == world.id } + source.logicalWorlds.removeAll { $0.itemID == world.id } + + let logicalPacksByID = Dictionary(uniqueKeysWithValues: source.logicalPacks.map { ($0.id, $0) }) + var usedPackIDs = Set() + var unresolvedReferences: [ContentPackReference] = [] + var relationships: [WorldPackRelationship] = [] + + for reference in world.packReferences { + let referenceIdentity = PackIdentity( + type: reference.type, + uuid: reference.uuid, + version: reference.version, + fallbackName: reference.name, + fallbackLocationHint: world.folderName + ) + let resolvedID = logicalPacksByID[referenceIdentity]?.id + + if let resolvedID { + usedPackIDs.insert(resolvedID) + } else { + unresolvedReferences.append(reference) + } + + relationships.append( + WorldPackRelationship( + worldItemID: world.id, + logicalPackID: resolvedID, + reference: reference + ) + ) + } + + source.worldPackRelationships.append(contentsOf: relationships) + source.logicalWorlds.append( + LogicalWorld( + id: world.id, + itemID: world.id, + usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, + unresolvedReferences: unresolvedReferences + ) + ) + let rawItemsByID = Dictionary(uniqueKeysWithValues: source.rawItems.map { ($0.id, $0) }) + source.logicalWorlds.sort { + guard + let lhs = rawItemsByID[$0.itemID], + let rhs = rawItemsByID[$1.itemID] + else { + return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + + return WorldScanner.sortItems(lhs, rhs) + } + } + + private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { + contentType == .behaviorPack || contentType == .resourcePack + } + private func refreshSidebarFooterState() { let scanningSources = sources.filter(\.isScanning) if let source = scanningSources.first { @@ -281,43 +696,551 @@ final class SourceLibrary: ObservableObject { self.refreshSidebarFooterState() } } + + private func buildSnapshot( + for source: MinecraftSource, + packMetadataByItemID: [URL: PackMetadata] + ) -> SourceSnapshot { + let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in + let collectionURL = source.folderURL.appendingPathComponent(type.collectionFolderName, isDirectory: true) + guard FileManager.default.fileExists(atPath: collectionURL.path) else { + return nil + } + + let children = (try? FileManager.default.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: "::") + ) + } + + let itemSnapshots = source.rawItems.map { item in + let relativePath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "") + let metadata = packMetadataByItemID[item.id] + return ItemSnapshot( + id: item.id, + relativePath: relativePath, + modifiedDate: item.modifiedDate, + sizeBytes: item.sizeBytes, + packUUID: metadata?.uuid, + packVersion: metadata?.version + ) + }.sorted { (lhs: ItemSnapshot, rhs: ItemSnapshot) in + lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending + } + + let rootModifiedDate = try? source.folderURL + .resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + + return SourceSnapshot( + sourceID: source.id, + rootModifiedDate: rootModifiedDate, + collectionSnapshots: collectionSnapshots, + itemSnapshots: itemSnapshots + ) + } + + private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { + let candidateEmbedded = isEmbeddedWorldPack(candidate) + let existingEmbedded = isEmbeddedWorldPack(existing) + + if candidateEmbedded != existingEmbedded { + return !candidateEmbedded + } + + if candidate.metadataLoaded != existing.metadataLoaded { + return candidate.metadataLoaded + } + + if candidate.modifiedDate != existing.modifiedDate { + return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast) + } + + return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending + } + + private func packOrigin(for item: MinecraftContentItem) -> PackSource { + isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection + } + + private func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool { + item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) + } + + private func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? { + rawWorlds.first(where: { world in + packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") + })?.id + } + + private func packMetadata(for item: MinecraftContentItem, sourceRootURL: URL) -> PackMetadata { + let uuid = item.packUUID + let version = item.packVersion + + return PackMetadata( + uuid: uuid, + version: version, + identity: PackIdentity( + type: item.contentType, + uuid: uuid, + version: version, + fallbackName: item.displayName, + fallbackLocationHint: relativePathHint(for: item, sourceRootURL: sourceRootURL) + ) + ) + } + + private func relativePathHint(for item: MinecraftContentItem, sourceRootURL: URL) -> String { + item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "") + } + } -private actor PendingEnrichmentTracker { - private var pendingCount = 0 +private struct PackMetadata { + let uuid: String? + let version: String? + let identity: PackIdentity +} + +private actor EnrichmentWorkQueue { + private var pendingItems: [MinecraftContentItem] = [] + private var isFinished = false + private var waitingContinuations: [CheckedContinuation] = [] + + func enqueue(_ item: MinecraftContentItem) { + if let continuation = waitingContinuations.first { + waitingContinuations.removeFirst() + continuation.resume(returning: item) + return + } + + pendingItems.append(item) + } + + func next() async -> MinecraftContentItem? { + if !pendingItems.isEmpty { + return pendingItems.removeFirst() + } + + if isFinished { + return nil + } + + return await withCheckedContinuation { continuation in + waitingContinuations.append(continuation) + } + } + + func finish() { + isFinished = true + + for continuation in waitingContinuations { + continuation.resume(returning: nil) + } + + waitingContinuations.removeAll() + } +} + +private struct SourceIndexSnapshot { + let displayItems: [MinecraftContentItem] + let rawItems: [MinecraftContentItem] + let logicalPacks: [LogicalPack] + let logicalWorlds: [LogicalWorld] + let packInstances: [PackInstance] + let worldPackRelationships: [WorldPackRelationship] + let indexedItemCount: Int + let indexedDetailCount: Int + let scanStatus: String + let isScanning: Bool + let lastScanDate: Date? +} + +private actor SourceIndexActor { + private let sourceID: URL + private let folderURL: URL + private let publishInterval: TimeInterval = 0.12 + + private var orderedItemIDs: [URL] = [] + private var itemsByID: [URL: MinecraftContentItem] = [:] + private var indexedItemCount = 0 + private var indexedDetailCount = 0 private var discoveryFinished = false - private var continuation: CheckedContinuation? + private var metadataFinished = false + private var sizesFinished = false + private var lastPublishedAt: Date? - func beginEnrichment() { - pendingCount += 1 + init(sourceID: URL, folderURL: URL) { + self.sourceID = sourceID + self.folderURL = folderURL } - func finishEnrichment() { - pendingCount -= 1 - resumeIfNeeded() + func addDiscoveredItem(_ item: MinecraftContentItem, discoveredCount: Int) -> SourceIndexSnapshot? { + orderedItemIDs.append(item.id) + itemsByID[item.id] = item + indexedItemCount = discoveredCount + return snapshotIfNeeded() } - func markDiscoveryFinished() { + func applyEnrichedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { + let previous = itemsByID[item.id] + itemsByID[item.id] = item + if item.metadataLoaded, previous?.metadataLoaded != true { + indexedDetailCount += 1 + } + return snapshotIfNeeded() + } + + func applySizedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { + guard var current = itemsByID[item.id] else { + return nil + } + + current.sizeBytes = item.sizeBytes + current.sizeLoaded = item.sizeLoaded + itemsByID[item.id] = current + return snapshotIfNeeded() + } + + func markDiscoveryFinished() -> SourceIndexSnapshot? { discoveryFinished = true - resumeIfNeeded() + return buildSnapshot(force: true) } - func waitForCompletion() async { - guard !(discoveryFinished && pendingCount == 0) else { - return - } - - await withCheckedContinuation { continuation in - self.continuation = continuation - } + func markMetadataFinished() -> SourceIndexSnapshot? { + discoveryFinished = true + metadataFinished = true + return buildSnapshot(force: true) } - private func resumeIfNeeded() { - guard discoveryFinished, pendingCount == 0 else { - return + func finishScan() -> SourceIndexSnapshot? { + discoveryFinished = true + metadataFinished = true + sizesFinished = true + return buildSnapshot(force: true) + } + + private func snapshotIfNeeded() -> SourceIndexSnapshot? { + buildSnapshot(force: false) + } + + private func buildSnapshot(force: Bool) -> SourceIndexSnapshot? { + let now = Date() + if !force, let lastPublishedAt, now.timeIntervalSince(lastPublishedAt) < publishInterval { + return nil } - continuation?.resume() - continuation = nil + lastPublishedAt = now + + let rawItems = orderedItemIDs.compactMap { itemsByID[$0] } + let scanStatus: String + + if !discoveryFinished { + scanStatus = indexedItemCount == 0 + ? "Scanning Minecraft library..." + : "Found \(indexedItemCount) items. Loading metadata..." + + return SourceIndexSnapshot( + displayItems: buildRawDisplayItems(from: rawItems), + rawItems: rawItems, + logicalPacks: [], + logicalWorlds: [], + packInstances: [], + worldPackRelationships: [], + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + scanStatus: scanStatus, + isScanning: true, + lastScanDate: nil + ) + } + + 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." + : "Deduplicating packs..." + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: [], + packInstances: [], + worldPackRelationships: [], + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + scanStatus: scanStatus, + isScanning: true, + lastScanDate: nil + ) + } + + let rawWorlds = rawItems.filter { $0.contentType == .world } + var packInstances: [PackInstance] = [] + for logicalPack in logicalPacks { + for itemID in logicalPack.instanceItemIDs { + guard let item = rawItemsByID[itemID] else { + continue + } + + packInstances.append( + PackInstance( + id: item.id, + itemID: item.id, + sourceID: sourceID, + logicalPackID: logicalPack.id, + origin: isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection, + hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds) + ) + ) + } + } + + let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id, $0) }) + var worldRelationships: [WorldPackRelationship] = [] + var logicalWorlds: [LogicalWorld] = [] + + for world in rawWorlds { + var usedPackIDs = Set() + var unresolvedReferences: [ContentPackReference] = [] + + for reference in world.packReferences { + let referenceIdentity = PackIdentity( + type: reference.type, + uuid: reference.uuid, + version: reference.version, + fallbackName: reference.name, + fallbackLocationHint: world.folderName + ) + let resolvedID = logicalPacksByID[referenceIdentity]?.id + + if let resolvedID { + usedPackIDs.insert(resolvedID) + } else { + unresolvedReferences.append(reference) + } + + worldRelationships.append( + WorldPackRelationship( + worldItemID: world.id, + logicalPackID: resolvedID, + reference: reference + ) + ) + } + + logicalWorlds.append( + LogicalWorld( + id: world.id, + itemID: world.id, + usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, + unresolvedReferences: unresolvedReferences + ) + ) + } + + logicalWorlds.sort { + guard + let lhs = rawItemsByID[$0.itemID], + let rhs = rawItemsByID[$1.itemID] + else { + return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + + return WorldScanner.sortItems(lhs, rhs) + } + + if !sizesFinished { + scanStatus = indexedItemCount == 0 + ? "No Minecraft items found." + : "Resolving pack relationships..." + } else { + scanStatus = indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(indexedDetailCount) items." + } + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: logicalWorlds, + packInstances: packInstances.sorted { + $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + }, + worldPackRelationships: worldRelationships, + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + scanStatus: scanStatus, + isScanning: !sizesFinished, + lastScanDate: sizesFinished ? now : nil + ) + } + + private func buildDisplayItems( + from rawItems: [MinecraftContentItem], + logicalPacks: [LogicalPack], + rawItemsByID: [URL: MinecraftContentItem] + ) -> [MinecraftContentItem] { + var normalizedItemIDs = Set() + var normalizedItems: [MinecraftContentItem] = [] + + for item in rawItems where item.contentType == .world { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + for logicalPack in logicalPacks { + guard + let item = rawItemsByID[logicalPack.representativeItemID], + normalizedItemIDs.insert(item.id).inserted + else { + continue + } + + normalizedItems.append(item) + } + + for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + return normalizedItems + } + + private func buildRawDisplayItems(from rawItems: [MinecraftContentItem]) -> [MinecraftContentItem] { + rawItems.sorted(by: WorldScanner.sortItems) + } + + private func packMetadata(for item: MinecraftContentItem) -> PackMetadata { + let uuid = item.packUUID + let version = item.packVersion + + return PackMetadata( + uuid: uuid, + version: version, + identity: PackIdentity( + type: item.contentType, + uuid: uuid, + version: version, + fallbackName: item.displayName, + fallbackLocationHint: relativePathHint(for: item) + ) + ) + } + + private func relativePathHint(for item: MinecraftContentItem) -> String { + item.folderURL.path.replacingOccurrences(of: folderURL.path + "/", with: "") + } + + private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { + let candidateEmbedded = isEmbeddedWorldPack(candidate) + let existingEmbedded = isEmbeddedWorldPack(existing) + + if candidateEmbedded != existingEmbedded { + return !candidateEmbedded + } + + if candidate.metadataLoaded != existing.metadataLoaded { + return candidate.metadataLoaded + } + + if candidate.modifiedDate != existing.modifiedDate { + return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast) + } + + return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending + } + + private func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool { + item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) + } + + private func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? { + rawWorlds.first(where: { world in + packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") + })?.id } } diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index 2d736f0..5734ad1 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -8,6 +8,22 @@ import Foundation enum WorldScanner { + nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem { + let fileManager = FileManager.default + var sizedItem = item + sizedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager) + sizedItem.sizeLoaded = true + return sizedItem + } + + nonisolated static func beginScanSession(for sourceRootURL: URL) async { + await packReferenceIndexStore.reset(for: sourceRootURL) + } + + nonisolated static func endScanSession(for sourceRootURL: URL) async { + await packReferenceIndexStore.reset(for: sourceRootURL) + } + nonisolated static func discoverItems( in searchRootURL: URL, onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } @@ -52,6 +68,16 @@ enum WorldScanner { seenItemURLs.insert(itemURL) discoveredItems.append(item) onDiscovered(item) + + if contentType == .world { + let embeddedPackItems = discoverEmbeddedPackItems( + in: childDirectory, + fileManager: fileManager, + seenItemURLs: &seenItemURLs + ) + discoveredItems.append(contentsOf: embeddedPackItems) + embeddedPackItems.forEach(onDiscovered) + } } } } @@ -60,7 +86,7 @@ enum WorldScanner { return discoveredItems } - nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { + nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem { let fileManager = FileManager.default var enrichedItem = item @@ -68,9 +94,16 @@ enum WorldScanner { enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager) enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager) enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) - enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager) - enrichedItem.packReferences = packReferences(for: item, fileManager: fileManager) + if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) { + enrichedItem.packUUID = manifestMetadata.uuid + enrichedItem.packVersion = manifestMetadata.version + if !manifestMetadata.name.isEmpty { + enrichedItem.displayName = manifestMetadata.name + } + } + enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager) enrichedItem.metadataLoaded = true + enrichedItem.sizeLoaded = false return enrichedItem } @@ -96,7 +129,7 @@ enum WorldScanner { } } - nonisolated private static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] { + nonisolated fileprivate static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] { let children = try fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: [.isDirectoryKey], @@ -122,6 +155,50 @@ enum WorldScanner { } } + nonisolated private static func discoverEmbeddedPackItems( + in worldDirectoryURL: URL, + fileManager: FileManager, + seenItemURLs: inout Set + ) -> [MinecraftContentItem] { + let embeddedCollections: [(MinecraftContentType, URL)] = [ + (.behaviorPack, worldDirectoryURL.appendingPathComponent("behavior_packs", isDirectory: true)), + (.resourcePack, worldDirectoryURL.appendingPathComponent("resource_packs", isDirectory: true)) + ] + + var embeddedItems: [MinecraftContentItem] = [] + + for (contentType, collectionURL) in embeddedCollections { + guard + fileManager.fileExists(atPath: collectionURL.path), + let childDirectories = try? immediateChildDirectories(of: collectionURL, fileManager: fileManager) + else { + continue + } + + for childDirectory in childDirectories { + let itemURL = childDirectory.standardizedFileURL + guard !seenItemURLs.contains(itemURL) else { + continue + } + + guard isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) else { + continue + } + + let item = MinecraftContentItem( + folderURL: childDirectory, + folderName: childDirectory.lastPathComponent, + contentType: contentType, + collectionRootURL: collectionURL + ) + seenItemURLs.insert(itemURL) + embeddedItems.append(item) + } + } + + return embeddedItems + } + nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String { switch item.contentType { case .world: @@ -145,19 +222,7 @@ enum WorldScanner { } nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? { - let manifestURL = directoryURL.appendingPathComponent("manifest.json") - guard - fileManager.fileExists(atPath: manifestURL.path), - let data = try? Data(contentsOf: manifestURL), - let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let header = jsonObject["header"] as? [String: Any], - let name = (header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty - else { - return nil - } - - return name + manifestMetadata(in: directoryURL, fileManager: fileManager)?.name } nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? { @@ -235,10 +300,10 @@ enum WorldScanner { return totalSize } - nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] { + nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] { switch item.contentType { case .world: - var references = referencedWorldPacks(for: item, fileManager: fileManager) + var references = await referencedWorldPacks(for: item, fileManager: fileManager) references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager)) return uniquePackReferences(references) case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: @@ -246,14 +311,14 @@ enum WorldScanner { } } - nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] { - let behaviorReferences = packReferences( + nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] { + let behaviorReferences = await packReferences( fromWorldReferenceFileNamed: "world_behavior_packs.json", type: .behaviorPack, worldFolderURL: item.folderURL, fileManager: fileManager ) - let resourceReferences = packReferences( + let resourceReferences = await packReferences( fromWorldReferenceFileNamed: "world_resource_packs.json", type: .resourcePack, worldFolderURL: item.folderURL, @@ -289,7 +354,7 @@ enum WorldScanner { type: MinecraftContentType, worldFolderURL: URL, fileManager: FileManager - ) -> [ContentPackReference] { + ) async -> [ContentPackReference] { let fileURL = worldFolderURL.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path), @@ -299,27 +364,35 @@ enum WorldScanner { return [] } - return jsonObject.compactMap { entry in + var references: [ContentPackReference] = [] + + for entry in jsonObject { let uuid = (entry["pack_id"] as? String)?.lowercased() let version = versionString(from: entry["version"]) - let resolvedPack = uuid.flatMap { - resolvedPackReference( - uuid: $0, + let resolvedPack: ContentPackReference? + if let uuid { + resolvedPack = await resolvedPackReference( + uuid: uuid, type: type, - worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(), - fileManager: fileManager + worldCollectionRootURL: worldFolderURL.deletingLastPathComponent() ) + } else { + resolvedPack = nil } let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack" - return ContentPackReference( - name: fallbackName, - type: type, - iconURL: resolvedPack?.iconURL, - uuid: uuid, - version: resolvedPack?.version ?? version, - source: .referencedByWorld + references.append( + ContentPackReference( + name: fallbackName, + type: type, + iconURL: resolvedPack?.iconURL, + uuid: uuid, + version: resolvedPack?.version ?? version, + source: .referencedByWorld + ) ) } + + return references } nonisolated private static func embeddedPackReferences( @@ -344,34 +417,22 @@ enum WorldScanner { } } - nonisolated private static func packReference( + nonisolated fileprivate static func packReference( fromPackFolder directoryURL: URL, type: MinecraftContentType, source: PackSource, fileManager: FileManager ) -> ContentPackReference? { - let manifestURL = directoryURL.appendingPathComponent("manifest.json") - guard - fileManager.fileExists(atPath: manifestURL.path), - let data = try? Data(contentsOf: manifestURL), - let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { + guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else { return nil } - let header = jsonObject["header"] as? [String: Any] - let name = ((header?["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { - $0.isEmpty ? nil : $0 - } ?? directoryURL.lastPathComponent - let uuid = (header?["uuid"] as? String)?.lowercased() - let version = versionString(from: header?["version"]) - return ContentPackReference( - name: name, + name: metadata.name, type: type, iconURL: packIconURL(in: directoryURL, fileManager: fileManager), - uuid: uuid, - version: version, + uuid: metadata.uuid, + version: metadata.version, source: source ) } @@ -379,37 +440,17 @@ enum WorldScanner { nonisolated private static func resolvedPackReference( uuid: String, type: MinecraftContentType, - worldCollectionRootURL: URL, - fileManager: FileManager - ) -> ContentPackReference? { + worldCollectionRootURL: URL + ) async -> ContentPackReference? { let siblingCollectionURL = worldCollectionRootURL .deletingLastPathComponent() .appendingPathComponent(type.collectionFolderName, isDirectory: true) - guard - fileManager.fileExists(atPath: siblingCollectionURL.path), - let childDirectories = try? immediateChildDirectories(of: siblingCollectionURL, fileManager: fileManager) - else { - return nil - } - - for childDirectory in childDirectories { - guard - let reference = packReference( - fromPackFolder: childDirectory, - type: type, - source: .foundInCollection, - fileManager: fileManager - ), - reference.uuid == uuid - else { - continue - } - - return reference - } - - return nil + return await packReferenceIndexStore.reference( + forUUID: uuid, + type: type, + in: siblingCollectionURL + ) } nonisolated private static func versionString(from value: Any?) -> String? { @@ -434,6 +475,28 @@ enum WorldScanner { return nil } + nonisolated private static func manifestMetadata(in directoryURL: URL, fileManager: FileManager) -> ManifestMetadata? { + let manifestURL = directoryURL.appendingPathComponent("manifest.json") + guard + fileManager.fileExists(atPath: manifestURL.path), + let data = try? Data(contentsOf: manifestURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let header = jsonObject["header"] as? [String: Any] + else { + return nil + } + + let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { + $0.isEmpty ? nil : $0 + } ?? directoryURL.lastPathComponent + + return ManifestMetadata( + name: name, + uuid: (header["uuid"] as? String)?.lowercased(), + version: versionString(from: header["version"]) + ) + } + nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] { var seen = Set() var uniqueReferences: [ContentPackReference] = [] @@ -457,3 +520,62 @@ enum WorldScanner { } } } + +private struct ManifestMetadata { + let name: String + let uuid: String? + let version: String? +} + +private actor PackReferenceIndexStore { + private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:] + + func reset(for sourceRootURL: URL) { + let sourceRootPath = sourceRootURL.standardizedFileURL.path + referencesByCollectionURL = referencesByCollectionURL.filter { collectionURL, _ in + !collectionURL.standardizedFileURL.path.hasPrefix(sourceRootPath + "/") + } + } + + func reference(forUUID uuid: String, type: MinecraftContentType, in collectionURL: URL) -> ContentPackReference? { + let normalizedCollectionURL = collectionURL.standardizedFileURL + + if let cachedReferences = referencesByCollectionURL[normalizedCollectionURL] { + return cachedReferences[uuid] + } + + let fileManager = FileManager.default + guard + fileManager.fileExists(atPath: normalizedCollectionURL.path), + let childDirectories = try? WorldScanner.immediateChildDirectories( + of: normalizedCollectionURL, + fileManager: fileManager + ) + else { + referencesByCollectionURL[normalizedCollectionURL] = [:] + return nil + } + + var referencesByUUID: [String: ContentPackReference] = [:] + for childDirectory in childDirectories { + guard + let reference = WorldScanner.packReference( + fromPackFolder: childDirectory, + type: type, + source: .foundInCollection, + fileManager: fileManager + ), + let referenceUUID = reference.uuid + else { + continue + } + + referencesByUUID[referenceUUID] = reference + } + + referencesByCollectionURL[normalizedCollectionURL] = referencesByUUID + return referencesByUUID[uuid] + } +} + +private let packReferenceIndexStore = PackReferenceIndexStore() diff --git a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift index 2631a6a..3f5a5f2 100644 --- a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift +++ b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift @@ -15,6 +15,7 @@ struct World_Manager_for_MinecraftApp: App { .tint(Color("AccentColor")) .background(WindowChromeConfigurator()) } + .defaultSize(width: 1520, height: 980) .windowStyle(.hiddenTitleBar) .windowToolbarStyle(.unified(showsTitle: false)) } diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index 796cfb0..5b74bec 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -5,13 +5,157 @@ // Created by John Burwell on 2026-05-25. // +import Foundation import Testing @testable import World_Manager_for_Minecraft +@MainActor struct World_Manager_for_MinecraftTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @Test func packIdentityUsesUUIDAndVersion() async throws { + let first = PackIdentity( + type: .behaviorPack, + uuid: "ABC-123", + version: "1.0.0", + fallbackName: "Pack A", + fallbackLocationHint: "behavior_packs/pack-a" + ) + let second = PackIdentity( + type: .behaviorPack, + uuid: "abc-123", + version: "1.0.0", + fallbackName: "Different Name", + fallbackLocationHint: "minecraftWorlds/world/behavior_packs/copy" + ) + let third = PackIdentity( + type: .behaviorPack, + uuid: "abc-123", + version: "2.0.0", + fallbackName: "Pack A", + fallbackLocationHint: "behavior_packs/pack-a-v2" + ) + + #expect(first == second) + #expect(first != third) + #expect(first.isSuspicious == false) + } + + @Test func minecraftSourceItemsUseLogicalPackRepresentative() async throws { + let sourceURL = URL(fileURLWithPath: "/tmp/source") + let worldURL = sourceURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true) + let topLevelPackURL = sourceURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true) + let embeddedPackURL = worldURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true) + + let world = MinecraftContentItem( + folderURL: worldURL, + folderName: "WorldA", + contentType: .world, + collectionRootURL: sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true) + ) + let topLevelPack = MinecraftContentItem( + folderURL: topLevelPackURL, + folderName: "PackA", + contentType: .behaviorPack, + collectionRootURL: sourceURL.appendingPathComponent("behavior_packs", isDirectory: true), + displayName: "Pack A" + ) + let embeddedPack = MinecraftContentItem( + folderURL: embeddedPackURL, + folderName: "PackA", + contentType: .behaviorPack, + collectionRootURL: worldURL.appendingPathComponent("behavior_packs", isDirectory: true), + displayName: "Pack A" + ) + let packID = PackIdentity( + type: .behaviorPack, + uuid: "pack-a", + version: "1.0.0", + fallbackName: "Pack A", + fallbackLocationHint: "behavior_packs/PackA" + ) + + var source = MinecraftSource(folderURL: sourceURL) + source.rawItems = [world, topLevelPack, embeddedPack] + source.logicalWorlds = [ + LogicalWorld( + id: world.id, + itemID: world.id, + usedPackIDs: [packID], + unresolvedReferences: [] + ) + ] + source.logicalPacks = [ + LogicalPack( + id: packID, + contentType: .behaviorPack, + displayName: "Pack A", + uuid: "pack-a", + version: "1.0.0", + representativeItemID: topLevelPack.id, + instanceItemIDs: [topLevelPack.id, embeddedPack.id], + isSuspicious: false + ) + ] + + let displayedItems = source.items + + #expect(displayedItems.count == 2) + #expect(displayedItems.contains(where: { $0.id == world.id })) + #expect(displayedItems.contains(where: { $0.id == topLevelPack.id })) + #expect(displayedItems.contains(where: { $0.id == embeddedPack.id }) == false) + } + + @Test func worldScannerResolvesReferencedPackFromIndexedCollection() async throws { + let fileManager = FileManager.default + let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let worldsURL = sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true) + let worldURL = worldsURL.appendingPathComponent("WorldA", isDirectory: true) + let packsURL = sourceURL.appendingPathComponent("behavior_packs", isDirectory: true) + let packURL = packsURL.appendingPathComponent("PackA", isDirectory: true) + + try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: sourceURL) } + + let manifest = """ + { + "header": { + "name": "Pack A", + "uuid": "pack-a", + "version": [1, 0, 0] + } + } + """ + let worldReference = """ + [ + { + "pack_id": "pack-a", + "version": [1, 0, 0] + } + ] + """ + + try manifest.write(to: packURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8) + try worldReference.write( + to: worldURL.appendingPathComponent("world_behavior_packs.json"), + atomically: true, + encoding: .utf8 + ) + + let world = MinecraftContentItem( + folderURL: worldURL, + folderName: "WorldA", + contentType: .world, + collectionRootURL: worldsURL + ) + + await WorldScanner.beginScanSession(for: sourceURL) + let enrichedWorld = await WorldScanner.enrich(item: world) + + #expect(enrichedWorld.packReferences.count == 1) + #expect(enrichedWorld.packReferences.first?.name == "Pack A") + #expect(enrichedWorld.packReferences.first?.uuid == "pack-a") + #expect(enrichedWorld.packReferences.first?.version == "1.0.0") } }