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.
204 lines
7.4 KiB
204 lines
7.4 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 }
|
|
}
|
|
|
|
/// Dateien ohne Format im Namen, für die noch keine Wahl getroffen ist.
|
|
var itemsMissingFormat: [DropItem] {
|
|
items.filter { $0.isMissingFormat }
|
|
}
|
|
|
|
var finishedCount: Int {
|
|
items.filter {
|
|
switch $0.status {
|
|
case .waiting, .running: return false
|
|
default: return true
|
|
}
|
|
}.count
|
|
}
|
|
|
|
/// 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 setCropPlan(_ plan: CropPlan, for itemID: UUID) {
|
|
guard let index = items.firstIndex(where: { $0.id == itemID }) else { return }
|
|
items[index].cropPlan = plan
|
|
}
|
|
|
|
/// "4x5" an den Namen anhängen, wo kein Format drinsteht. Setzt einen
|
|
/// schon abgelehnten Eintrag zurück auf "wartet".
|
|
func setAppendFormat(_ append: Bool, for itemIDs: [UUID]) {
|
|
for id in itemIDs {
|
|
guard let index = items.firstIndex(where: { $0.id == id }) else { continue }
|
|
items[index].appendFormatWhenMissing = append
|
|
if case .rejected(.formatSegmentMissing) = items[index].status {
|
|
items[index].status = .waiting
|
|
}
|
|
}
|
|
}
|
|
|
|
func remove(_ itemID: UUID) {
|
|
guard !isRunning else { return }
|
|
items.removeAll { $0.id == itemID }
|
|
}
|
|
|
|
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,
|
|
appendFormatWhenMissing: item.appendFormatWhenMissing
|
|
),
|
|
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,
|
|
cropPlan: $0.cropPlan,
|
|
appendFormatWhenMissing: $0.appendFormatWhenMissing
|
|
)
|
|
}
|
|
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
|
|
}
|
|
}
|