From 2a608c89620477175d5a22282aabe32d9b66d9b4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:09:19 +0100 Subject: [PATCH] =?UTF-8?q?File=20dialogs=20&=20artefact=20helpers=20?= =?UTF-8?q?=E2=80=94=20dedicated=20pickers=20+=20host=20helpers=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One dedicated method per purpose: selectTargetFile (save .ti1), selectExistingTarget (ti1/ti2), selectProfileFile (icc/icm/mpp — never ti*, #172), selectSpectrumFile (.sp), selectDatasetFile (open-only ti3/txt/cgats/csv, #211), selectCsvSavePath, selectCalFile, selectDirectory. No shared generic picker API (#103/#210/#211). - Ti2Header: TARGET_INSTRUMENT / NUMBER_OF_SETS / NUMBER_OF_PAGES + sibling .ti1 detection; NUMBER_OF_FIELDS explicitly not patch count - TiffPreview: host-side TIFF→PNG thumbnail, 1200px max edge (#58) - ArtefactFiles: defaultWorkingDirectory, readBase64, appInfo - 7 new tests; 47/47 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Files/ArtefactFiles.swift | 28 ++++ .../Sources/ICCeryCore/Files/Ti2Header.swift | 53 ++++++++ .../ICCeryCore/Files/TiffPreview.swift | 38 ++++++ Sources/ICCery/FileDialogService.swift | 96 ++++++++------ .../ICCeryCoreTests/ArtefactFilesTests.swift | 124 ++++++++++++++++++ 5 files changed, 298 insertions(+), 41 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/TiffPreview.swift create mode 100644 Tests/ICCeryCoreTests/ArtefactFilesTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift new file mode 100644 index 0000000..48303ab --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Small host-side file helpers (issue #6). +public enum ArtefactFiles { + + /// `get_default_working_dir` — `resolveSafeCwd(nil)`. + public static func defaultWorkingDirectory() -> URL { + PathSecurity.resolveSafeCwd(nil) + } + + /// `read_file_base64` — for **text artefacts** the UI needs verbatim + /// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF) + /// go through `TiffPreview` instead. + public static func readBase64(_ url: URL) throws -> String { + try Data(contentsOf: url).base64EncodedString() + } + + /// `get_app_info` — version + build for the About dialog. + public static func appInfo( + bundle: Bundle = .main + ) -> (version: String, build: String) { + let info = bundle.infoDictionary ?? [:] + return ( + info["CFBundleShortVersionString"] as? String ?? "0.0.0", + info["CFBundleVersion"] as? String ?? "0" + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift new file mode 100644 index 0000000..79560e9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume). +/// `parse_ti2_header` reads only CGATS keyword lines — the data grid +/// itself belongs to issue #30. +public struct Ti2Header: Sendable, Equatable { + /// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`). + public var instrument: String? + /// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is + /// the CGATS column count, *not* the patch count. + public var patchCount: Int? + /// `NUMBER_OF_PAGES`. + public var pageCount: Int? + /// A sibling `.ti1` exists next to the parsed file. + public var hasSiblingTi1 = false + + public static func parse( + _ url: URL, + fileManager: FileManager = .default + ) -> Ti2Header { + var header = Ti2Header() + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return header + } + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") { + break + } + // CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`. + guard let space = line.firstIndex(of: " ") else { continue } + let key = String(line[.. Data? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxEdge, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, 0, options as CFDictionary + ) else { return nil } + + let out = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + out, UTType.png.identifier as CFString, 1, nil + ) else { return nil } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { return nil } + return out as Data + } +} diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index 84b7c0d..d7b7a33 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -1,20 +1,20 @@ import AppKit import UniformTypeIdentifiers -/// NSOpenPanel / NSSavePanel wrappers (issue #6). All CGATS/ICC file -/// picking in the app goes through this service — the v1 equivalent of -/// the `select_*` Tauri commands (docs/21 §Dialogs). +/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method +/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs). +/// No call site shares a generic picker (#103/#210/#211). @MainActor final class FileDialogService { static let shared = FileDialogService() private init() {} - // MARK: - Directory + // MARK: - selectDirectory /// `#btnBrowse` — working directory for Argyll artefacts. /// Defaults to Documents (docs/06 §Empty cwd). - func chooseDirectory(startingAt start: URL? = nil) -> URL? { + func selectDirectory(startingAt start: URL? = nil) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false @@ -25,69 +25,83 @@ final class FileDialogService { return run(panel) } - // MARK: - Open files + // MARK: - Dedicated open pickers - func chooseTI1(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti1"], startingAt: start) + /// `selectTargetFile` — **save** panel for the new `.ti1` target. + func selectTargetFile(startingAt start: URL? = nil) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = "target.ti1" + panel.allowedContentTypes = utTypes(["ti1"]) + panel.allowsOtherFileTypes = false + panel.directoryURL = start + panel.message = "Choose the .ti1 target file to create" + return run(panel) } - func chooseTI2(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti2"], startingAt: start) + /// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140). + func selectExistingTarget(startingAt start: URL? = nil) -> URL? { + open(extensions: ["ti1", "ti2"], startingAt: start, + message: "Open an existing target (.ti1 or .ti2)") } - /// Stage 1 "Open Existing" — `.ti1` or `.ti2` (docs/06 §Resume). - func chooseExistingTarget(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti1", "ti2"], startingAt: start) + /// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*` + /// (#172: the profile filter must not accept datasets). + func selectProfileFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["icc", "icm", "mpp"], startingAt: start, + message: "Choose an ICC/ICM profile or measurement preconditioning file") } - func chooseTI3(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti3"], startingAt: start) + /// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i). + func selectSpectrumFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["sp"], startingAt: start, + message: "Choose a custom illuminant spectrum (.sp)") } - /// ICC/ICM picker (profiles, preconditioning, calibration `.cal`). - func chooseProfile(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["icc", "icm"], startingAt: start) + /// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`, + /// `.cgats`, `.csv`). Always an *open* dialog, never save (#211). + func selectDatasetFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start, + message: "Import a measured dataset") } - func chooseCalibration(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["cal"], startingAt: start) + /// `selectCsvSavePath` — verification-history CSV export. + func selectCsvSavePath(startingAt start: URL? = nil) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = "verification-history.csv" + panel.allowedContentTypes = utTypes(["csv"]) + panel.allowsOtherFileTypes = false + panel.directoryURL = start + return run(panel) } - func chooseFile( + /// `selectCalFile` — `.cal` calibration curves. + func selectCalFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["cal"], startingAt: start, + message: "Choose a calibration file (.cal)") + } + + // MARK: - Internals (private — not a shared public picker API) + + private func open( extensions: [String], - startingAt start: URL? = nil, - message: String? = nil + startingAt start: URL?, + message: String? ) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false - panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } + panel.allowedContentTypes = utTypes(extensions) panel.allowsOtherFileTypes = true panel.directoryURL = start if let message { panel.message = message } return run(panel) } - // MARK: - Save - - func saveFile( - defaultName: String, - extensions: [String], - startingAt start: URL? = nil, - message: String? = nil - ) -> URL? { - let panel = NSSavePanel() - panel.nameFieldStringValue = defaultName - panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } - panel.allowsOtherFileTypes = true - panel.directoryURL = start - if let message { panel.message = message } - return run(panel) + private func utTypes(_ extensions: [String]) -> [UTType] { + extensions.compactMap { UTType(filenameExtension: $0) } } - // MARK: - Internals - private func run(_ panel: NSOpenPanel) -> URL? { panel.runModal() == .OK ? panel.url : nil } diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift new file mode 100644 index 0000000..9a8b591 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift @@ -0,0 +1,124 @@ +import Testing +import Foundation +import ImageIO +import UniformTypeIdentifiers +@testable import ICCeryCore + +private func tempURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-af-\(UUID().uuidString)") + .appendingPathComponent(name) +} + +@Suite("Ti2Header") +struct Ti2HeaderTests { + @Test func parsesKeywordsAndSibling() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ti2-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try """ + CTI2 + TARGET_INSTRUMENT "i1iO" + NUMBER_OF_FIELDS 9 + NUMBER_OF_SETS 800 + NUMBER_OF_PAGES 3 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + """.write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8) + try "CGATS".write( + to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8 + ) + + let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) + #expect(h.instrument == "i1iO") + #expect(h.patchCount == 800) + #expect(h.pageCount == 3) + #expect(h.hasSiblingTi1) + } + + @Test func missingFileYieldsEmptyHeader() { + let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) + #expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) + } + + @Test func numberOfFieldsIsNotPatchCount() throws { + let url = tempURL("t.ti2") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( + to: url, atomically: true, encoding: .utf8 + ) + #expect(Ti2Header.parse(url).patchCount == 52) + } +} + +@Suite("TiffPreview") +struct TiffPreviewTests { + /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. + private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { + let url = tempURL("big.tif") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let ctx = CGContext( + data: nil, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let image = ctx.makeImage()! + + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, UTType.tiff.identifier as CFString, 1, nil + ) else { throw CocoaError(.fileWriteUnknown) } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) } + return url + } + + @Test func producesCappedPNG() throws { + let tiff = try makeTiff() + let png = TiffPreview.previewPNG(tiff: tiff) + #expect(png != nil) + // PNG magic + #expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + // Verify the cap by decoding the thumbnail header. + let src = CGImageSourceCreateWithData(png! as CFData, nil)! + let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! + #expect(max(img.width, img.height) <= TiffPreview.maxEdge) + #expect(img.width == 1200) + } + + @Test func nonTiffReturnsNil() throws { + let url = tempURL("not-tiff.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + #expect(TiffPreview.previewPNG(tiff: url) == nil) + } +} + +@Suite("ArtefactFiles") +struct ArtefactFilesTests { + @Test func base64RoundTrip() throws { + let url = tempURL("a.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + let b64 = try ArtefactFiles.readBase64(url) + #expect(Data(base64Encoded: b64) == Data("hello".utf8)) + } + + @Test func defaultWorkingDirExists() { + #expect(FileManager.default.fileExists( + atPath: ArtefactFiles.defaultWorkingDirectory().path + )) + } +} -- 2.39.5