Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/LockIME/UI/Settings/BackupSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
51 changes: 51 additions & 0 deletions Sources/LockIMEKit/Backup/ConfigBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<stem>.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.
Expand Down
113 changes: 113 additions & 0 deletions Tests/LockIMEKitTests/ConfigBackupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading