From 5b33e07220082e028f3f52a9a741aa55c6495690 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:22:49 +0000 Subject: [PATCH] Refactor: unify capture filename assembly into CaptureFilename The ' .' rule lived in CaptureManager.saveImage and was re-implemented in PreferencesWindow.filenamePreview, so the two could drift (the preview also hardcoded .png regardless of the chosen format). Extract the assembly into a pure CaptureFilename.make(prefix:slug:timestamp:ext:) used by both, and add CaptureFormat.fileExtension to unify the jpg/png derivation. Real save path is behavior-preserving. The settings preview now reflects the selected format's extension (was always .png) and otherwise renders identically. Adds CaptureFilenameTests (6 cases). --- Sources/Stag/Capture/CaptureFilename.swift | 14 +++++++ Sources/Stag/CaptureManager.swift | 11 ++++-- Sources/Stag/Models/Preferences.swift | 8 ++++ Sources/Stag/Views/PreferencesWindow.swift | 10 +++-- Tests/StagTests/CaptureFilenameTests.swift | 46 ++++++++++++++++++++++ 5 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 Sources/Stag/Capture/CaptureFilename.swift create mode 100644 Tests/StagTests/CaptureFilenameTests.swift diff --git a/Sources/Stag/Capture/CaptureFilename.swift b/Sources/Stag/Capture/CaptureFilename.swift new file mode 100644 index 0000000..3c10f1c --- /dev/null +++ b/Sources/Stag/Capture/CaptureFilename.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Assembles capture filenames from their parts so the real save path +/// (`CaptureManager`) and the settings preview (`PreferencesWindow`) share one +/// rule and can't drift. Format: `.`. +enum CaptureFilename { + /// Builds a capture filename. An empty `prefix` falls back to `"Stag_"`; an + /// empty `slug` is omitted along with its separating space. + static func make(prefix: String, slug: String, timestamp: String, ext: String) -> String { + let resolvedPrefix = prefix.isEmpty ? "Stag_" : prefix + let middle = slug.isEmpty ? "" : "\(slug) " + return "\(resolvedPrefix)\(middle)\(timestamp).\(ext)" + } +} diff --git a/Sources/Stag/CaptureManager.swift b/Sources/Stag/CaptureManager.swift index 81e9a2c..b9d1aad 100644 --- a/Sources/Stag/CaptureManager.swift +++ b/Sources/Stag/CaptureManager.swift @@ -363,13 +363,16 @@ final class CaptureManager { let prefs = store.preferences let saveDir = URL(fileURLWithPath: prefs.expandedSavePath) try? FileManager.default.createDirectory(at: saveDir, withIntermediateDirectories: true) - let ext = prefs.defaultFormat == .jpeg ? "jpg" : "png" - let prefix = prefs.filePrefix.isEmpty ? "Stag_" : prefs.filePrefix // Smart filename: insert the source app (e.g. "Slack") between the prefix // and the timestamp when available — falls back to prefix+timestamp. let slug = prefs.useSmartFilenames ? CaptureContext.shared.filenameSlug() : "" - let middle = slug.isEmpty ? "" : "\(slug) " - let url = saveDir.appendingPathComponent("\(prefix)\(middle)\(Date().shotTimestamp).\(ext)") + let filename = CaptureFilename.make( + prefix: prefs.filePrefix, + slug: slug, + timestamp: Date().shotTimestamp, + ext: prefs.defaultFormat.fileExtension + ) + let url = saveDir.appendingPathComponent(filename) switch prefs.defaultFormat { case .png: image.pngWrite(to: url) diff --git a/Sources/Stag/Models/Preferences.swift b/Sources/Stag/Models/Preferences.swift index 7cb7cea..38511de 100644 --- a/Sources/Stag/Models/Preferences.swift +++ b/Sources/Stag/Models/Preferences.swift @@ -5,6 +5,14 @@ import Combine enum CaptureFormat: String, Codable, CaseIterable { case png, jpeg + + /// File extension for saved captures ("png" / "jpg"). + var fileExtension: String { + switch self { + case .png: return "png" + case .jpeg: return "jpg" + } + } } enum AfterCaptureAction: String, Codable, CaseIterable { diff --git a/Sources/Stag/Views/PreferencesWindow.swift b/Sources/Stag/Views/PreferencesWindow.swift index 0b87f40..be28010 100644 --- a/Sources/Stag/Views/PreferencesWindow.swift +++ b/Sources/Stag/Views/PreferencesWindow.swift @@ -599,9 +599,13 @@ private struct PreferencesView: View { // MARK: Helpers private var filenamePreview: String { - let prefix = prefs.filePrefix.isEmpty ? "Stag_" : prefs.filePrefix - let mid = prefs.useSmartFilenames ? "Safari " : "" - return "\(prefix)\(mid)2026-01-01.png" + let slug = prefs.useSmartFilenames ? "Safari" : "" + return CaptureFilename.make( + prefix: prefs.filePrefix, + slug: slug, + timestamp: "2026-01-01", + ext: prefs.defaultFormat.fileExtension + ) } private func actionDisplayName(_ action: AfterCaptureAction) -> String { diff --git a/Tests/StagTests/CaptureFilenameTests.swift b/Tests/StagTests/CaptureFilenameTests.swift new file mode 100644 index 0000000..847d1a3 --- /dev/null +++ b/Tests/StagTests/CaptureFilenameTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import Stag + +/// Filename assembly shared by CaptureManager (real save) and PreferencesWindow +/// (settings preview), plus CaptureFormat's extension mapping. +final class CaptureFilenameTests: XCTestCase { + + func testEmptyPrefixFallsBackToStag() { + XCTAssertEqual( + CaptureFilename.make(prefix: "", slug: "", timestamp: "2026-01-01", ext: "png"), + "Stag_2026-01-01.png" + ) + } + + func testCustomPrefixIsUsedVerbatim() { + XCTAssertEqual( + CaptureFilename.make(prefix: "Shot-", slug: "", timestamp: "2026-01-01", ext: "png"), + "Shot-2026-01-01.png" + ) + } + + func testSlugInsertedWithSeparatingSpace() { + XCTAssertEqual( + CaptureFilename.make(prefix: "", slug: "Slack", timestamp: "T", ext: "png"), + "Stag_Slack T.png" + ) + } + + func testEmptySlugOmitsSpace() { + let result = CaptureFilename.make(prefix: "Stag_", slug: "", timestamp: "T", ext: "jpg") + XCTAssertEqual(result, "Stag_T.jpg") + XCTAssertFalse(result.contains(" ")) + } + + func testExtensionIsApplied() { + XCTAssertEqual( + CaptureFilename.make(prefix: "P", slug: "App", timestamp: "ts", ext: "jpg"), + "PApp ts.jpg" + ) + } + + func testCaptureFormatFileExtension() { + XCTAssertEqual(CaptureFormat.png.fileExtension, "png") + XCTAssertEqual(CaptureFormat.jpeg.fileExtension, "jpg") + } +}