// SPDX-FileCopyrightText: 2026 John Burwell and contributors // SPDX-License-Identifier: AGPL-3.0-or-later import Foundation enum SourceRestoration { static func restoredSource( from record: PersistedSourceRecord, connectedDeviceDisplayName: (ConnectedDevice, DeviceAppContainer) -> String ) -> MinecraftSource { var source = MinecraftSource( sourceID: record.sourceID, folderURL: record.folderURL, bookmarkData: record.bookmarkData, origin: record.origin, accessDescriptor: record.accessDescriptor, availability: record.availability ) source.providerID = record.accessDescriptor.accessorIdentifier source.edition = edition(for: record.accessDescriptor, origin: record.origin) if case .connectedDevice(let device, let container) = source.origin { var repairedDevice = device repairedDevice.name = ConnectedDeviceSourcePolicy.preferredDeviceName( currentName: device.name, fallbackDeviceName: "", fallbackDisplayName: record.displayName ) source.origin = .connectedDevice(device: repairedDevice, container: container) let persistedDeviceName = record.displayName.components(separatedBy: " • ").first ?? record.displayName if ConnectedDeviceSourcePolicy.sanitizedDeviceName(persistedDeviceName) == nil { source.displayName = connectedDeviceDisplayName(repairedDevice, container) } else { source.displayName = record.displayName } } else { source.displayName = record.displayName } applyRestoredItemState( record.rawItems, lastScanDate: record.lastScanDate, snapshot: record.snapshot, to: &source ) source.scanStatus = restoredStatus(for: source) return source } static func applyRestoredItemState( _ items: [MinecraftContentItem], lastScanDate: Date?, snapshot: SourceSnapshot?, to source: inout MinecraftSource ) { source.rawItems = items source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in counts[item.contentType, default: 0] += 1 } source.displayItemCountsByKind = items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in counts[item.contentKind, default: 0] += 1 } source.indexedItemCount = items.count source.indexedDetailCount = items.filter(\.metadataLoaded).count source.previewLoadedCount = items.filter(\.previewLoaded).count source.sizeLoadedCount = items.filter(\.sizeLoaded).count source.lastScanDate = lastScanDate source.snapshot = snapshot } static func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] { var restoredItems: [MinecraftContentItem] = [] restoredItems.reserveCapacity(items.count) for var item in items { item.iconURL = await ImageCacheStore.shared.cachedImageURL(for: item.iconURL) item.packReferences = await restoreCachedImages(in: item.packReferences) restoredItems.append(item) } return restoredItems } static func restoreCachedImages(in references: [ContentPackReference]) async -> [ContentPackReference] { var restoredReferences: [ContentPackReference] = [] restoredReferences.reserveCapacity(references.count) for reference in references { let cachedIconURL = await ImageCacheStore.shared.cachedImageURL(for: reference.iconURL) restoredReferences.append( ContentPackReference( name: reference.name, type: reference.type, iconURL: cachedIconURL, uuid: reference.uuid, version: reference.version, source: reference.source ) ) } return restoredReferences } static func startupRefreshReason( for source: MinecraftSource, persistedRecord: PersistedSourceRecord?, currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> String? { guard source.availability == .available else { return nil } switch source.origin.kind { case .localFolder: guard let persistedRecord else { return nil } if needsRescan(persistedRecord, currentCollectionSnapshots: currentCollectionSnapshots) || reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots) { return defaultRefreshReason(for: source) } return nil case .connectedDevice: guard source.rawItems.isEmpty || source.lastScanDate == nil else { return nil } return defaultRefreshReason(for: source) } } static func needsReconcile( _ source: MinecraftSource, currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots) } private static func restoredStatus(for source: MinecraftSource) -> String { source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items." } private static func defaultRefreshReason(for source: MinecraftSource) -> String { source.hasCachedContent ? "Refreshing cached library..." : "Scanning Minecraft library..." } private static func needsRescan( _ record: PersistedSourceRecord, currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { guard record.accessDescriptor.refreshStrategy == .eagerFullScan else { return record.rawItems.isEmpty } guard let snapshot = record.snapshot else { return true } let sourceURL = record.folderURL guard FileManager.default.fileExists(atPath: sourceURL.path) else { return true } let edition = edition(for: record.accessDescriptor, origin: record.origin) return collectionsDiffer( currentCollectionSnapshots(sourceURL, edition), persistedCollections: snapshot.collectionSnapshots ) } private static func reconcileIsNeeded( _ source: MinecraftSource, currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { guard source.accessDescriptor.refreshStrategy == .eagerFullScan else { return source.rawItems.isEmpty } guard let snapshot = source.snapshot else { return true } let sourceURL = source.folderURL guard FileManager.default.fileExists(atPath: sourceURL.path) else { return false } return collectionsDiffer( currentCollectionSnapshots(sourceURL, source.edition), persistedCollections: snapshot.collectionSnapshots ) } private static func edition( for accessDescriptor: SourceAccessDescriptor, origin: MinecraftSourceOrigin ) -> MinecraftEdition { if accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier { return .java } return origin.defaultEdition } private static func collectionsDiffer( _ currentCollections: [CollectionSnapshot], persistedCollections: [CollectionSnapshot] ) -> Bool { let currentCollectionsByName = Dictionary(grouping: currentCollections, by: \.folderName) .mapValues { $0.map(\.fingerprint).sorted() } let persistedCollectionsByName = Dictionary(grouping: persistedCollections, by: \.folderName) .mapValues { $0.map(\.fingerprint).sorted() } if currentCollectionsByName.count != persistedCollectionsByName.count { return true } for (folderName, persistedFingerprints) in persistedCollectionsByName { guard let currentFingerprints = currentCollectionsByName[folderName], currentFingerprints == persistedFingerprints else { return true } } return false } }