From da00c4e1480b9f95301e7594d8e52329aa662ff3 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 14:11:53 -0400 Subject: [PATCH 1/2] Tuck file handling into Settings and add optional transfer details --- BitMatch-iPad/Views/ModularContentView.swift | 5 ++ .../TransferDetailsReportTests.swift | 67 +++++++++++++++++++ .../WorkflowSnapshotTests.swift | 22 ++++++ BitMatch/Views/PreferencesWindow.swift | 7 ++ BitMatchTests/AppleDoubleWorkflowTests.swift | 35 +++++++++- BitMatchTests/ReportEvidenceBytesTests.swift | 25 ++++++- .../SharedSettingsPersistenceTests.swift | 4 ++ .../TransferDetailsReportTests.swift | 67 +++++++++++++++++++ BitMatchTests/WorkflowSnapshotTests.swift | 9 ++- CHANGELOG.md | 2 + .../BitMatchEngine/ASCMHLGenerator.swift | 24 +++++-- .../Evidence/EvidenceWriter.swift | 9 ++- .../BitMatchEngine/Models/Results.swift | 31 ++++++++- .../BitMatchEngine/TransferCompletion.swift | 5 +- .../ASCMHLGeneratorTests.swift | 32 +++++++++ .../TransferDetailsTests.swift | 45 +++++++++++++ README.md | 2 +- Shared/Core/Models/ReportSummary.swift | 1 + Shared/Core/Services/CopyVerifyExecutor.swift | 4 +- Shared/Core/Services/ReportExporter.swift | 3 +- .../Core/Services/SharedAppCoordinator.swift | 4 +- .../Services/TransferHistoryDocument.swift | 11 +++ .../ViewModels/AppleDoubleReviewModel.swift | 7 +- Shared/Views/ReportView.swift | 6 ++ .../Setup/AppleDoubleSelectionView.swift | 34 +++------- .../Views/Setup/CoordinatorSetupScreen.swift | 4 +- .../Views/Setup/TransferDetailsSection.swift | 38 +++++++++++ Shared/Views/TransferLibraryView.swift | 6 ++ docs/GUIDE.md | 6 +- docs/TODO.md | 8 +-- docs/TRANSFER_DETAILS_VALIDATION.md | 36 ++++++++++ 31 files changed, 507 insertions(+), 52 deletions(-) create mode 100644 BitMatch-iPadTests/TransferDetailsReportTests.swift create mode 100644 BitMatchTests/TransferDetailsReportTests.swift create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/TransferDetailsTests.swift create mode 100644 Shared/Views/Setup/TransferDetailsSection.swift create mode 100644 docs/TRANSFER_DETAILS_VALIDATION.md 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/TransferDetailsReportTests.swift b/BitMatch-iPadTests/TransferDetailsReportTests.swift new file mode 100644 index 00000000..393cf9bd --- /dev/null +++ b/BitMatch-iPadTests/TransferDetailsReportTests.swift @@ -0,0 +1,67 @@ +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 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/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/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..3d8ddd29 --- /dev/null +++ b/BitMatchTests/TransferDetailsReportTests.swift @@ -0,0 +1,67 @@ +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 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..e8cf2e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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. - 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. 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/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..e3a27671 100644 --- a/Shared/Views/ReportView.swift +++ b/Shared/Views/ReportView.swift @@ -413,6 +413,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 +425,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)) 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