// SPDX-FileCopyrightText: 2026 John Burwell and contributors // SPDX-License-Identifier: AGPL-3.0-or-later import Foundation nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL var edition: MinecraftEdition var providerID: PlatformProviderID var origin: MinecraftSourceOrigin var accessDescriptor: SourceAccessDescriptor var accessStatus: SourceAccessStatus var availability: SourceAvailability var capabilities: SourceCapabilities var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] var displayItemCountsByType: [MinecraftContentType: Int] var displayItemCountsByKind: [MinecraftContentKind: Int] 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.edition = resolvedOrigin.defaultEdition self.providerID = resolvedOrigin.defaultAccessorIdentifier self.origin = resolvedOrigin self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor( accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier, kind: resolvedOrigin.kind, refreshStrategy: resolvedOrigin.defaultRefreshStrategy ) self.accessStatus = resolvedOrigin.defaultAccessStatus(displayName: normalizedFolderURL.lastPathComponent) self.availability = availability self.capabilities = resolvedOrigin.defaultCapabilities self.bookmarkData = bookmarkData self.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] self.displayItemCountsByType = [:] self.displayItemCountsByKind = [:] 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 items: [MinecraftContentItem] { displayItems } func items(for contentType: MinecraftContentType) -> [MinecraftContentItem] { displayItems.filter { $0.contentType == contentType } } func items(matching selection: SidebarSelection?) -> [MinecraftContentItem] { guard let selection else { return [] } switch selection { case .sourceCandidate, .connectedDevice: return [] case .source(let sourceID), .allContent(let sourceID): guard sourceID == id else { return [] } return displayItems case .contentType(let sourceID, let contentType): guard sourceID == id else { return [] } return items(for: contentType) case .contentKind(let sourceID, let contentKind): guard sourceID == id else { return [] } return displayItems.filter { $0.contentKind == contentKind } } } 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: MinecraftContentItem.displaySort) } 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 { nonisolated 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 } }