From 64a230a6676118d3c8cb3b02a63c4804e21ff0e8 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Mon, 14 Sep 2026 01:35:43 +0200 Subject: [PATCH] =?UTF-8?q?Overwrite-R=C3=BCckfrage=20statt=20stillem=20Sc?= =?UTF-8?q?heitern=20bei=20existierender=20Zieldatei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../DropTwo/Model/ConversionCoordinator.swift | 62 +++++++++++++++++-- Sources/DropTwo/Model/DropItem.swift | 9 ++- Sources/DropTwo/Model/OverwriteConflict.swift | 10 +++ Sources/DropTwo/Views/ContentView.swift | 20 ++++++ Sources/DropTwo/Views/FileListView.swift | 4 +- Sources/DropTwoCore/ConversionPipeline.swift | 13 ++++ .../ConversionPipelineTests.swift | 18 ++++++ 7 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 Sources/DropTwo/Model/OverwriteConflict.swift diff --git a/Sources/DropTwo/Model/ConversionCoordinator.swift b/Sources/DropTwo/Model/ConversionCoordinator.swift index 18668c4..3054722 100644 --- a/Sources/DropTwo/Model/ConversionCoordinator.swift +++ b/Sources/DropTwo/Model/ConversionCoordinator.swift @@ -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? + 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() : 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 } - 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) diff --git a/Sources/DropTwo/Model/DropItem.swift b/Sources/DropTwo/Model/DropItem.swift index 6411294..4d39736 100644 --- a/Sources/DropTwo/Model/DropItem.swift +++ b/Sources/DropTwo/Model/DropItem.swift @@ -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 } } diff --git a/Sources/DropTwo/Model/OverwriteConflict.swift b/Sources/DropTwo/Model/OverwriteConflict.swift new file mode 100644 index 0000000..bcf9b49 --- /dev/null +++ b/Sources/DropTwo/Model/OverwriteConflict.swift @@ -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 +} diff --git a/Sources/DropTwo/Views/ContentView.swift b/Sources/DropTwo/Views/ContentView.swift index 82e278d..7952d70 100644 --- a/Sources/DropTwo/Views/ContentView.swift +++ b/Sources/DropTwo/Views/ContentView.swift @@ -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?" } } diff --git a/Sources/DropTwo/Views/FileListView.swift b/Sources/DropTwo/Views/FileListView.swift index 652ee42..8973d6e 100644 --- a/Sources/DropTwo/Views/FileListView.swift +++ b/Sources/DropTwo/Views/FileListView.swift @@ -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: diff --git a/Sources/DropTwoCore/ConversionPipeline.swift b/Sources/DropTwoCore/ConversionPipeline.swift index 313f874..23619c6 100644 --- a/Sources/DropTwoCore/ConversionPipeline.swift +++ b/Sources/DropTwoCore/ConversionPipeline.swift @@ -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, diff --git a/Tests/DropTwoCoreTests/ConversionPipelineTests.swift b/Tests/DropTwoCoreTests/ConversionPipelineTests.swift index b202e3c..e9a9cb3 100644 --- a/Tests/DropTwoCoreTests/ConversionPipelineTests.swift +++ b/Tests/DropTwoCoreTests/ConversionPipelineTests.swift @@ -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)