diff --git a/BitMatch-iPad/Views/ModularContentView.swift b/BitMatch-iPad/Views/ModularContentView.swift index 4fe9c6c9..7a566508 100644 --- a/BitMatch-iPad/Views/ModularContentView.swift +++ b/BitMatch-iPad/Views/ModularContentView.swift @@ -306,6 +306,11 @@ struct SettingsSheetView: View { var body: some View { NavigationView { Form { + Section("File handling") { + AppleDoubleSelectionView(review: coordinator.appleDoubleSelection) + .disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer) + } + Section(GeneralSettingsPresentation.notificationsSection) { Toggle(GeneralSettingsPresentation.notifyAttention, isOn: $generalSettings.notifyWhenCardNeedsAttention) Toggle(GeneralSettingsPresentation.notifyFinish, isOn: $generalSettings.notifyWhenTransferOrQueueFinishes) diff --git a/BitMatch-iPadTests/ReportThumbnailTests.swift b/BitMatch-iPadTests/ReportThumbnailTests.swift index a2b4d0f3..6c48c825 100644 --- a/BitMatch-iPadTests/ReportThumbnailTests.swift +++ b/BitMatch-iPadTests/ReportThumbnailTests.swift @@ -173,7 +173,7 @@ struct ReportThumbnailTests { @Test func automaticExporterHonorsPreferenceWithoutPuttingImagesInJSON() async throws { let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } - var prefs = ReportPrefs(makeReport: true) + var prefs = ReportPrefs(notes: "Card 01 · keep until reviewed", makeReport: true, operatorName: "Alex & Sam") prefs.includeThumbnails = true prefs.verificationMode = .standard try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(), @@ -184,6 +184,8 @@ struct ReportThumbnailTests { let pdf = try #require(files.first { $0.pathExtension == "pdf" }) let text = try #require(PDFDocument(url: pdf)?.string) #expect(text.contains("Clip previews: 1/1")) + #expect(text.contains("Operator: Alex & Sam")) + #expect(text.contains(prefs.notes)) #expect(text.contains("VERIFICATION SUCCESSFUL")) let json = files.filter { $0.pathExtension == "json" } #expect(!json.isEmpty) @@ -191,6 +193,8 @@ struct ReportThumbnailTests { let value = try String(contentsOf: url, encoding: .utf8) #expect(!value.contains("images")) #expect(!value.contains("base64")) + #expect(value.contains("Alex & Sam")) + #expect(value.contains(prefs.notes)) } #expect(!text.contains("Performance Metrics")) #expect(!text.contains("Top File Types")) @@ -226,11 +230,16 @@ struct ReportThumbnailTests { source: "/card", destinations: [root.path], totalFiles: rows.count, matched: rows.count, issues: 0, workers: 1, appVersion: "test", osVersion: "test", client: "", production: "", company: "", verificationMethod: "Standard", totalBytesProcessed: row.size * Int64(rows.count), averageSpeed: 0, - clientLogoData: nil, companyLogoData: nil, photographyJob: nil, safetyState: .safeToErase) + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, operatorName: "Alex & Sam · " + String(repeating: "Long operator name ", count: 12), + notes: (0..<180).map { "NOTE-\($0)-END · retained handoff notes" }.joined(separator: "\n"), + safetyState: .safeToErase) let document = try #require(PDFDocument(data: ReportPDFRenderer.renderPDF(summary: summary, results: rows, thumbnails: previews))) #expect(document.pageCount > 1) + #expect(try #require(document.page(at: 0)?.string).contains("NOTE-0-END")) let text = try #require(document.string) for index in 0..<75 { #expect(text.components(separatedBy: "clip-\(index).mp4").count == 2) } + for index in 0..<180 { #expect(text.contains("NOTE-\(index)-END")) } + #expect(text.contains("Operator: Alex & Sam")) if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]), let data = document.dataRepresentation() { try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("preview-pagination.pdf")) diff --git a/BitMatch-iPadTests/TransferDetailsReportTests.swift b/BitMatch-iPadTests/TransferDetailsReportTests.swift new file mode 100644 index 00000000..94a144b9 --- /dev/null +++ b/BitMatch-iPadTests/TransferDetailsReportTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +import PDFKit +@testable import BitMatch_iPad +import BitMatchEngine + +@MainActor +struct TransferDetailsReportTests { + @Test func historyExportsKeepSavedDetailsAndUnsafeVerdict() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("details-history-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(notes: "Keep card, backup B missing", operatorName: "Alex, \"DIT\"")) + record.state = .issues + record.summary = "Transfer incomplete" + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.failed.statusText, size: 5, + checksum: nil, destination: "Backup")] + let json = try TransferHistoryDocument(record: record, asCSV: false).data + let object = try #require(JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(object["operatorName"] as? String == record.reportSettings.operatorName) + #expect(object["notes"] as? String == record.reportSettings.notes) + #expect(object["state"] as? String == LocalTransferState.issues.rawValue) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("\"operatorName\",\"Alex, \"\"DIT\"\"\"")) + #expect(csv.contains(record.reportSettings.notes)) + let pdfData = try await ReportExporter.historyPDF(record: record, includeThumbnails: false) + let pdf = try #require(PDFDocument(data: pdfData)) + if let path = ProcessInfo.processInfo.environment["BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] + ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] { + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try pdfData.write(to: directory.appendingPathComponent("transfer-details-history.pdf")) + } + let text = try #require(pdf.string) + #expect(text.contains("Operator: Alex")) + #expect(text.contains(record.reportSettings.notes)) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(record.state == .issues) + } + + @Test func excludedMetadataIsDisclosedWithoutInventingFileErrors() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("excluded-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues; record.summary = "Selected files verified" + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 42, checksum: nil, destination: "Backup")] + let document = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(document.string) + #expect(text.contains("0 file issues")) + #expect(!text.contains("Issues Detail")) + #expect(text.contains("1 intentionally excluded result")) + #expect(text.contains("AppleDouble metadata was intentionally excluded")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + } + + @Test func csvDetailsCannotBecomeSpreadsheetFormulas() throws { + let root = FileManager.default.temporaryDirectory + let record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [], verificationMode: .standard, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(notes: " =1+1", operatorName: "@SUM(1,2)")) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("'@SUM(1,2)")) + #expect(csv.contains("' =1+1")) + let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) + #expect(json["operatorName"] as? String == "@SUM(1,2)") + } + + @Test func rememberedDetailsRoundTripOnThisPlatform() throws { + let name = "BitMatch.DetailsTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let store = ReportPrefsStore(defaults: defaults) + store.save(ReportPrefs(notes: "Next card", operatorName: "Alex")) + #expect(store.load().operatorName == "Alex") + #expect(store.load().notes == "Next card") + var cleared = store.load(); cleared.operatorName = ""; cleared.notes = ""; store.save(cleared) + #expect(store.load().operatorName.isEmpty) + #expect(store.load().notes.isEmpty) + } +} diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 7d9ecb94..ff93484a 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -142,6 +142,28 @@ final class WorkflowSnapshotTests: XCTestCase { } } + func testCaptureAppleDoubleSettings() throws { + try requireCaptureConfiguration() + let suite = "AppleDoubleCapture-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let review = AppleDoubleReviewModel(defaults: defaults) + XCTAssertTrue(review.enabled) + for width in [320.0, 500.0, 820.0] { + try capture(Form { Section("File handling") { AppleDoubleSelectionView(review: review) } }, + size: CGSize(width: width, height: 300), name: "mobile-appledouble-settings-\(Int(width))") + } + } + + func testCaptureTransferDetails() throws { + try requireCaptureConfiguration() + let prefs = ReportPrefs(notes: "Card 01 · backups to Alex.\nKeep the source until both drives are checked.", operatorName: "Alex Morgan") + for width in [320.0, 500.0, 820.0] { + try capture(TransferDetailsSection(prefs: .constant(prefs), isExpanded: true).padding(), + size: CGSize(width: width, height: 400), name: "mobile-transfer-details-\(Int(width))") + } + } + private func requireCaptureConfiguration() throws { let environment = ProcessInfo.processInfo.environment let enabled = environment["BITMATCH_CAPTURE_WORKFLOW_SNAPSHOTS"] == "1" diff --git a/BitMatch/Views/PreferencesWindow.swift b/BitMatch/Views/PreferencesWindow.swift index 41095cd2..12bf0d09 100644 --- a/BitMatch/Views/PreferencesWindow.swift +++ b/BitMatch/Views/PreferencesWindow.swift @@ -143,6 +143,11 @@ struct PreferencesWindow: View { Toggle(GeneralSettingsPresentation.playSounds, isOn: $generalSettings.playSounds) } + Section("File handling") { + AppleDoubleSelectionView(review: coordinator.appleDoubleSelection) + .disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer) + } + Section("Updates") { Toggle( "Check for updates automatically", @@ -221,6 +226,7 @@ struct PreferencesWindow: View { if coordinator.reportSettings.makeReport { Section { + reportField("Operator", text: $coordinator.reportSettings.operatorName, prompt: "Operator name") reportField("Client", text: $coordinator.reportSettings.clientName, prompt: "Client name") reportField("Project", text: $coordinator.reportSettings.projectName, prompt: "Project name") reportField("Production", text: $coordinator.reportSettings.production, prompt: "Production title") @@ -370,6 +376,7 @@ private extension PreferencesWindow { prefs.projectName = "" prefs.production = "" prefs.company = "" + prefs.operatorName = "" prefs.notes = "" coordinator.reportSettings = prefs } diff --git a/BitMatchTests/AppleDoubleWorkflowTests.swift b/BitMatchTests/AppleDoubleWorkflowTests.swift index ab333d7d..e4db51ea 100644 --- a/BitMatchTests/AppleDoubleWorkflowTests.swift +++ b/BitMatchTests/AppleDoubleWorkflowTests.swift @@ -106,6 +106,7 @@ final class AppleDoubleWorkflowTests: XCTestCase { let journal = LocalTransferJournal(fileURL: folders.journalURL) let id = try journal.enqueue(sourceURL: folders.source, destinationURLs: [folders.primary, folders.secondary], verificationMode: .standard, cameraSettings: settings, reportSettings: reports, generateASCMHL: false) + folders.defaults.set(false, forKey: "BitMatchExcludeAppleDouble") let coordinator = SharedAppCoordinator(platformManager: MacOSPlatformManager.shared, transferJournal: journal, defaults: folders.defaults) XCTAssertFalse(coordinator.appleDoubleSelection.enabled) @@ -125,6 +126,33 @@ final class AppleDoubleWorkflowTests: XCTestCase { } } + func testQueuedPreservePolicySurvivesNewIgnoreDefault() async throws { + try await FileOperationsTestLock.shared.run { + let folders = try CoordinatorFolders(); defer { folders.cleanup() } + try seed(folders.source) + var reports = ReportPrefs(); reports.makeReport = false + let journal = LocalTransferJournal(fileURL: folders.journalURL) + let id = try journal.enqueue(sourceURL: folders.source, destinationURLs: [folders.primary], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: reports, + generateASCMHL: false) + let coordinator = SharedAppCoordinator(platformManager: MacOSPlatformManager.shared, + transferJournal: journal, defaults: folders.defaults) + XCTAssertTrue(coordinator.appleDoubleSelection.enabled) + coordinator.startQueue() + let finished = await waitUntil(timeout: .seconds(10)) { + journal.records.first(where: { $0.id == id })?.state == .completed && !coordinator.isOperationInProgress + } + if !finished { coordinator.cancelOperation() } + XCTAssertTrue(finished, coordinator.queueMessage ?? "Queue did not finish") + let record = try XCTUnwrap(journal.records.first { $0.id == id }) + XCTAssertNil(record.cameraSettings.excludedAppleDoublePaths) + XCTAssertEqual(record.results.count, 3) + XCTAssertTrue(record.results.allSatisfy(\.isVerifiedStatus)) + XCTAssertTrue(coordinator.appleDoubleSelection.enabled) + XCTAssertNil(folders.defaults.object(forKey: "BitMatchExcludeAppleDouble")) + } + } + func testCancelAfterExclusionRowsKeepsPartialEvidenceAndNeverCompletes() async throws { try await FileOperationsTestLock.shared.run { let folders = try CoordinatorFolders(); defer { folders.cleanup() } @@ -165,15 +193,18 @@ final class AppleDoubleWorkflowTests: XCTestCase { let folders = try CoordinatorFolders(); defer { folders.cleanup() } try seed(folders.source) let review = AppleDoubleReviewModel(defaults: folders.defaults) - XCTAssertFalse(review.enabled) + XCTAssertTrue(review.enabled) + XCTAssertNil(folders.defaults.object(forKey: "BitMatchExcludeAppleDouble")) review.refresh(source: folders.source) - review.enabled = true XCTAssertNotNil(review.readinessIssue) let ready = await waitUntil { review.paths != nil } XCTAssertTrue(ready) XCTAssertEqual(review.paths, ["._A.ARW"]) XCTAssertTrue(try XCTUnwrap(review.summaryLine).contains("1 AppleDouble")) XCTAssertTrue(AppleDoubleReviewModel(defaults: folders.defaults).enabled) + review.enabled = false + XCTAssertFalse(AppleDoubleReviewModel(defaults: folders.defaults).enabled) + review.enabled = true review.refresh(source: nil) XCTAssertNil(review.paths) XCTAssertNil(review.readinessIssue) diff --git a/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift b/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift index 1a4425aa..b329b28e 100644 --- a/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift +++ b/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift @@ -755,7 +755,9 @@ final class CopyVerifyExecutorIntegrityTests: XCTestCase { let indices = count == 1 ? [di] : [di, (di + 1) % names.count] let destinations = indices.map { root.appendingPathComponent("destination-" + names[$0]).appendingPathComponent(key) } for destination in destinations { try fm.createDirectory(at: destination, withIntermediateDirectories: true) } - let defaults = UserDefaults(suiteName: "BitMatchFeatureMatrix-" + UUID().uuidString)! + let suite = "BitMatchFeatureMatrix-" + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } let store = InMemoryPhotographerJobStore() let project = PhotographerJobViewModel(store: store, workflowDefaults: defaults) project.selectWorkflow(workflow) @@ -778,7 +780,9 @@ final class CopyVerifyExecutorIntegrityTests: XCTestCase { let harness = ExecutorHarness(returnedResults: [], emittedResults: [], lifecycleCompletion: finalizer, sourceURL: source, destinationURLs: destinations, verificationMode: mode, - generateASCMHL: true, makeReport: true, cameraSettings: settings, + generateASCMHL: true, makeReport: true, + reportPrefs: ReportPrefs(notes: "Card 01 · 日本語 & ", operatorName: "Alex & Sam"), + cameraSettings: settings, realFileOperations: TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared), estimatedFiles: entries.count, estimatedBytes: entries.reduce(0) { $0 + $1.size }) let executed = try await harness.execute() @@ -800,6 +804,11 @@ final class CopyVerifyExecutorIntegrityTests: XCTestCase { let document = try XCTUnwrap(PDFDocument(url: pdf), key) XCTAssertGreaterThan(document.pageCount, 0) XCTAssertTrue(document.string?.contains("BitMatch") == true, key) + XCTAssertTrue(document.string?.contains("Operator: Alex & Sam") == true, key) + let detailJSONURL = try XCTUnwrap(files.first { $0.pathExtension == "json" }, key) + let detailJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: detailJSONURL)) as? [String: Any], key) + XCTAssertEqual(detailJSON["operatorName"] as? String, "Alex & Sam", key) + XCTAssertTrue((detailJSON["notes"] as? String)?.contains("Card 01 · 日本語 & ") == true, key) XCTAssertTrue(files.contains { $0.pathExtension == "csv" }, key) let scan = await ReportScanner.scanReports(at: destinations[0]) XCTAssertTrue(scan.skipped.isEmpty, key) @@ -977,6 +986,7 @@ private final class ExecutorHarness { verificationMode: VerificationMode = .standard, generateASCMHL: Bool = false, makeReport: Bool = false, + reportPrefs: ReportPrefs? = nil, cameraSettings: CameraLabelSettings = CameraLabelSettings(), thrownError: Error? = nil, realFileOperations: FileOperationsService? = nil, @@ -1007,7 +1017,7 @@ private final class ExecutorHarness { destinationURLs: destinationURLs, verificationMode: verificationMode, cameraLabelSettings: cameraSettings, - reportSettings: ReportPrefs(makeReport: makeReport), + reportSettings: reportPrefs ?? ReportPrefs(makeReport: makeReport), estimatedFiles: estimatedFiles ?? returnedResults.count, estimatedBytes: estimatedBytes ?? returnedResults.reduce(0) { $0 + $1.fileSize }, currentMode: .copyAndVerify, diff --git a/BitMatchTests/ReportEvidenceBytesTests.swift b/BitMatchTests/ReportEvidenceBytesTests.swift index 14b9427c..dbb381e0 100644 --- a/BitMatchTests/ReportEvidenceBytesTests.swift +++ b/BitMatchTests/ReportEvidenceBytesTests.swift @@ -5,6 +5,7 @@ // measured, and that estimate used to reach the report. import Foundation import XCTest +import PDFKit @testable import BitMatch import BitMatchEngine @@ -47,10 +48,11 @@ final class ReportEvidenceBytesTests: XCTestCase { destinationURLs: fixture.destinations, verificationMode: .standard, cameraLabelSettings: CameraLabelSettings(), - reportSettings: ReportPrefs(makeReport: true), + reportSettings: ReportPrefs(notes: "Card 01 handoff", makeReport: true, operatorName: "Alex & Sam"), estimatedFiles: fixture.manifest.count, estimatedBytes: 1_000_000_000, - currentMode: .copyAndVerify + currentMode: .copyAndVerify, + generateASCMHL: true ) _ = try await executor.execute( config: config, @@ -68,6 +70,25 @@ final class ReportEvidenceBytesTests: XCTestCase { .filter { $0.pathExtension == "json" } let jsonURL = try XCTUnwrap(jsonFiles.first, "no JSON report in \(reports.path)") let object = try JSONSerialization.jsonObject(with: Data(contentsOf: jsonURL)) + let report = try XCTUnwrap(object as? [String: Any]) + XCTAssertEqual(report["operatorName"] as? String, "Alex & Sam") + XCTAssertTrue((report["notes"] as? String)?.contains("Card 01 handoff") == true) + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: fixture.destinations[0], includingPropertiesForKeys: nil)) + let manifests = enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "mhl" } + XCTAssertFalse(manifests.isEmpty) + for url in manifests { + let xml = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(xml.contains("Operator: Alex & Sam")) + XCTAssertTrue(xml.contains("Notes: Card 01 handoff")) + } + let csvURL = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: reports, includingPropertiesForKeys: nil).first { $0.pathExtension == "csv" }) + let csv = try String(contentsOf: csvURL, encoding: .utf8) + XCTAssertTrue(csv.contains("Operator,Alex & Sam")) + XCTAssertTrue(csv.contains("Card 01 handoff")) + let pdfURL = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: reports, includingPropertiesForKeys: nil).first { $0.pathExtension == "pdf" }) + let pdfText = try XCTUnwrap(PDFDocument(url: pdfURL)?.string) + XCTAssertTrue(pdfText.contains("Operator: Alex & Sam")) + XCTAssertTrue(pdfText.contains("Card 01 handoff")) let statistics = try XCTUnwrap((object as? [String: Any])?["statistics"] as? [String: Any]) let averageFileSize = try XCTUnwrap((statistics["averageFileSize"] as? NSNumber)?.int64Value) diff --git a/BitMatchTests/ReportThumbnailTests.swift b/BitMatchTests/ReportThumbnailTests.swift index 7c59800c..8dbe49ce 100644 --- a/BitMatchTests/ReportThumbnailTests.swift +++ b/BitMatchTests/ReportThumbnailTests.swift @@ -1,5 +1,6 @@ import Foundation import PDFKit +import CryptoKit import Testing @testable import BitMatch import BitMatchEngine @@ -173,7 +174,7 @@ struct ReportThumbnailTests { @Test func automaticExporterHonorsPreferenceWithoutPuttingImagesInJSON() async throws { let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } - var prefs = ReportPrefs(makeReport: true) + var prefs = ReportPrefs(notes: "Card 01 · keep until reviewed", makeReport: true, operatorName: "Alex & Sam") prefs.includeThumbnails = true prefs.verificationMode = .standard try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(), @@ -184,6 +185,8 @@ struct ReportThumbnailTests { let pdf = try #require(files.first { $0.pathExtension == "pdf" }) let text = try #require(PDFDocument(url: pdf)?.string) #expect(text.contains("Clip previews: 1/1")) + #expect(text.contains("Operator: Alex & Sam")) + #expect(text.contains(prefs.notes)) #expect(text.contains("VERIFICATION SUCCESSFUL")) let json = files.filter { $0.pathExtension == "json" } #expect(!json.isEmpty) @@ -191,12 +194,131 @@ struct ReportThumbnailTests { let value = try String(contentsOf: url, encoding: .utf8) #expect(!value.contains("images")) #expect(!value.contains("base64")) + #expect(value.contains("Alex & Sam")) + #expect(value.contains(prefs.notes)) } #expect(!text.contains("Performance Metrics")) #expect(!text.contains("Top File Types")) #expect(!text.contains("Workers:")) } + /// Real copy/readback/handoff/report matrix, rather than seeded success rows. + @Test func transferDetailsPreviewsAndSelectionStayConsistentAcrossModes() async throws { + try await FileOperationsTestLock.shared.run { + let fm = FileManager.default + for mode in VerificationMode.allCases { + for exclude in [false, true] { + let root = fm.temporaryDirectory.appendingPathComponent("bitmatch-combined-\(UUID())") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let source = root.appendingPathComponent("Card") + let destinations = [root.appendingPathComponent("Backup A"), root.appendingPathComponent("Backup B")] + for folder in [source] + destinations { try fm.createDirectory(at: folder, withIntermediateDirectories: true) } + var companion = [UInt8](repeating: 0, count: 42) + func put(_ value: UInt32, _ offset: Int) { + for index in 0..<4 { companion[offset + index] = UInt8(truncatingIfNeeded: value >> (24 - 8 * index)) } + } + put(0x00051607, 0); put(0x00020000, 4); companion[25] = 1 + put(2, 26); put(38, 30); put(4, 34) + let originals = ["preview.mp4": try #require(Data(base64Encoded: previewFixture)), + "unsupported.braw": Data("synthetic proprietary placeholder".utf8), + "._notes.txt": Data("legitimate sidecar".utf8), "._preview.mp4": Data(companion)] + for (name, data) in originals { try data.write(to: source.appendingPathComponent(name)) } + var settings = CameraLabelSettings() + if exclude { settings.excludedAppleDoublePaths = try AppleDoubleSelection.review(source: source) } + let prefs = ReportPrefs(verificationMode: mode, includeThumbnails: true, + notes: "Card 01 · 日本語 & ", operatorName: "Alex & Sam") + let executor = CopyVerifyExecutor(platformManager: MacOSPlatformManager.shared, + timingService: OperationTimingService(), errorService: ErrorReportingService(), + stateService: OperationStateService(), backgroundTaskService: IOSBackgroundTaskService.shared) + var terminal: OperationCompletionInfo? + let operation = try #require(try await executor.execute(config: CopyVerifyConfig(operationId: UUID(), + sourceURL: source, destinationURLs: destinations, verificationMode: mode, + cameraLabelSettings: settings, reportSettings: prefs, estimatedFiles: originals.count, + estimatedBytes: 0, currentMode: .copyAndVerify, generateASCMHL: mode != .quick), + callbacks: CopyVerifyCallbacks(onProgress: { _ in }, onResult: { _ in }, + onStateChange: { if case .completed(let info) = $0 { terminal = info } }, onAuthoritativeResults: { _ in }))) + #expect(operation.results.count == originals.count * destinations.count) + #expect(operation.results.filter(\.excludedAppleDouble).count == (exclude ? 2 : 0)) + #expect(terminal?.success == (mode != .quick && !exclude)) + for (name, data) in originals { + #expect(try Data(contentsOf: source.appendingPathComponent(name)) == data) + for destination in destinations { + let copied = destination.appendingPathComponent("Card").appendingPathComponent(name) + if exclude && name == "._preview.mp4" { #expect(!fm.fileExists(atPath: copied.path)) } + else { #expect(SHA256.hash(data: try Data(contentsOf: copied)) == SHA256.hash(data: data)) } + } + } + for destination in destinations { + if destination == destinations[0] { + let reports = try fm.contentsOfDirectory(at: destination.appendingPathComponent("Reports"), includingPropertiesForKeys: nil) + let pdf = try #require(reports.first { $0.pathExtension == "pdf" }) + let text = try #require(PDFDocument(url: pdf)?.string) + #expect(text.contains("Operator: Alex & Sam")) + #expect(text.contains("preview.mp4")) + #expect(text.contains("unsupported.braw")) + let previewCount = exclude ? "Clip previews: 1/1" : "Clip previews: 1/2" + #expect(text.contains(previewCount) == (mode != .quick)) + let jsonURL = try #require(reports.first { $0.pathExtension == "json" }) + let json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: jsonURL)) as? [String: Any]) + #expect(json["operatorName"] as? String == prefs.operatorName) + #expect((json["notes"] as? String)?.contains(prefs.notes) == true) + #expect(json["safeToErase"] as? Bool == (mode != .quick && !exclude)) + #expect(json["images"] == nil) + #expect((json["destinations"] as? [[String: Any]])?.count == destinations.count) + #expect((json["results"] as? [[String: Any]])?.count == operation.results.count) + if mode == .standard && exclude, + let output = ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] { + try Data(contentsOf: pdf).write(to: URL(fileURLWithPath: output).appendingPathComponent("combined-transfer.pdf")) + } + } + if mode != .quick { + let mhlFiles = try fm.contentsOfDirectory(at: destination.appendingPathComponent("Card/ascmhl"), includingPropertiesForKeys: nil) + let mhl = try String(contentsOf: #require(mhlFiles.first { $0.pathExtension == "mhl" }), encoding: .utf8) + #expect(mhl.contains("Operator: Alex & Sam")) + #expect(mhl.contains("<notes>")) + #expect(mhl.contains("._preview.mp4") == !exclude) + } + } + } + } + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["BITMATCH_CODEC_FIXTURE_ROOT"] != nil)) + func generatedCodecPreviewsRetainAllRowsAndUnsafeVerdict() async throws { + let root = URL(fileURLWithPath: try #require(ProcessInfo.processInfo.environment["BITMATCH_CODEC_FIXTURE_ROOT"])) + try #require(FileManager.default.fileExists(atPath: root.appendingPathComponent(".bitmatch-codec-owned").path)) + let names = ["h264.mp4", "hevc.MOV", "prores.mov", "h264.m4v", "broken.mov", "unsupported.braw"] + let rows = try names.map { name in + let file = root.appendingPathComponent(name) + let size = try #require(FileManager.default.attributesOfItem(atPath: file.path)[.size] as? NSNumber).int64Value + return ResultRow(path: "/synthetic-card/\(name)", status: ResultOutcome.verified.statusText, + size: size, checksum: "seeded-preview-fixture", destination: root.path, destinationPath: file.path) + } + let previews = try await ReportThumbnailService.collect(results: rows, enabled: true) + #expect(previews.images.count == 4) + #expect(previews.requestedCount == 5) + for row in rows.prefix(4) { #expect(previews.images[row.path]?.isEmpty == false) } + #expect(previews.images[rows[4].path] == nil) + let prefs = ReportPrefs(verificationMode: .standard, includeThumbnails: true, + notes: "Synthetic codec check", operatorName: "Alex & Sam") + let reportFolder = root.appendingPathComponent("Reports") + let existingReports = Set(((try? FileManager.default.contentsOfDirectory(at: reportFolder, includingPropertiesForKeys: nil)) ?? []).map(\.path)) + try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(), + sourceURL: nil, destinationURLs: [root], results: rows, fileCount: rows.count, matchCount: rows.count, + prefs: prefs, workers: 1, totalBytesProcessed: rows.reduce(0) { $0 + $1.size }, safetyState: .needsAttention) + let reports = try FileManager.default.contentsOfDirectory(at: root.appendingPathComponent("Reports"), includingPropertiesForKeys: nil) + let pdf = try #require(reports.first { $0.pathExtension == "pdf" && !existingReports.contains($0.path) }) + let text = try #require(PDFDocument(url: pdf)?.string) + #expect(text.contains("Clip previews: 4/5")) + #expect(text.contains("NOT SAFE TO ERASE")) + for name in names { #expect(text.contains(name)) } + if let output = ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] { + try Data(contentsOf: pdf).write(to: URL(fileURLWithPath: output).appendingPathComponent("codec-previews.pdf")) + } + } + @Test func cancellationDuringCollectionDoesNotStartAnotherClip() async throws { let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } let (started, signal) = AsyncStream.makeStream() @@ -226,11 +348,16 @@ struct ReportThumbnailTests { source: "/card", destinations: [root.path], totalFiles: rows.count, matched: rows.count, issues: 0, workers: 1, appVersion: "test", osVersion: "test", client: "", production: "", company: "", verificationMethod: "Standard", totalBytesProcessed: row.size * Int64(rows.count), averageSpeed: 0, - clientLogoData: nil, companyLogoData: nil, photographyJob: nil, safetyState: .safeToErase) + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, operatorName: "Alex & Sam · " + String(repeating: "Long operator name ", count: 12), + notes: (0..<180).map { "NOTE-\($0)-END · retained handoff notes" }.joined(separator: "\n"), + safetyState: .safeToErase) let document = try #require(PDFDocument(data: ReportPDFRenderer.renderPDF(summary: summary, results: rows, thumbnails: previews))) #expect(document.pageCount > 1) + #expect(try #require(document.page(at: 0)?.string).contains("NOTE-0-END")) let text = try #require(document.string) for index in 0..<75 { #expect(text.components(separatedBy: "clip-\(index).mp4").count == 2) } + for index in 0..<180 { #expect(text.contains("NOTE-\(index)-END")) } + #expect(text.contains("Operator: Alex & Sam")) if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]), let data = document.dataRepresentation() { try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("preview-pagination.pdf")) diff --git a/BitMatchTests/SharedSettingsPersistenceTests.swift b/BitMatchTests/SharedSettingsPersistenceTests.swift index 0a87e74e..918022f7 100644 --- a/BitMatchTests/SharedSettingsPersistenceTests.swift +++ b/BitMatchTests/SharedSettingsPersistenceTests.swift @@ -39,10 +39,14 @@ struct SharedSettingsPersistenceTests { let first = makeCoordinator(suite.defaults, folders: folders) first.reportSettings.clientName = "Acme" + first.reportSettings.operatorName = "Alex" + first.reportSettings.notes = "Next card too" first.reportSettings.makeReport = false let relaunched = makeCoordinator(suite.defaults, folders: folders) #expect(relaunched.reportSettings.clientName == "Acme") + #expect(relaunched.reportSettings.operatorName == "Alex") + #expect(relaunched.reportSettings.notes == "Next card too") #expect(!relaunched.reportSettings.makeReport) } diff --git a/BitMatchTests/TransferDetailsReportTests.swift b/BitMatchTests/TransferDetailsReportTests.swift new file mode 100644 index 00000000..1a8de6dd --- /dev/null +++ b/BitMatchTests/TransferDetailsReportTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +import PDFKit +@testable import BitMatch +import BitMatchEngine + +@MainActor +struct TransferDetailsReportTests { + @Test func historyExportsKeepSavedDetailsAndUnsafeVerdict() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("details-history-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(notes: "Keep card, backup B missing", operatorName: "Alex, \"DIT\"")) + record.state = .issues + record.summary = "Transfer incomplete" + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.failed.statusText, size: 5, + checksum: nil, destination: "Backup")] + let json = try TransferHistoryDocument(record: record, asCSV: false).data + let object = try #require(JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(object["operatorName"] as? String == record.reportSettings.operatorName) + #expect(object["notes"] as? String == record.reportSettings.notes) + #expect(object["state"] as? String == LocalTransferState.issues.rawValue) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("\"operatorName\",\"Alex, \"\"DIT\"\"\"")) + #expect(csv.contains(record.reportSettings.notes)) + let pdfData = try await ReportExporter.historyPDF(record: record, includeThumbnails: false) + let pdf = try #require(PDFDocument(data: pdfData)) + if let path = ProcessInfo.processInfo.environment["BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] + ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] { + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try pdfData.write(to: directory.appendingPathComponent("transfer-details-history.pdf")) + } + let text = try #require(pdf.string) + #expect(text.contains("Operator: Alex")) + #expect(text.contains(record.reportSettings.notes)) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(record.state == .issues) + } + + @Test func excludedMetadataIsDisclosedWithoutInventingFileErrors() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("excluded-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues; record.summary = "Selected files verified" + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 42, checksum: nil, destination: "Backup")] + let document = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(document.string) + #expect(text.contains("0 file issues")) + #expect(!text.contains("Issues Detail")) + #expect(text.contains("1 intentionally excluded result")) + #expect(text.contains("AppleDouble metadata was intentionally excluded")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + } + + @Test func csvDetailsCannotBecomeSpreadsheetFormulas() throws { + let root = FileManager.default.temporaryDirectory + let record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [], verificationMode: .standard, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(notes: " =1+1", operatorName: "@SUM(1,2)")) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("'@SUM(1,2)")) + #expect(csv.contains("' =1+1")) + let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) + #expect(json["operatorName"] as? String == "@SUM(1,2)") + } + + @Test func rememberedDetailsRoundTripOnThisPlatform() throws { + let name = "BitMatch.DetailsTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let store = ReportPrefsStore(defaults: defaults) + store.save(ReportPrefs(notes: "Next card", operatorName: "Alex")) + #expect(store.load().operatorName == "Alex") + #expect(store.load().notes == "Next card") + var cleared = store.load(); cleared.operatorName = ""; cleared.notes = ""; store.save(cleared) + #expect(store.load().operatorName.isEmpty) + #expect(store.load().notes.isEmpty) + } +} diff --git a/BitMatchTests/WorkflowSnapshotTests.swift b/BitMatchTests/WorkflowSnapshotTests.swift index 0560ca9c..eef8091d 100644 --- a/BitMatchTests/WorkflowSnapshotTests.swift +++ b/BitMatchTests/WorkflowSnapshotTests.swift @@ -62,7 +62,14 @@ final class WorkflowSnapshotTests: XCTestCase { let deadline = ContinuousClock.now.advanced(by: .seconds(5)) while review.paths == nil && ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } XCTAssertEqual(review.paths, ["._Camera clip.bin"]) - try capture(AppleDoubleSelectionView(review: review).padding(), size: CGSize(width: 580, height: 600), name: "mac-appledouble-selection") + try capture(Form { Section("File handling") { AppleDoubleSelectionView(review: review) } }.formStyle(.grouped), size: CGSize(width: 580, height: 260), name: "mac-appledouble-settings") + } + + func testCaptureTransferDetails() throws { + try requireCaptureConfiguration() + let prefs = ReportPrefs(notes: "Card 01 · backups to Alex.\nKeep the source until both drives are checked.", operatorName: "Alex Morgan") + try capture(TransferDetailsSection(prefs: .constant(prefs), isExpanded: true).padding(), + size: CGSize(width: 580, height: 340), name: "mac-transfer-details") } private func settle() async { diff --git a/CHANGELOG.md b/CHANGELOG.md index ec05bc0f..944523a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Moved AppleDouble handling to Settings → File handling. Recognized metadata companions are ignored by default for unset preferences; saved choices and queued transfers keep their policy. Other hidden files and camera sidecars are preserved. +- Added optional operator and notes under Advanced → Transfer details. Remembered between cards, saved separately for each queued transfer, and included in reports and ASC MHL. - Added optional clip previews to PDFs, off by default. Supported videos use a small frame from a verified backup; unavailable clips stay listed. A preview is not a new verification. +- PDF reports separate intentional metadata exclusions from file errors, retain the whole-card safety warning, and fit long multiline notes across pages without wasting the first page. - Simplified PDF reports: a compact verdict, transfer summary and file list, with previews beside filenames. Removed repeated statistics and empty technical fields. - History can export a PDF with or without previews, without reconnecting the original card. Expanded rows show recorded dates, sizes, copy/verify times and measured average speed; missing measurements stay blank. - Added a plain-language verification guide and expanded source-preservation tests to include filesystem metadata and extended attributes. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift index 1e679c5f..b1f000a9 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift @@ -89,7 +89,8 @@ public enum ASCMHLGenerator: Sendable { /// the destination with F_NOCACHE. Publication remains atomic and no-replace. public static func generateInitialHistory( destinationURL: URL, files: [VerifiedFile], startTime: Date, sourceURL: URL? = nil, - toolVersion: String, progress: ProgressHandler? = nil, diagnosticRun: UUID? = nil + toolVersion: String, progress: ProgressHandler? = nil, diagnosticRun: UUID? = nil, + operatorName: String = "", notes: String = "" ) throws -> URL { try generateInitialHistory( destinationURL: destinationURL, @@ -97,14 +98,15 @@ public enum ASCMHLGenerator: Sendable { startTime: startTime, sourceURL: sourceURL, toolVersion: toolVersion, - readHooks: nil, progress: progress, diagnosticRun: diagnosticRun + readHooks: nil, progress: progress, diagnosticRun: diagnosticRun, operatorName: operatorName, notes: notes ) } static func generateInitialHistory( destinationURL: URL, files: [VerifiedFile], startTime: Date, sourceURL: URL? = nil, toolVersion: String, - readHooks: ReadHooks?, progress: ProgressHandler? = nil, diagnosticRun: UUID? = nil + readHooks: ReadHooks?, progress: ProgressHandler? = nil, diagnosticRun: UUID? = nil, + operatorName: String = "", notes: String = "" ) throws -> URL { guard !files.isEmpty else { throw GenerationError.emptyInventory } let sourceFD: Int32 @@ -242,7 +244,7 @@ public enum ASCMHLGenerator: Sendable { \(date.string(from: startTime)) \(escape(ProcessInfo.processInfo.hostName)) BitMatch - Initial destination inventory. MD5 computed after checking destination bytes against the transfer SHA-256. Includes listed files only; no inherited history or directory hashes. + \(escape(creatorComment(operatorName: operatorName, notes: notes))) in-place @@ -464,6 +466,20 @@ public enum ASCMHLGenerator: Sendable { return "c4" + String(repeating: "1", count: max(0, 88 - output.count)) + output } + /// Keep user-entered context in the schema's existing free-text comment. + /// Replace characters XML 1.0 cannot represent; never insert raw XML markup. + private static func creatorComment(operatorName: String, notes: String) -> String { + var parts = ["Initial destination inventory. MD5 computed after checking destination bytes against the transfer SHA-256. Includes listed files only; no inherited history or directory hashes."] + if let name = EvidenceWriter.normalizedNotes(operatorName) { parts.append("Operator: \(name)") } + if let notes = EvidenceWriter.normalizedNotes(notes) { parts.append("Notes: \(notes)") } + return String(String.UnicodeScalarView(parts.joined(separator: "\n").unicodeScalars.map { scalar in + switch scalar.value { + case 0x9, 0xA, 0xD, 0x20...0xD7FF, 0xE000...0xFFFD, 0x10000...0x10FFFF: scalar + default: UnicodeScalar(0xFFFD)! + } + })) + } + private static func escape(_ string: String) -> String { string.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift index c6ea4623..5eebf8dc 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift @@ -70,6 +70,7 @@ public struct EnhancedJSONReport: Codable, Sendable public let verification: Verification public let results: [JSONReportItem] public let photographyJob: Project? + public var operatorName: String? = nil public var notes: String? = nil public let safeToErase: Bool? public let pdfEvidence: ReportPDFEvidence? @@ -228,7 +229,7 @@ public struct EnhancedJSONReport: Codable, Sendable } } - public init(reportVersion: String, timestamp: Date, jobId: UUID, mode: String, source: SourceInfo, destinations: [DestinationInfo], statistics: Statistics, extensions: [String: Int], performance: Performance, verification: Verification, results: [JSONReportItem], photographyJob: Project?, notes: String? = nil, safeToErase: Bool? = nil, pdfEvidence: ReportPDFEvidence? = nil) { + public init(reportVersion: String, timestamp: Date, jobId: UUID, mode: String, source: SourceInfo, destinations: [DestinationInfo], statistics: Statistics, extensions: [String: Int], performance: Performance, verification: Verification, results: [JSONReportItem], photographyJob: Project?, notes: String? = nil, safeToErase: Bool? = nil, pdfEvidence: ReportPDFEvidence? = nil, operatorName: String? = nil) { self.reportVersion = reportVersion self.timestamp = timestamp self.jobId = jobId @@ -241,6 +242,7 @@ public struct EnhancedJSONReport: Codable, Sendable self.verification = verification self.results = results self.photographyJob = photographyJob + self.operatorName = operatorName self.notes = notes self.safeToErase = safeToErase self.pdfEvidence = pdfEvidence @@ -566,6 +568,9 @@ public enum EvidenceWriter: Sendable { if let prefs { csvContent += csvRow(["Verification", verificationDescription(for: prefs).label]) + if let operatorName = normalizedNotes(prefs.operatorName) { + csvContent += csvRow(["Operator", operatorName]) + } if let notes = normalizedNotes(prefs.notes) { csvContent += csvRow(["Notes", notes]) } @@ -808,7 +813,7 @@ public enum EvidenceWriter: Sendable { results: results.map { JSONReportItem(from: $0) }, photographyJob: project, notes: normalizedNotes(prefs.notes), - safeToErase: safeToErase, pdfEvidence: pdfEvidence + safeToErase: safeToErase, pdfEvidence: pdfEvidence, operatorName: normalizedNotes(prefs.operatorName) ) if results.contains(where: { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) { report.selection = TransferSelectionEvidence(results: results) diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Results.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Results.swift index ed23ece0..f21fae20 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Results.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Models/Results.swift @@ -122,6 +122,7 @@ public struct ReportPrefs: Codable, Sendable { public var projectName: String = "" public var production: String = "" public var company: String = "" + public var operatorName: String = "" public var notes: String = "" public var makeReport: Bool = true public var verifyWithChecksum: Bool = true @@ -130,13 +131,14 @@ public struct ReportPrefs: Codable, Sendable { public var showCameraDetectionNotifications: Bool = true public var checksumAlgorithm: ChecksumAlgorithm = .sha256 - public init(verificationMode: VerificationMode? = nil, includeThumbnails: Bool = false, clientName: String = "", projectName: String = "", production: String = "", company: String = "", notes: String = "", makeReport: Bool = true, verifyWithChecksum: Bool = true, enableAutoCameraDetection: Bool = true, autoPopulateSource: Bool = true, showCameraDetectionNotifications: Bool = true, checksumAlgorithm: ChecksumAlgorithm = .sha256) { + public init(verificationMode: VerificationMode? = nil, includeThumbnails: Bool = false, clientName: String = "", projectName: String = "", production: String = "", company: String = "", notes: String = "", makeReport: Bool = true, verifyWithChecksum: Bool = true, enableAutoCameraDetection: Bool = true, autoPopulateSource: Bool = true, showCameraDetectionNotifications: Bool = true, checksumAlgorithm: ChecksumAlgorithm = .sha256, operatorName: String = "") { self.verificationMode = verificationMode self.includeThumbnails = includeThumbnails self.clientName = clientName self.projectName = projectName self.production = production self.company = company + self.operatorName = operatorName self.notes = notes self.makeReport = makeReport self.verifyWithChecksum = verifyWithChecksum @@ -145,6 +147,33 @@ public struct ReportPrefs: Codable, Sendable { self.showCameraDetectionNotifications = showCameraDetectionNotifications self.checksumAlgorithm = checksumAlgorithm } + + private enum CodingKeys: String, CodingKey { + case verificationMode, includeThumbnails, clientName, projectName, production, company + case operatorName, notes, makeReport, verifyWithChecksum, enableAutoCameraDetection + case autoPopulateSource, showCameraDetectionNotifications, checksumAlgorithm + } + + /// New optional fields must not discard settings or queued records from older builds. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + verificationMode: try c.decodeIfPresent(VerificationMode.self, forKey: .verificationMode), + includeThumbnails: try c.decodeIfPresent(Bool.self, forKey: .includeThumbnails) ?? false, + clientName: try c.decodeIfPresent(String.self, forKey: .clientName) ?? "", + projectName: try c.decodeIfPresent(String.self, forKey: .projectName) ?? "", + production: try c.decodeIfPresent(String.self, forKey: .production) ?? "", + company: try c.decodeIfPresent(String.self, forKey: .company) ?? "", + notes: try c.decodeIfPresent(String.self, forKey: .notes) ?? "", + makeReport: try c.decodeIfPresent(Bool.self, forKey: .makeReport) ?? true, + verifyWithChecksum: try c.decodeIfPresent(Bool.self, forKey: .verifyWithChecksum) ?? true, + enableAutoCameraDetection: try c.decodeIfPresent(Bool.self, forKey: .enableAutoCameraDetection) ?? true, + autoPopulateSource: try c.decodeIfPresent(Bool.self, forKey: .autoPopulateSource) ?? true, + showCameraDetectionNotifications: try c.decodeIfPresent(Bool.self, forKey: .showCameraDetectionNotifications) ?? true, + checksumAlgorithm: try c.decodeIfPresent(ChecksumAlgorithm.self, forKey: .checksumAlgorithm) ?? .sha256, + operatorName: try c.decodeIfPresent(String.self, forKey: .operatorName) ?? "" + ) + } } extension ResultRow: Codable { diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift index b6976e7b..56d0714b 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift @@ -277,7 +277,8 @@ public enum TransferCompletion: Sendable { planIssues: [String], startTime: Date, source: URL, - toolVersion: String, progress: ASCMHLGenerator.ProgressHandler? = nil, diagnosticRun: UUID? = nil + toolVersion: String, progress: ASCMHLGenerator.ProgressHandler? = nil, diagnosticRun: UUID? = nil, + operatorName: String = "", notes: String = "" ) throws -> [String] { var failures = planIssues let totalBytes = jobs.flatMap(\.files).reduce(Int64(0)) { $0 + max(0, $1.size) } @@ -300,7 +301,7 @@ public enum TransferCompletion: Sendable { bytesProcessed: byteOffset + update.bytesProcessed, totalBytes: totalBytes, filesProcessed: fileOffset + update.filesProcessed, totalFiles: totalFiles, published: precedingJobsSucceeded && update.published && fileOffset + update.filesProcessed == totalFiles)) - }, diagnosticRun: diagnosticRun + }, diagnosticRun: diagnosticRun, operatorName: operatorName, notes: notes ) } catch is CancellationError { throw CancellationError() diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift index 76b416ed..63b50175 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ASCMHLGeneratorTests.swift @@ -5,6 +5,38 @@ import XCTest @testable import BitMatchEngine final class ASCMHLGeneratorTests: XCTestCase { + func testCreatorDetailsAreEscapedWithoutChangingMediaOrExistingHistory() throws { + let (root, file) = try fixture() + let media = try Data(contentsOf: root.appendingPathComponent("clip.txt")) + let manifest = try ASCMHLGenerator.generateInitialHistory( + destinationURL: root, files: [file], startTime: Date(), toolVersion: "test", + operatorName: " Renée & ", notes: "First line\nBackup \"B\" \u{0}" + ) + let bytes = try Data(contentsOf: manifest) + let document = try XMLDocument(data: bytes) + let comment = try XCTUnwrap(document.nodes(forXPath: "//*[local-name()='comment']").first?.stringValue) + XCTAssertTrue(comment.contains("Operator: Renée & ")) + XCTAssertTrue(comment.contains("Notes: First line\nBackup \"B\" �")) + XCTAssertEqual(try Data(contentsOf: root.appendingPathComponent("clip.txt")), media) + let chain = try XMLDocument(contentsOf: manifest.deletingLastPathComponent().appendingPathComponent("ascmhl_chain.xml")) + XCTAssertEqual(try chain.nodes(forXPath: "//*[local-name()='c4']").first?.stringValue, + ASCMHLGenerator.c4(bytes)) + XCTAssertThrowsError(try ASCMHLGenerator.generateInitialHistory( + destinationURL: root, files: [file], startTime: Date(), toolVersion: "test", + operatorName: "Someone else", notes: "Replacement")) + XCTAssertEqual(try Data(contentsOf: manifest), bytes) + } + + func testBlankCreatorDetailsKeepOriginalComment() throws { + let (root, file) = try fixture() + let manifest = try ASCMHLGenerator.generateInitialHistory( + destinationURL: root, files: [file], startTime: Date(), toolVersion: "test", operatorName: " ", notes: "\n") + let document = try XMLDocument(contentsOf: manifest) + let comment = try XCTUnwrap(document.nodes(forXPath: "//*[local-name()='comment']").first?.stringValue) + XCTAssertFalse(comment.contains("Operator:")) + XCTAssertFalse(comment.contains("Notes:")) + } + func testC4MatchesOfficialReferenceEmptyDataVector() { XCTAssertEqual(ASCMHLGenerator.c4(Data()), "c459dsjfscH38cYeXXYogktxf4Cd9ibshE3BHUo6a58hBXmRQdZrAkZzsWcbWtDg5oQstpDuni4Hirj75GEmTc1sFT") } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/TransferDetailsTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/TransferDetailsTests.swift new file mode 100644 index 00000000..51f08307 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/TransferDetailsTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import BitMatchEngine + +struct TransferDetailsTests { + @Test func olderSettingsKeepTheirValuesWhenNewFieldsAreAbsent() throws { + let original = ReportPrefs(clientName: "Client", notes: "Existing notes", makeReport: false, + autoPopulateSource: false, checksumAlgorithm: .md5) + let data = try JSONEncoder().encode(original) + var json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + json.removeValue(forKey: "operatorName") + json.removeValue(forKey: "includeThumbnails") + let restored = try JSONDecoder().decode(ReportPrefs.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(restored.operatorName.isEmpty) + #expect(!restored.includeThumbnails) + #expect(restored.notes == "Existing notes") + #expect(restored.clientName == "Client") + #expect(!restored.makeReport) + #expect(!restored.autoPopulateSource) + #expect(restored.checksumAlgorithm == .md5) + } + + @Test func journalKeepsPerTransferDetailsAfterSettingsChangeAndRelaunch() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("details-journal-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("Card") + let destination = root.appendingPathComponent("Backup") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let url = root.appendingPathComponent("journal.json") + var journal: TransferJournal? = TransferJournal(fileURL: url) + var prefs = ReportPrefs(notes: "Card one", operatorName: "Alex") + let first = try journal!.enqueue(sourceURL: source, destinationURLs: [destination], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: prefs, generateASCMHL: true) + prefs.notes = "Card two"; prefs.operatorName = "Sam" + _ = try journal!.enqueue(sourceURL: source, destinationURLs: [destination], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: prefs, generateASCMHL: true) + journal = nil // A relaunched instance must acquire the real journal lock. + let restored = TransferJournal(fileURL: url) + let record = try #require(restored.records.first { $0.id == first }) + #expect(record.reportSettings.notes == "Card one") + #expect(record.reportSettings.operatorName == "Alex") + } +} diff --git a/README.md b/README.md index f4c3e22d..b39d443f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Plug in your card and drives, allow BitMatch to use them once, and it picks up t ## What It Does - **Multi destination copy**: the card is read once and written to every drive at the same time, then each drive is read back and checked with SHA-256. Each destination gets its own results. -- **Careful about "safe to erase"**: a card or folder that changes during the copy, online-only iCloud/Dropbox files, or two "backups" on the same physical drive never count as safe. Hidden `._` files are copied and verified by default. You can exclude recognized AppleDouble companions under **Advanced**, with a list of what will be skipped. That copy can verify the selected files, but it won't mark the whole card safe to erase. +- **Careful about "safe to erase"**: a card or folder that changes during the copy, online-only iCloud/Dropbox files, or two "backups" on the same physical drive never count as safe. Recognized AppleDouble `._` metadata companions are skipped by default; other hidden files and camera sidecars stay in the copy. Turn off **Ignore AppleDouble metadata files** in **Settings → File handling** to preserve them. That copy can verify the selected files, but it won't mark the whole card safe to erase. - **Photographer jobs** for shoots with several photographers, cameras, and cards. Save the setup instead of rebuilding it every time. - **Camera detection** for Sony, Canon, ARRI, RED, Blackmagic, Panasonic, Fujifilm, GoPro, DJI, Insta360, and generic DCIM. - **Check** for stuff you already copied: compare two folders, or check a backup against its saved checksums. diff --git a/Scripts/test-report-codecs.sh b/Scripts/test-report-codecs.sh new file mode 100755 index 00000000..ec184de2 --- /dev/null +++ b/Scripts/test-report-codecs.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Generated clips only; no camera footage or downloaded assets. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +command -v ffmpeg >/dev/null || { echo "ffmpeg is required to generate codec fixtures" >&2; exit 1; } +PAIR=$(mktemp -d /tmp/bitmatch-codecs.XXXXXX) +LOG_DIR="$ROOT/dist/validation/report-codecs-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$LOG_DIR" +touch "$PAIR/.bitmatch-codec-owned" +trap 'rm -rf "$PAIR"' EXIT +make_clip() { + local name=$1 + shift + ffmpeg -nostdin -v error -f lavfi -i testsrc2=size=320x180:rate=5 -t 1 -threads 1 "$@" "$PAIR/$name" +} +make_clip h264.mp4 -c:v libx264 +make_clip hevc.MOV -c:v libx265 -tag:v hvc1 -x265-params pools=1:frame-threads=1:log-level=error +make_clip prores.mov -c:v prores_ks -profile:v 2 -pix_fmt yuv422p10le +make_clip h264.m4v -c:v libx264 +printf 'not a movie' > "$PAIR/broken.mov" +printf 'synthetic proprietary placeholder' > "$PAIR/unsupported.braw" +TEST_RUNNER_BITMATCH_CODEC_FIXTURE_ROOT="$PAIR" \ +TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT="$LOG_DIR" \ +xcodebuild -project "$ROOT/BitMatch.xcodeproj" \ + -derivedDataPath "${REPORT_DERIVED_DATA:-$ROOT/.derived-data/mac-test}" CODE_SIGNING_ALLOWED=NO \ + test -scheme BitMatch -destination 'platform=macOS' -only-testing:BitMatchTests/ReportThumbnailTests \ + > "$LOG_DIR/test.log" 2>&1 +cat "$LOG_DIR/test.log" diff --git a/Shared/Core/Models/ReportSummary.swift b/Shared/Core/Models/ReportSummary.swift index 6f956ef0..be08b8d6 100644 --- a/Shared/Core/Models/ReportSummary.swift +++ b/Shared/Core/Models/ReportSummary.swift @@ -29,6 +29,7 @@ struct ReportSummary { let clientLogoData: Data? let companyLogoData: Data? let photographyJob: PhotographerReportPayload? + var operatorName: String? = nil var notes: String? = nil /// The same fail-safe card verdict shown by the outcome screen. Report /// rendering must never infer safety from an empty issue list alone. diff --git a/Shared/Core/Services/CopyVerifyExecutor.swift b/Shared/Core/Services/CopyVerifyExecutor.swift index 1f74a59f..25ce444e 100644 --- a/Shared/Core/Services/CopyVerifyExecutor.swift +++ b/Shared/Core/Services/CopyVerifyExecutor.swift @@ -451,12 +451,14 @@ final class CopyVerifyExecutor { let startTime = operation.startTime let run = config.operationId let toolVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0" + let reportSettings = config.reportSettings let work = Task.detached(priority: .utility) { () throws -> [String] in defer { channel.continuation.finish() } return try TransferCompletion.writeASCMHL( plan.jobs, planIssues: plan.issues, startTime: startTime, source: sourceURL, toolVersion: toolVersion, - progress: { channel.continuation.yield($0) }, diagnosticRun: run + progress: { channel.continuation.yield($0) }, diagnosticRun: run, + operatorName: reportSettings.operatorName, notes: reportSettings.notes ) } handoffTask = work diff --git a/Shared/Core/Services/ReportExporter.swift b/Shared/Core/Services/ReportExporter.swift index 89aeff75..94a98274 100644 --- a/Shared/Core/Services/ReportExporter.swift +++ b/Shared/Core/Services/ReportExporter.swift @@ -75,6 +75,7 @@ enum ReportExporter { clientLogoData: nil, companyLogoData: nil, photographyJob: photographerPayload, + operatorName: EvidenceWriter.normalizedNotes(prefs.operatorName), notes: EvidenceWriter.normalizedNotes(reportPrefs.notes), safetyState: safetyState ) @@ -165,7 +166,7 @@ enum ReportExporter { verificationMethod: record.verificationMode.rawValue, verificationMode: record.verificationMode, totalBytesProcessed: recordedBytes, averageSpeed: 0, copyDurationSeconds: record.copyDurationSeconds, verifyDurationSeconds: record.verifyDurationSeconds, - clientLogoData: nil, companyLogoData: nil, photographyJob: nil, notes: notes, + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, operatorName: EvidenceWriter.normalizedNotes(record.reportSettings.operatorName), notes: notes, safetyState: TransferLibraryPresentation.safetyState(for: record), hasRecordedTiming: record.startedAt != nil && record.endedAt != nil) try Task.checkCancellation() diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index a8e19a61..f4bb0593 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -905,7 +905,7 @@ class SharedAppCoordinator: ObservableObject { /// waits for it on every platform (the Mac's stricter rule, decided). var isAnalysingSource: Bool { guard let sourceURL else { return false } - return folderInfoService.isAwaitingSourceInfo(for: sourceURL) + return folderInfoService.isAwaitingSourceInfo(for: sourceURL) || appleDoubleSelection.isReviewing } var isSelectedSourceKnownEmpty: Bool { @@ -2831,7 +2831,7 @@ class SharedAppCoordinator: ObservableObject { destinations: destinationURLs, settings: cameraLabelSettings, verificationMode: verificationMode, - sourceIssue: folderInfoService.sourceScanError ?? appleDoubleSelection.readinessIssue, + sourceIssue: folderInfoService.sourceScanError ?? appleDoubleSelection.error, destinationWarnings: independence.warnings, availableBytes: { self.getDriveCapacity(for: $0) }, isWritable: TransferReadiness.isWritableFolder diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index 04fd81d3..a6d6bf91 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -24,9 +24,16 @@ struct TransferHistoryDocument: FileDocument { record.reportSettings.clientName].map(quote).joined(separator: ",") } func pair(_ key: String, _ value: String) -> String { quote(key) + "," + quote(value) } + func detail(_ value: String) -> String { + guard let first = value.unicodeScalars.first(where: { !CharacterSet.whitespacesAndNewlines.contains($0) }), + ["=", "+", "-", "@"].contains(String(first)) else { return value } + return "'" + value + } let telemetry = record.performanceTelemetry let timingSummary = [ "\n# Summary", + pair("operatorName", detail(record.reportSettings.operatorName)), + pair("notes", detail(record.reportSettings.notes)), pair("copyDurationSeconds", record.copyDurationSeconds.map { String($0) } ?? ""), pair("verifyDurationSeconds", record.verifyDurationSeconds.map { String($0) } ?? ""), pair("overlapDurationSeconds", telemetry?.overlapDurationSeconds.map { String($0) } ?? ""), @@ -50,6 +57,8 @@ struct TransferHistoryDocument: FileDocument { let verificationMode: VerificationMode let projectName: String let clientName: String + let operatorName: String? + let notes: String? let ascMHLRequested: Bool let copyDurationSeconds: TimeInterval? let verifyDurationSeconds: TimeInterval? @@ -63,6 +72,8 @@ struct TransferHistoryDocument: FileDocument { destinations: record.destinations.map { $0.url.path }, state: record.state, summary: record.summary, createdAt: record.createdAt, verificationMode: record.verificationMode, projectName: record.reportSettings.projectName, clientName: record.reportSettings.clientName, + operatorName: EvidenceWriter.normalizedNotes(record.reportSettings.operatorName), + notes: EvidenceWriter.normalizedNotes(record.reportSettings.notes), ascMHLRequested: record.generateASCMHL, copyDurationSeconds: record.copyDurationSeconds, verifyDurationSeconds: record.verifyDurationSeconds, diff --git a/Shared/Core/ViewModels/AppleDoubleReviewModel.swift b/Shared/Core/ViewModels/AppleDoubleReviewModel.swift index e74c2f1a..20409d42 100644 --- a/Shared/Core/ViewModels/AppleDoubleReviewModel.swift +++ b/Shared/Core/ViewModels/AppleDoubleReviewModel.swift @@ -22,15 +22,18 @@ final class AppleDoubleReviewModel: ObservableObject { init(defaults: UserDefaults) { self.defaults = defaults - enabled = defaults.bool(forKey: "BitMatchExcludeAppleDouble") + enabled = defaults.object(forKey: "BitMatchExcludeAppleDouble") == nil + ? true : defaults.bool(forKey: "BitMatchExcludeAppleDouble") } deinit { task?.cancel() } var summaryLine: String? { guard enabled, let paths else { return nil } - return paths.isEmpty ? "No AppleDouble companions will be excluded" : "\(paths.count) AppleDouble source \(paths.count == 1 ? "file" : "files") will be excluded. Keep the source." + return paths.isEmpty ? nil : "\(paths.count) AppleDouble metadata \(paths.count == 1 ? "file" : "files") excluded" } + var isReviewing: Bool { enabled && source != nil && paths == nil && error == nil } + var readinessIssue: String? { guard enabled, source != nil else { return nil } return error ?? (paths == nil ? "Reviewing AppleDouble exclusions…" : nil) diff --git a/Shared/Views/ReportView.swift b/Shared/Views/ReportView.swift index afe6ac65..5ee882a5 100644 --- a/Shared/Views/ReportView.swift +++ b/Shared/Views/ReportView.swift @@ -70,16 +70,30 @@ struct ReportView: View { rows } - private var integritySummary: ResultIntegritySummary { - ResultIntegritySummary(rows: rows) - } - private var verifiedFileCount: Int { rows.filter(TransferOutcomePresentation.isVerified).count } private var issueCount: Int { - integritySummary.issueRows.count + reportIssueRows.count + } + + private var reportIssueRows: [ResultRow] { + rows.filter { !$0.isSuccessStatus && ResultOutcome(statusText: $0.status) != .excludedAppleDouble } + } + + private var excludedResultCount: Int { + rows.filter { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }.count + } + + private var reportUnsafeReason: String { + let selected = rows.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble } + if s.safetyState == .needsAttention, s.photographyJob == nil, + excludedResultCount > 0, reportIssueRows.isEmpty, !selected.isEmpty, + selected.allSatisfy(TransferOutcomePresentation.isVerified) { + return "Selected files verified. AppleDouble metadata was intentionally excluded; the whole card is not backed up." + } + return Self.unsafeReportReason(safetyState: s.safetyState, photographyJob: s.photographyJob) } private var isFullyVerified: Bool { s.safetyState == .safeToErase } @@ -191,10 +205,16 @@ struct ReportView: View { append(pdfOutcomeSection) append(pdfSummarySection) if let notes = s.notes, !notes.isEmpty { - append(VStack(alignment: .leading, spacing: 5) { - Text("Notes").font(.system(size: 12, weight: .semibold)) - Text(notes).font(.system(size: 10)).fixedSize(horizontal: false, vertical: true) - }) + let lines = notes.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .components(separatedBy: "\n") + for (index, line) in lines.enumerated() { + append(VStack(alignment: .leading, spacing: 5) { + if index == 0 { Text("Notes").font(.system(size: 12, weight: .semibold)) } + Text(line.isEmpty ? " " : line).font(.system(size: 10)) + .fixedSize(horizontal: false, vertical: true) + }, separated: index == 0) + } } if s.photographyJob != nil { append(photographyJobSection) } append(pdfLocationsSection) @@ -228,7 +248,7 @@ struct ReportView: View { } if issueCount > 0 { append(Text("Issues Detail (\(issueCount))").font(.system(size: 14, weight: .semibold))) - for group in ResultPresentation.issueGroups(rows) { + for group in ResultPresentation.issueGroups(reportIssueRows) { for (index, row) in group.rows.prefix(10).enumerated() { append(VStack(alignment: .leading, spacing: 4) { if index == 0 { issueGroupHeader(group) } @@ -307,7 +327,7 @@ struct ReportView: View { Image(systemName: safe ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") VStack(alignment: .leading, spacing: 3) { Text(safe ? "VERIFICATION SUCCESSFUL" : "NOT SAFE TO ERASE").font(.system(size: 12, weight: .semibold)) - Text(safe ? "All required copies passed verification." : Self.unsafeReportReason(safetyState: s.safetyState, photographyJob: s.photographyJob)) + Text(safe ? "All required copies passed verification." : reportUnsafeReason) .font(.system(size: 10)) } Spacer(minLength: 0) @@ -404,7 +424,7 @@ struct ReportView: View { Text("\(s.totalFiles) \(s.totalFiles == 1 ? "file" : "files") · \(verifiedFileCount) verified \(verifiedFileCount == 1 ? "copy" : "copies") · \(issueCount) file \(issueCount == 1 ? "issue" : "issues") · \(totalSizeFormatted)") .font(.system(size: 10)) if rows.contains(where: { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) { - Text("\(rows.filter { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }.count) intentionally excluded results").font(.system(size: 10)) + Text("\(excludedResultCount) intentionally excluded \(excludedResultCount == 1 ? "result" : "results")").font(.system(size: 10)) } if s.hasRecordedTiming { Text("Elapsed: \(duration)" + (s.averageSpeed > 0 ? " · \(throughput)" : "")) @@ -413,6 +433,9 @@ struct ReportView: View { if let phaseDuration { Text(phaseDuration).font(.system(size: 10)).foregroundColor(.secondary) } + if let operatorName = s.operatorName, !operatorName.isEmpty { + Text("Operator: \(operatorName)").font(.system(size: 10)).fixedSize(horizontal: false, vertical: true) + } }.frame(maxWidth: .infinity, alignment: .leading) } @@ -422,6 +445,9 @@ struct ReportView: View { Text("Verification Summary") .font(.system(size: 14, weight: .semibold)) + if let operatorName = s.operatorName, !operatorName.isEmpty { + Text("Operator: \(operatorName)").font(.system(size: 11)) + } Grid(horizontalSpacing: 24, verticalSpacing: 8) { GridRow { Text("Mode:").foregroundColor(.secondary).font(.system(size: 11)) @@ -763,7 +789,7 @@ struct ReportView: View { Text("Issues Detail (\(issueCount))") .font(.system(size: 14, weight: .semibold)) - let groups = ResultPresentation.issueGroups(rows) + let groups = ResultPresentation.issueGroups(reportIssueRows) VStack(alignment: .leading, spacing: 12) { ForEach(groups) { group in @@ -929,7 +955,7 @@ struct ReportView: View { Text("NOT SAFE TO ERASE") .font(.system(size: 14, weight: .bold)) .foregroundColor(s.safetyState == .failed ? .red : .orange) - Text(Self.unsafeReportReason(safetyState: s.safetyState, photographyJob: s.photographyJob)) + Text(reportUnsafeReason) .font(.system(size: 11, weight: .medium)) if issueCount > 0 { Text("Review the \(issueCount) file \(issueCount == 1 ? "issue" : "issues") in this report before trying again.") @@ -1002,7 +1028,7 @@ struct ReportView: View { @ViewBuilder private var summaryTableContent: some View { - let groups = ResultPresentation.issueGroups(rows) + let groups = ResultPresentation.issueGroups(reportIssueRows) VStack(spacing: 2) { ForEach(groups) { group in diff --git a/Shared/Views/Setup/AppleDoubleSelectionView.swift b/Shared/Views/Setup/AppleDoubleSelectionView.swift index f0d454d8..3cf88e8e 100644 --- a/Shared/Views/Setup/AppleDoubleSelectionView.swift +++ b/Shared/Views/Setup/AppleDoubleSelectionView.swift @@ -1,32 +1,20 @@ import SwiftUI +/// Shared File handling preference. The exclusion inventory stays available here. struct AppleDoubleSelectionView: View { @ObservedObject var review: AppleDoubleReviewModel var body: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle("Exclude AppleDouble companion files", isOn: $review.enabled) - Text("Preserve all files by default. Only recognized AppleDouble metadata companions can be excluded; other sidecars stay in the copy.") - .font(.footnote).foregroundStyle(.secondary) - if review.enabled { - if let paths = review.paths { - Text("\(paths.count) source \(paths.count == 1 ? "file" : "files") will be excluded") - .font(.subheadline.weight(.semibold)) - if !paths.isEmpty { - DisclosureGroup("Review excluded files") { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(paths, id: \.self) { Text($0).font(.caption).textSelection(.enabled) } - }.frame(maxWidth: .infinity, alignment: .leading) - }.frame(maxHeight: 160) - } - Text("These companions won't be copied or checked. Keep the source: this cannot mark the whole card safe to erase.") - .font(.footnote).foregroundStyle(.orange) - } - } else { - Text(review.readinessIssue ?? "Choose a source to review exclusions.") - .font(.footnote).foregroundStyle(.secondary) - } + Toggle("Ignore AppleDouble metadata files", isOn: $review.enabled) + Text("Skip recognized macOS ._ companion files during transfers. Turn off to preserve them. Other hidden files and camera sidecars are kept.") + .font(.footnote).foregroundStyle(.secondary) + if review.enabled, let paths = review.paths, !paths.isEmpty { + DisclosureGroup("Excluded files (\(paths.count))") { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(paths, id: \.self) { Text($0).font(.caption).textSelection(.enabled) } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 160) } } } diff --git a/Shared/Views/Setup/CoordinatorSetupScreen.swift b/Shared/Views/Setup/CoordinatorSetupScreen.swift index e8c355f5..c74137a5 100644 --- a/Shared/Views/Setup/CoordinatorSetupScreen.swift +++ b/Shared/Views/Setup/CoordinatorSetupScreen.swift @@ -125,9 +125,9 @@ struct CoordinatorSetupScreen