import DropTwoCore import Foundation /// Treibt die Verarbeitung der Dateiliste über `JobQueue` an und hält den /// UI-Status pro Datei nach. Kennt keine Views — nur `@Published`-Zustand, /// den SwiftUI beobachtet. @MainActor final class ConversionCoordinator: ObservableObject { @Published private(set) var items: [DropItem] = [] @Published private(set) var isRunning = false /// Nicht-leer, solange eine Overwrite-Rückfrage aussteht — die View zeigt /// dann einen Alert und ruft `resolveConflicts`/`cancelConflictResolution`. @Published private(set) var pendingConflicts: [OverwriteConflict] = [] private var runningTask: Task? private var pendingTargetFolder: URL? var overallProgress: Double { guard !items.isEmpty else { return 0 } let sum = items.reduce(0.0) { partial, item in switch item.status { case .waiting: return partial case .running(let progress): return partial + progress case .skipped, .rejected, .succeeded, .failed, .skippedByUser: return partial + 1 } } return sum / Double(items.count) } var problemItems: [DropItem] { items.filter { $0.status.needsAttention } } /// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst. func addFiles(_ urls: [URL]) { let existing = Set(items.map(\.sourceURL)) let newURLs = urls .filter { !existing.contains($0) } .sorted { modificationDate(for: $0) > modificationDate(for: $1) } items.append(contentsOf: newURLs.map(DropItem.init)) } func removeAll() { guard !isRunning else { return } items.removeAll() } /// Prüft vor dem eigentlichen Lauf, ob Zieldateien schon existieren, und /// stellt bei Konflikten erst eine Rückfrage statt sie mitten im Batch als /// Fehler auffliegen zu lassen (README: "überschreiben nur nach Rückfrage"). func start(targetFolder: URL) { guard !isRunning, !items.isEmpty else { return } let conflicts = items.compactMap { item -> OverwriteConflict? in guard let destination = ConversionPipeline.previewDestination(sourceURL: item.sourceURL, outputRootDirectory: targetFolder), FileManager.default.fileExists(atPath: destination.path) else { return nil } return OverwriteConflict(itemID: item.id, fileName: item.fileName, destination: destination) } guard conflicts.isEmpty else { pendingTargetFolder = targetFolder pendingConflicts = conflicts return } runBatch(targetFolder: targetFolder, overwrite: false, skipping: []) } /// Antwort auf die Overwrite-Rückfrage: `overwrite` gilt für den ganzen /// Batch (bewusst keine Datei-für-Datei-Auswahl fürs MVP). func resolveConflicts(overwrite: Bool) { guard let targetFolder = pendingTargetFolder else { return } let skipIDs = overwrite ? Set() : Set(pendingConflicts.map(\.itemID)) for id in skipIDs { guard let index = items.firstIndex(where: { $0.id == id }) else { continue } items[index].status = .skippedByUser(reason: String(localized: "Zieldatei existiert bereits.")) } pendingConflicts = [] pendingTargetFolder = nil runBatch(targetFolder: targetFolder, overwrite: overwrite, skipping: skipIDs) } func cancelConflictResolution() { pendingConflicts = [] pendingTargetFolder = nil } private func runBatch(targetFolder: URL, overwrite: Bool, skipping skipIDs: Set) { let jobs = items .filter { !skipIDs.contains($0.id) } .map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) } guard !jobs.isEmpty else { return } isRunning = true for index in items.indices where !skipIDs.contains(items[index].id) { items[index].status = .waiting } var options = ConversionPipeline.Options() options.cropOptions.overwrite = overwrite runningTask = Task { let stream = JobQueue.run( jobs: jobs, outputRootDirectory: targetFolder, options: options, progress: { [weak self] job, value in Task { @MainActor in self?.updateProgress(jobID: job.id, value: value) } } ) for await result in stream { apply(result) } isRunning = false } } private func updateProgress(jobID: UUID, value: Double) { guard let index = items.firstIndex(where: { $0.id == jobID }) else { return } items[index].status = .running(progress: value) } private func apply(_ result: JobQueue.JobResult) { guard let index = items.firstIndex(where: { $0.id == result.job.id }) else { return } switch result.outcome { case .skipped(_, let reason): items[index].status = .skipped(reason) case .rejected(_, let reason): items[index].status = .rejected(reason) case .succeeded(let success): items[index].status = .succeeded(croppedDestination: success.croppedDestination) case .failed(_, let error): items[index].status = .failed(message: Self.message(for: error)) } } private static func message(for error: Error) -> String { if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { return description } return error.localizedDescription } private func modificationDate(for url: URL) -> Date { (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast } }