world-manager/World Manager for Minecraft/Services/SourceLibrary.swift

324 lines
9.6 KiB
Swift

//
// SourceLibrary.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Combine
import Foundation
struct SidebarFooterState {
enum Style {
case idle
case inProgress
case failure
case success
}
let style: Style
let title: String
let subtitle: String?
let revealURL: URL?
}
@MainActor
final class SourceLibrary: ObservableObject {
@Published var sources: [MinecraftSource] = []
@Published private(set) var sidebarFooterState = SidebarFooterState(
style: .idle,
title: "",
subtitle: nil,
revealURL: nil
)
private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>?
func addSource(at url: URL) -> URL {
let normalizedURL = url.standardizedFileURL
if sources.contains(where: { $0.id == normalizedURL }) {
startScan(for: normalizedURL)
return normalizedURL
}
sources.append(MinecraftSource(folderURL: normalizedURL))
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
startScan(for: normalizedURL)
return normalizedURL
}
func source(withID sourceID: URL) -> MinecraftSource? {
sources.first(where: { $0.id == sourceID })
}
func rescanSource(withID sourceID: URL) {
startScan(for: sourceID)
}
func removeSource(withID sourceID: URL) {
scanTasks[sourceID]?.cancel()
scanTasks[sourceID] = nil
sources.removeAll { $0.id == sourceID }
refreshSidebarFooterState()
}
func setItemActionInProgress(_ description: String) {
cancelFooterReset()
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: description,
subtitle: nil,
revealURL: nil
)
}
func setItemActionFailure(_ message: String) {
sidebarFooterState = SidebarFooterState(
style: .failure,
title: "Action Failed",
subtitle: message,
revealURL: nil
)
scheduleFooterReset()
}
func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) {
sidebarFooterState = SidebarFooterState(
style: .success,
title: title,
subtitle: subtitle,
revealURL: revealURL
)
scheduleFooterReset()
}
var activeScanSummary: String? {
let scanningSources = sources.filter(\.isScanning)
guard !scanningSources.isEmpty else {
return nil
}
if scanningSources.count == 1, let source = scanningSources.first {
return "\(source.displayName): \(source.scanStatus)"
}
return "Scanning \(scanningSources.count) sources..."
}
private func startScan(for sourceID: URL) {
scanTasks[sourceID]?.cancel()
let task = Task { [weak self] in
guard let self else {
return
}
await self.scanSource(withID: sourceID)
}
scanTasks[sourceID] = task
}
private func scanSource(withID sourceID: URL) async {
updateSource(sourceID) { source in
source.isScanning = true
source.scanError = nil
source.scanStatus = "Scanning Minecraft library..."
source.items = []
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 discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
let discoveryTask = Task.detached(priority: .userInitiated) {
do {
_ = try WorldScanner.discoverItems(in: sourceID) { item in
continuation.yield(item)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
discoveryTask.cancel()
}
}
var discoveredCount = 0
for try await item in discoveryStream {
guard !Task.isCancelled else {
break
}
discoveredCount += 1
updateSource(sourceID) { source in
source.items.append(item)
source.indexedItemCount = discoveredCount
source.scanStatus = "Found \(discoveredCount) items. Loading details..."
}
refreshSidebarFooterState()
await enrichmentTracker.beginEnrichment()
let tracker = enrichmentTracker
Task.detached(priority: .utility) {
let enrichedItem = WorldScanner.enrich(item: item)
await applyEnrichedItem(enrichedItem)
await tracker.finishEnrichment()
}
}
await enrichmentTracker.markDiscoveryFinished()
await enrichmentTracker.waitForCompletion()
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()
}
refreshSidebarFooterState()
} catch {
guard !Task.isCancelled else {
return
}
updateSource(sourceID) { source in
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
source.scanStatus = ""
source.isScanning = false
}
refreshSidebarFooterState()
}
scanTasks[sourceID] = nil
}
private func handleEnrichedItem(_ enrichedItem: MinecraftContentItem, for sourceID: URL) {
updateSource(sourceID) { source in
guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else {
return
}
source.items[index] = enrichedItem
source.indexedDetailCount += 1
if source.indexedDetailCount < source.indexedItemCount {
source.scanStatus = "Loaded details for \(source.indexedDetailCount) of \(source.indexedItemCount) items..."
}
}
refreshSidebarFooterState()
}
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
return
}
mutate(&sources[index])
}
private func refreshSidebarFooterState() {
let scanningSources = sources.filter(\.isScanning)
if let source = scanningSources.first {
cancelFooterReset()
let subtitle: String
if source.indexedItemCount > 0 {
subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
} else {
subtitle = "Searching \(source.displayName)"
}
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: "Scanning...",
subtitle: subtitle,
revealURL: nil
)
return
}
if let source = sources.first(where: { $0.scanError != nil }) {
sidebarFooterState = SidebarFooterState(
style: .failure,
title: "Scan failed",
subtitle: source.scanError,
revealURL: nil
)
scheduleFooterReset()
return
}
cancelFooterReset()
sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, revealURL: nil)
}
private func cancelFooterReset() {
footerResetTask?.cancel()
footerResetTask = nil
}
private func scheduleFooterReset(after seconds: Double = 5) {
cancelFooterReset()
footerResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(seconds))
guard let self, !Task.isCancelled else {
return
}
self.refreshSidebarFooterState()
}
}
}
private actor PendingEnrichmentTracker {
private var pendingCount = 0
private var discoveryFinished = false
private var continuation: CheckedContinuation<Void, Never>?
func beginEnrichment() {
pendingCount += 1
}
func finishEnrichment() {
pendingCount -= 1
resumeIfNeeded()
}
func markDiscoveryFinished() {
discoveryFinished = true
resumeIfNeeded()
}
func waitForCompletion() async {
guard !(discoveryFinished && pendingCount == 0) else {
return
}
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
private func resumeIfNeeded() {
guard discoveryFinished, pendingCount == 0 else {
return
}
continuation?.resume()
continuation = nil
}
}