// // MinecraftSource.swift // World Manager for Minecraft // // Created by John Burwell on 2026-05-25. // import Foundation enum SourceScanPhase { case discovering case metadata case previews case sizing case completed case idle } struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL var origin: MinecraftSourceOrigin var accessDescriptor: SourceAccessDescriptor var availability: SourceAvailability var bookmarkData: Data? var displayName: String 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? var scanDiagnostic: String? var scanProgress: Double? var indexedItemCount: Int var indexedDetailCount: Int var previewLoadedCount: Int var sizeLoadedCount: Int var previewStageElapsed: TimeInterval? var previewStageDuration: TimeInterval? var sizeStageElapsed: TimeInterval? var sizeStageDuration: TimeInterval? var lastScanDate: Date? nonisolated init( sourceID: URL? = nil, folderURL: URL, bookmarkData: Data? = nil, origin: MinecraftSourceOrigin? = nil, accessDescriptor: SourceAccessDescriptor? = nil, availability: SourceAvailability = .unknown ) { let normalizedFolderURL = normalizedSourceURL(folderURL) let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData) self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL) self.folderURL = normalizedFolderURL self.origin = resolvedOrigin self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor( accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier, kind: resolvedOrigin.kind, refreshStrategy: resolvedOrigin.defaultRefreshStrategy ) self.availability = availability self.bookmarkData = bookmarkData self.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] self.rawItems = [] self.logicalPacks = [] self.logicalWorlds = [] self.packInstances = [] self.worldPackRelationships = [] self.snapshot = nil self.isScanning = false self.scanStatus = "" self.scanError = nil self.scanDiagnostic = nil self.scanProgress = nil self.indexedItemCount = 0 self.indexedDetailCount = 0 self.previewLoadedCount = 0 self.sizeLoadedCount = 0 self.previewStageElapsed = nil self.previewStageDuration = nil self.sizeStageElapsed = nil self.sizeStageDuration = nil self.lastScanDate = nil } var itemCount: Int { displayItems.count } var hasCachedContent: Bool { !displayItems.isEmpty || !rawItems.isEmpty || snapshot != nil } var isOfflineCached: Bool { availability != .available && hasCachedContent } var availabilityDisplayText: String { switch availability { case .available: return "Available" case .unknown: return "Checking availability" case .disconnected: return origin.kind == .connectedDevice ? "Device offline" : "Folder offline" case .limited: return origin.kind == .connectedDevice ? "Device access limited" : "Limited access" case .unavailable: return origin.kind == .connectedDevice ? "Device unavailable" : "Folder unavailable" } } var cachedAvailabilityDetailText: String? { guard isOfflineCached else { return nil } switch availability { case .disconnected: return origin.kind == .connectedDevice ? "Showing cached results until this device reconnects." : "Showing cached results until this folder becomes reachable again." case .limited: return origin.kind == .connectedDevice ? "Showing cached results until the device is unlocked and trusted." : "Showing cached results until full access is restored." case .unavailable, .unknown: return "Showing cached results while the source is unavailable." case .available: return nil } } var items: [MinecraftContentItem] { displayItems } var scanPhase: SourceScanPhase { guard isScanning else { if scanStatus.hasPrefix("Loaded ") || scanStatus == "No Minecraft items found." { return .completed } return .idle } if sizeLoadedCount > 0 { return .sizing } if previewLoadedCount > 0 { return .previews } if let scanProgress { if scanProgress >= 0.75 { return .sizing } if scanProgress >= 0.65 { return .previews } if scanProgress >= 0.1 { return .metadata } } if scanStatus.contains("Calculating sizes") { return .sizing } if scanStatus.contains("Loading previews") { return .previews } if scanStatus.contains("metadata") { return .metadata } return .discovering } var liveScanStatusTitle: String { guard isScanning else { return scanStatus } if indexedItemCount == 0 { return "Scanning Minecraft library..." } let discoveryIsComplete = (scanProgress ?? 0) >= 0.65 if !discoveryIsComplete { return "Discovering items..." } if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 { return "Preparing size calculations..." } if scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..." { return scanStatus } switch scanPhase { case .discovering, .metadata, .previews: return "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." case .sizing: return "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." case .completed: return indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(indexedDetailCount) items." case .idle: return scanStatus } } var showsIndeterminateScanActivityIndicator: Bool { guard isScanning else { return false } let discoveryIsComplete = (scanProgress ?? 0) >= 0.65 if !discoveryIsComplete { return true } if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 { return true } return scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..." } 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) } var sourceRecord: SourceRecord { SourceRecord( id: id, displayName: displayName, rootURL: folderURL, origin: origin, accessDescriptor: accessDescriptor, availability: availability, lastRefreshDate: lastScanDate ) } private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool { switch item.contentType { case .world, .behaviorPack, .resourcePack: return false case .skinPack, .worldTemplate: return true } } } nonisolated private func normalizedSourceURL(_ url: URL) -> URL { if url.isFileURL { return url.standardizedFileURL } return url.standardized } 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 } }