diff --git a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift index ae6672d..30677f4 100644 --- a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift @@ -76,10 +76,15 @@ struct BackupSettingsPane: View { let panel = NSSavePanel() panel.title = state.loc("Export Configuration") panel.prompt = state.loc("Export") - panel.nameFieldStringValue = "LockIME Backup.\(ConfigBackup.fileExtension)" + // Stamp the suggested name with the local time so successive exports are + // distinguishable in Finder instead of all defaulting to one filename. + // Feed the panel the *stem* (no extension): `allowedContentTypes` makes it + // append the single correct `.lockime`. Embedding the extension here makes + // NSSavePanel append a SECOND one (`.lockime.lockime`), so we leave it off. if let type = UTType(filenameExtension: ConfigBackup.fileExtension) { panel.allowedContentTypes = [type] } + panel.nameFieldStringValue = ConfigBackup.suggestedFileNameStem(date: Date()) panel.isExtensionHidden = false NSApp.activate(ignoringOtherApps: true) guard panel.runModal() == .OK, let url = panel.url else { return } diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift index 5275222..610f34f 100644 --- a/Sources/LockIMEKit/Backup/ConfigBackup.swift +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -149,6 +149,57 @@ public extension ConfigBackup { /// The conventional file extension for exported backups. static let fileExtension = "lockime" + /// The fixed, brand prefix every exported backup's default filename carries + /// (before the timestamp). ASCII and **unlocalized on purpose**: it keeps a + /// folder of exports grouped and sortable regardless of the app's display + /// language, and "LockIME" is the brand (never translated). + static let fileNamePrefix = "LockIME Backup" + + /// The default export filename **without** the extension — the value the + /// export `NSSavePanel` should put in its name field. The panel appends the + /// single, correct `.lockime` from its `allowedContentTypes`; see + /// `suggestedFileName` for why the extension is *not* embedded here. + /// + /// The stem is the brand prefix plus a wall-clock timestamp built from + /// calendar components in a fixed, locale-independent layout + /// (`yyyy-MM-dd HH-mm-ss`) — deliberately *not* a localized `DateFormatter` + /// string, which would (a) leak the **system** locale into a name the app's + /// language override is meant to govern and (b) risk filename-illegal + /// separators (a `:` is reserved on macOS). Time fields use `-`, never `.`, + /// so the stem carries **no dots at all** — nothing `NSSavePanel` could + /// mistake for an existing extension. The ASCII layout sorts chronologically + /// by name. + /// + /// - Parameters: + /// - date: the moment to stamp (the caller passes `Date()` at export time). + /// - timeZone: the zone the wall-clock time is rendered in; defaults to the + /// user's current zone, injectable for deterministic tests. + static func suggestedFileNameStem(date: Date, timeZone: TimeZone = .current) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let stamp = String( + format: "%04d-%02d-%02d %02d-%02d-%02d", + c.year ?? 0, c.month ?? 0, c.day ?? 0, c.hour ?? 0, c.minute ?? 0, c.second ?? 0 + ) + return "\(fileNamePrefix) \(stamp)" + } + + /// The full default export filename, `.lockime`, for contexts that + /// need a complete name (logging, non-panel saves). + /// + /// The export panel does **not** use this — it feeds `suggestedFileNameStem` + /// and lets `allowedContentTypes` append the extension. Embedding the + /// extension in `NSSavePanel.nameFieldStringValue` risks a doubled + /// `….lockime.lockime`: the panel ensures the saved file carries an allowed + /// extension, and if its own extension-detection doesn't cleanly match the + /// embedded one (notably when the stem contains dots, e.g. a `23.15.28` + /// time), it appends `.lockime` a second time. Letting the panel own the + /// extension sidesteps that entirely. + static func suggestedFileName(date: Date, timeZone: TimeZone = .current) -> String { + "\(suggestedFileNameStem(date: date, timeZone: timeZone)).\(fileExtension)" + } + /// Build a backup envelope from a live configuration, dropping the per-device /// runtime state and capturing a display-name catalog for every referenced /// input source whose name is known. diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift index 0fb3704..86d801f 100644 --- a/Tests/LockIMEKitTests/ConfigBackupTests.swift +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -248,4 +248,117 @@ struct ConfigBackupTests { #expect(payload.urlRules.isEmpty) #expect(payload.sourceNames.isEmpty) } + + // MARK: - Suggested export filename + + /// A `Date` at the given wall-clock components in a fixed zone, so the + /// expected filename is deterministic regardless of the test host's zone. + private func date( + _ y: Int, _ mo: Int, _ d: Int, _ h: Int, _ mi: Int, _ s: Int, + in timeZone: TimeZone + ) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + var c = DateComponents() + (c.year, c.month, c.day, c.hour, c.minute, c.second) = (y, mo, d, h, mi, s) + return calendar.date(from: c)! + } + + @Test("suggestedFileNameStem is the panel name field value — no extension, no dots") + func suggestedFileNameStemHasNoDots() { + // The export panel feeds this to NSSavePanel and lets allowedContentTypes + // append the extension. The stem must carry NO dots — a dot would look + // like an extension to the panel and risk a doubled `.lockime.lockime`. + let tz = TimeZone(identifier: "America/New_York")! + let stem = ConfigBackup.suggestedFileNameStem(date: date(2026, 6, 22, 23, 15, 28, in: tz), timeZone: tz) + #expect(stem == "LockIME Backup 2026-06-22 23-15-28") + #expect(!stem.contains(".")) + #expect((stem as NSString).pathExtension.isEmpty) + } + + @Test("suggestedFileName is the stem plus exactly the .lockime extension") + func suggestedFileNameIsStemPlusExtension() { + let tz = TimeZone(identifier: "UTC")! + let date = date(2026, 6, 22, 23, 15, 28, in: tz) + let stem = ConfigBackup.suggestedFileNameStem(date: date, timeZone: tz) + let full = ConfigBackup.suggestedFileName(date: date, timeZone: tz) + #expect(full == "\(stem).\(ConfigBackup.fileExtension)") + } + + @Test("suggestedFileName stamps a fixed yyyy-MM-dd HH-mm-ss layout") + func suggestedFileNameLayout() { + let tz = TimeZone(identifier: "America/New_York")! + let name = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 14, 30, 15, in: tz), timeZone: tz) + #expect(name == "LockIME Backup 2026-06-22 14-30-15.lockime") + } + + @Test("suggestedFileName zero-pads every component") + func suggestedFileNameZeroPads() { + let tz = TimeZone(identifier: "UTC")! + let name = ConfigBackup.suggestedFileName(date: date(2026, 1, 2, 3, 4, 5, in: tz), timeZone: tz) + #expect(name == "LockIME Backup 2026-01-02 03-04-05.lockime") + } + + @Test("suggestedFileName renders wall-clock time in the given zone") + func suggestedFileNameHonorsZone() { + // The same instant reads as different wall-clock times per zone, so the + // stamp must reflect the zone passed in — not the host's. + let instant = date(2026, 6, 22, 12, 0, 0, in: TimeZone(identifier: "UTC")!) + let tokyo = ConfigBackup.suggestedFileName(date: instant, timeZone: TimeZone(identifier: "Asia/Tokyo")!) + let utc = ConfigBackup.suggestedFileName(date: instant, timeZone: TimeZone(identifier: "UTC")!) + #expect(tokyo == "LockIME Backup 2026-06-22 21-00-00.lockime") + #expect(utc == "LockIME Backup 2026-06-22 12-00-00.lockime") + } + + @Test("suggestedFileName carries the brand prefix and the backup extension") + func suggestedFileNamePrefixAndExtension() { + let tz = TimeZone(identifier: "UTC")! + let name = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 9, 8, 7, in: tz), timeZone: tz) + #expect(name.hasPrefix(ConfigBackup.fileNamePrefix + " ")) + #expect(name.hasSuffix("." + ConfigBackup.fileExtension)) + } + + @Test("suggestedFileName produces a filename-legal name (no reserved separators)") + func suggestedFileNameIsLegal() { + let tz = TimeZone(identifier: "UTC")! + let name = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 14, 30, 15, in: tz), timeZone: tz) + // `:` is reserved on macOS (and `/` is the path separator); the stamp + // must avoid both — that's why the time uses `-`. + #expect(!name.contains(":")) + #expect(!name.contains("/")) + } + + @Test("suggestedFileName contains exactly one dot — the extension separator") + func suggestedFileNameHasSingleDot() { + // Regression guard: a timestamp with interior dots (e.g. `…23.15.28`) + // gives the name a multi-part trailing extension that fools NSSavePanel + // into appending `.lockime` a second time → `….lockime.lockime`. The only + // `.` in the name must be the one before the extension. + let tz = TimeZone(identifier: "UTC")! + let name = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 23, 15, 28, in: tz), timeZone: tz) + #expect(name.filter { $0 == "." }.count == 1) + #expect((name as NSString).pathExtension == ConfigBackup.fileExtension) + } + + @Test("suggestedFileName defaults to the current zone (the production call site)") + func suggestedFileNameDefaultsToCurrentZone() { + // The export panel calls `suggestedFileName(date:)` with no zone, leaning + // on the `timeZone = .current` default. Exercise that default path without + // hardcoding a host-zone-dependent expected string: it must equal the same + // call made with `.current` passed explicitly. + let instant = date(2026, 6, 22, 12, 0, 0, in: TimeZone(identifier: "UTC")!) + #expect(ConfigBackup.suggestedFileName(date: instant) + == ConfigBackup.suggestedFileName(date: instant, timeZone: .current)) + } + + @Test("suggestedFileName names later exports so they sort after earlier ones") + func suggestedFileNameSortsChronologically() { + let tz = TimeZone(identifier: "UTC")! + // The distinguishability goal: distinct moments give distinct names, and + // lexicographic order matches chronological order (sortable in Finder). + let earlier = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 9, 0, 0, in: tz), timeZone: tz) + let later = ConfigBackup.suggestedFileName(date: date(2026, 6, 22, 9, 0, 1, in: tz), timeZone: tz) + #expect(earlier != later) + #expect(earlier < later) + } }