You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
drop.two/Sources/DropTwo/Model/ConversionCoordinator.swift

157 lines
5.9 KiB

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<Void, Never>?
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<UUID>() : 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<UUID>) {
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
}
}

Powered by TurnKey Linux.