Overwrite-Rückfrage statt stillem Scheitern bei existierender Zieldatei

Schließt eine MVP-Lücke: README verlangt "überschreiben nur nach
Rückfrage", bisher landete CropEngineError.destinationAlreadyExists
nur als Fehler in der Liste.

ConversionPipeline.previewDestination berechnet den Zielpfad einer
Datei, ohne irgendetwas anzufassen — damit kann die Oberfläche vor dem
Start prüfen, ob Konflikte bestehen, statt es mitten im Batch zu
entdecken. Bei Konflikten zeigt ConversionCoordinator einen Sammel-
Alert (Ersetzen/Überspringen/Abbrechen) für den ganzen Batch; eine
Datei-für-Datei-Auswahl wäre für das MVP unverhältnismäßig viel UI für
einen Randfall.

Neuer DropItem.Status-Fall .skippedByUser unterscheidet eine bewusste
Nutzerentscheidung von einem echten Fehler/Reject.
main
Samuel Zielke 1 month ago
parent b1d2fb1713
commit 64a230a667

@ -8,8 +8,12 @@ import Foundation
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 }
@ -19,7 +23,7 @@ final class ConversionCoordinator: ObservableObject {
return partial
case .running(let progress):
return partial + progress
case .skipped, .rejected, .succeeded, .failed:
case .skipped, .rejected, .succeeded, .failed, .skippedByUser:
return partial + 1
}
}
@ -44,19 +48,69 @@ final class ConversionCoordinator: ObservableObject {
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 }
isRunning = true
for index in items.indices {
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
}
let jobs = items.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) }
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)

@ -16,14 +16,17 @@ struct DropItem: Identifiable {
case rejected(RejectionReason)
case succeeded(croppedDestination: URL)
case failed(message: String)
/// Nutzer hat sich bei einer Overwrite-Rückfrage bewusst fürs
/// Überspringen entschieden — kein Fehler, aber sichtbar im Verlauf.
case skippedByUser(reason: String)
/// Fehlerliste statt stiller Abbrüche: alles außer "läuft"/"fertig"
/// braucht sichtbare Aufmerksamkeit.
/// Fehlerliste statt stiller Abbrüche: alles außer "läuft"/"fertig"/
/// "bewusst vom Nutzer übersprungen" braucht sichtbare Aufmerksamkeit.
var needsAttention: Bool {
switch self {
case .skipped, .rejected, .failed:
return true
case .waiting, .running, .succeeded:
case .waiting, .running, .succeeded, .skippedByUser:
return false
}
}

@ -0,0 +1,10 @@
import Foundation
/// Eine Datei, deren Zielpfad bereits existiert — Grundlage für die
/// Overwrite-Rückfrage vor dem eigentlichen Lauf.
struct OverwriteConflict: Identifiable {
let id = UUID()
let itemID: UUID
let fileName: String
let destination: URL
}

@ -44,5 +44,25 @@ struct ContentView: View {
.sheet(isPresented: $showSettings) {
SettingsView()
}
.alert(
"Zieldatei existiert bereits",
isPresented: Binding(
get: { !coordinator.pendingConflicts.isEmpty },
set: { isPresented in
if !isPresented { coordinator.cancelConflictResolution() }
}
)
) {
Button("Überspringen") { coordinator.resolveConflicts(overwrite: false) }
Button("Ersetzen", role: .destructive) { coordinator.resolveConflicts(overwrite: true) }
Button("Abbrechen", role: .cancel) { coordinator.cancelConflictResolution() }
} message: {
Text(conflictMessage)
}
}
private var conflictMessage: String {
let names = coordinator.pendingConflicts.map(\.fileName).joined(separator: ", ")
return "Für folgende Datei(en) existiert die Zieldatei schon: \(names). Ersetzen oder überspringen?"
}
}

@ -36,6 +36,8 @@ struct FileListView: View {
Text("Fertig — \(destination.lastPathComponent)")
case .failed(let message):
Text(message)
case .skippedByUser(let reason):
Text(reason)
}
}
@ -48,7 +50,7 @@ struct FileListView: View {
case .running(let progress):
ProgressView(value: progress)
.frame(width: 60)
case .skipped:
case .skipped, .skippedByUser:
Image(systemName: "arrow.uturn.forward.circle")
.foregroundStyle(.secondary)
case .rejected, .failed:

@ -31,6 +31,19 @@ public enum ConversionPipeline {
public let croppedInfo: VideoInfo
}
/// Berechnet nur, wohin die 4:5-Fassung geschrieben würde — ohne
/// irgendetwas anzufassen. `nil`, wenn der Dateiname nicht geparst werden
/// kann (dann übernimmt `process` die Skip/Reject-Meldung). Für die
/// Oberfläche gedacht: vor dem eigentlichen Lauf prüfen, ob eine Zieldatei
/// schon existiert, und bei Bedarf eine Rückfrage stellen.
public static func previewDestination(sourceURL: URL, outputRootDirectory: URL) -> URL? {
guard case .parsed(let parsed) = FileNameParser.parse(sourceURL.lastPathComponent).outcome else {
return nil
}
let projectDirectory = outputRootDirectory.appendingPathComponent(parsed.projectName, isDirectory: true)
return projectDirectory.appendingPathComponent(mp4FileName(from: parsed.targetFileName))
}
public static func process(
sourceURL: URL,
outputRootDirectory: URL,

@ -15,6 +15,24 @@ final class ConversionPipelineTests: XCTestCase {
try? FileManager.default.removeItem(at: tempDirectory)
}
func testPreviewDestinationMatchesActualOutput() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory)
let preview = ConversionPipeline.previewDestination(sourceURL: clip.url, outputRootDirectory: outputDirectory)
let outcome = await ConversionPipeline.process(sourceURL: clip.url, outputRootDirectory: outputDirectory)
guard case .succeeded(let success) = outcome else {
return XCTFail("Erwartete .succeeded, bekam \(outcome)")
}
XCTAssertEqual(preview, success.croppedDestination)
}
func testPreviewDestinationIsNilForUnparsableNames() {
let sourceURL = tempDirectory.appendingPathComponent("SOMOS-v3-DE.mov")
XCTAssertNil(ConversionPipeline.previewDestination(sourceURL: sourceURL, outputRootDirectory: outputDirectory))
}
func testProcessesValidFileEndToEnd() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory)

Loading…
Cancel
Save

Powered by TurnKey Linux.