diff --git a/Package.swift b/Package.swift index 83cbfca65..804ca0b00 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency = let package = Package( name: "CodexBar", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -98,6 +99,9 @@ let package = Package( name: "CodexBarWidget", dependencies: ["CodexBarCore"], path: "Sources/CodexBarWidget", + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/Sources/CodexBar/About.swift b/Sources/CodexBar/About.swift index 677ea6e5a..a49571ef7 100644 --- a/Sources/CodexBar/About.swift +++ b/Sources/CodexBar/About.swift @@ -21,16 +21,16 @@ func showAbout() { ]) } - let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n") - credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar")) + let credits = NSMutableAttributedString(string: "\(AppStrings.tr("© 2025 Peter Steinberger. MIT License."))\n") + credits.append(makeLink(AppStrings.tr("GitHub"), urlString: "https://github.com/steipete/CodexBar")) credits.append(separator) - credits.append(makeLink("Website", urlString: "https://codexbar.app")) + credits.append(makeLink(AppStrings.tr("Website"), urlString: "https://codexbar.app")) credits.append(separator) - credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete")) + credits.append(makeLink(AppStrings.tr("Twitter"), urlString: "https://twitter.com/steipete")) credits.append(separator) - credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me")) + credits.append(makeLink(AppStrings.tr("Email"), urlString: "mailto:peter@steipete.me")) if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) { - var builtLine = "Built \(formatted)" + var builtLine = AppStrings.fmt("Built %@", formatted) if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" { builtLine += " (\(gitCommit)" #if DEBUG @@ -68,7 +68,7 @@ private func formattedBuildTimestamp(_ timestamp: String) -> String? { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short - formatter.locale = .current + formatter.locale = AppStrings.locale return formatter.string(from: date) } diff --git a/Sources/CodexBar/AppLanguage.swift b/Sources/CodexBar/AppLanguage.swift new file mode 100644 index 000000000..94152688a --- /dev/null +++ b/Sources/CodexBar/AppLanguage.swift @@ -0,0 +1,57 @@ +import Foundation + +enum AppLanguage: String, CaseIterable, Identifiable { + case system + case english = "en" + case simplifiedChinese = "zh-Hans" + case traditionalChinese = "zh-Hant" + + static let userDefaultsKey = "appLanguage" + + var id: String { + self.rawValue + } + + var localizationIdentifier: String? { + switch self { + case .system: + nil + case .english: + "en" + case .simplifiedChinese: + "zh-Hans" + case .traditionalChinese: + "zh-Hant" + } + } + + var locale: Locale { + switch self { + case .system: + .autoupdatingCurrent + case .english: + Locale(identifier: "en") + case .simplifiedChinese: + Locale(identifier: "zh-Hans") + case .traditionalChinese: + Locale(identifier: "zh-Hant") + } + } + + var displayName: String { + switch self { + case .system: + "System" + case .english: + "English" + case .simplifiedChinese: + "简体中文" + case .traditionalChinese: + "繁體中文" + } + } + + static func resolve(from defaults: UserDefaults) -> AppLanguage { + AppLanguage(rawValue: defaults.string(forKey: self.userDefaultsKey) ?? "") ?? .system + } +} diff --git a/Sources/CodexBar/AppStringResources.swift b/Sources/CodexBar/AppStringResources.swift new file mode 100644 index 000000000..0e11bac97 --- /dev/null +++ b/Sources/CodexBar/AppStringResources.swift @@ -0,0 +1,37 @@ +import Foundation + +enum AppStringResources { + private static let supportedLocalizations = ["en", "zh-Hans", "zh-Hant"] + + static func localizedString(for key: String, table: String, language: AppLanguage) -> String { + if let localized = self.localizedString( + for: key, + table: table, + localization: language.localizationIdentifier) + { + return localized + } + if let english = self.localizedString(for: key, table: table, localization: "en") { + return english + } + return key + } + + private static func localizedString(for key: String, table: String, localization: String?) -> String? { + guard let bundle = self.bundle(for: localization) else { return nil } + let sentinel = "__codexbar_missing_translation__" + let value = bundle.localizedString(forKey: key, value: sentinel, table: table) + return value == sentinel ? nil : value + } + + private static func bundle(for localization: String?) -> Bundle? { + guard let localization else { return Bundle.module } + guard self.supportedLocalizations.contains(localization), + let bundleURL = Bundle.module.resourceURL?.appendingPathComponent("\(localization).lproj"), + FileManager.default.fileExists(atPath: bundleURL.path) + else { + return nil + } + return Bundle(url: bundleURL) + } +} diff --git a/Sources/CodexBar/AppStrings.swift b/Sources/CodexBar/AppStrings.swift new file mode 100644 index 000000000..f04ac21b5 --- /dev/null +++ b/Sources/CodexBar/AppStrings.swift @@ -0,0 +1,415 @@ +import CodexBarCore +import Foundation + +enum AppStrings { + private static let table = "Localizable" + #if DEBUG + private nonisolated(unsafe) static var testingLanguageOverride: AppLanguage? + #endif + + static func tr(_ key: String) -> String { + AppStringResources.localizedString(for: key, table: self.table, language: self.currentLanguage) + } + + static func fmt(_ key: String, _ args: CVarArg...) -> String { + self.fmt(key, arguments: args) + } + + static func fmt(_ key: String, arguments: [CVarArg]) -> String { + String(format: self.tr(key), locale: self.locale, arguments: arguments) + } + + static var locale: Locale { + self.currentLanguage.locale + } + + #if DEBUG + static func withTestingLanguage(_ language: AppLanguage?, operation: () throws -> T) rethrows -> T { + let previous = self.testingLanguageOverride + self.testingLanguageOverride = language + defer { self.testingLanguageOverride = previous } + return try operation() + } + #endif + + private static var currentLanguage: AppLanguage { + #if DEBUG + if let testingLanguageOverride { + return testingLanguageOverride + } + #endif + return AppLanguage.resolve(from: .standard) + } + + static func refreshFrequency(_ frequency: RefreshFrequency) -> String { + switch frequency { + case .manual: + self.tr("Manual") + case .oneMinute: + self.tr("1 min") + case .twoMinutes: + self.tr("2 min") + case .fiveMinutes: + self.tr("5 min") + case .fifteenMinutes: + self.tr("15 min") + case .thirtyMinutes: + self.tr("30 min") + } + } + + static func menuBarMetricPreference(_ preference: MenuBarMetricPreference) -> String { + switch preference { + case .automatic: + self.tr("Automatic") + case .primary: + self.tr("Primary") + case .secondary: + self.tr("Secondary") + case .average: + self.tr("Average") + } + } + + static func menuBarDisplayMode(_ mode: MenuBarDisplayMode) -> String { + switch mode { + case .percent: + self.tr("Percent") + case .pace: + self.tr("Pace") + case .both: + self.tr("Both") + } + } + + static func menuBarDisplayModeDescription(_ mode: MenuBarDisplayMode) -> String { + switch mode { + case .percent: + self.tr("Show remaining/used percentage (e.g. 45%)") + case .pace: + self.tr("Show pace indicator (e.g. +5%)") + case .both: + self.tr("Show both percentage and pace (e.g. 45% · +5%)") + } + } + + static func loadingPattern(_ pattern: LoadingPattern) -> String { + switch pattern { + case .knightRider: + self.tr("Knight Rider") + case .cylon: + self.tr("Cylon") + case .outsideIn: + self.tr("Outside-In") + case .race: + self.tr("Race") + case .pulse: + self.tr("Pulse") + case .unbraid: + self.tr("Unbraid (logo → bars)") + } + } + + static func updateChannel(_ channel: UpdateChannel) -> String { + switch channel { + case .stable: + self.tr("Stable") + case .beta: + self.tr("Beta") + } + } + + static func updateChannelDescription(_ channel: UpdateChannel) -> String { + switch channel { + case .stable: + self.tr("Receive only stable, production-ready releases.") + case .beta: + self.tr("Receive stable releases plus beta previews.") + } + } + + static func providerStatus(_ indicator: ProviderStatusIndicator) -> String { + switch indicator { + case .none: + self.tr("Operational") + case .minor: + self.tr("Partial outage") + case .major: + self.tr("Major outage") + case .critical: + self.tr("Critical issue") + case .maintenance: + self.tr("Maintenance") + case .unknown: + self.tr("Status unknown") + } + } + + static func localizedProviderStatusDescription( + _ description: String?, + indicator: ProviderStatusIndicator) -> String + { + let trimmed = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return self.providerStatus(indicator) } + + switch trimmed.lowercased() { + case "operational", "all systems operational": + return self.tr("Operational") + case "partially degraded", "partially degraded service", "partial outage", "partial system outage", + "degraded performance": + return self.tr("Partial outage") + case "major outage", "major service outage", "service outage": + return self.tr("Major outage") + case "critical outage", "critical service outage": + return self.tr("Critical issue") + case "maintenance", "under maintenance", "service under maintenance": + return self.tr("Maintenance") + default: + return trimmed + } + } + + static func localizedSourceLabel(_ rawValue: String) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + switch trimmed.lowercased() { + case "auto": + return self.tr("Auto") + case "oauth": + return self.tr("OAuth API") + case "cli": + return self.tr("CLI") + case "web": + return self.tr("web") + case "api": + return self.tr("API") + case "openai-web": + return self.tr("OpenAI web extras") + default: + return trimmed + } + } + + static func localizedOpenAIDashboardError(_ rawValue: String) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + let notFoundPrefix = "OpenAI dashboard data not found. Body sample: " + if trimmed.hasPrefix(notFoundPrefix) { + let sample = String(trimmed.dropFirst(notFoundPrefix.count)).trimmingCharacters(in: .whitespaces) + return self.fmt("OpenAI dashboard data not found. Body sample: %@", sample) + } + + let refreshPrefix = "Last OpenAI dashboard refresh failed: " + let refreshSuffix = ". Cached values from " + if trimmed.hasPrefix(refreshPrefix), + let suffixRange = trimmed.range(of: refreshSuffix, options: .backwards) + { + let messageStart = trimmed.index(trimmed.startIndex, offsetBy: refreshPrefix.count) + let message = trimmed[messageStart.. String { + switch source { + case .auto: + self.tr("Auto") + case .manual: + self.tr("Manual") + case .off: + self.tr("Off") + } + } + + static func codexUsageSource(_ source: CodexUsageDataSource) -> String { + switch source { + case .auto: + self.tr("Auto") + case .oauth: + self.tr("OAuth API") + case .cli: + self.tr("CLI (RPC/PTY)") + } + } + + static func claudeUsageSource(_ source: ClaudeUsageDataSource) -> String { + switch source { + case .auto: + self.tr("Auto") + case .oauth: + self.tr("OAuth API") + case .web: + self.tr("Web API (cookies)") + case .cli: + self.tr("CLI (PTY)") + } + } + + static func kiloUsageSource(_ source: KiloUsageDataSource) -> String { + switch source { + case .auto: + self.tr("Auto") + case .api: + self.tr("API") + case .cli: + self.tr("CLI") + } + } + + static func miniMaxRegion(_ region: MiniMaxAPIRegion) -> String { + switch region { + case .global: + self.tr("Global (platform.minimax.io)") + case .chinaMainland: + self.tr("China mainland (platform.minimaxi.com)") + } + } + + static func zaiRegion(_ region: ZaiAPIRegion) -> String { + switch region { + case .global: + self.tr("Global (api.z.ai)") + case .bigmodelCN: + self.tr("BigModel CN (open.bigmodel.cn)") + } + } + + static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { + let percent = showUsed ? used : remaining + let clamped = min(100, max(0, percent)) + let suffix = showUsed ? self.tr("used") : self.tr("left") + return String(format: "%.0f%% %@", locale: self.locale, clamped, suffix) + } + + static func resetLine(for window: RateWindow, style: ResetTimeDisplayStyle, now: Date = .init()) -> String? { + if let date = window.resetsAt { + let text = style == .countdown + ? self.resetCountdownDescription(from: date, now: now) + : self.resetDescription(from: date, now: now) + return self.fmt("Resets %@", text) + } + + if let desc = window.resetDescription { + let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let localizedResets = self.tr("Resets") + if trimmed.lowercased().hasPrefix(localizedResets.lowercased()) { + return trimmed + } + if trimmed.lowercased().hasPrefix("resets") { + let suffix = trimmed.dropFirst("Resets".count).trimmingCharacters(in: .whitespacesAndNewlines) + return self.fmt("Resets %@", suffix) + } + return self.fmt("Resets %@", trimmed) + } + return nil + } + + static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String { + let seconds = max(0, date.timeIntervalSince(now)) + if seconds < 1 { return self.tr("now") } + + let totalMinutes = max(1, Int(ceil(seconds / 60.0))) + let days = totalMinutes / (24 * 60) + let hours = (totalMinutes / 60) % 24 + let minutes = totalMinutes % 60 + + if days > 0 { + if hours > 0 { return self.fmt("in %dd %dh", days, hours) } + return self.fmt("in %dd", days) + } + if hours > 0 { + if minutes > 0 { return self.fmt("in %dh %dm", hours, minutes) } + return self.fmt("in %dh", hours) + } + return self.fmt("in %dm", totalMinutes) + } + + static func resetDescription(from date: Date, now: Date = .init()) -> String { + let calendar = Calendar.current + let timeFormatter = DateFormatter() + timeFormatter.timeStyle = .short + timeFormatter.locale = self.locale + let dateTimeFormatter = DateFormatter() + dateTimeFormatter.dateStyle = .medium + dateTimeFormatter.timeStyle = .short + dateTimeFormatter.locale = self.locale + if calendar.isDate(date, inSameDayAs: now) { + return timeFormatter.string(from: date) + } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), + calendar.isDate(date, inSameDayAs: tomorrow) + { + return self.fmt("tomorrow, %@", timeFormatter.string(from: date)) + } + return dateTimeFormatter.string(from: date) + } + + static func updatedString(from date: Date, now: Date = .init()) -> String { + let delta = now.timeIntervalSince(date) + if abs(delta) < 60 { + return self.tr("Updated just now") + } + if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .abbreviated + rel.locale = self.locale + return self.fmt("Updated %@", rel.localizedString(for: date, relativeTo: now)) + } + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.locale = self.locale + return self.fmt("Updated %@", formatter.string(from: date)) + } + + static func creditsString(from value: Double) -> String { + let number = NumberFormatter() + number.numberStyle = .decimal + number.maximumFractionDigits = 2 + number.locale = self.locale + let formatted = number.string(from: NSNumber(value: value)) + ?? String(format: "%.2f", locale: self.locale, arguments: [value]) + return self.fmt("%@ left", formatted) + } + + static func creditEventSummary(_ event: CreditEvent) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.locale = self.locale + let number = NumberFormatter() + number.numberStyle = .decimal + number.maximumFractionDigits = 2 + number.locale = self.locale + let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" + return self.fmt("%@ · %@ · %@ credits", formatter.string(from: event.date), event.service, credits) + } + + static func creditEventCompact(_ event: CreditEvent) -> String { + let formatter = DateFormatter() + formatter.locale = self.locale + formatter.setLocalizedDateFormatFromTemplate("MMM d") + let number = NumberFormatter() + number.numberStyle = .decimal + number.maximumFractionDigits = 2 + number.locale = self.locale + let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" + return self.fmt("%@ — %@: %@", formatter.string(from: event.date), event.service, credits) + } + + static func monthDayString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = self.locale + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) + } +} diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9b0d65b89..280df1e44 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -37,7 +37,7 @@ struct CostHistoryChartMenuView: View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No cost history data.") + Text(AppStrings.tr("No cost history data.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -107,7 +107,7 @@ struct CostHistoryChartMenuView: View { } if let total = self.totalCostUSD { - Text("Total (30d): \(UsageFormatter.usdString(total))") + Text(AppStrings.fmt("Total (30d): %@", UsageFormatter.usdString(total))) .font(.caption) .foregroundStyle(.secondary) } @@ -291,17 +291,21 @@ struct CostHistoryChartMenuView: View { let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (AppStrings.tr("Hover a bar for details"), nil) } - let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let dayLabel = AppStrings.monthDayString(from: date) let cost = UsageFormatter.usdString(point.costUSD) if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + let primary = AppStrings.fmt( + "%@: %@ · %@ tokens", + dayLabel, + cost, + UsageFormatter.tokenCountString(tokens)) let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } - let primary = "\(dayLabel): \(cost)" + let primary = AppStrings.fmt("%@: %@", dayLabel, cost) let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } @@ -323,6 +327,6 @@ struct CostHistoryChartMenuView: View { .prefix(3) .map { "\($0.name) \($0.detail)" } guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + return AppStrings.fmt("Top: %@", parts.joined(separator: " · ")) } } diff --git a/Sources/CodexBar/CreditsHistoryChartMenuView.swift b/Sources/CodexBar/CreditsHistoryChartMenuView.swift index 9c5ca0b50..8e04dcd1b 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -29,7 +29,7 @@ struct CreditsHistoryChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No credits history data.") + Text(AppStrings.tr("No credits history data.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -98,7 +98,10 @@ struct CreditsHistoryChartMenuView: View { } if let total = model.totalCreditsUsed { - Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits") + Text( + AppStrings.fmt( + "Total (30d): %@ credits", + total.formatted(.number.precision(.fractionLength(0...2))))) .font(.caption) .foregroundStyle(.secondary) } @@ -299,17 +302,17 @@ struct CreditsHistoryChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (AppStrings.tr("Hover a bar for details"), nil) } - let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let dayLabel = AppStrings.monthDayString(from: date) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { - return ("\(dayLabel): \(total) credits", nil) + return (AppStrings.fmt("%@: %@ credits", dayLabel, total), nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) - return ("\(dayLabel): \(used) credits", first.service) + return (AppStrings.fmt("%@: %@ credits", dayLabel, used), first.service) } let services = day.services @@ -321,6 +324,6 @@ struct CreditsHistoryChartMenuView: View { .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") - return ("\(dayLabel): \(total) credits", services) + return (AppStrings.fmt("%@: %@ credits", dayLabel, total), services) } } diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index 7356f9671..02dc9c1ee 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -1,21 +1,19 @@ import Foundation -enum RelativeTimeFormatters { - @MainActor - static let full: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter - }() -} - extension Date { @MainActor - func relativeDescription(now: Date = .now) -> String { + func relativeDescription( + now: Date = .now, + unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) + -> String + { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { - return "just now" + return AppStrings.tr("just now") } - return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = unitsStyle + formatter.locale = AppStrings.locale + return formatter.localizedString(for: self, relativeTo: now) } } diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index a6add39ab..df57671e7 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -134,7 +134,7 @@ enum KeychainPromptCoordinator { let alert = NSAlert() alert.messageText = title alert.informativeText = message - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: AppStrings.tr("OK")) _ = alert.runModal() } } diff --git a/Sources/CodexBar/LoadingPattern.swift b/Sources/CodexBar/LoadingPattern.swift index a32fbe406..1241ee784 100644 --- a/Sources/CodexBar/LoadingPattern.swift +++ b/Sources/CodexBar/LoadingPattern.swift @@ -13,14 +13,7 @@ enum LoadingPattern: String, CaseIterable, Identifiable { } var displayName: String { - switch self { - case .knightRider: "Knight Rider" - case .cylon: "Cylon" - case .outsideIn: "Outside-In" - case .race: "Race" - case .pulse: "Pulse" - case .unbraid: "Unbraid (logo → bars)" - } + AppStrings.loadingPattern(self) } /// Secondary offset so the lower bar moves differently. diff --git a/Sources/CodexBar/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift index 8daa30ccf..2f23e1a22 100644 --- a/Sources/CodexBar/MenuBarDisplayMode.swift +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -11,18 +11,10 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { } var label: String { - switch self { - case .percent: "Percent" - case .pace: "Pace" - case .both: "Both" - } + AppStrings.menuBarDisplayMode(self) } var description: String { - switch self { - case .percent: "Show remaining/used percentage (e.g. 45%)" - case .pace: "Show pace indicator (e.g. +5%)" - case .both: "Show both percentage and pace (e.g. 45% · +5%)" - } + AppStrings.menuBarDisplayModeDescription(self) } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 34ac51846..7c242d483 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,15 +11,15 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: AppStrings.tr("left") + case .used: AppStrings.tr("used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: AppStrings.tr("Usage remaining") + case .used: AppStrings.tr("Usage used") } } } @@ -37,7 +37,7 @@ struct UsageMenuCardView: View { let paceOnTop: Bool var percentLabel: String { - String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) + AppStrings.fmt("%.0f%% %@", self.percent, self.percentStyle.labelSuffix) } } @@ -85,7 +85,7 @@ struct UsageMenuCardView: View { static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { - return "API key limit" + return AppStrings.tr("API key limit") } return metric.title } @@ -150,7 +150,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(AppStrings.tr("Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -283,7 +283,7 @@ private struct CopyIconButton: View { .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) - .accessibilityLabel(self.didCopy ? "Copied" : "Copy error") + .accessibilityLabel(self.didCopy ? AppStrings.tr("Copied") : AppStrings.tr("Copy error")) } private func copyToPasteboard() { @@ -306,12 +306,12 @@ private struct ProviderCostContent: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Extra usage spent") + accessibilityLabel: AppStrings.tr("Extra usage spent")) HStack(alignment: .firstTextBaseline) { Text(self.section.spendLine) .font(.footnote) Spacer() - Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed)))) + Text(AppStrings.fmt("%.0f%% used", min(100, max(0, self.section.percentUsed)))) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } @@ -502,19 +502,19 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + return AppStrings.fmt("%@ tokens", scale) } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(AppStrings.tr("Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: AppStrings.tr("Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -555,7 +555,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(AppStrings.tr("Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -715,7 +715,9 @@ extension UsageMenuCardView.Model { isRefreshing: input.isRefreshing, lastError: input.lastError) let redacted = Self.redactedText(input: input, subtitle: subtitle) - let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil + ? AppStrings.tr("No usage yet") + : nil return UsageMenuCardView.Model( provider: input.provider, @@ -739,14 +741,15 @@ extension UsageMenuCardView.Model { private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) + let cliFallback = AppStrings.tr("Using CLI fallback") let resolvedSource = input.sourceLabel? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() if input.kiloAutoMode, resolvedSource == "cli", - !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) + !notes.contains(where: { $0.caseInsensitiveCompare(cliFallback) == .orderedSame }) { - notes.append("Using CLI fallback") + notes.append(cliFallback) } return notes } @@ -759,8 +762,8 @@ extension UsageMenuCardView.Model { return switch openRouter.keyQuotaStatus { case .available: [] - case .noLimitConfigured: ["No limit set for the API key"] - case .unavailable: ["API key limit unavailable right now"] + case .noLimitConfigured: [AppStrings.tr("No limit set for the API key")] + case .unavailable: [AppStrings.tr("API key limit unavailable right now")] } } @@ -848,14 +851,14 @@ extension UsageMenuCardView.Model { } if isRefreshing, snapshot == nil { - return ("Refreshing...", .loading) + return (AppStrings.tr("Refreshing..."), .loading) } if let updated = snapshot?.updatedAt { - return (UsageFormatter.updatedString(from: updated), .info) + return (AppStrings.updatedString(from: updated), .info) } - return ("Not fetched yet", .info) + return (AppStrings.tr("Not fetched yet"), .info) } private struct RedactedText { @@ -923,7 +926,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "primary", - title: input.metadata.sessionLabel, + title: AppStrings.tr(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -960,7 +963,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: AppStrings.tr(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -985,7 +988,7 @@ extension UsageMenuCardView.Model { if input.metadata.supportsOpus, let opus = snapshot.tertiary { metrics.append(Metric( id: "tertiary", - title: input.metadata.opusLabel ?? "Sonnet", + title: AppStrings.tr(input.metadata.opusLabel ?? "Sonnet"), percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), @@ -1000,7 +1003,7 @@ extension UsageMenuCardView.Model { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", - title: "Code review", + title: AppStrings.tr("Code review"), percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, @@ -1023,7 +1026,7 @@ extension UsageMenuCardView.Model { let currentStr = UsageFormatter.tokenCountString(currentValue) let usageStr = UsageFormatter.tokenCountString(usage) let remainingStr = UsageFormatter.tokenCountString(remaining) - return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" + return AppStrings.fmt("%@ / %@ (%@ remaining)", currentStr, usageStr, remainingStr) } return nil @@ -1041,7 +1044,7 @@ extension UsageMenuCardView.Model { let remaining = UsageFormatter.usdString(keyRemaining) let limit = UsageFormatter.usdString(keyLimit) - return "\(remaining)/\(limit) left" + return AppStrings.fmt("%@/%@ left", remaining, limit) } private struct PaceDetail { @@ -1080,7 +1083,7 @@ extension UsageMenuCardView.Model { { guard metadata.supportsCredits else { return nil } if let credits { - return UsageFormatter.creditsString(from: credits.remaining) + return AppStrings.creditsString(from: credits.remaining) } if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1091,7 +1094,7 @@ extension UsageMenuCardView.Model { private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { guard provider == .codex else { return nil } guard let error, !error.isEmpty else { return nil } - return error + return AppStrings.localizedOpenAIDashboardError(error) } private static func tokenUsageSection( @@ -1108,9 +1111,9 @@ extension UsageMenuCardView.Model { let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + return AppStrings.fmt("Today: %@ · %@ tokens", sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + return AppStrings.fmt("Today: %@", sessionCost) }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" @@ -1119,9 +1122,9 @@ extension UsageMenuCardView.Model { let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + return AppStrings.fmt("Last 30 days: %@ · %@ tokens", monthCost, monthTokens) } - return "Last 30 days: \(monthCost)" + return AppStrings.fmt("Last 30 days: %@", monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -1144,22 +1147,22 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = AppStrings.tr("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = AppStrings.tr("Extra usage") used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) - let periodLabel = cost.period ?? "This month" + let periodLabel = AppStrings.tr(cost.period ?? "This month") return ProviderCostSection( title: title, percentUsed: percentUsed, - spendLine: "\(periodLabel): \(used) / \(limit)") + spendLine: AppStrings.fmt("%@: %@ / %@", periodLabel, used, limit)) } private static func clamped(_ value: Double) -> Double { @@ -1176,7 +1179,7 @@ extension UsageMenuCardView.Model { style: ResetTimeDisplayStyle, now: Date) -> String? { - UsageFormatter.resetLine(for: window, style: style, now: now) + AppStrings.resetLine(for: window, style: style, now: now) } } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..144c6f96f 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -87,7 +87,7 @@ struct MenuDescriptor { sections.append(accountSection) } } else { - sections.append(Section(entries: [.text("No usage configured.", .secondary)])) + sections.append(Section(entries: [.text(AppStrings.tr("No usage configured."), .secondary)])) } } @@ -131,7 +131,7 @@ struct MenuDescriptor { } Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: AppStrings.tr(meta.sessionLabel), window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -154,7 +154,7 @@ struct MenuDescriptor { }() Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: AppStrings.tr(meta.weeklyLabel), window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, @@ -174,7 +174,7 @@ struct MenuDescriptor { if meta.supportsOpus, let opus = snap.tertiary { Self.appendRateWindow( entries: &entries, - title: meta.opusLabel ?? "Sonnet", + title: AppStrings.tr(meta.opusLabel ?? "Sonnet"), window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -184,11 +184,11 @@ struct MenuDescriptor { if cost.currencyCode == "Quota" { let used = String(format: "%.0f", cost.used) let limit = String(format: "%.0f", cost.limit) - entries.append(.text("Quota: \(used) / \(limit)", .primary)) + entries.append(.text(AppStrings.fmt("Quota: %@ / %@", used, limit), .primary)) } } } else { - entries.append(.text("No usage yet", .secondary)) + entries.append(.text(AppStrings.tr("No usage yet"), .secondary)) } let usageContext = ProviderMenuUsageContext( @@ -236,27 +236,27 @@ struct MenuDescriptor { let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) if let emailText, !emailText.isEmpty { - entries.append(.text("Account: \(redactedEmail)", .secondary)) + entries.append(.text(AppStrings.fmt("Account: %@", redactedEmail), .secondary)) } if provider == .kilo { let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText) if let pass = kiloLogin.pass { - entries.append(.text("Plan: \(AccountFormatter.plan(pass))", .secondary)) + entries.append(.text(AppStrings.fmt("Plan: %@", AccountFormatter.plan(pass)), .secondary)) } for detail in kiloLogin.details { - entries.append(.text("Activity: \(detail)", .secondary)) + entries.append(.text(AppStrings.fmt("Activity: %@", detail), .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) + entries.append(.text(AppStrings.fmt("Plan: %@", AccountFormatter.plan(loginMethodText)), .secondary)) } if metadata.usesAccountFallback { if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty { let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo) - entries.append(.text("Account: \(redacted)", .secondary)) + entries.append(.text(AppStrings.fmt("Account: %@", redacted), .secondary)) } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan))", .secondary)) + entries.append(.text(AppStrings.fmt("Plan: %@", AccountFormatter.plan(fallbackPlan)), .secondary)) } } @@ -327,7 +327,7 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) - let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + let accountLabel = hasAccount ? AppStrings.tr("Switch Account...") : AppStrings.tr("Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -343,10 +343,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(AppStrings.tr("Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(AppStrings.tr("Status Page"), .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -359,12 +359,12 @@ struct MenuDescriptor { private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { - entries.append(.action("Update ready, restart now?", .installUpdate)) + entries.append(.action(AppStrings.tr("Update ready, restart now?"), .installUpdate)) } entries.append(contentsOf: [ - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(AppStrings.tr("Settings..."), .settings), + .action(AppStrings.tr("About CodexBar"), .about), + .action(AppStrings.tr("Quit"), .quit), ]) return Section(entries: entries) } @@ -375,11 +375,10 @@ struct MenuDescriptor { let status = store.status(for: target), status.indicator != .none else { return nil } - let description = status.description?.trimmingCharacters(in: .whitespacesAndNewlines) - let label = description?.isEmpty == false ? description! : status.indicator.label + let label = AppStrings.localizedProviderStatusDescription(status.description, indicator: status.indicator) if let updated = status.updatedAt { - let freshness = UsageFormatter.updatedString(from: updated) - return "\(label) — \(freshness)" + let freshness = AppStrings.updatedString(from: updated) + return AppStrings.fmt("%@ — %@", label, freshness) } return label } @@ -415,12 +414,14 @@ struct MenuDescriptor { showUsed: Bool, resetOverride: String? = nil) { - let line = UsageFormatter - .usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed) - entries.append(.text("\(title): \(line)", .primary)) + let line = AppStrings.usageLine( + remaining: window.remainingPercent, + used: window.usedPercent, + showUsed: showUsed) + entries.append(.text(AppStrings.fmt("%@: %@", title, line), .primary)) if let resetOverride { entries.append(.text(resetOverride, .secondary)) - } else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { + } else if let reset = AppStrings.resetLine(for: window, style: resetStyle) { entries.append(.text(reset, .secondary)) } } diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 16e27189e..050a68805 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -25,7 +25,7 @@ struct AboutPane: View { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short - formatter.locale = .current + formatter.locale = AppStrings.locale return formatter.string(from: date) } @@ -49,16 +49,16 @@ struct AboutPane: View { } VStack(spacing: 2) { - Text("CodexBar") + Text(AppStrings.tr("CodexBar")) .font(.title3).bold() - Text("Version \(self.versionString)") + Text(AppStrings.fmt("Version %@", self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(AppStrings.fmt("Built %@", buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(AppStrings.tr("May your tokens never run out—keep agent limits in view.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -66,11 +66,11 @@ struct AboutPane: View { VStack(alignment: .center, spacing: 10) { AboutLinkRow( icon: "chevron.left.slash.chevron.right", - title: "GitHub", + title: AppStrings.tr("GitHub"), url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") - AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") + AboutLinkRow(icon: "globe", title: AppStrings.tr("Website"), url: "https://steipete.me") + AboutLinkRow(icon: "bird", title: AppStrings.tr("Twitter"), url: "https://twitter.com/steipete") + AboutLinkRow(icon: "envelope", title: AppStrings.tr("Email"), url: "mailto:peter@steipete.me") } .padding(.top, 8) .frame(maxWidth: .infinity) @@ -80,12 +80,12 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle(AppStrings.tr("Check for updates automatically"), isOn: self.$autoUpdateEnabled) .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .center) VStack(spacing: 6) { HStack(spacing: 12) { - Text("Update Channel") + Text(AppStrings.tr("Update Channel")) Spacer() Picker("", selection: self.updateChannelBinding) { ForEach(UpdateChannel.allCases) { channel in @@ -102,14 +102,14 @@ struct AboutPane: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) } - Button("Check for Updates…") { self.updater.checkForUpdates(nil) } + Button(AppStrings.tr("Check for Updates…")) { self.updater.checkForUpdates(nil) } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? AppStrings.tr("Updates unavailable in this build.")) .foregroundStyle(.secondary) } - Text("© 2025 Peter Steinberger. MIT License.") + Text(AppStrings.tr("© 2025 Peter Steinberger. MIT License.")) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 4) diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..42533f84a 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -11,17 +11,17 @@ struct AdvancedPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { - Text("Keyboard shortcut") + Text(AppStrings.tr("Keyboard shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(AppStrings.tr("Open menu")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(AppStrings.tr("Trigger the menu bar menu from anywhere.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -36,7 +36,7 @@ struct AdvancedPane: View { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { - Text("Install CLI") + Text(AppStrings.tr("Install CLI")) } } .disabled(self.isInstallingCLI) @@ -48,7 +48,7 @@ struct AdvancedPane: View { .lineLimit(2) } } - Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") + Text(AppStrings.tr("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -57,12 +57,12 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Show Debug Settings", - subtitle: "Expose troubleshooting tools in the Debug tab.", + title: AppStrings.tr("Show Debug Settings"), + subtitle: AppStrings.tr("Expose troubleshooting tools in the Debug tab."), binding: self.$settings.debugMenuEnabled) PreferenceToggleRow( - title: "Surprise me", - subtitle: "Check if you like your agents having some fun up there.", + title: AppStrings.tr("Surprise me"), + subtitle: AppStrings.tr("Check if you like your agents having some fun up there."), binding: self.$settings.randomBlinkEnabled) } @@ -70,24 +70,24 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Hide personal information", - subtitle: "Obscure email addresses in the menu bar and menu UI.", + title: AppStrings.tr("Hide personal information"), + subtitle: AppStrings.tr("Obscure email addresses in the menu bar and menu UI."), binding: self.$settings.hidePersonalInfo) } Divider() SettingsSection( - title: "Keychain access", - caption: """ - Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \ - headers manually in Providers. - """) { - PreferenceToggleRow( - title: "Disable Keychain access", - subtitle: "Prevents any Keychain access while enabled.", - binding: self.$settings.debugDisableKeychainAccess) - } + title: AppStrings.tr("Keychain access"), + caption: AppStrings.tr( + "Disable all Keychain reads and writes. Browser cookie import is unavailable; " + + "paste Cookie headers manually in Providers.")) + { + PreferenceToggleRow( + title: AppStrings.tr("Disable Keychain access"), + subtitle: AppStrings.tr("Prevents any Keychain access while enabled."), + binding: self.$settings.debugDisableKeychainAccess) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) @@ -105,7 +105,7 @@ extension AdvancedPane { let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI") let fm = FileManager.default guard fm.fileExists(atPath: helperURL.path) else { - self.cliStatus = "CodexBarCLI not found in app bundle." + self.cliStatus = AppStrings.tr("CodexBarCLI not found in app bundle.") return } @@ -119,29 +119,29 @@ extension AdvancedPane { let dir = (dest as NSString).deletingLastPathComponent guard fm.fileExists(atPath: dir) else { continue } guard fm.isWritableFile(atPath: dir) else { - results.append("No write access: \(dir)") + results.append(AppStrings.fmt("No write access: %@", dir)) continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + results.append(AppStrings.fmt("Installed: %@", dir)) } else { - results.append("Exists: \(dir)") + results.append(AppStrings.fmt("Exists: %@", dir)) } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + results.append(AppStrings.fmt("Installed: %@", dir)) } catch { - results.append("Failed: \(dir)") + results.append(AppStrings.fmt("Failed: %@", dir)) } } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? AppStrings.tr("No writable bin dirs found.") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..4f9b85b8d 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -26,10 +26,10 @@ struct DebugPane: View { var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 20) { - SettingsSection(title: "Logging") { + SettingsSection(title: AppStrings.tr("Logging")) { PreferenceToggleRow( - title: "Enable file logging", - subtitle: "Write logs to \(self.fileLogPath) for debugging.", + title: AppStrings.tr("Enable file logging"), + subtitle: AppStrings.fmt("Write logs to %@ for debugging.", self.fileLogPath), binding: self.$debugFileLoggingEnabled) .onChange(of: self.debugFileLoggingEnabled) { _, newValue in if self.settings.debugFileLoggingEnabled != newValue { @@ -39,14 +39,14 @@ struct DebugPane: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Verbosity") + Text(AppStrings.tr("Verbosity")) .font(.body) - Text("Controls how much detail is logged.") + Text(AppStrings.tr("Controls how much detail is logged.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Verbosity", selection: self.$settings.debugLogLevel) { + Picker(AppStrings.tr("Verbosity"), selection: self.$settings.debugLogLevel) { ForEach(CodexBarLog.Level.allCases) { level in Text(level.displayName).tag(level) } @@ -59,31 +59,32 @@ struct DebugPane: View { Button { NSWorkspace.shared.open(CodexBarLog.fileLogURL) } label: { - Label("Open log file", systemImage: "doc.text.magnifyingglass") + Label(AppStrings.tr("Open log file"), systemImage: "doc.text.magnifyingglass") } .controlSize(.small) } SettingsSection { PreferenceToggleRow( - title: "Force animation on next refresh", - subtitle: "Temporarily shows the loading animation after the next refresh.", + title: AppStrings.tr("Force animation on next refresh"), + subtitle: AppStrings.tr("Temporarily shows the loading animation after the next refresh."), binding: self.$store.debugForceAnimation) } SettingsSection( - title: "Loading animations", - caption: "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.") + title: AppStrings.tr("Loading animations"), + caption: AppStrings + .tr("Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.")) { - Picker("Animation pattern", selection: self.animationPatternBinding) { - Text("Random (default)").tag(nil as LoadingPattern?) + Picker(AppStrings.tr("Animation pattern"), selection: self.animationPatternBinding) { + Text(AppStrings.tr("Random (default)")).tag(nil as LoadingPattern?) ForEach(LoadingPattern.allCases) { pattern in Text(pattern.displayName).tag(Optional(pattern)) } } .pickerStyle(.radioGroup) - Button("Replay selected animation") { + Button(AppStrings.tr("Replay selected animation")) { self.replaySelectedAnimation() } .keyboardShortcut(.defaultAction) @@ -91,16 +92,16 @@ struct DebugPane: View { Button { NotificationCenter.default.post(name: .codexbarDebugBlinkNow, object: nil) } label: { - Label("Blink now", systemImage: "eyes") + Label(AppStrings.tr("Blink now"), systemImage: "eyes") } .controlSize(.small) } SettingsSection( - title: "Probe logs", - caption: "Fetch the latest probe output for debugging; Copy keeps the full text.") + title: AppStrings.tr("Probe logs"), + caption: AppStrings.tr("Fetch the latest probe output for debugging; Copy keeps the full text.")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(AppStrings.tr("Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Cursor").tag(UsageProvider.cursor) @@ -113,23 +114,23 @@ struct DebugPane: View { HStack(spacing: 12) { Button { self.loadLog(self.currentLogProvider) } label: { - Label("Fetch log", systemImage: "arrow.clockwise") + Label(AppStrings.tr("Fetch log"), systemImage: "arrow.clockwise") } .disabled(self.isLoadingLog) Button { self.copyToPasteboard(self.logText) } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(AppStrings.tr("Copy"), systemImage: "doc.on.doc") } .disabled(self.logText.isEmpty) Button { self.saveLog(self.currentLogProvider) } label: { - Label("Save to file", systemImage: "externaldrive.badge.plus") + Label(AppStrings.tr("Save to file"), systemImage: "externaldrive.badge.plus") } .disabled(self.isLoadingLog && self.logText.isEmpty) if self.currentLogProvider == .claude { Button { self.loadClaudeDump() } label: { - Label("Load parse dump", systemImage: "doc.text.magnifyingglass") + Label(AppStrings.tr("Load parse dump"), systemImage: "doc.text.magnifyingglass") } .disabled(self.isLoadingLog) } @@ -139,7 +140,7 @@ struct DebugPane: View { self.settings.rerunProviderDetection() self.loadLog(self.currentLogProvider) } label: { - Label("Re-run provider autodetect", systemImage: "dot.radiowaves.left.and.right") + Label(AppStrings.tr("Re-run provider autodetect"), systemImage: "dot.radiowaves.left.and.right") } .controlSize(.small) @@ -165,10 +166,10 @@ struct DebugPane: View { } SettingsSection( - title: "Fetch strategy attempts", - caption: "Last fetch pipeline decisions and errors for a provider.") + title: AppStrings.tr("Fetch strategy attempts"), + caption: AppStrings.tr("Last fetch pipeline decisions and errors for a provider.")) { - Picker("Provider", selection: self.$currentFetchProvider) { + Picker(AppStrings.tr("Provider"), selection: self.$currentFetchProvider) { ForEach(UsageProvider.allCases, id: \.self) { provider in Text(provider.rawValue.capitalized).tag(provider) } @@ -190,14 +191,15 @@ struct DebugPane: View { if !self.settings.debugDisableKeychainAccess { SettingsSection( - title: "OpenAI cookies", - caption: "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.") + title: AppStrings.tr("OpenAI cookies"), + caption: AppStrings + .tr("Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.")) { HStack(spacing: 12) { Button { self.copyToPasteboard(self.store.openAIDashboardCookieImportDebugLog ?? "") } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(AppStrings.tr("Copy"), systemImage: "doc.on.doc") } .disabled((self.store.openAIDashboardCookieImportDebugLog ?? "").isEmpty) } @@ -206,7 +208,8 @@ struct DebugPane: View { Text( self.store.openAIDashboardCookieImportDebugLog?.isEmpty == false ? (self.store.openAIDashboardCookieImportDebugLog ?? "") - : "No log yet. Update OpenAI cookies in Providers → Codex to run an import.") + : AppStrings + .tr("No log yet. Update OpenAI cookies in Providers → Codex to run an import.")) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -219,8 +222,8 @@ struct DebugPane: View { } SettingsSection( - title: "Caches", - caption: "Clear cached cost scan results.") + title: AppStrings.tr("Caches"), + caption: AppStrings.tr("Clear cached cost scan results.")) { let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex) || self.store.isTokenRefreshInFlight(for: .claude) @@ -229,7 +232,7 @@ struct DebugPane: View { Button { Task { await self.clearCostCache() } } label: { - Label("Clear cost cache", systemImage: "trash") + Label(AppStrings.tr("Clear cost cache"), systemImage: "trash") } .disabled(self.isClearingCostCache || isTokenRefreshActive) @@ -242,10 +245,11 @@ struct DebugPane: View { } SettingsSection( - title: "Notifications", - caption: "Trigger test notifications for the 5-hour session window (depleted/restored).") + title: AppStrings.tr("Notifications"), + caption: AppStrings + .tr("Trigger test notifications for the 5-hour session window (depleted/restored).")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(AppStrings.tr("Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) } @@ -256,26 +260,27 @@ struct DebugPane: View { Button { self.postSessionNotification(.depleted, provider: self.currentLogProvider) } label: { - Label("Post depleted", systemImage: "bell.badge") + Label(AppStrings.tr("Post depleted"), systemImage: "bell.badge") } .controlSize(.small) Button { self.postSessionNotification(.restored, provider: self.currentLogProvider) } label: { - Label("Post restored", systemImage: "bell") + Label(AppStrings.tr("Post restored"), systemImage: "bell") } .controlSize(.small) } } SettingsSection( - title: "CLI sessions", - caption: "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.") + title: AppStrings.tr("CLI sessions"), + caption: AppStrings + .tr("Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.")) { PreferenceToggleRow( - title: "Keep CLI sessions alive", - subtitle: "Skip teardown between probes (debug-only).", + title: AppStrings.tr("Keep CLI sessions alive"), + subtitle: AppStrings.tr("Skip teardown between probes (debug-only)."), binding: self.$settings.debugKeepCLISessionsAlive) Button { @@ -283,17 +288,17 @@ struct DebugPane: View { await CLIProbeSessionResetter.resetAll() } } label: { - Label("Reset CLI sessions", systemImage: "arrow.counterclockwise") + Label(AppStrings.tr("Reset CLI sessions"), systemImage: "arrow.counterclockwise") } .controlSize(.small) } #if DEBUG SettingsSection( - title: "Error simulation", - caption: "Inject a fake error message into the menu card for layout testing.") + title: AppStrings.tr("Error simulation"), + caption: AppStrings.tr("Inject a fake error message into the menu card for layout testing.")) { - Picker("Provider", selection: self.$currentErrorProvider) { + Picker(AppStrings.tr("Provider"), selection: self.$currentErrorProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Gemini").tag(UsageProvider.gemini) @@ -305,7 +310,7 @@ struct DebugPane: View { .pickerStyle(.segmented) .frame(width: 360) - TextField("Simulated error text", text: self.$simulatedErrorText, axis: .vertical) + TextField(AppStrings.tr("Simulated error text"), text: self.$simulatedErrorText, axis: .vertical) .lineLimit(4) HStack(spacing: 12) { @@ -314,14 +319,14 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set menu error", systemImage: "exclamationmark.triangle") + Label(AppStrings.tr("Set menu error"), systemImage: "exclamationmark.triangle") } .controlSize(.small) Button { self.store._setErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear menu error", systemImage: "xmark.circle") + Label(AppStrings.tr("Clear menu error"), systemImage: "xmark.circle") } .controlSize(.small) } @@ -333,7 +338,7 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set cost error", systemImage: "banknote") + Label(AppStrings.tr("Set cost error"), systemImage: "banknote") } .controlSize(.small) .disabled(!supportsTokenError) @@ -341,7 +346,7 @@ struct DebugPane: View { Button { self.store._setTokenErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear cost error", systemImage: "xmark.circle") + Label(AppStrings.tr("Clear cost error"), systemImage: "xmark.circle") } .controlSize(.small) .disabled(!supportsTokenError) @@ -350,19 +355,20 @@ struct DebugPane: View { #endif SettingsSection( - title: "CLI paths", - caption: "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).") + title: AppStrings.tr("CLI paths"), + caption: AppStrings + .tr("Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).")) { - self.binaryRow(title: "Codex binary", value: self.store.pathDebugInfo.codexBinary) - self.binaryRow(title: "Claude binary", value: self.store.pathDebugInfo.claudeBinary) + self.binaryRow(title: AppStrings.tr("Codex binary"), value: self.store.pathDebugInfo.codexBinary) + self.binaryRow(title: AppStrings.tr("Claude binary"), value: self.store.pathDebugInfo.claudeBinary) VStack(alignment: .leading, spacing: 6) { - Text("Effective PATH") + Text(AppStrings.tr("Effective PATH")) .font(.callout.weight(.semibold)) ScrollView { Text( self.store.pathDebugInfo.effectivePATH.isEmpty - ? "Unavailable" + ? AppStrings.tr("Unavailable") : self.store.pathDebugInfo.effectivePATH) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) @@ -376,7 +382,7 @@ struct DebugPane: View { if let loginPATH = self.store.pathDebugInfo.loginShellPATH { VStack(alignment: .leading, spacing: 6) { - Text("Login shell PATH (startup capture)") + Text(AppStrings.tr("Login shell PATH (startup capture)")) .font(.callout.weight(.semibold)) ScrollView { Text(loginPATH) @@ -422,7 +428,7 @@ struct DebugPane: View { private var displayedLog: String { if self.logText.isEmpty { - return self.isLoadingLog ? "Loading…" : "No log yet. Fetch to load." + return self.isLoadingLog ? AppStrings.tr("Loading…") : AppStrings.tr("No log yet. Fetch to load.") } return self.logText } @@ -472,7 +478,7 @@ struct DebugPane: View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.callout.weight(.semibold)) - Text(value ?? "Not found") + Text(value ?? AppStrings.tr("Not found")) .font(.system(.footnote, design: .monospaced)) .foregroundStyle(value == nil ? .secondary : .primary) } @@ -500,22 +506,22 @@ struct DebugPane: View { defer { self.isClearingCostCache = false } if let error = await self.store.clearCostUsageCache() { - self.costCacheStatus = "Failed: \(error)" + self.costCacheStatus = AppStrings.fmt("Failed: %@", String(describing: error)) return } - self.costCacheStatus = "Cleared." + self.costCacheStatus = AppStrings.tr("Cleared.") } private func fetchAttemptsText(for provider: UsageProvider) -> String { let attempts = self.store.fetchAttempts(for: provider) - guard !attempts.isEmpty else { return "No fetch attempts yet." } + guard !attempts.isEmpty else { return AppStrings.tr("No fetch attempts yet.") } return attempts.map { attempt in let kind = Self.fetchKindLabel(attempt.kind) var line = "\(attempt.strategyID) (\(kind))" - line += attempt.wasAvailable ? " available" : " unavailable" + line += attempt.wasAvailable ? " \(AppStrings.tr("available"))" : " \(AppStrings.tr("unavailable"))" if let error = attempt.errorDescription, !error.isEmpty { - line += " error=\(error)" + line += AppStrings.fmt(" error=%@", error) } return line }.joined(separator: "\n") diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 04050b3bb..b8bf9eb6e 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -13,40 +13,41 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + Text(AppStrings.tr("Menu bar")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Merge Icons", - subtitle: "Use a single menu bar icon with a provider switcher.", + title: AppStrings.tr("Merge Icons"), + subtitle: AppStrings.tr("Use a single menu bar icon with a provider switcher."), binding: self.$settings.mergeIcons) PreferenceToggleRow( - title: "Switcher shows icons", - subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).", + title: AppStrings.tr("Switcher shows icons"), + subtitle: AppStrings.tr( + "Show provider icons in the switcher (otherwise show a weekly progress line)."), binding: self.$settings.switcherShowsIcons) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Show most-used provider", - subtitle: "Menu bar auto-shows the provider closest to its rate limit.", + title: AppStrings.tr("Show most-used provider"), + subtitle: AppStrings.tr("Menu bar auto-shows the provider closest to its rate limit."), binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Menu bar shows percent", - subtitle: "Replace critter bars with provider branding icons and a percentage.", + title: AppStrings.tr("Menu bar shows percent"), + subtitle: AppStrings.tr("Replace critter bars with provider branding icons and a percentage."), binding: self.$settings.menuBarShowsBrandIconWithPercent) HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Display mode") + Text(AppStrings.tr("Display mode")) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + Text(AppStrings.tr("Choose what to show in the menu bar (Pace shows usage vs. expected).")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { + Picker(AppStrings.tr("Display mode"), selection: self.$settings.menuBarDisplayMode) { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -62,25 +63,28 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + Text(AppStrings.tr("Menu content")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Show usage as used", - subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", + title: AppStrings.tr("Show usage as used"), + subtitle: AppStrings.tr( + "Progress bars fill as you consume quota (instead of showing remaining)."), binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( - title: "Show reset time as clock", - subtitle: "Display reset times as absolute clock values instead of countdowns.", + title: AppStrings.tr("Show reset time as clock"), + subtitle: AppStrings.tr( + "Display reset times as absolute clock values instead of countdowns."), binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( - title: "Show credits + extra usage", - subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", + title: AppStrings.tr("Show credits + extra usage"), + subtitle: AppStrings.tr("Show Codex Credits and Claude Extra usage sections in the menu."), binding: self.$settings.showOptionalCreditsAndExtraUsage) PreferenceToggleRow( - title: "Show all token accounts", - subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", + title: AppStrings.tr("Show all token accounts"), + subtitle: AppStrings.tr( + "Stack token accounts in the menu (otherwise show an account switcher bar)."), binding: self.$settings.showAllTokenAccountsInMenu) self.overviewProviderSelector } @@ -110,11 +114,11 @@ struct DisplayPane: View { private var overviewProviderSelector: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { - Text("Overview tab providers") + Text(AppStrings.tr("Overview tab providers")) .font(.body) Spacer(minLength: 0) if self.showsOverviewConfigureButton { - Button("Configure…") { + Button(AppStrings.tr("Configure…")) { self.isOverviewProviderPopoverPresented = true } .offset(y: 1) @@ -125,11 +129,11 @@ struct DisplayPane: View { } if !self.settings.mergeIcons { - Text("Enable Merge Icons to configure Overview tab providers.") + Text(AppStrings.tr("Enable Merge Icons to configure Overview tab providers.")) .font(.footnote) .foregroundStyle(.tertiary) } else if self.activeProvidersInOrder.isEmpty { - Text("No enabled providers available for Overview.") + Text(AppStrings.tr("No enabled providers available for Overview.")) .font(.footnote) .foregroundStyle(.tertiary) } else { @@ -144,9 +148,9 @@ struct DisplayPane: View { private var overviewProviderPopover: some View { VStack(alignment: .leading, spacing: 10) { - Text("Choose up to \(Self.maxOverviewProviders) providers") + Text(AppStrings.fmt("Choose up to %d providers", Self.maxOverviewProviders)) .font(.headline) - Text("Overview rows always follow provider order.") + Text(AppStrings.tr("Overview rows always follow provider order.")) .font(.footnote) .foregroundStyle(.tertiary) @@ -191,7 +195,7 @@ struct DisplayPane: View { private var overviewProviderSelectionSummary: String { let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) - guard !selectedNames.isEmpty else { return "No providers selected" } + guard !selectedNames.isEmpty else { return AppStrings.tr("No providers selected") } return selectedNames.joined(separator: ", ") } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..4acb978e9 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,20 +11,38 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(AppStrings.tr("System")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(AppStrings.tr("Language")) + .font(.body) + Text(AppStrings.tr("Default follows system language.")) + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker(AppStrings.tr("Language"), selection: self.$settings.appLanguage) { + ForEach(AppLanguage.allCases) { language in + Text(language.displayName).tag(language) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: AppStrings.tr("Start at Login"), + subtitle: AppStrings.tr("Automatically opens CodexBar when you start your Mac."), binding: self.$settings.launchAtLogin) } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(AppStrings.tr("Usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +50,18 @@ struct GeneralPane: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { - Text("Show cost summary") + Text(AppStrings.tr("Show cost summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(AppStrings.tr("Reads local usage logs. Shows today + last 30 days cost in the menu.")) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) if self.settings.costUsageEnabled { - Text("Auto-refresh: hourly · Timeout: 10m") + Text(AppStrings.tr("Auto-refresh: hourly · Timeout: 10m")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,21 +75,21 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(AppStrings.tr("Automation")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Refresh cadence") + Text(AppStrings.tr("Refresh cadence")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(AppStrings.tr("How often CodexBar polls providers in the background.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker(AppStrings.tr("Refresh cadence"), selection: self.$settings.refreshFrequency) { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -81,20 +99,21 @@ struct GeneralPane: View { .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { - Text("Auto-refresh is off; use the menu's Refresh command.") + Text(AppStrings.tr("Auto-refresh is off; use the menu's Refresh command.")) .font(.footnote) .foregroundStyle(.secondary) } } PreferenceToggleRow( - title: "Check provider status", - subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " + - "Gemini/Antigravity, surfacing incidents in the icon and menu.", + title: AppStrings.tr("Check provider status"), + subtitle: AppStrings.tr( + "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, " + + "surfacing incidents in the icon and menu."), binding: self.$settings.statusChecksEnabled) PreferenceToggleRow( - title: "Session quota notifications", - subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + - "available again.", + title: AppStrings.tr("Session quota notifications"), + subtitle: AppStrings.tr( + "Notifies when the 5-hour session quota hits 0% and when it becomes available again."), binding: self.$settings.sessionQuotaNotificationsEnabled) } @@ -103,7 +122,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(AppStrings.tr("Quit CodexBar")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +138,7 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + return Text(AppStrings.fmt("%@: unsupported", name)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,32 +152,30 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + return Text(AppStrings.fmt("%@: fetching…%@", name, elapsed)) .font(.footnote) .foregroundStyle(.tertiary) } if let snapshot = self.store.tokenSnapshot(for: provider) { - let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) - let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - return Text("\(name): \(updated) · 30d \(cost)") + let updated = AppStrings.updatedString(from: snapshot.updatedAt) + let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? AppStrings.tr("—") + return Text(AppStrings.fmt("%@: %@ · 30d %@", name, updated, cost)) .font(.footnote) .foregroundStyle(.tertiary) } if let error = self.store.tokenError(for: provider), !error.isEmpty { let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) - return Text("\(name): \(truncated)") + return Text(AppStrings.fmt("%@: %@", name, truncated)) .font(.footnote) .foregroundStyle(.tertiary) } if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { - let rel = RelativeDateTimeFormatter() - rel.unitsStyle = .abbreviated - let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + let when = lastAttempt.relativeDescription(now: Date(), unitsStyle: .abbreviated) + return Text(AppStrings.fmt("%@: last attempt %@", name, when)) .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + return Text(AppStrings.fmt("%@: no data yet", name)) .font(.footnote) .foregroundStyle(.tertiary) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..8dfc0b67f 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -28,7 +28,7 @@ struct ProviderDetailView: View { return nil } guard provider == .openrouter else { - return (label: "Plan", value: rawPlan) + return (label: AppStrings.tr("Plan"), value: rawPlan) } let prefix = "Balance:" @@ -36,10 +36,10 @@ struct ProviderDetailView: View { let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count) let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedValue.isEmpty { - return (label: "Balance", value: trimmedValue) + return (label: AppStrings.tr("Balance"), value: trimmedValue) } } - return (label: "Balance", value: rawPlan) + return (label: AppStrings.tr("Balance"), value: rawPlan) } var body: some View { @@ -63,14 +63,16 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: AppStrings.fmt( + "Last %@ fetch failed:", + self.store.metadata(for: self.provider).displayName), display: errorDisplay, isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) } if self.hasSettings { - ProviderSettingsSection(title: "Settings") { + ProviderSettingsSection(title: AppStrings.tr("Settings")) { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } @@ -86,7 +88,7 @@ struct ProviderDetailView: View { } if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: "Options") { + ProviderSettingsSection(title: AppStrings.tr("Options")) { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } @@ -107,12 +109,17 @@ struct ProviderDetailView: View { } private var detailLabelWidth: CGFloat { - var infoLabels = ["State", "Source", "Version", "Updated"] + var infoLabels = [ + AppStrings.tr("State"), + AppStrings.tr("Source"), + AppStrings.tr("Version"), + AppStrings.tr("Updated"), + ] if self.store.status(for: self.provider) != nil { - infoLabels.append("Status") + infoLabels.append(AppStrings.tr("Status")) } if !self.model.email.isEmpty { - infoLabels.append("Account") + infoLabels.append(AppStrings.tr("Account")) } if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) @@ -122,13 +129,13 @@ struct ProviderDetailView: View { Self.metricTitle(provider: self.provider, metric: metric) } if self.model.creditsText != nil { - metricLabels.append("Credits") + metricLabels.append(AppStrings.tr("Credits")) } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { - metricLabels.append("Cost") + metricLabels.append(AppStrings.tr("Cost")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -174,7 +181,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(AppStrings.tr("Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -233,27 +240,29 @@ private struct ProviderDetailInfoGrid: View { var body: some View { let status = self.store.status(for: self.provider) - let source = self.store.sourceLabel(for: self.provider) - let version = self.store.version(for: self.provider) ?? "not detected" + let source = AppStrings.localizedSourceLabel(self.store.sourceLabel(for: self.provider)) + let version = self.store.version(for: self.provider) ?? AppStrings.tr("not detected") let updated = self.updatedText let email = self.model.email - let enabledText = self.isEnabled ? "Enabled" : "Disabled" + let enabledText = self.isEnabled ? AppStrings.tr("Enabled") : AppStrings.tr("Disabled") Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - ProviderDetailInfoRow(label: "State", value: enabledText, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Source", value: source, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Version", value: version, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Updated", value: updated, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: AppStrings.tr("State"), value: enabledText, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: AppStrings.tr("Source"), value: source, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: AppStrings.tr("Version"), value: version, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: AppStrings.tr("Updated"), value: updated, labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( - label: "Status", - value: status.description ?? status.indicator.label, + label: AppStrings.tr("Status"), + value: AppStrings.localizedProviderStatusDescription( + status.description, + indicator: status.indicator), labelWidth: self.labelWidth) } if !email.isEmpty { - ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: AppStrings.tr("Account"), value: email, labelWidth: self.labelWidth) } if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { @@ -266,12 +275,12 @@ private struct ProviderDetailInfoGrid: View { private var updatedText: String { if let updated = self.store.snapshot(for: self.provider)?.updatedAt { - return UsageFormatter.updatedString(from: updated) + return AppStrings.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { - return "Refreshing" + return AppStrings.tr("Refreshing") } - return "Not fetched yet" + return AppStrings.tr("Not fetched yet") } } @@ -304,7 +313,7 @@ struct ProviderMetricsInlineView: View { let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil ProviderSettingsSection( - title: "Usage", + title: AppStrings.tr("Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -331,7 +340,7 @@ struct ProviderMetricsInlineView: View { if let credits = self.model.creditsText { ProviderMetricInlineTextRow( - title: "Credits", + title: AppStrings.tr("Credits"), value: credits, labelWidth: self.labelWidth) } @@ -345,7 +354,7 @@ struct ProviderMetricsInlineView: View { if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( - title: "Cost", + title: AppStrings.tr("Cost"), value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( @@ -359,9 +368,9 @@ struct ProviderMetricsInlineView: View { private var placeholderText: String { if !self.isEnabled { - return "Disabled — no recent data" + return AppStrings.tr("Disabled — no recent data") } - return self.model.placeholder ?? "No usage yet" + return self.model.placeholder ?? AppStrings.tr("No usage yet") } } @@ -497,11 +506,11 @@ private struct ProviderMetricInlineCostRow: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Usage used") + accessibilityLabel: AppStrings.tr("Usage used")) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% used", self.section.percentUsed)) + Text(AppStrings.fmt("%.0f%% used", self.section.percentUsed)) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 0fa246d88..617f2be4a 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -26,7 +26,7 @@ struct ProviderErrorView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Copy error") + .help(AppStrings.tr("Copy error")) } Text(self.display.preview) @@ -36,9 +36,11 @@ struct ProviderErrorView: View { .fixedSize(horizontal: false, vertical: true) if self.display.preview != self.display.full { - Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() } - .buttonStyle(.link) - .font(.footnote) + Button(self.isExpanded ? AppStrings.tr("Hide details") : AppStrings.tr("Show details")) { + self.isExpanded.toggle() + } + .buttonStyle(.link) + .font(.footnote) } if self.isExpanded { diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..d4e46a01d 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -220,7 +220,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(AppStrings.tr("No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -237,7 +237,7 @@ struct ProviderSettingsTokenAccountsRowView: View { .pickerStyle(.menu) .controlSize(.small) - Button("Remove selected account") { + Button(AppStrings.tr("Remove selected account")) { let account = accounts[selectedIndex] self.descriptor.removeAccount(account.id) } @@ -246,13 +246,13 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) + TextField(AppStrings.tr("Label"), text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) SecureField(self.descriptor.placeholder, text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(AppStrings.tr("Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } @@ -267,12 +267,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 10) { - Button("Open token file") { + Button(AppStrings.tr("Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(AppStrings.tr("Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7..184af0fdb 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -62,7 +62,7 @@ private struct ProviderSidebarRowView: View { .contentShape(Rectangle()) .padding(.vertical, 4) .padding(.horizontal, 2) - .help("Drag to reorder") + .help(AppStrings.tr("Drag to reorder")) .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) @@ -109,9 +109,9 @@ private struct ProviderSidebarRowView: View { if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") - return "Disabled — \(first)\n\(rest)" + return AppStrings.fmt("Disabled — %@", "\(first)\n\(rest)") } - return "Disabled — \(self.subtitle)" + return AppStrings.fmt("Disabled — %@", self.subtitle) } } @@ -135,7 +135,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(AppStrings.tr("Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a040dafd..0a38b6682 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -50,7 +50,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(AppStrings.tr("Select a provider")) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @@ -78,7 +78,7 @@ struct ProvidersPane: View { active.onConfirm() self.activeConfirmation = nil } - Button("Cancel", role: .cancel) { self.activeConfirmation = nil } + Button(AppStrings.tr("Cancel"), role: .cancel) { self.activeConfirmation = nil } } }, message: { @@ -115,9 +115,9 @@ struct ProvidersPane: View { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else if self.store.isStale(provider: provider) { - usageText = "last fetch failed" + usageText = AppStrings.tr("last fetch failed") } else { - usageText = "usage not fetched yet" + usageText = AppStrings.tr("usage not fetched yet") } let presentationContext = ProviderPresentationContext( @@ -267,34 +267,41 @@ struct ProvidersPane: View { let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: AppStrings.tr("Automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (API key limit)"), + title: AppStrings.tr("Primary (API key limit)")), ] } else { let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) var metricOptions: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: AppStrings.tr("Automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: AppStrings.fmt("Primary (%@)", AppStrings.tr(metadata.sessionLabel))), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: AppStrings.fmt("Secondary (%@)", AppStrings.tr(metadata.weeklyLabel))), ] if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: AppStrings.fmt( + "Average (%@ + %@)", + AppStrings.tr(metadata.sessionLabel), + AppStrings.tr(metadata.weeklyLabel)))) } options = metricOptions } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: AppStrings.tr("Menu bar metric"), + subtitle: AppStrings.tr("Choose which window drives the menu bar percent."), binding: Binding( get: { self.settings.menuBarMetricPreference(for: provider).rawValue }, set: { rawValue in diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index a6f893950..e652366cc 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -20,6 +20,23 @@ enum PreferencesTab: String, Hashable { var preferredHeight: CGFloat { PreferencesTab.windowHeight } + + var localizedTitle: String { + switch self { + case .general: + AppStrings.tr("General") + case .providers: + AppStrings.tr("Providers") + case .display: + AppStrings.tr("Display") + case .advanced: + AppStrings.tr("Advanced") + case .about: + AppStrings.tr("About") + case .debug: + AppStrings.tr("Debug") + } + } } @MainActor @@ -34,33 +51,34 @@ struct PreferencesView: View { var body: some View { TabView(selection: self.$selection.tab) { GeneralPane(settings: self.settings, store: self.store) - .tabItem { Label("General", systemImage: "gearshape") } + .tabItem { Label(PreferencesTab.general.localizedTitle, systemImage: "gearshape") } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + .tabItem { Label(PreferencesTab.providers.localizedTitle, systemImage: "square.grid.2x2") } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings, store: self.store) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { Label(PreferencesTab.display.localizedTitle, systemImage: "eye") } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { Label(PreferencesTab.advanced.localizedTitle, systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { Label(PreferencesTab.about.localizedTitle, systemImage: "info.circle") } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) - .tabItem { Label("Debug", systemImage: "ladybug") } + .tabItem { Label(PreferencesTab.debug.localizedTitle, systemImage: "ladybug") } .tag(PreferencesTab.debug) } } .padding(.horizontal, 24) .padding(.vertical, 16) + .environment(\.locale, self.settings.appLocale) .frame(width: self.contentWidth, height: self.contentHeight) .onAppear { self.updateLayout(for: self.selection.tab, animate: false) @@ -93,3 +111,12 @@ struct PreferencesView: View { } } } + +extension PreferencesView { + static func _test_visibleTabTitles(debugMenuEnabled: Bool) -> [String] { + let tabs: [PreferencesTab] = debugMenuEnabled + ? [.general, .providers, .display, .advanced, .about, .debug] + : [.general, .providers, .display, .advanced, .about] + return tabs.map(\.localizedTitle) + } +} diff --git a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift index 25ca6c932..964fc4f25 100644 --- a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift @@ -34,16 +34,16 @@ struct AmpProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.ampCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from Amp settings.", - off: "Amp cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies."), + manual: AppStrings.tr("Paste a Cookie header or cURL capture from Amp settings."), + off: AppStrings.tr("Amp cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "amp-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -60,12 +60,12 @@ struct AmpProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: AppStrings.tr("Cookie: …"), binding: context.stringBinding(\.ampCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "amp-open-settings", - title: "Open Amp Settings", + title: AppStrings.tr("Open Amp Settings"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift index e41aa8fe6..7f7ae42f2 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift @@ -5,7 +5,7 @@ extension StatusItemController { func runAntigravityLoginFlow() async { self.loginPhase = .idle self.presentLoginAlert( - title: "Antigravity login is managed in the app", - message: "Open Antigravity to sign in, then refresh CodexBar.") + title: AppStrings.tr("Antigravity login is managed in the app"), + message: AppStrings.tr("Open Antigravity to sign in, then refresh CodexBar.")) } } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift index c1529bd58..c2464cef2 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift @@ -52,16 +52,16 @@ struct AugmentProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.augmentCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from the Augment dashboard.", - off: "Augment cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies."), + manual: AppStrings.tr("Paste a Cookie header or cURL capture from the Augment dashboard."), + off: AppStrings.tr("Augment cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "augment-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -70,7 +70,7 @@ struct AugmentProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .augment) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } @@ -83,14 +83,14 @@ struct AugmentProviderImplementation: ProviderImplementation { @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) { - entries.append(.action("Refresh Session", .refreshAugmentSession)) + entries.append(.action(AppStrings.tr("Refresh Session"), .refreshAugmentSession)) if let error = context.store.error(for: .augment) { if error.contains("session has expired") || error.contains("No Augment session cookie found") { entries.append(.action( - "Open Augment (Log Out & Back In)", + AppStrings.tr("Open Augment (Log Out & Back In)"), .loginToProvider(url: "https://app.augmentcode.com"))) } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index de1bdffc7..c726e9e9d 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -65,9 +65,9 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { let subtitle = if context.settings.debugDisableKeychainAccess { - "Inactive while \"Disable Keychain access\" is enabled in Advanced." + AppStrings.tr("Inactive while \"Disable Keychain access\" is enabled in Advanced.") } else { - "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." + AppStrings.tr("Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts.") } let promptFreeBinding = Binding( @@ -80,7 +80,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", - title: "Avoid Keychain prompts (experimental)", + title: AppStrings.tr("Avoid Keychain prompts (experimental)"), subtitle: subtitle, binding: promptFreeBinding, statusText: nil, @@ -112,7 +112,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { }) let usageOptions = ClaudeUsageDataSource.allCases.map { - ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + ProviderSettingsPickerOption(id: $0.rawValue, title: AppStrings.claudeUsageSource($0)) } let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, @@ -120,35 +120,37 @@ struct ClaudeProviderImplementation: ProviderImplementation { let keychainPromptPolicyOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.never.rawValue, - title: "Never prompt"), + title: AppStrings.tr("Never prompt")), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue, - title: "Only on user action"), + title: AppStrings.tr("Only on user action")), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.always.rawValue, - title: "Always allow prompts"), + title: AppStrings.tr("Always allow prompts")), ] let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.claudeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies for the web API.", - manual: "Paste a Cookie header from a claude.ai request.", - off: "Claude cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies for the web API."), + manual: AppStrings.tr("Paste a Cookie header from a claude.ai request."), + off: AppStrings.tr("Claude cookies are disabled.")) } let keychainPromptPolicySubtitle: () -> String? = { if context.settings.debugDisableKeychainAccess { - return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." + return AppStrings + .tr("Global Keychain access is disabled in Advanced, so this setting is currently inactive.") } - return "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing " + - "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." + return AppStrings.tr( + "Controls Claude OAuth Keychain prompts when experimental reader mode is off. " + + "Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed.") } return [ ProviderSettingsPickerDescriptor( id: "claude-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: AppStrings.tr("Usage source"), + subtitle: AppStrings.tr("Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -156,12 +158,12 @@ struct ClaudeProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.claudeUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .claude) - return label == "auto" ? nil : label + return label == "auto" ? nil : AppStrings.localizedSourceLabel(label) }), ProviderSettingsPickerDescriptor( id: "claude-keychain-prompt-policy", - title: "Keychain prompt policy", - subtitle: "Applies only to the Security.framework OAuth keychain reader.", + title: AppStrings.tr("Keychain prompt policy"), + subtitle: AppStrings.tr("Applies only to the Security.framework OAuth keychain reader."), dynamicSubtitle: keychainPromptPolicySubtitle, binding: keychainPromptPolicyBinding, options: keychainPromptPolicyOptions, @@ -170,8 +172,8 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil), ProviderSettingsPickerDescriptor( id: "claude-cookie-source", - title: "Claude cookies", - subtitle: "Automatic imports browser cookies for the web API.", + title: AppStrings.tr("Claude cookies"), + subtitle: AppStrings.tr("Automatic imports browser cookies for the web API."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -180,7 +182,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .claude) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } @@ -200,7 +202,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { if context.snapshot?.secondary == nil { - entries.append(.text("Weekly usage unavailable for this account.", .secondary)) + entries.append(.text(AppStrings.tr("Weekly usage unavailable for this account."), .secondary)) } if let cost = context.snapshot?.providerCost, @@ -209,7 +211,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("Extra usage: \(used) / \(limit)", .primary)) + entries.append(.text(AppStrings.fmt("Extra usage: %@ / %@", used, limit), .primary)) } } @@ -218,7 +220,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { -> (label: String, action: MenuDescriptor.MenuAction)? { guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil } - return ("Open Terminal", .openTerminal(command: "claude")) + return (AppStrings.tr("Open Terminal"), .openTerminal(command: "claude")) } @MainActor diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 61aa3a501..fee651511 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -73,8 +73,8 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", - title: "Historical tracking", - subtitle: "Stores local Codex usage history (8 weeks) to personalize Pace predictions.", + title: AppStrings.tr("Historical tracking"), + subtitle: AppStrings.tr("Stores local Codex usage history (8 weeks) to personalize Pace predictions."), binding: context.boolBinding(\.historicalTrackingEnabled), statusText: nil, actions: [], @@ -84,8 +84,8 @@ struct CodexProviderImplementation: ProviderImplementation { onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", - title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", + title: AppStrings.tr("OpenAI web extras"), + subtitle: AppStrings.tr("Show usage breakdown, credits history, and code review via chatgpt.com."), binding: extrasBinding, statusText: nil, actions: [], @@ -110,7 +110,7 @@ struct CodexProviderImplementation: ProviderImplementation { }) let usageOptions = CodexUsageDataSource.allCases.map { - ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + ProviderSettingsPickerOption(id: $0.rawValue, title: AppStrings.codexUsageSource($0)) } let cookieOptions = ProviderCookieSourceUI.options( allowsOff: true, @@ -120,16 +120,16 @@ struct CodexProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.codexCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies for dashboard extras.", - manual: "Paste a Cookie header from a chatgpt.com request.", - off: "Disable OpenAI dashboard cookie usage.") + auto: AppStrings.tr("Automatic imports browser cookies for dashboard extras."), + manual: AppStrings.tr("Paste a Cookie header from a chatgpt.com request."), + off: AppStrings.tr("Disable OpenAI dashboard cookie usage.")) } return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: AppStrings.tr("Usage source"), + subtitle: AppStrings.tr("Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -137,12 +137,12 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.codexUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .codex) - return label == "auto" ? nil : label + return label == "auto" ? nil : AppStrings.localizedSourceLabel(label) }), ProviderSettingsPickerDescriptor( id: "codex-cookie-source", - title: "OpenAI cookies", - subtitle: "Automatic imports browser cookies for dashboard extras.", + title: AppStrings.tr("OpenAI cookies"), + subtitle: AppStrings.tr("Automatic imports browser cookies for dashboard extras."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -151,7 +151,7 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } @@ -164,7 +164,7 @@ struct CodexProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: AppStrings.tr("Cookie: …"), binding: context.stringBinding(\.codexCookieHeader), actions: [], isVisible: { @@ -181,9 +181,13 @@ struct CodexProviderImplementation: ProviderImplementation { else { return } if let credits = context.store.credits { - entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) + entries.append(.text( + AppStrings.fmt("Credits: %@", AppStrings.creditsString(from: credits.remaining)), + .primary)) if let latest = credits.events.first { - entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) + entries.append(.text( + AppStrings.fmt("Last spend: %@", AppStrings.creditEventSummary(latest)), + .secondary)) } } else { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 55275ae61..e8e8dea25 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -16,14 +16,13 @@ struct CopilotLoginFlow { pb.setString(code.userCode, forType: .string) let alert = NSAlert() - alert.messageText = "GitHub Copilot Login" - alert.informativeText = """ - A device code has been copied to your clipboard: \(code.userCode) - - Please verify it at: \(code.verificationUri) - """ - alert.addButton(withTitle: "Open Browser") - alert.addButton(withTitle: "Cancel") + alert.messageText = AppStrings.tr("GitHub Copilot Login") + alert.informativeText = AppStrings.fmt( + "A device code has been copied to your clipboard: %@\n\nPlease verify it at: %@", + code.userCode, + code.verificationUri) + alert.addButton(withTitle: AppStrings.tr("Open Browser")) + alert.addButton(withTitle: AppStrings.tr("Cancel")) let response = alert.runModal() if response == .alertSecondButtonReturn { @@ -43,12 +42,10 @@ struct CopilotLoginFlow { // Let's show a "Waiting" alert that can be cancelled. let waitingAlert = NSAlert() - waitingAlert.messageText = "Waiting for Authentication..." - waitingAlert.informativeText = """ - Please complete the login in your browser. - This window will close automatically when finished. - """ - waitingAlert.addButton(withTitle: "Cancel") + waitingAlert.messageText = AppStrings.tr("Waiting for Authentication...") + waitingAlert.informativeText = AppStrings.tr( + "Please complete the login in your browser.\nThis window will close automatically when finished.") + waitingAlert.addButton(withTitle: AppStrings.tr("Cancel")) let parentWindow = Self.resolveWaitingParentWindow() let hostWindow = parentWindow ?? Self.makeWaitingHostWindow() let shouldCloseHostWindow = parentWindow == nil @@ -87,19 +84,19 @@ struct CopilotLoginFlow { enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = AppStrings.tr("Login Successful") success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = AppStrings.tr("Login Failed") err.informativeText = error.localizedDescription err.runModal() } } catch { let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = AppStrings.tr("Login Failed") err.informativeText = error.localizedDescription err.runModal() } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f..e6c41b298 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -10,7 +10,7 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "github api" } + ProviderPresentation { _ in AppStrings.tr("github api") } } @MainActor @@ -29,15 +29,15 @@ struct CopilotProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "copilot-api-token", - title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", + title: AppStrings.tr("GitHub Login"), + subtitle: AppStrings.tr("Requires authentication via GitHub Device Flow."), kind: .secure, - placeholder: "Sign in via button below", + placeholder: AppStrings.tr("Sign in via button below"), binding: context.stringBinding(\.copilotAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "copilot-login", - title: "Sign in with GitHub", + title: AppStrings.tr("Sign in with GitHub"), style: .bordered, isVisible: { context.settings.copilotAPIToken.isEmpty }, perform: { @@ -45,7 +45,7 @@ struct CopilotProviderImplementation: ProviderImplementation { }), ProviderSettingsActionDescriptor( id: "copilot-relogin", - title: "Sign in again", + title: AppStrings.tr("Sign in again"), style: .link, isVisible: { !context.settings.copilotAPIToken.isEmpty }, perform: { diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 48db614f9..d94b4a8d4 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -10,7 +10,7 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in AppStrings.tr("web") } } @MainActor @@ -53,16 +53,16 @@ struct CursorProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.cursorCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies or stored sessions.", - manual: "Paste a Cookie header from a cursor.com request.", - off: "Cursor cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies or stored sessions."), + manual: AppStrings.tr("Paste a Cookie header from a cursor.com request."), + off: AppStrings.tr("Cursor cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "cursor-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies or stored sessions.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies or stored sessions."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -71,7 +71,7 @@ struct CursorProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .cursor) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } @@ -94,9 +94,9 @@ struct CursorProviderImplementation: ProviderImplementation { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) if cost.limit > 0 { let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary)) + entries.append(.text(AppStrings.fmt("On-Demand: %@ / %@", used, limitStr), .primary)) } else { - entries.append(.text("On-Demand: \(used)", .primary)) + entries.append(.text(AppStrings.fmt("On-Demand: %@", used), .primary)) } } } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index d8d2d2024..75d9d65fb 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -48,16 +48,16 @@ struct FactoryProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.factoryCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies and WorkOS tokens.", - manual: "Paste a Cookie header from app.factory.ai.", - off: "Factory cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies and WorkOS tokens."), + manual: AppStrings.tr("Paste a Cookie header from app.factory.ai."), + off: AppStrings.tr("Factory cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "factory-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies and WorkOS tokens.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies and WorkOS tokens."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -66,7 +66,7 @@ struct FactoryProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .factory) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift index bfb92438e..8d11a8ae2 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift @@ -7,20 +7,24 @@ extension StatusItemController { let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs(includeMissingQuota: true) if detectedIDEs.isEmpty { let message = [ - "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar.", - "Alternatively, set a custom path in Settings.", + AppStrings.tr("Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar."), + AppStrings.tr("Alternatively, set a custom path in Settings."), ].joined(separator: " ") self.presentLoginAlert( - title: "No JetBrains IDE detected", + title: AppStrings.tr("No JetBrains IDE detected"), message: message) } else { let ideNames = detectedIDEs.prefix(3).map(\.displayName).joined(separator: ", ") let hasQuotaFile = !JetBrainsIDEDetector.detectInstalledIDEs().isEmpty let message = hasQuotaFile - ? "Detected: \(ideNames). Select your preferred IDE in Settings, then refresh CodexBar." - : "Detected: \(ideNames). Use AI Assistant once to generate quota data, then refresh CodexBar." + ? AppStrings.fmt( + "Detected: %@. Select your preferred IDE in Settings, then refresh CodexBar.", + ideNames) + : AppStrings.fmt( + "Detected: %@. Use AI Assistant once to generate quota data, then refresh CodexBar.", + ideNames) self.presentLoginAlert( - title: "JetBrains AI is ready", + title: AppStrings.tr("JetBrains AI is ready"), message: message) } } diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift index 7e7096f50..3e2712f5a 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift @@ -19,7 +19,7 @@ struct JetBrainsProviderImplementation: ProviderImplementation { guard !detectedIDEs.isEmpty else { return [] } var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: "", title: "Auto-detect"), + ProviderSettingsPickerOption(id: "", title: AppStrings.tr("Auto-detect")), ] for ide in detectedIDEs { options.append(ProviderSettingsPickerOption(id: ide.basePath, title: ide.displayName)) @@ -28,8 +28,8 @@ struct JetBrainsProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "jetbrains.ide", - title: "JetBrains IDE", - subtitle: "Select the IDE to monitor", + title: AppStrings.tr("JetBrains IDE"), + subtitle: AppStrings.tr("Select the IDE to monitor"), binding: context.stringBinding(\.jetbrainsIDEBasePath), options: options, isVisible: nil, @@ -50,8 +50,8 @@ struct JetBrainsProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "jetbrains.customPath", - title: "Custom Path", - subtitle: "Override auto-detection with a custom IDE base path", + title: AppStrings.tr("Custom Path"), + subtitle: AppStrings.tr("Override auto-detection with a custom IDE base path"), kind: .plain, placeholder: "~/Library/Application Support/JetBrains/IntelliJIdea2024.3", binding: context.stringBinding(\.jetbrainsIDEBasePath), diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index e2bdb3cfa..1f701c363 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -49,13 +49,13 @@ struct KiloProviderImplementation: ProviderImplementation { context.settings.kiloUsageDataSource = KiloUsageDataSource(rawValue: raw) ?? .auto }) let usageOptions = KiloUsageDataSource.allCases.map { - ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + ProviderSettingsPickerOption(id: $0.rawValue, title: AppStrings.kiloUsageSource($0)) } return [ ProviderSettingsPickerDescriptor( id: "kilo-usage-source", - title: "Usage source", - subtitle: "Auto uses API first, then falls back to CLI on auth failures.", + title: AppStrings.tr("Usage source"), + subtitle: AppStrings.tr("Auto uses API first, then falls back to CLI on auth failures."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -63,7 +63,7 @@ struct KiloProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.kiloUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .kilo) - return label == "auto" ? nil : label + return label == "auto" ? nil : AppStrings.localizedSourceLabel(label) }), ] } @@ -73,9 +73,10 @@ struct KiloProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "kilo-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " - + "~/.local/share/kilo/auth.json (kilo.access).", + title: AppStrings.tr("API key"), + subtitle: AppStrings.tr( + "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " + + "~/.local/share/kilo/auth.json (kilo.access)."), kind: .secure, placeholder: "kilo_...", binding: context.stringBinding(\.kiloAPIToken), diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index d48511963..c4b637239 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -10,7 +10,7 @@ struct KimiProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in AppStrings.tr("web") } } @MainActor @@ -39,16 +39,16 @@ struct KimiProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.kimiCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a cookie header or the kimi-auth token value.", - off: "Kimi cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies."), + manual: AppStrings.tr("Paste a cookie header or the kimi-auth token value."), + off: AppStrings.tr("Kimi cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "kimi-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies."), dynamicSubtitle: subtitle, binding: cookieBinding, options: options, @@ -65,12 +65,12 @@ struct KimiProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: \u{2026}\n\nor paste the kimi-auth token value", + placeholder: AppStrings.tr("Cookie: …\n\nor paste the kimi-auth token value"), binding: context.stringBinding(\.kimiManualCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "kimi-open-console", - title: "Open Console", + title: AppStrings.tr("Open Console"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift index 9209bba71..76f72a5c1 100644 --- a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift @@ -17,15 +17,15 @@ struct KimiK2ProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "kimi-k2-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai.", + title: AppStrings.tr("API key"), + subtitle: AppStrings.tr("Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai."), kind: .secure, - placeholder: "Paste API key…", + placeholder: AppStrings.tr("Paste API key…"), binding: context.stringBinding(\.kimiK2APIToken), actions: [ ProviderSettingsActionDescriptor( id: "kimi-k2-open-api-keys", - title: "Open API Keys", + title: AppStrings.tr("Open API Keys"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 6b7432edc..24b075416 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -64,9 +64,9 @@ struct MiniMaxProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies and local storage tokens.", - manual: "Paste a Cookie header or cURL capture from the Coding Plan page.", - off: "MiniMax cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies and local storage tokens."), + manual: AppStrings.tr("Paste a Cookie header or cURL capture from the Coding Plan page."), + off: AppStrings.tr("MiniMax cookies are disabled.")) } let regionBinding = Binding( @@ -81,8 +81,8 @@ struct MiniMaxProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "minimax-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies and local storage tokens.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies and local storage tokens."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -91,12 +91,12 @@ struct MiniMaxProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .minimax) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ProviderSettingsPickerDescriptor( id: "minimax-region", - title: "API region", - subtitle: "Choose the MiniMax host (global .io or China mainland .com).", + title: AppStrings.tr("API region"), + subtitle: AppStrings.tr("Choose the MiniMax host (global .io or China mainland .com)."), binding: regionBinding, options: regionOptions, isVisible: nil, @@ -114,15 +114,15 @@ struct MiniMaxProviderImplementation: ProviderImplementation { return [ ProviderSettingsFieldDescriptor( id: "minimax-api-token", - title: "API token", - subtitle: "Stored in ~/.codexbar/config.json. Paste your MiniMax API key.", + title: AppStrings.tr("API token"), + subtitle: AppStrings.tr("Stored in ~/.codexbar/config.json. Paste your MiniMax API key."), kind: .secure, - placeholder: "Paste API token…", + placeholder: AppStrings.tr("Paste API token…"), binding: context.stringBinding(\.minimaxAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard", - title: "Open Coding Plan", + title: AppStrings.tr("Open Coding Plan"), style: .link, isVisible: nil, perform: { @@ -133,15 +133,15 @@ struct MiniMaxProviderImplementation: ProviderImplementation { onActivate: { context.settings.ensureMiniMaxAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "minimax-cookie", - title: "Cookie header", + title: AppStrings.tr("Cookie header"), subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: AppStrings.tr("Cookie: …"), binding: context.stringBinding(\.minimaxCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard-cookie", - title: "Open Coding Plan", + title: AppStrings.tr("Open Coding Plan"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift index 99d8582f1..215743541 100644 --- a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift @@ -48,16 +48,16 @@ struct OllamaProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.ollamaCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from Ollama settings.", - off: "Ollama cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies."), + manual: AppStrings.tr("Paste a Cookie header or cURL capture from Ollama settings."), + off: AppStrings.tr("Ollama cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "ollama-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -74,12 +74,12 @@ struct OllamaProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: AppStrings.tr("Cookie: …"), binding: context.stringBinding(\.ollamaCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "ollama-open-settings", - title: "Open Ollama Settings", + title: AppStrings.tr("Open Ollama Settings"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift index 5e069f0a1..2e74f60cd 100644 --- a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift @@ -10,7 +10,7 @@ struct OpenCodeProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in AppStrings.tr("web") } } @MainActor @@ -54,16 +54,16 @@ struct OpenCodeProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.opencodeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies from opencode.ai.", - manual: "Paste a Cookie header captured from the billing page.", - off: "OpenCode cookies are disabled.") + auto: AppStrings.tr("Automatic imports browser cookies from opencode.ai."), + manual: AppStrings.tr("Paste a Cookie header captured from the billing page."), + off: AppStrings.tr("OpenCode cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "opencode-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies from opencode.ai.", + title: AppStrings.tr("Cookie source"), + subtitle: AppStrings.tr("Automatic imports browser cookies from opencode.ai."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -72,7 +72,7 @@ struct OpenCodeProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .opencode) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return AppStrings.fmt("Cached: %@ • %@", entry.sourceLabel, when) }), ] } @@ -82,10 +82,10 @@ struct OpenCodeProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "opencode-workspace-id", - title: "Workspace ID", - subtitle: "Optional override if workspace lookup fails.", + title: AppStrings.tr("Workspace ID"), + subtitle: AppStrings.tr("Optional override if workspace lookup fails."), kind: .plain, - placeholder: "wrk_…", + placeholder: AppStrings.tr("wrk_…"), binding: context.stringBinding(\.opencodeWorkspaceID), actions: [], isVisible: nil, diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift index d584a2430..82ebacbbc 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -10,7 +10,7 @@ struct OpenRouterProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in AppStrings.tr("api") } } @MainActor @@ -42,10 +42,10 @@ struct OpenRouterProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "openrouter-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. " - + "Get your key from openrouter.ai/settings/keys and set a key spending limit " - + "there to enable API key quota tracking.", + title: AppStrings.tr("API key"), + subtitle: AppStrings.tr( + "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys " + + "and set a key spending limit there to enable API key quota tracking."), kind: .secure, placeholder: "sk-or-v1-...", binding: context.stringBinding(\.openRouterAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift index 4964f4df4..110ee8a7d 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift @@ -2,22 +2,22 @@ import CodexBarCore enum ProviderCookieSourceUI { static let keychainDisabledPrefix = - "Keychain access is disabled in Advanced, so browser cookie import is unavailable." + AppStrings.tr("Keychain access is disabled in Advanced, so browser cookie import is unavailable.") static func options(allowsOff: Bool, keychainDisabled: Bool) -> [ProviderSettingsPickerOption] { var options: [ProviderSettingsPickerOption] = [] if !keychainDisabled { options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.auto.rawValue, - title: ProviderCookieSource.auto.displayName)) + title: AppStrings.cookieSource(.auto))) } options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.manual.rawValue, - title: ProviderCookieSource.manual.displayName)) + title: AppStrings.cookieSource(.manual))) if allowsOff { options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.off.rawValue, - title: ProviderCookieSource.off.displayName)) + title: AppStrings.cookieSource(.off))) } return options } diff --git a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift index dcef3eb67..cb905ec5f 100644 --- a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift @@ -9,7 +9,7 @@ struct SyntheticProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in AppStrings.tr("api") } } @MainActor @@ -31,10 +31,11 @@ struct SyntheticProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "synthetic-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard.", + title: AppStrings.tr("API key"), + subtitle: AppStrings + .tr("Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard."), kind: .secure, - placeholder: "Paste key…", + placeholder: AppStrings.tr("Paste key…"), binding: context.stringBinding(\.syntheticAPIToken), actions: [], isVisible: nil, diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 1f8fe5418..e69a4a3d8 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -7,20 +7,17 @@ extension StatusItemController { func runVertexAILoginFlow() async { // Show alert with instructions let alert = NSAlert() - alert.messageText = "Vertex AI Login" - alert.informativeText = """ - To use Vertex AI tracking, you need to authenticate with Google Cloud. - - 1. Open Terminal - 2. Run: gcloud auth application-default login - 3. Follow the browser prompts to sign in - 4. Set your project: gcloud config set project PROJECT_ID - - Would you like to open Terminal now? - """ + alert.messageText = AppStrings.tr("Vertex AI Login") + alert.informativeText = AppStrings.tr( + "To use Vertex AI tracking, you need to authenticate with Google Cloud.\n\n" + + "1. Open Terminal\n" + + "2. Run: gcloud auth application-default login\n" + + "3. Follow the browser prompts to sign in\n" + + "4. Set your project: gcloud config set project PROJECT_ID\n\n" + + "Would you like to open Terminal now?") alert.alertStyle = .informational - alert.addButton(withTitle: "Open Terminal") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: AppStrings.tr("Open Terminal")) + alert.addButton(withTitle: AppStrings.tr("Cancel")) let response = alert.runModal() diff --git a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift index e9cb82de9..722888806 100644 --- a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift @@ -17,16 +17,17 @@ struct WarpProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "warp-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, " - + "then create one.", + title: AppStrings.tr("API key"), + subtitle: AppStrings.tr( + "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, " + + "then create one."), kind: .secure, placeholder: "wk-...", binding: context.stringBinding(\.warpAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "warp-open-api-keys", - title: "Open Warp API Key Guide", + title: AppStrings.tr("Open Warp API Key Guide"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift index d4bc64a9f..141d4bff3 100644 --- a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift @@ -10,7 +10,7 @@ struct ZaiProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in AppStrings.tr("api") } } @MainActor @@ -48,8 +48,8 @@ struct ZaiProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "zai-api-region", - title: "API region", - subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).", + title: AppStrings.tr("API region"), + subtitle: AppStrings.tr("Use BigModel for the China mainland endpoints (open.bigmodel.cn)."), binding: binding, options: options, isVisible: nil, diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..dcf5093a1 --- /dev/null +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -0,0 +1,426 @@ +"1 min" = "1 min"; +"2 min" = "2 min"; +"5 min" = "5 min"; +"15 min" = "15 min"; +"30 min" = "30 min"; +"%@ left" = "%@ left"; +"%@ — %@: %@" = "%@ — %@: %@"; +"%@ · %@ · %@ credits" = "%@ · %@ · %@ credits"; +"API" = "API"; +"API key limit" = "API key limit"; +"Auto" = "Auto"; +"Automatic" = "Automatic"; +"Average" = "Average"; +"Beta" = "Beta"; +"Both" = "Both"; +"CLI" = "CLI"; +"CLI (PTY)" = "CLI (PTY)"; +"CLI (RPC/PTY)" = "CLI (RPC/PTY)"; +"China mainland (platform.minimaxi.com)" = "China mainland (platform.minimaxi.com)"; +"Critical issue" = "Critical issue"; +"Cylon" = "Cylon"; +"Global (api.z.ai)" = "Global (api.z.ai)"; +"Global (platform.minimax.io)" = "Global (platform.minimax.io)"; +"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "Global Keychain access is disabled in Advanced, so this setting is currently inactive."; +"Knight Rider" = "Knight Rider"; +"Maintenance" = "Maintenance"; +"Major outage" = "Major outage"; +"Manual" = "Manual"; +"Monthly" = "Monthly"; +"Off" = "Off"; +"OAuth API" = "OAuth API"; +"On pace" = "On pace"; +"Operational" = "Operational"; +"Outside-In" = "Outside-In"; +"Pace" = "Pace"; +"Partial outage" = "Partial outage"; +"Percent" = "Percent"; +"Primary" = "Primary"; +"Pulse" = "Pulse"; +"Race" = "Race"; +"Receive only stable, production-ready releases." = "Receive only stable, production-ready releases."; +"Receive stable releases plus beta previews." = "Receive stable releases plus beta previews."; +"Resets" = "Resets"; +"Resets %@" = "Resets %@"; +"Secondary" = "Secondary"; +"Show both percentage and pace (e.g. 45%% · +5%%)" = "Show both percentage and pace (e.g. 45%% · +5%%)"; +"Show pace indicator (e.g. +5%%)" = "Show pace indicator (e.g. +5%%)"; +"Show remaining/used percentage (e.g. 45%%)" = "Show remaining/used percentage (e.g. 45%%)"; +"Weekly" = "Weekly"; +"Stable" = "Stable"; +"Status unknown" = "Status unknown"; +"Unbraid (logo → bars)" = "Unbraid (logo → bars)"; +"Updated %@" = "Updated %@"; +"Updated just now" = "Updated just now"; +"Web API (cookies)" = "Web API (cookies)"; +"Pace: %@" = "Pace: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"%d%% in deficit" = "%d%% in deficit"; +"%d%% in reserve" = "%d%% in reserve"; +"Lasts until reset" = "Lasts until reset"; +"Runs out %@" = "Runs out %@"; +"Runs out now" = "Runs out now"; +"≈ %d%% run-out risk" = "≈ %d%% run-out risk"; +"BigModel CN (open.bigmodel.cn)" = "BigModel CN (open.bigmodel.cn)"; +"in %dd" = "in %dd"; +"in %dd %dh" = "in %dd %dh"; +"in %dh" = "in %dh"; +"in %dh %dm" = "in %dh %dm"; +"in %dm" = "in %dm"; +"left" = "left"; +"now" = "now"; +"tomorrow, %@" = "tomorrow, %@"; +"used" = "used"; +" error=%@" = " error=%@"; +"%.0f%% %@" = "%.0f%% %@"; +"%.0f%% used" = "%.0f%% used"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ remaining)"; +"%@ login successful" = "%@ login successful"; +"%@ tokens" = "%@ tokens"; +"%@/%@ left" = "%@/%@ left"; +"%@: %@ / %@" = "%@: %@ / %@"; +"API key" = "API key"; +"API key limit unavailable right now" = "API key limit unavailable right now"; +"API region" = "API region"; +"API token" = "API token"; +"About CodexBar" = "About CodexBar"; +"Add Account..." = "Add Account..."; +"Advanced" = "Advanced"; +"Activity: %@" = "Activity: %@"; +"Animation pattern" = "Animation pattern"; +"Balance" = "Balance"; +"Blink now" = "Blink now"; +"Buy Credits..." = "Buy Credits..."; +"CLI paths" = "CLI paths"; +"CLI sessions" = "CLI sessions"; +"Cached: %@ • %@" = "Cached: %@ • %@"; +"Caches" = "Caches"; +"Cancel" = "Cancel"; +"Claude cookies" = "Claude cookies"; +"Code review" = "Code review"; +"Controls how much detail is logged." = "Controls how much detail is logged."; +"Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed."; +"Cookie header" = "Cookie header"; +"Cookie source" = "Cookie source"; +"Cookie: …" = "Cookie: …"; +"Copied" = "Copied"; +"Copy" = "Copy"; +"Copy error" = "Copy error"; +"Cost" = "Cost"; +"Credits" = "Credits"; +"Credits history" = "Credits history"; +"Credits remaining" = "Credits remaining"; +"Credits: %@" = "Credits: %@"; +"Custom Path" = "Custom Path"; +"Debug" = "Debug"; +"Display" = "Display"; +"Effective PATH" = "Effective PATH"; +"Enable file logging" = "Enable file logging"; +"Error simulation" = "Error simulation"; +"Extra usage" = "Extra usage"; +"Extra usage spent" = "Extra usage spent"; +"Extra usage: %@ / %@" = "Extra usage: %@ / %@"; +"Fetch log" = "Fetch log"; +"Fetch strategy attempts" = "Fetch strategy attempts"; +"Force animation on next refresh" = "Force animation on next refresh"; +"GitHub Login" = "GitHub Login"; +"Historical tracking" = "Historical tracking"; +"Keychain prompt policy" = "Keychain prompt policy"; +"Last 30 days: %@" = "Last 30 days: %@"; +"Last 30 days: %@ · %@ tokens" = "Last 30 days: %@ · %@ tokens"; +"Last spend: %@" = "Last spend: %@"; +"Load parse dump" = "Load parse dump"; +"Loading animations" = "Loading animations"; +"Loading…" = "Loading…"; +"Logging" = "Logging"; +"Login Failed" = "Login Failed"; +"Login Successful" = "Login Successful"; +"Login shell PATH (startup capture)" = "Login shell PATH (startup capture)"; +"MCP details" = "MCP details"; +"Menu bar metric" = "Menu bar metric"; +"No cost history data." = "No cost history data."; +"No credits history data." = "No credits history data."; +"No fetch attempts yet." = "No fetch attempts yet."; +"No providers selected for Overview." = "No providers selected for Overview."; +"No log yet. Fetch to load." = "No log yet. Fetch to load."; +"No usage breakdown data." = "No usage breakdown data."; +"No usage configured." = "No usage configured."; +"No usage yet" = "No usage yet"; +"Not fetched yet" = "Not fetched yet"; +"Not found" = "Not found"; +"Notifications" = "Notifications"; +"OK" = "OK"; +"On-Demand: %@" = "On-Demand: %@"; +"On-Demand: %@ / %@" = "On-Demand: %@ / %@"; +"Open API Keys" = "Open API Keys"; +"Open Amp Settings" = "Open Amp Settings"; +"Open Augment (Log Out & Back In)" = "Open Augment (Log Out & Back In)"; +"Open Browser" = "Open Browser"; +"Open log file" = "Open log file"; +"Open Coding Plan" = "Open Coding Plan"; +"Open Console" = "Open Console"; +"Open Ollama Settings" = "Open Ollama Settings"; +"Open Terminal" = "Open Terminal"; +"Open Warp API Key Guide" = "Open Warp API Key Guide"; +"OpenAI cookies" = "OpenAI cookies"; +"OpenAI web extras" = "OpenAI web extras"; +"Optional override if workspace lookup fails." = "Optional override if workspace lookup fails."; +"Overview" = "Overview"; +"Paste API key…" = "Paste API key…"; +"Paste API token…" = "Paste API token…"; +"Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior." = "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior."; +"Plan" = "Plan"; +"Plan: %@" = "Plan: %@"; +"Primary (%@)" = "Primary (%@)"; +"Primary (API key limit)" = "Primary (API key limit)"; +"Post depleted" = "Post depleted"; +"Post restored" = "Post restored"; +"Probe logs" = "Probe logs"; +"Provider" = "Provider"; +"Providers" = "Providers"; +"Quota usage" = "Quota usage"; +"Quota: %@ / %@" = "Quota: %@ / %@"; +"Random (default)" = "Random (default)"; +"Re-run provider autodetect" = "Re-run provider autodetect"; +"Refresh Session" = "Refresh Session"; +"Replay selected animation" = "Replay selected animation"; +"Reset CLI sessions" = "Reset CLI sessions"; +"Resets: %@" = "Resets: %@"; +"Save to file" = "Save to file"; +"Secondary (%@)" = "Secondary (%@)"; +"Average (%@ + %@)" = "Average (%@ + %@)"; +"Session" = "Session"; +"Set cost error" = "Set cost error"; +"Set menu error" = "Set menu error"; +"Settings..." = "Settings..."; +"Status Page" = "Status Page"; +"This month" = "This month"; +"Today" = "Today"; +"Today: %@" = "Today: %@"; +"Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; +"Total (30d): %@" = "Total (30d): %@"; +"Total (30d): %@ credits" = "Total (30d): %@ credits"; +"Usage Dashboard" = "Usage Dashboard"; +"Usage breakdown" = "Usage breakdown"; +"Usage history (30 days)" = "Usage history (30 days)"; +"Usage remaining" = "Usage remaining"; +"Usage source" = "Usage source"; +"Usage used" = "Usage used"; +"Using CLI fallback" = "Using CLI fallback"; +"Verbosity" = "Verbosity"; +"Weekly usage unavailable for this account." = "Weekly usage unavailable for this account."; +"OpenAI dashboard data not found. Body sample: %@" = "OpenAI dashboard data not found. Body sample: %@"; +"Last OpenAI dashboard refresh failed: %@. Cached values from %@." = "Last OpenAI dashboard refresh failed: %@. Cached values from %@."; +"Window: %@" = "Window: %@"; +"Workspace ID" = "Workspace ID"; +"Write logs to %@ for debugging." = "Write logs to %@ for debugging."; +"%@ — %@" = "%@ — %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · 30d %@" = "%@: %@ · 30d %@"; +"%@: fetching…%@" = "%@: fetching…%@"; +"%@: last attempt %@" = "%@: last attempt %@"; +"%@: no data yet" = "%@: no data yet"; +"%@: unsupported" = "%@: unsupported"; +"About" = "About"; +"Account" = "Account"; +"Account: %@" = "Account: %@"; +"Add" = "Add"; +"Alternatively, set a custom path in Settings." = "Alternatively, set a custom path in Settings."; +"Always allow prompts" = "Always allow prompts"; +"Amp cookies are disabled." = "Amp cookies are disabled."; +"Antigravity login is managed in the app" = "Antigravity login is managed in the app"; +"Applies only to the Security.framework OAuth keychain reader." = "Applies only to the Security.framework OAuth keychain reader."; +"Augment cookies are disabled." = "Augment cookies are disabled."; +"Auto falls back to the next source if the preferred one fails." = "Auto falls back to the next source if the preferred one fails."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto uses API first, then falls back to CLI on auth failures."; +"Auto-detect" = "Auto-detect"; +"Auto-refresh is off; use the menu's Refresh command." = "Auto-refresh is off; use the menu's Refresh command."; +"Auto-refresh: hourly · Timeout: 10m" = "Auto-refresh: hourly · Timeout: 10m"; +"Automatic imports browser cookies and WorkOS tokens." = "Automatic imports browser cookies and WorkOS tokens."; +"Automatic imports browser cookies and local storage tokens." = "Automatic imports browser cookies and local storage tokens."; +"Automatic imports browser cookies for dashboard extras." = "Automatic imports browser cookies for dashboard extras."; +"Automatic imports browser cookies for the web API." = "Automatic imports browser cookies for the web API."; +"Automatic imports browser cookies from opencode.ai." = "Automatic imports browser cookies from opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Automatic imports browser cookies or stored sessions."; +"Automatic imports browser cookies." = "Automatic imports browser cookies."; +"Automatically opens CodexBar when you start your Mac." = "Automatically opens CodexBar when you start your Mac."; +"Automation" = "Automation"; +"Avoid Keychain prompts (experimental)" = "Avoid Keychain prompts (experimental)"; +"Built %@" = "Built %@"; +"Check for Updates…" = "Check for Updates…"; +"Check for updates automatically" = "Check for updates automatically"; +"Check if you like your agents having some fun up there." = "Check if you like your agents having some fun up there."; +"Check provider status" = "Check provider status"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Choose the MiniMax host (global .io or China mainland .com)."; +"Choose up to %d providers" = "Choose up to %d providers"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; +"Claude CLI not found" = "Claude CLI not found"; +"Claude cookies are disabled." = "Claude cookies are disabled."; +"Claude login failed" = "Claude login failed"; +"Claude login timed out" = "Claude login timed out"; +"Codex CLI not found" = "Codex CLI not found"; +"Codex login failed" = "Codex login failed"; +"Codex login timed out" = "Codex login timed out"; +"CodexBar" = "CodexBar"; +"CodexBarCLI not found in app bundle." = "CodexBarCLI not found in app bundle."; +"Configure…" = "Configure…"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nor paste the kimi-auth token value"; +"Could not open Terminal for Gemini" = "Could not open Terminal for Gemini"; +"Could not start claude /login" = "Could not start claude /login"; +"Could not start codex login" = "Could not start codex login"; +"Cursor cookies are disabled." = "Cursor cookies are disabled."; +"Cursor login failed" = "Cursor login failed"; +"Default follows system language." = "Default follows system language."; +"Disable Keychain access" = "Disable Keychain access"; +"Disable OpenAI dashboard cookie usage." = "Disable OpenAI dashboard cookie usage."; +"Disabled" = "Disabled"; +"Disabled — %@" = "Disabled — %@"; +"Disabled — no recent data" = "Disabled — no recent data"; +"Display mode" = "Display mode"; +"Drag to reorder" = "Drag to reorder"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Enable Merge Icons to configure Overview tab providers."; +"Enabled" = "Enabled"; +"Exists: %@" = "Exists: %@"; +"Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; +"Factory cookies are disabled." = "Factory cookies are disabled."; +"Failed: %@" = "Failed: %@"; +"Gemini CLI not found" = "Gemini CLI not found"; +"General" = "General"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot Login"; +"Hide personal information" = "Hide personal information"; +"How often CodexBar polls providers in the background." = "How often CodexBar polls providers in the background."; +"Inactive while \"Disable Keychain access\" is enabled in Advanced." = "Inactive while \"Disable Keychain access\" is enabled in Advanced."; +"Install CLI" = "Install CLI"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar."; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Install the Codex CLI (npm i -g @openai/codex) and try again."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."; +"Installed: %@" = "Installed: %@"; +"JetBrains AI is ready" = "JetBrains AI is ready"; +"JetBrains IDE" = "JetBrains IDE"; +"Keyboard shortcut" = "Keyboard shortcut"; +"Keychain access" = "Keychain access"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain access is disabled in Advanced, so browser cookie import is unavailable."; +"Kimi cookies are disabled." = "Kimi cookies are disabled."; +"Label" = "Label"; +"Language" = "Language"; +"May your tokens never run out—keep agent limits in view." = "May your tokens never run out—keep agent limits in view."; +"Menu bar" = "Menu bar"; +"Menu bar auto-shows the provider closest to its rate limit." = "Menu bar auto-shows the provider closest to its rate limit."; +"Menu bar shows percent" = "Menu bar shows percent"; +"Menu content" = "Menu content"; +"Merge Icons" = "Merge Icons"; +"MiniMax cookies are disabled." = "MiniMax cookies are disabled."; +"Never prompt" = "Never prompt"; +"No JetBrains IDE detected" = "No JetBrains IDE detected"; +"No enabled providers available for Overview." = "No enabled providers available for Overview."; +"No output captured." = "No output captured."; +"No providers selected" = "No providers selected"; +"No token accounts yet." = "No token accounts yet."; +"No writable bin dirs found." = "No writable bin dirs found."; +"No write access: %@" = "No write access: %@"; +"Obscure email addresses in the menu bar and menu UI." = "Obscure email addresses in the menu bar and menu UI."; +"Ollama cookies are disabled." = "Ollama cookies are disabled."; +"Only on user action" = "Only on user action"; +"Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity to sign in, then refresh CodexBar."; +"Open menu" = "Open menu"; +"Open token file" = "Open token file"; +"OpenCode cookies are disabled." = "OpenCode cookies are disabled."; +"Options" = "Options"; +"Override auto-detection with a custom IDE base path" = "Override auto-detection with a custom IDE base path"; +"Overview rows always follow provider order." = "Overview rows always follow provider order."; +"Overview tab providers" = "Overview tab providers"; +"Paste a Cookie header captured from the billing page." = "Paste a Cookie header captured from the billing page."; +"Paste a Cookie header from a chatgpt.com request." = "Paste a Cookie header from a chatgpt.com request."; +"Paste a Cookie header from a claude.ai request." = "Paste a Cookie header from a claude.ai request."; +"Paste a Cookie header from a cursor.com request." = "Paste a Cookie header from a cursor.com request."; +"Paste a Cookie header from app.factory.ai." = "Paste a Cookie header from app.factory.ai."; +"Paste a Cookie header or cURL capture from Amp settings." = "Paste a Cookie header or cURL capture from Amp settings."; +"Paste a Cookie header or cURL capture from Ollama settings." = "Paste a Cookie header or cURL capture from Ollama settings."; +"Paste a Cookie header or cURL capture from the Augment dashboard." = "Paste a Cookie header or cURL capture from the Augment dashboard."; +"Paste a Cookie header or cURL capture from the Coding Plan page." = "Paste a Cookie header or cURL capture from the Coding Plan page."; +"Paste a cookie header or the kimi-auth token value." = "Paste a cookie header or the kimi-auth token value."; +"Paste key…" = "Paste key…"; +"Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled."; +"Quit" = "Quit"; +"Quit CodexBar" = "Quit CodexBar"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"Refresh" = "Refresh"; +"Refresh cadence" = "Refresh cadence"; +"Refreshing" = "Refreshing"; +"Reload" = "Reload"; +"Remove selected account" = "Remove selected account"; +"Reorder" = "Reorder"; +"Replace critter bars with provider branding icons and a percentage." = "Replace critter bars with provider branding icons and a percentage."; +"Requires authentication via GitHub Device Flow." = "Requires authentication via GitHub Device Flow."; +"Select a provider" = "Select a provider"; +"Select the IDE to monitor" = "Select the IDE to monitor"; +"Session quota notifications" = "Session quota notifications"; +"Settings" = "Settings"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Show Codex Credits and Claude Extra usage sections in the menu."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"Show Debug Settings" = "Show Debug Settings"; +"Show all token accounts" = "Show all token accounts"; +"Show cost summary" = "Show cost summary"; +"Show credits + extra usage" = "Show credits + extra usage"; +"Show most-used provider" = "Show most-used provider"; +"Show reset time as clock" = "Show reset time as clock"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"Show usage as used" = "Show usage as used"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Progress bars fill as you consume quota (instead of showing remaining)."; +"Display reset times as absolute clock values instead of countdowns." = "Display reset times as absolute clock values instead of countdowns."; +"Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu." = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; +"Notifies when the 5-hour session quota hits 0% and when it becomes available again." = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; +"Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers." = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."; +"Show usage breakdown, credits history, and code review via chatgpt.com." = "Show usage breakdown, credits history, and code review via chatgpt.com."; +"Sign in again" = "Sign in again"; +"Sign in via button below" = "Sign in via button below"; +"Sign in with GitHub" = "Sign in with GitHub"; +"Source" = "Source"; +"Start at Login" = "Start at Login"; +"State" = "State"; +"Status" = "Status"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stored in ~/.codexbar/config.json. Paste your MiniMax API key."; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores local Codex usage history (8 weeks) to personalize Pace predictions."; +"Surprise me" = "Surprise me"; +"Switch Account..." = "Switch Account..."; +"Switcher shows icons" = "Switcher shows icons"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"System" = "System"; +"Trigger the menu bar menu from anywhere." = "Trigger the menu bar menu from anywhere."; +"Twitter" = "Twitter"; +"Update Channel" = "Update Channel"; +"Update ready, restart now?" = "Update ready, restart now?"; +"Updated" = "Updated"; +"Updates unavailable in this build." = "Updates unavailable in this build."; +"Usage" = "Usage"; +"Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts."; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Use a single menu bar icon with a provider switcher."; +"Version" = "Version"; +"Version %@" = "Version %@"; +"Vertex AI Login" = "Vertex AI Login"; +"Waiting for Authentication..." = "Waiting for Authentication..."; +"Website" = "Website"; +"You can return to the app; authentication finished." = "You can return to the app; authentication finished."; +"api" = "api"; +"github api" = "github api"; +"Hide details" = "Hide details"; +"Hover a bar for details" = "Hover a bar for details"; +"just now" = "just now"; +"last fetch failed" = "last fetch failed"; +"No limit set for the API key" = "No limit set for the API key"; +"not detected" = "not detected"; +"Refreshing..." = "Refreshing..."; +"Show details" = "Show details"; +"Top: %@" = "Top: %@"; +"usage not fetched yet" = "usage not fetched yet"; +"web" = "web"; +"wrk_…" = "wrk_…"; +"%@: %@ credits" = "%@: %@ credits"; +"© 2025 Peter Steinberger. MIT License." = "© 2025 Peter Steinberger. MIT License."; +"—" = "—"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..2b43b3744 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,426 @@ +"1 min" = "1 分钟"; +"2 min" = "2 分钟"; +"5 min" = "5 分钟"; +"15 min" = "15 分钟"; +"30 min" = "30 分钟"; +"%@ left" = "剩余 %@"; +"%@ — %@: %@" = "%@ — %@: %@"; +"%@ · %@ · %@ credits" = "%@ · %@ · %@ 积分"; +"API" = "API"; +"API key limit" = "API 密钥限额"; +"Auto" = "自动"; +"Automatic" = "自动"; +"Average" = "平均"; +"Beta" = "测试版"; +"BigModel CN (open.bigmodel.cn)" = "BigModel 中国区 (open.bigmodel.cn)"; +"Both" = "两者"; +"CLI" = "CLI"; +"CLI (PTY)" = "CLI (PTY)"; +"CLI (RPC/PTY)" = "CLI (RPC/PTY)"; +"China mainland (platform.minimaxi.com)" = "中国大陆 (platform.minimaxi.com)"; +"Critical issue" = "严重故障"; +"Cylon" = "赛昂"; +"Global (api.z.ai)" = "全球 (api.z.ai)"; +"Global (platform.minimax.io)" = "全球 (platform.minimax.io)"; +"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "高级中已禁用全局钥匙串访问,因此当前设置暂不可用。"; +"Knight Rider" = "霹雳游侠"; +"Maintenance" = "维护中"; +"Major outage" = "重大故障"; +"Manual" = "手动"; +"Monthly" = "每月"; +"OAuth API" = "OAuth API"; +"Off" = "关闭"; +"On pace" = "节奏正常"; +"Operational" = "运行正常"; +"Outside-In" = "由外向内"; +"Pace" = "节奏"; +"Partial outage" = "部分故障"; +"Percent" = "百分比"; +"Primary" = "主窗口"; +"Pulse" = "脉冲"; +"Race" = "竞速"; +"Receive only stable, production-ready releases." = "只接收稳定的正式版本。"; +"Receive stable releases plus beta previews." = "接收稳定版本以及测试预览版。"; +"Resets" = "重置"; +"Resets %@" = "%@ 重置"; +"Secondary" = "次窗口"; +"Show both percentage and pace (e.g. 45%% · +5%%)" = "同时显示百分比和节奏(例如 45%% · +5%%)"; +"Show pace indicator (e.g. +5%%)" = "显示节奏指示(例如 +5%%)"; +"Show remaining/used percentage (e.g. 45%%)" = "显示剩余/已用百分比(例如 45%%)"; +"Weekly" = "每周"; +"Stable" = "稳定版"; +"Status unknown" = "状态未知"; +"tomorrow, %@" = "明天 %@"; +"Unbraid (logo → bars)" = "解编(logo → bars)"; +"Updated %@" = "更新于 %@"; +"Updated just now" = "刚刚更新"; +"Web API (cookies)" = "Web API(cookies)"; +"Pace: %@" = "节奏:%@"; +"Pace: %@ · %@" = "节奏:%@ · %@"; +"%d%% in deficit" = "超用 %d%%"; +"%d%% in reserve" = "预留 %d%%"; +"Lasts until reset" = "可撑到重置"; +"Runs out %@" = "%@耗尽"; +"Runs out now" = "现在耗尽"; +"≈ %d%% run-out risk" = "≈ %d%% 耗尽风险"; +"in %dd" = "%d 天后"; +"in %dd %dh" = "%d 天 %d 小时后"; +"in %dh" = "%d 小时后"; +"in %dh %dm" = "%d 小时 %d 分钟后"; +"in %dm" = "%d 分钟后"; +"left" = "剩余"; +"now" = "现在"; +"used" = "已用"; +" error=%@" = " error=%@"; +"%.0f%% %@" = "%.0f%% %@"; +"%.0f%% used" = "已用 %.0f%%"; +"%@ / %@ (%@ remaining)" = "%@ / %@(剩余 %@)"; +"%@ login successful" = "%@ 登录成功"; +"%@ tokens" = "%@ tokens"; +"%@/%@ left" = "%@/%@ 剩余"; +"%@: %@ / %@" = "%@: %@ / %@"; +"API key" = "API 密钥"; +"API key limit unavailable right now" = "当前无法获取 API 密钥限额"; +"API region" = "API 区域"; +"API token" = "API Token"; +"About CodexBar" = "关于 CodexBar"; +"Add Account..." = "添加账号..."; +"Advanced" = "高级"; +"Activity: %@" = "活动:%@"; +"Animation pattern" = "动画样式"; +"Balance" = "余额"; +"Blink now" = "立即闪烁"; +"Buy Credits..." = "购买积分..."; +"CLI paths" = "CLI 路径"; +"CLI sessions" = "CLI 会话"; +"Cached: %@ • %@" = "已缓存:%@ • %@"; +"Caches" = "缓存"; +"Cancel" = "取消"; +"Claude cookies" = "Claude Cookies"; +"Code review" = "代码审查"; +"Controls how much detail is logged." = "控制日志记录的详细程度。"; +"Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "实验性读取器关闭时,控制 Claude OAuth 的钥匙串提示。选择“从不提示”可能会导致 OAuth 不可用,需要时请改用 Web/CLI。"; +"Cookie header" = "Cookie Header"; +"Cookie source" = "Cookie 来源"; +"Cookie: …" = "Cookie: …"; +"Copied" = "已复制"; +"Copy" = "复制"; +"Copy error" = "复制错误"; +"Cost" = "花费"; +"Credits" = "积分"; +"Credits history" = "积分历史"; +"Credits remaining" = "剩余积分"; +"Credits: %@" = "积分:%@"; +"Custom Path" = "自定义路径"; +"Debug" = "调试"; +"Display" = "显示"; +"Effective PATH" = "生效的 PATH"; +"Enable file logging" = "启用文件日志"; +"Error simulation" = "错误模拟"; +"Extra usage" = "额外用量"; +"Extra usage spent" = "额外用量花费"; +"Extra usage: %@ / %@" = "额外用量:%@ / %@"; +"Fetch log" = "获取日志"; +"Fetch strategy attempts" = "抓取策略尝试"; +"Force animation on next refresh" = "下次刷新强制播放动画"; +"GitHub Login" = "GitHub 登录"; +"Historical tracking" = "历史追踪"; +"Keychain prompt policy" = "钥匙串提示策略"; +"Last 30 days: %@" = "最近 30 天:%@"; +"Last 30 days: %@ · %@ tokens" = "最近 30 天:%@ · %@ tokens"; +"Last spend: %@" = "最近消费:%@"; +"Load parse dump" = "加载解析转储"; +"Loading animations" = "加载动画"; +"Loading…" = "加载中…"; +"Logging" = "日志"; +"Login Failed" = "登录失败"; +"Login Successful" = "登录成功"; +"Login shell PATH (startup capture)" = "登录 shell PATH(启动捕获)"; +"MCP details" = "MCP 详情"; +"Menu bar metric" = "菜单栏指标"; +"No cost history data." = "没有花费历史数据。"; +"No credits history data." = "没有积分历史数据。"; +"No fetch attempts yet." = "还没有抓取尝试。"; +"No providers selected for Overview." = "总览中尚未选择提供商。"; +"No log yet. Fetch to load." = "还没有日志,点击获取后加载。"; +"No usage breakdown data." = "没有用量拆分数据。"; +"No usage configured." = "尚未配置用量来源。"; +"No usage yet" = "还没有用量数据"; +"Not fetched yet" = "尚未抓取"; +"Not found" = "未找到"; +"Notifications" = "通知"; +"OK" = "确定"; +"On-Demand: %@" = "按需:%@"; +"On-Demand: %@ / %@" = "按需:%@ / %@"; +"Open API Keys" = "打开 API Keys"; +"Open Amp Settings" = "打开 Amp 设置"; +"Open Augment (Log Out & Back In)" = "打开 Augment(退出后重新登录)"; +"Open Browser" = "打开浏览器"; +"Open log file" = "打开日志文件"; +"Open Coding Plan" = "打开 Coding Plan"; +"Open Console" = "打开控制台"; +"Open Ollama Settings" = "打开 Ollama 设置"; +"Open Terminal" = "打开终端"; +"Open Warp API Key Guide" = "打开 Warp API Key 指南"; +"OpenAI cookies" = "OpenAI Cookies"; +"OpenAI web extras" = "OpenAI 网页扩展"; +"Optional override if workspace lookup fails." = "如果工作区查找失败,可选填覆盖值。"; +"Overview" = "总览"; +"Paste API key…" = "粘贴 API 密钥…"; +"Paste API token…" = "粘贴 API Token…"; +"Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior." = "选择一种样式并在菜单栏中重放;“随机”会保持现有行为。"; +"Plan" = "套餐"; +"Plan: %@" = "套餐:%@"; +"Primary (%@)" = "主窗口(%@)"; +"Primary (API key limit)" = "主窗口(API 密钥限额)"; +"Post depleted" = "发送耗尽通知"; +"Post restored" = "发送恢复通知"; +"Probe logs" = "探测日志"; +"Provider" = "提供商"; +"Providers" = "提供商"; +"Quota usage" = "配额用量"; +"Quota: %@ / %@" = "配额:%@ / %@"; +"Random (default)" = "随机(默认)"; +"Re-run provider autodetect" = "重新运行提供商自动检测"; +"Refresh Session" = "刷新会话"; +"Replay selected animation" = "重放所选动画"; +"Reset CLI sessions" = "重置 CLI 会话"; +"Resets: %@" = "重置:%@"; +"Save to file" = "保存到文件"; +"Secondary (%@)" = "次窗口(%@)"; +"Average (%@ + %@)" = "平均(%@ + %@)"; +"Session" = "会话"; +"Set cost error" = "设置花费错误"; +"Set menu error" = "设置菜单错误"; +"Settings..." = "设置..."; +"Status Page" = "状态页"; +"This month" = "本月"; +"Today" = "今天"; +"Today: %@" = "今天:%@"; +"Today: %@ · %@ tokens" = "今天:%@ · %@ tokens"; +"Total (30d): %@" = "总计(30 天):%@"; +"Total (30d): %@ credits" = "总计(30 天):%@ 积分"; +"Usage Dashboard" = "用量仪表盘"; +"Usage breakdown" = "用量拆分"; +"Usage history (30 days)" = "用量历史(30 天)"; +"Usage remaining" = "剩余用量"; +"Usage source" = "用量来源"; +"Usage used" = "已用用量"; +"Using CLI fallback" = "正在使用 CLI 回退"; +"Verbosity" = "详细级别"; +"Weekly usage unavailable for this account." = "该账号暂无每周用量信息。"; +"OpenAI dashboard data not found. Body sample: %@" = "未找到 OpenAI 仪表盘数据。页面片段:%@"; +"Last OpenAI dashboard refresh failed: %@. Cached values from %@." = "OpenAI 仪表盘刷新失败:%@。当前显示的是 %@ 的缓存数据。"; +"Window: %@" = "窗口:%@"; +"Workspace ID" = "工作区 ID"; +"Write logs to %@ for debugging." = "将日志写入 %@ 以便调试。"; +"%@ — %@" = "%@ — %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · 30d %@" = "%@: %@ · 30 天 %@"; +"%@: fetching…%@" = "%@: 正在抓取…%@"; +"%@: last attempt %@" = "%@: 上次尝试 %@"; +"%@: no data yet" = "%@: 暂无数据"; +"%@: unsupported" = "%@: 不支持"; +"About" = "关于"; +"Account" = "账号"; +"Account: %@" = "账号:%@"; +"Add" = "添加"; +"Alternatively, set a custom path in Settings." = "或者在设置中指定自定义路径。"; +"Always allow prompts" = "总是允许提示"; +"Amp cookies are disabled." = "Amp Cookies 已禁用。"; +"Antigravity login is managed in the app" = "Antigravity 登录在应用内完成"; +"Applies only to the Security.framework OAuth keychain reader." = "仅适用于 Security.framework 的 OAuth 钥匙串读取器。"; +"Augment cookies are disabled." = "Augment Cookies 已禁用。"; +"Auto falls back to the next source if the preferred one fails." = "首选来源失败时,自动回退到下一个来源。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自动模式优先使用 API,认证失败时回退到 CLI。"; +"Auto-detect" = "自动检测"; +"Auto-refresh is off; use the menu's Refresh command." = "自动刷新已关闭;请使用菜单中的“刷新”。"; +"Auto-refresh: hourly · Timeout: 10m" = "自动刷新:每小时 · 超时:10 分钟"; +"Automatic imports browser cookies and WorkOS tokens." = "自动导入浏览器 Cookies 和 WorkOS Tokens。"; +"Automatic imports browser cookies and local storage tokens." = "自动导入浏览器 Cookies 和本地存储 Tokens。"; +"Automatic imports browser cookies for dashboard extras." = "自动导入浏览器 Cookies 以获取控制台附加数据。"; +"Automatic imports browser cookies for the web API." = "自动导入浏览器 Cookies 以访问 Web API。"; +"Automatic imports browser cookies from opencode.ai." = "自动从 opencode.ai 导入浏览器 Cookies。"; +"Automatic imports browser cookies or stored sessions." = "自动导入浏览器 Cookies 或已保存的会话。"; +"Automatic imports browser cookies." = "自动导入浏览器 Cookies。"; +"Automatically opens CodexBar when you start your Mac." = "在 Mac 启动时自动打开 CodexBar。"; +"Automation" = "自动化"; +"Avoid Keychain prompts (experimental)" = "避免钥匙串提示(实验性)"; +"Built %@" = "构建于 %@"; +"Check for Updates…" = "检查更新…"; +"Check for updates automatically" = "自动检查更新"; +"Check if you like your agents having some fun up there." = "看看你是否喜欢让代理在上面玩点花样。"; +"Check provider status" = "检查提供商状态"; +"Choose the MiniMax host (global .io or China mainland .com)." = "选择 MiniMax 主机(全球 .io 或中国大陆 .com)。"; +"Choose up to %d providers" = "最多选择 %d 个提供商"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "选择菜单栏中显示的内容(节奏显示实际用量与预期对比)。"; +"Choose which window drives the menu bar percent." = "选择由哪个窗口决定菜单栏百分比。"; +"Claude CLI not found" = "未找到 Claude CLI"; +"Claude cookies are disabled." = "Claude Cookies 已禁用。"; +"Claude login failed" = "Claude 登录失败"; +"Claude login timed out" = "Claude 登录超时"; +"Codex CLI not found" = "未找到 Codex CLI"; +"Codex login failed" = "Codex 登录失败"; +"Codex login timed out" = "Codex 登录超时"; +"CodexBar" = "CodexBar"; +"CodexBarCLI not found in app bundle." = "在应用包中未找到 CodexBarCLI。"; +"Configure…" = "配置…"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或粘贴 kimi-auth Token 值"; +"Could not open Terminal for Gemini" = "无法为 Gemini 打开终端"; +"Could not start claude /login" = "无法启动 claude /login"; +"Could not start codex login" = "无法启动 codex 登录"; +"Cursor cookies are disabled." = "Cursor Cookies 已禁用。"; +"Cursor login failed" = "Cursor 登录失败"; +"Default follows system language." = "默认跟随系统语言。"; +"Disable Keychain access" = "禁用钥匙串访问"; +"Disable OpenAI dashboard cookie usage." = "禁用 OpenAI 控制台 Cookies 的使用。"; +"Disabled" = "已禁用"; +"Disabled — %@" = "已禁用 — %@"; +"Disabled — no recent data" = "已禁用 — 无近期数据"; +"Display mode" = "显示模式"; +"Drag to reorder" = "拖拽以重新排序"; +"Email" = "邮箱"; +"Enable Merge Icons to configure Overview tab providers." = "启用合并图标后才能配置总览页提供商。"; +"Enabled" = "已启用"; +"Exists: %@" = "已存在:%@"; +"Expose troubleshooting tools in the Debug tab." = "在调试标签页中显示排障工具。"; +"Factory cookies are disabled." = "Factory Cookies 已禁用。"; +"Failed: %@" = "失败:%@"; +"Gemini CLI not found" = "未找到 Gemini CLI"; +"General" = "通用"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot 登录"; +"Hide personal information" = "隐藏个人信息"; +"How often CodexBar polls providers in the background." = "CodexBar 在后台轮询提供商的频率。"; +"Inactive while \"Disable Keychain access\" is enabled in Advanced." = "当“高级”中的“禁用钥匙串访问”开启时,此项不可用。"; +"Install CLI" = "安装 CLI"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "安装启用了 AI Assistant 的 JetBrains IDE,然后刷新 CodexBar。"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "请先安装 Claude CLI(npm i -g @anthropic-ai/claude-code)后再试。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "请先安装 Codex CLI(npm i -g @openai/codex)后再试。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "请先安装 Gemini CLI(npm i -g @google/gemini-cli)后再试。"; +"Installed: %@" = "已安装:%@"; +"JetBrains AI is ready" = "JetBrains AI 已就绪"; +"JetBrains IDE" = "JetBrains IDE"; +"Keyboard shortcut" = "键盘快捷键"; +"Keychain access" = "钥匙串访问"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "高级设置中已禁用钥匙串访问,因此无法导入浏览器 Cookies。"; +"Kimi cookies are disabled." = "Kimi Cookies 已禁用。"; +"Label" = "标签"; +"Language" = "语言"; +"May your tokens never run out—keep agent limits in view." = "愿你的 tokens 永不耗尽,始终看清代理额度。"; +"Menu bar" = "菜单栏"; +"Menu bar auto-shows the provider closest to its rate limit." = "菜单栏会自动显示最接近限额的提供商。"; +"Menu bar shows percent" = "菜单栏显示百分比"; +"Menu content" = "菜单内容"; +"Merge Icons" = "合并图标"; +"MiniMax cookies are disabled." = "MiniMax Cookies 已禁用。"; +"Never prompt" = "从不提示"; +"No JetBrains IDE detected" = "未检测到 JetBrains IDE"; +"No enabled providers available for Overview." = "总览中没有可用的已启用提供商。"; +"No output captured." = "未捕获到输出。"; +"No providers selected" = "未选择提供商"; +"No token accounts yet." = "还没有 Token 账号。"; +"No writable bin dirs found." = "未找到可写的 bin 目录。"; +"No write access: %@" = "无写入权限:%@"; +"Obscure email addresses in the menu bar and menu UI." = "在菜单栏和菜单界面中模糊显示邮箱地址。"; +"Ollama cookies are disabled." = "Ollama Cookies 已禁用。"; +"Only on user action" = "仅在用户操作时"; +"Open Antigravity to sign in, then refresh CodexBar." = "打开 Antigravity 登录,然后刷新 CodexBar。"; +"Open menu" = "打开菜单"; +"Open token file" = "打开 Token 文件"; +"OpenCode cookies are disabled." = "OpenCode Cookies 已禁用。"; +"Options" = "选项"; +"Override auto-detection with a custom IDE base path" = "使用自定义 IDE 基础路径覆盖自动检测"; +"Overview rows always follow provider order." = "总览行始终遵循提供商顺序。"; +"Overview tab providers" = "总览页提供商"; +"Paste a Cookie header captured from the billing page." = "粘贴从账单页面抓取的 Cookie Header。"; +"Paste a Cookie header from a chatgpt.com request." = "粘贴 chatgpt.com 请求中的 Cookie Header。"; +"Paste a Cookie header from a claude.ai request." = "粘贴 claude.ai 请求中的 Cookie Header。"; +"Paste a Cookie header from a cursor.com request." = "粘贴 cursor.com 请求中的 Cookie Header。"; +"Paste a Cookie header from app.factory.ai." = "粘贴来自 app.factory.ai 的 Cookie Header。"; +"Paste a Cookie header or cURL capture from Amp settings." = "粘贴从 Amp 设置中获取的 Cookie Header 或 cURL 抓包。"; +"Paste a Cookie header or cURL capture from Ollama settings." = "粘贴从 Ollama 设置中获取的 Cookie Header 或 cURL 抓包。"; +"Paste a Cookie header or cURL capture from the Augment dashboard." = "粘贴从 Augment 控制台获取的 Cookie Header 或 cURL 抓包。"; +"Paste a Cookie header or cURL capture from the Coding Plan page." = "粘贴从 Coding Plan 页面获取的 Cookie Header 或 cURL 抓包。"; +"Paste a cookie header or the kimi-auth token value." = "粘贴 Cookie Header 或 kimi-auth Token 值。"; +"Paste key…" = "粘贴密钥…"; +"Prevents any Keychain access while enabled." = "启用后会阻止任何钥匙串访问。"; +"Quit" = "退出"; +"Quit CodexBar" = "退出 CodexBar"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "读取本地用量日志,并在菜单中显示今日和最近 30 天花费。"; +"Refresh" = "刷新"; +"Refresh cadence" = "刷新频率"; +"Refreshing" = "刷新中"; +"Reload" = "重新加载"; +"Remove selected account" = "移除所选账号"; +"Reorder" = "重新排序"; +"Replace critter bars with provider branding icons and a percentage." = "用提供商品牌图标和百分比替换小条形图。"; +"Requires authentication via GitHub Device Flow." = "需要通过 GitHub Device Flow 认证。"; +"Select a provider" = "选择一个提供商"; +"Select the IDE to monitor" = "选择要监控的 IDE"; +"Session quota notifications" = "会话配额通知"; +"Settings" = "设置"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在菜单中显示 Codex 积分和 Claude 额外用量区块。"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在菜单中堆叠显示所有 Token 账号(否则显示账号切换条)。"; +"Show Debug Settings" = "显示调试设置"; +"Show all token accounts" = "显示所有 Token 账号"; +"Show cost summary" = "显示花费摘要"; +"Show credits + extra usage" = "显示积分和额外用量"; +"Show most-used provider" = "显示最常用的提供商"; +"Show reset time as clock" = "以时钟时间显示重置时间"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切换器中显示提供商图标(否则显示每周进度线)。"; +"Show usage as used" = "按已用量显示"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "进度条会随着你消耗配额而填充(而不是显示剩余量)。"; +"Display reset times as absolute clock values instead of countdowns." = "将重置时间显示为绝对时钟时间,而不是倒计时。"; +"Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu." = "轮询 OpenAI/Claude 状态页以及 Gemini/Antigravity 的 Google Workspace 状态,并在图标和菜单中显示故障。"; +"Notifies when the 5-hour session quota hits 0% and when it becomes available again." = "在 5 小时会话配额降到 0% 以及恢复可用时发送通知。"; +"Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers." = "禁用所有钥匙串读取和写入。浏览器 Cookie 导入将不可用,请在“提供商”中手动粘贴 Cookie Header。"; +"Show usage breakdown, credits history, and code review via chatgpt.com." = "通过 chatgpt.com 显示用量拆分、积分历史和代码审查。"; +"Sign in again" = "重新登录"; +"Sign in via button below" = "通过下方按钮登录"; +"Sign in with GitHub" = "使用 GitHub 登录"; +"Source" = "来源"; +"Start at Login" = "登录时启动"; +"State" = "状态"; +"Status" = "状态"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "存储于 ~/.codexbar/config.json,可在 kimi-k2.ai 生成。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存储于 ~/.codexbar/config.json,请粘贴你的 MiniMax API 密钥。"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "存储本地 Codex 用量历史(8 周),用于个性化节奏预测。"; +"Surprise me" = "给我点惊喜"; +"Switch Account..." = "切换账号..."; +"Switcher shows icons" = "切换器显示图标"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "将 CodexBarCLI 软链接到 /usr/local/bin 和 /opt/homebrew/bin,命名为 codexbar。"; +"System" = "系统"; +"Trigger the menu bar menu from anywhere." = "可在任意位置触发菜单栏菜单。"; +"Twitter" = "Twitter"; +"Update Channel" = "更新通道"; +"Update ready, restart now?" = "更新已就绪,是否现在重启?"; +"Updated" = "更新时间"; +"Updates unavailable in this build." = "此构建版本不支持更新。"; +"Usage" = "用量"; +"Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "使用 /usr/bin/security 读取 Claude 凭证,避免 CodexBar 弹出钥匙串提示。"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中国大陆端点使用 BigModel(open.bigmodel.cn)。"; +"Use a single menu bar icon with a provider switcher." = "使用单个菜单栏图标并带提供商切换器。"; +"Version" = "版本"; +"Version %@" = "版本 %@"; +"Vertex AI Login" = "Vertex AI 登录"; +"Waiting for Authentication..." = "等待认证中..."; +"Website" = "网站"; +"You can return to the app; authentication finished." = "你可以返回应用;认证已完成。"; +"api" = "api"; +"github api" = "GitHub API"; +"Hide details" = "隐藏详情"; +"Hover a bar for details" = "将鼠标悬停在柱形上查看详情"; +"just now" = "刚刚"; +"last fetch failed" = "上次抓取失败"; +"No limit set for the API key" = "API 密钥未设置限额"; +"not detected" = "未检测到"; +"Refreshing..." = "正在刷新..."; +"Show details" = "显示详情"; +"Top: %@" = "Top:%@"; +"usage not fetched yet" = "尚未抓取用量"; +"web" = "网页"; +"wrk_…" = "wrk_…"; +"%@: %@ credits" = "%@: %@ 积分"; +"© 2025 Peter Steinberger. MIT License." = "© 2025 Peter Steinberger。MIT 许可证。"; +"—" = "—"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 000000000..95d5a5503 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,624 @@ +"1 min" = "1 分鐘"; +"2 min" = "2 分鐘"; +"5 min" = "5 分鐘"; +"15 min" = "15 分鐘"; +"30 min" = "30 分鐘"; +"%@ left" = "剩餘 %@"; +"%@ — %@: %@" = "%@ — %@: %@"; +"%@ · %@ · %@ credits" = "%@ · %@ · %@ 點數"; +"API" = "API"; +"API key limit" = "API 金鑰額度"; +"Auto" = "自動"; +"Automatic" = "自動"; +"Average" = "平均"; +"Beta" = "測試版"; +"BigModel CN (open.bigmodel.cn)" = "BigModel 中國區 (open.bigmodel.cn)"; +"Both" = "兩者"; +"CLI" = "CLI"; +"CLI (PTY)" = "CLI (PTY)"; +"CLI (RPC/PTY)" = "CLI (RPC/PTY)"; +"China mainland (platform.minimaxi.com)" = "中國大陸 (platform.minimaxi.com)"; +"Critical issue" = "嚴重故障"; +"Cylon" = "賽隆"; +"Global (api.z.ai)" = "全球 (api.z.ai)"; +"Global (platform.minimax.io)" = "全球 (platform.minimax.io)"; +"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "進階中已停用全域鑰匙圈存取,因此目前這個設定暫時無法使用。"; +"Knight Rider" = "霹靂遊俠"; +"Maintenance" = "維護中"; +"Major outage" = "重大故障"; +"Manual" = "手動"; +"Monthly" = "每月"; +"OAuth API" = "OAuth API"; +"Off" = "關閉"; +"On pace" = "節奏正常"; +"Operational" = "運作正常"; +"Outside-In" = "由外向內"; +"Pace" = "節奏"; +"Partial outage" = "部分故障"; +"Percent" = "百分比"; +"Primary" = "主視窗"; +"Pulse" = "脈衝"; +"Race" = "競速"; +"Receive only stable, production-ready releases." = "只接收穩定的正式版本。"; +"Receive stable releases plus beta previews." = "接收穩定版本以及測試預覽版。"; +"Resets" = "重設"; +"Resets %@" = "%@ 重設"; +"Secondary" = "次視窗"; +"Show both percentage and pace (e.g. 45%% · +5%%)" = "同時顯示百分比和節奏(例如 45%% · +5%%)"; +"Show pace indicator (e.g. +5%%)" = "顯示節奏指示(例如 +5%%)"; +"Show remaining/used percentage (e.g. 45%%)" = "顯示剩餘/已用百分比(例如 45%%)"; +"Weekly" = "每週"; +"Stable" = "穩定版"; +"Status unknown" = "狀態未知"; +"tomorrow, %@" = "明天 %@"; +"Unbraid (logo → bars)" = "解編(logo → bars)"; +"Updated %@" = "更新於 %@"; +"Updated just now" = "剛剛更新"; +"Web API (cookies)" = "Web API(cookies)"; +"Pace: %@" = "節奏:%@"; +"Pace: %@ · %@" = "節奏:%@ · %@"; +"%d%% in deficit" = "超用 %d%%"; +"%d%% in reserve" = "預留 %d%%"; +"Lasts until reset" = "可撐到重設"; +"Runs out %@" = "%@耗盡"; +"Runs out now" = "現在耗盡"; +"≈ %d%% run-out risk" = "≈ %d%% 耗盡風險"; +"in %dd" = "%d 天後"; +"in %dd %dh" = "%d 天 %d 小時後"; +"in %dh" = "%d 小時後"; +"in %dh %dm" = "%d 小時 %d 分鐘後"; +"in %dm" = "%d 分鐘後"; +"left" = "剩餘"; +"now" = "現在"; +"used" = "已用"; +" error=%@" = " error=%@"; +"%.0f%% %@" = "%.0f%% %@"; +"%.0f%% used" = "已用 %.0f%%"; +"%@ / %@ (%@ remaining)" = "%@ / %@(剩餘 %@)"; +"%@ login successful" = "%@ 登入成功"; +"%@ tokens" = "%@ tokens"; +"%@/%@ left" = "%@/%@ 剩餘"; +"%@: %@ / %@" = "%@: %@ / %@"; +"API key" = "API 金鑰"; +"API key limit unavailable right now" = "目前無法取得 API 金鑰額度"; +"API region" = "API 區域"; +"API token" = "API Token"; +"About CodexBar" = "關於 CodexBar"; +"Add Account..." = "新增帳號..."; +"Advanced" = "進階"; +"Activity: %@" = "活動:%@"; +"Animation pattern" = "動畫樣式"; +"Balance" = "餘額"; +"Blink now" = "立即閃爍"; +"Buy Credits..." = "購買點數..."; +"CLI paths" = "CLI 路徑"; +"CLI sessions" = "CLI 工作階段"; +"Cached: %@ • %@" = "已快取:%@ • %@"; +"Caches" = "快取"; +"Cancel" = "取消"; +"Claude cookies" = "Claude Cookies"; +"Code review" = "程式碼審查"; +"Controls how much detail is logged." = "控制日誌記錄的詳細程度。"; +"Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "實驗性讀取器關閉時,控制 Claude OAuth 的鑰匙圈提示。選擇「永不提示」可能會讓 OAuth 無法使用,需要時請改用 Web/CLI。"; +"Cookie header" = "Cookie Header"; +"Cookie source" = "Cookie 來源"; +"Cookie: …" = "Cookie: …"; +"Copied" = "已複製"; +"Copy" = "複製"; +"Copy error" = "複製錯誤"; +"Cost" = "花費"; +"Credits" = "點數"; +"Credits history" = "點數歷史"; +"Credits remaining" = "剩餘點數"; +"Credits: %@" = "點數:%@"; +"Custom Path" = "自訂路徑"; +"Debug" = "偵錯"; +"Display" = "顯示"; +"Effective PATH" = "生效的 PATH"; +"Enable file logging" = "啟用檔案日誌"; +"Error simulation" = "錯誤模擬"; +"Extra usage" = "額外用量"; +"Extra usage spent" = "額外用量花費"; +"Extra usage: %@ / %@" = "額外用量:%@ / %@"; +"Fetch log" = "取得日誌"; +"Fetch strategy attempts" = "抓取策略嘗試"; +"Force animation on next refresh" = "下次重新整理強制播放動畫"; +"GitHub Login" = "GitHub 登入"; +"Historical tracking" = "歷史追蹤"; +"Keychain prompt policy" = "鑰匙圈提示策略"; +"Last 30 days: %@" = "最近 30 天:%@"; +"Last 30 days: %@ · %@ tokens" = "最近 30 天:%@ · %@ tokens"; +"Last spend: %@" = "最近消費:%@"; +"Load parse dump" = "載入解析轉儲"; +"Loading animations" = "載入動畫"; +"Loading…" = "載入中…"; +"Logging" = "日誌"; +"Login Failed" = "登入失敗"; +"Login Successful" = "登入成功"; +"Login shell PATH (startup capture)" = "登入 shell PATH(啟動擷取)"; +"MCP details" = "MCP 詳情"; +"Menu bar metric" = "選單列指標"; +"No cost history data." = "沒有花費歷史資料。"; +"No credits history data." = "沒有點數歷史資料。"; +"No fetch attempts yet." = "尚未有抓取嘗試。"; +"No providers selected for Overview." = "總覽中尚未選擇供應商。"; +"No log yet. Fetch to load." = "尚未有日誌,點擊取得後載入。"; +"No usage breakdown data." = "沒有用量拆分資料。"; +"No usage configured." = "尚未設定用量來源。"; +"No usage yet" = "尚未有用量資料"; +"Not fetched yet" = "尚未抓取"; +"Not found" = "未找到"; +"Notifications" = "通知"; +"OK" = "確定"; +"On-Demand: %@" = "按需:%@"; +"On-Demand: %@ / %@" = "按需:%@ / %@"; +"Open API Keys" = "打開 API Keys"; +"Open Amp Settings" = "打開 Amp 設定"; +"Open Augment (Log Out & Back In)" = "打開 Augment(登出後重新登入)"; +"Open Browser" = "打開瀏覽器"; +"Open Coding Plan" = "打開 Coding Plan"; +"Open Console" = "打開主控台"; +"Open Ollama Settings" = "打開 Ollama 設定"; +"Open Terminal" = "打開終端機"; +"Open Warp API Key Guide" = "打開 Warp API Key 指南"; +"OpenAI cookies" = "OpenAI Cookies"; +"OpenAI web extras" = "OpenAI 網頁擴充"; +"Optional override if workspace lookup fails." = "若工作區查找失敗,可選填覆寫值。"; +"Overview" = "總覽"; +"Paste API key…" = "貼上 API 金鑰…"; +"Paste API token…" = "貼上 API Token…"; +"Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior." = "選擇一種樣式並在選單列中重播;「隨機」會保留現有行為。"; +"Plan" = "方案"; +"Plan: %@" = "方案:%@"; +"Primary (%@)" = "主視窗(%@)"; +"Primary (API key limit)" = "主視窗(API 金鑰額度)"; +"Post depleted" = "發送耗盡通知"; +"Post restored" = "發送恢復通知"; +"Probe logs" = "探測日誌"; +"Provider" = "供應商"; +"Providers" = "供應商"; +"Quota usage" = "配額用量"; +"Quota: %@ / %@" = "配額:%@ / %@"; +"Random (default)" = "隨機(預設)"; +"Re-run provider autodetect" = "重新執行供應商自動偵測"; +"Refresh Session" = "重新整理工作階段"; +"Replay selected animation" = "重播所選動畫"; +"Reset CLI sessions" = "重設 CLI 工作階段"; +"Resets: %@" = "重設:%@"; +"Save to file" = "儲存到檔案"; +"Secondary (%@)" = "次視窗(%@)"; +"Average (%@ + %@)" = "平均(%@ + %@)"; +"Session" = "工作階段"; +"Set cost error" = "設定花費錯誤"; +"Set menu error" = "設定選單錯誤"; +"Settings..." = "設定..."; +"Status Page" = "狀態頁"; +"This month" = "本月"; +"Today" = "今天"; +"Today: %@" = "今天:%@"; +"Today: %@ · %@ tokens" = "今天:%@ · %@ tokens"; +"Total (30d): %@" = "總計(30 天):%@"; +"Total (30d): %@ credits" = "總計(30 天):%@ 點數"; +"Usage Dashboard" = "用量儀表板"; +"Usage breakdown" = "用量拆分"; +"Usage history (30 days)" = "用量歷史(30 天)"; +"Usage remaining" = "剩餘用量"; +"Usage source" = "用量來源"; +"Usage used" = "已用用量"; +"Using CLI fallback" = "正在使用 CLI 備援"; +"Verbosity" = "詳細程度"; +"Weekly usage unavailable for this account." = "此帳號暫無每週用量資訊。"; +"OpenAI dashboard data not found. Body sample: %@" = "找不到 OpenAI 儀表板資料。頁面片段:%@"; +"Last OpenAI dashboard refresh failed: %@. Cached values from %@." = "OpenAI 儀表板重新整理失敗:%@。目前顯示的是 %@ 的快取資料。"; +"Window: %@" = "視窗:%@"; +"Workspace ID" = "工作區 ID"; +"Write logs to %@ for debugging." = "將日誌寫入 %@ 以利偵錯。"; + "%@ — %@" = "%@ — %@"; + "%@ — %@" = "%@ — %@"; + "%@: %@" = "%@: %@"; + "%@: %@ · 30d %@" = "%@: %@ · 30 天 %@"; + "%@: fetching…%@" = "%@: 正在抓取…%@"; + "%@: last attempt %@" = "%@: 最後嘗試 %@"; + "%@: no data yet" = "%@: 尚無資料"; + "%@: unsupported" = "%@: 不支援"; + "About" = "關於"; + "Account" = "帳號"; + "Account: %@" = "帳號:%@"; + "Add" = "新增"; + "Alternatively, set a custom path in Settings." = "或者在設定中指定自訂路徑。"; + "Always allow prompts" = "始終允許提示"; + "Amp cookies are disabled." = "Amp Cookies 已停用。"; + "Antigravity login is managed in the app" = "Antigravity 登入由應用程式管理。"; + "Applies only to the Security.framework OAuth keychain reader." = "僅套用於 Security.framework OAuth 金鑰圈讀取程式。"; + "Augment cookies are disabled." = "Augment Cookies 已停用。"; + "Auto falls back to the next source if the preferred one fails." = "當偏好的來源失敗時,Auto 會回退到下一個來源。"; + "Auto uses API first, then falls back to CLI on auth failures." = "Auto 先使用 API,授權失敗時再退到 CLI。"; + "Auto-detect" = "自動偵測"; + "Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單的重新整理。"; + "Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 逾時 10 分鐘"; + "Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookies 和 WorkOS Tokens。"; + "Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookies 和本機儲存 Tokens。"; + "Automatic imports browser cookies for dashboard extras." = "自動匯入瀏覽器 Cookie,提供儀表板額外資訊。"; + "Automatic imports browser cookies for the web API." = "自動匯入瀏覽器 Cookie,以使用 Web API。"; + "Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookie。"; + "Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已儲存的工作階段。"; + "Automatic imports browser cookies." = "自動匯入瀏覽器 Cookie。"; + "Automatically opens CodexBar when you start your Mac." = "開機時自動打開 CodexBar。"; + "Automation" = "自動化"; + "Avoid Keychain prompts (experimental)" = "避免金鑰圈提示(實驗性)。"; + "Built %@" = "建置於 %@"; + "Check for Updates…" = "檢查更新…"; + "Check for updates automatically" = "自動檢查更新"; + "Check if you like your agents having some fun up there." = "看看是否喜歡代理在上方的互動。"; + "Check provider status" = "檢查提供商狀態"; + "Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; + "Choose up to %d providers" = "最多選擇 %d 個提供商"; + "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列要顯示什麼(節奏顯示實際用量對預期)。"; + "Choose which window drives the menu bar percent." = "選擇哪個視窗驅動選單列百分比。"; + "Claude CLI not found" = "找不到 Claude CLI"; + "Claude cookies are disabled." = "Claude Cookies 已停用。"; + "Claude login failed" = "Claude 登入失敗"; + "Claude login timed out" = "Claude 登入逾時"; + "Codex CLI not found" = "找不到 Codex CLI"; + "Codex login failed" = "Codex 登入失敗"; + "Codex login timed out" = "Codex 登入逾時"; + "CodexBar" = "CodexBar"; + "CodexBarCLI not found in app bundle." = "在 app 包內找不到 CodexBarCLI。"; + "Configure…" = "設定…"; + "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或貼上 kimi-auth Token 值"; + "Could not open Terminal for Gemini" = "無法為 Gemini 開啟終端機"; + "Could not start claude /login" = "無法啟動 claude /login"; + "Could not start codex login" = "無法啟動 codex 登入"; + "Cursor cookies are disabled." = "Cursor Cookies 已停用。"; + "Cursor login failed" = "Cursor 登入失敗"; + "Default follows system language." = "預設跟隨系統語言。"; + "Disable Keychain access" = "停用金鑰圈存取"; + "Disable OpenAI dashboard cookie usage." = "停用 OpenAI 儀表板 Cookie 使用。"; + "Disabled" = "已停用"; + "Disabled — %@" = "已停用 — %@"; + "Disabled — no recent data" = "已停用 — 沒有近期資料"; + "Display mode" = "顯示模式"; + "Drag to reorder" = "拖曳重新排序"; + "Email" = "電子郵件"; + "Enable Merge Icons to configure Overview tab providers." = "啟用合併圖示以設定總覽分頁提供商。"; + "Enabled" = "已啟用"; + "Exists: %@" = "已存在:%@"; + "Expose troubleshooting tools in the Debug tab." = "在偵錯分頁顯示故障排除工具。"; + "Factory cookies are disabled." = "Factory Cookies 已停用。"; + "Failed: %@" = "失敗:%@"; + "Gemini CLI not found" = "找不到 Gemini CLI"; + "General" = "一般"; + "GitHub" = "GitHub"; + "GitHub Copilot Login" = "GitHub Copilot 登入"; + "Hide details" = "隱藏詳細資料"; + "Hide personal information" = "隱藏個人資訊"; + "How often CodexBar polls providers in the background." = "CodexBar 在背景多久輪詢一次提供商。"; + "Inactive while \"Disable Keychain access\" is enabled in Advanced." = "進階啟用「停用金鑰圈存取」時會停用此項。"; + "Install CLI" = "安裝 CLI"; + "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "安裝啟用 AI Assistant 的 JetBrains IDE後重新整理 CodexBar。"; + "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後再試。"; + "Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後再試。"; + "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後再試。"; + "Installed: %@" = "已安裝:%@"; + "JetBrains AI is ready" = "JetBrains AI 已準備就緒"; + "JetBrains IDE" = "JetBrains IDE"; + "Keyboard shortcut" = "快速鍵"; + "Keychain access" = "金鑰圈存取"; + "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "在進階停用金鑰圈存取會讓瀏覽器 Cookie 匯入無法使用。"; + "Kimi cookies are disabled." = "Kimi Cookies 已停用。"; + "Label" = "標籤"; + "Language" = "語言"; + "May your tokens never run out—keep agent limits in view." = "願你的 Token 額度永不耗盡,始終看清代理限額。"; + "Menu bar" = "選單列"; + "Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的提供商。"; + "Menu bar shows percent" = "選單列顯示百分比"; + "Menu content" = "選單內容"; + "Merge Icons" = "合併圖示"; + "MiniMax cookies are disabled." = "MiniMax Cookies 已停用。"; + "Never prompt" = "永不提示"; + "No JetBrains IDE detected" = "未偵測到 JetBrains IDE"; + "No enabled providers available for Overview." = "總覽沒有可用的已啟用提供商。"; + "No limit set for the API key" = "API 金鑰尚未設定額度"; + "No output captured." = "未擷取到輸出。"; + "No providers selected" = "未選擇提供商"; + "No token accounts yet." = "還沒有 Token 帳號。"; + "No writable bin dirs found." = "找不到可寫入的 bin 目錄。"; + "No write access: %@" = "沒有寫入權限:%@"; + "Obscure email addresses in the menu bar and menu UI." = "在選單列和選單 UI 隱藏電子郵件地址。"; + "Ollama cookies are disabled." = "Ollama Cookies 已停用。"; + "Only on user action" = "僅在使用者操作時"; + "Open Antigravity to sign in, then refresh CodexBar." = "打開 Antigravity 登入後再重新整理 CodexBar。"; + "Open log file" = "打開日誌檔案"; + "Open menu" = "打開選單"; + "Open token file" = "打開 Token 檔案"; + "OpenCode cookies are disabled." = "OpenCode Cookies 已停用。"; + "Options" = "選項"; + "Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基底路徑覆寫自動偵測"; + "Overview rows always follow provider order." = "總覽列永遠依提供商順序排列。"; + "Overview tab providers" = "總覽分頁提供商"; + "Paste a Cookie header captured from the billing page." = "貼上從計費頁面擷取的 Cookie 標頭。"; + "Paste a Cookie header from a chatgpt.com request." = "貼上 chatgpt.com 請求的 Cookie 標頭。"; + "Paste a Cookie header from a claude.ai request." = "貼上 claude.ai 請求的 Cookie 標頭。"; + "Paste a Cookie header from a cursor.com request." = "貼上 cursor.com 請求的 Cookie 標頭。"; + "Paste a Cookie header from app.factory.ai." = "貼上 app.factory.ai 的 Cookie 標頭。"; + "Paste a Cookie header or cURL capture from Amp settings." = "貼上 Amp 設定中的 Cookie 標頭或 cURL 擷取。"; + "Paste a Cookie header or cURL capture from Ollama settings." = "貼上 Ollama 設定中的 Cookie 標頭或 cURL 擷取。"; + "Paste a Cookie header or cURL capture from the Augment dashboard." = "貼上 Augment 儀表板的 Cookie 標頭或 cURL 擷取。"; + "Paste a Cookie header or cURL capture from the Coding Plan page." = "貼上 Coding Plan 頁面中的 Cookie 標頭或 cURL 擷取。"; + "Paste a cookie header or the kimi-auth token value." = "貼上 Cookie 標頭或 kimi-auth Token 值。"; + "Paste key…" = "貼上金鑰…"; + "Prevents any Keychain access while enabled." = "啟用時會阻止所有金鑰圈存取。"; + "Quit" = "離開"; + "Quit CodexBar" = "離開 CodexBar"; + "Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本機使用紀錄;在選單顯示今天與過去 30 天花費。"; + "Refresh" = "重新整理"; + "Refresh cadence" = "重新整理頻率"; + "Refreshing" = "重新整理中"; + "Refreshing..." = "正在重新整理…"; + "Reload" = "重新載入"; + "Remove selected account" = "移除選取的帳號"; + "Reorder" = "重新排列"; + "Replace critter bars with provider branding icons and a percentage." = "以提供商品牌圖示與百分比取代 critter 條。"; + "Requires authentication via GitHub Device Flow." = "需要透過 GitHub 裝置流程驗證。"; + "Select a provider" = "選擇提供商"; + "Select the IDE to monitor" = "選擇要監控的 IDE"; + "Session quota notifications" = "會話額度通知"; + "Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 積分與 Claude 額外用量區塊。"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在選單中堆疊顯示所有 Token 帳號(否則顯示帳號切換列)。"; + "Show Debug Settings" = "顯示偵錯設定"; + "Show all token accounts" = "顯示所有 Token 帳號"; + "Show cost summary" = "顯示花費摘要"; + "Show credits + extra usage" = "顯示積分與額外使用"; + "Show details" = "顯示詳細資料"; + "Show most-used provider" = "顯示最常使用的提供商"; + "Show reset time as clock" = "以時鐘顯示重置時間"; + "Show usage as used" = "以已用方式顯示使用量"; + "Show usage breakdown, credits history, and code review via chatgpt.com." = "透過 chatgpt.com 顯示用量拆分、積分歷史與程式碼審查。"; + "Sign in again" = "重新登入"; + "Sign in via button below" = "透過下方按鈕登入"; + "Sign in with GitHub" = "使用 GitHub 登入"; + "Source" = "來源"; + "Start at Login" = "開機啟動"; + "State" = "狀態"; + "Status" = "狀態"; + "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存在 ~/.codexbar/config.json。可在 kimi-k2.ai 生成。"; + "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存在 ~/.codexbar/config.json。貼上你的 MiniMax API 金鑰。"; + "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 使用歷史(8 週),以個人化節奏預測。"; + "Surprise me" = "驚喜一下"; + "Switch Account..." = "切換帳號..."; + "Switcher shows icons" = "切換器顯示圖示"; + "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "將 CodexBarCLI 符號連結至 /usr/local/bin 與 /opt/homebrew/bin,名稱為 codexbar。"; + "System" = "系統"; + "Trigger the menu bar menu from anywhere." = "從任何地方觸發選單列選單。"; + "Twitter" = "Twitter"; + "Update Channel" = "更新頻道"; + "Update ready, restart now?" = "更新已就緒,立即重新啟動?"; + "Updated" = "已更新"; + "Updates unavailable in this build." = "此版本無法更新。"; + "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "使用 /usr/bin/security 讀取 Claude 憑證,避免 CodexBar 的金鑰圈提示。"; + "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "對中國大陸端點(open.bigmodel.cn)使用 BigModel。"; + "Use a single menu bar icon with a provider switcher." = "使用單一選單列圖示搭配提供商切換器。"; + "Version" = "版本"; + "Version %@" = "版本 %@"; + "Vertex AI Login" = "Vertex AI 登入"; + "Waiting for Authentication..." = "等待驗證中…"; + "Website" = "網站"; + "You can return to the app; authentication finished." = "可返回應用程式;驗證完成。"; + "api" = "api"; + "github api" = "github api"; + "just now" = "剛剛"; + "last fetch failed" = "最後抓取失敗"; + "not detected" = "未偵測到"; + "usage not fetched yet" = "尚未抓取使用量"; + "web" = "web"; + "wrk_…" = "wrk_…"; +"%@ — %@" = "%@ — %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · 30d %@" = "%@: %@ · 30 天 %@"; +"%@: fetching…%@" = "%@: 正在抓取…%@"; +"%@: last attempt %@" = "%@: 上次嘗試 %@"; +"%@: no data yet" = "%@: 暫無資料"; +"%@: unsupported" = "%@: 不支援"; +"About" = "關於"; +"Account" = "帳號"; +"Account: %@" = "帳號:%@"; +"Add" = "新增"; +"Alternatively, set a custom path in Settings." = "或者在設定中指定自訂路徑。"; +"Always allow prompts" = "永遠允許提示"; +"Amp cookies are disabled." = "Amp Cookies 已停用。"; +"Antigravity login is managed in the app" = "Antigravity 登入在應用程式內完成"; +"Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework 的 OAuth 鑰匙圈讀取器。"; +"Augment cookies are disabled." = "Augment Cookies 已停用。"; +"Auto falls back to the next source if the preferred one fails." = "首選來源失敗時,自動回退到下一個來源。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動模式優先使用 API,認證失敗時回退到 CLI。"; +"Auto-detect" = "自動偵測"; +"Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單中的「重新整理」。"; +"Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 逾時:10 分鐘"; +"Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookies 和 WorkOS Tokens。"; +"Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookies 和本機儲存 Tokens。"; +"Automatic imports browser cookies for dashboard extras." = "自動匯入瀏覽器 Cookies 以取得控制台附加資料。"; +"Automatic imports browser cookies for the web API." = "自動匯入瀏覽器 Cookies 以存取 Web API。"; +"Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookies。"; +"Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookies 或已儲存的工作階段。"; +"Automatic imports browser cookies." = "自動匯入瀏覽器 Cookies。"; +"Automatically opens CodexBar when you start your Mac." = "在 Mac 啟動時自動打開 CodexBar。"; +"Automation" = "自動化"; +"Avoid Keychain prompts (experimental)" = "避免鑰匙圈提示(實驗性)"; +"Built %@" = "建置於 %@"; +"Check for Updates…" = "檢查更新…"; +"Check for updates automatically" = "自動檢查更新"; +"Check if you like your agents having some fun up there." = "看看你是否喜歡讓代理在上面玩點花樣。"; +"Check provider status" = "檢查供應商狀態"; +"Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; +"Choose up to %d providers" = "最多選擇 %d 個供應商"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(節奏顯示實際用量與預期對比)。"; +"Choose which window drives the menu bar percent." = "選擇由哪個視窗決定選單列百分比。"; +"Claude CLI not found" = "未找到 Claude CLI"; +"Claude cookies are disabled." = "Claude Cookies 已停用。"; +"Claude login failed" = "Claude 登入失敗"; +"Claude login timed out" = "Claude 登入逾時"; +"Codex CLI not found" = "未找到 Codex CLI"; +"Codex login failed" = "Codex 登入失敗"; +"Codex login timed out" = "Codex 登入逾時"; +"CodexBar" = "CodexBar"; +"CodexBarCLI not found in app bundle." = "在應用程式包中未找到 CodexBarCLI。"; +"Configure…" = "設定…"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或貼上 kimi-auth Token 值"; +"Could not open Terminal for Gemini" = "無法為 Gemini 打開終端機"; +"Could not start claude /login" = "無法啟動 claude /login"; +"Could not start codex login" = "無法啟動 codex 登入"; +"Cursor cookies are disabled." = "Cursor Cookies 已停用。"; +"Cursor login failed" = "Cursor 登入失敗"; +"Default follows system language." = "預設跟隨系統語言。"; +"Disable Keychain access" = "停用鑰匙圈存取"; +"Disable OpenAI dashboard cookie usage." = "停用 OpenAI 控制台 Cookies 的使用。"; +"Disabled" = "已停用"; +"Disabled — %@" = "已停用 — %@"; +"Disabled — no recent data" = "已停用 — 無近期資料"; +"Display mode" = "顯示模式"; +"Drag to reorder" = "拖曳以重新排序"; +"Email" = "電子郵件"; +"Enable Merge Icons to configure Overview tab providers." = "啟用合併圖示後才能設定總覽頁供應商。"; +"Enabled" = "已啟用"; +"Exists: %@" = "已存在:%@"; +"Expose troubleshooting tools in the Debug tab." = "在偵錯分頁中顯示排障工具。"; +"Factory cookies are disabled." = "Factory Cookies 已停用。"; +"Failed: %@" = "失敗:%@"; +"Gemini CLI not found" = "未找到 Gemini CLI"; +"General" = "一般"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot 登入"; +"Hide personal information" = "隱藏個人資訊"; +"How often CodexBar polls providers in the background." = "CodexBar 在背景輪詢供應商的頻率。"; +"Inactive while \"Disable Keychain access\" is enabled in Advanced." = "當「進階」中的「停用鑰匙圈存取」啟用時,此項不可用。"; +"Install CLI" = "安裝 CLI"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "安裝啟用了 AI Assistant 的 JetBrains IDE,然後重新整理 CodexBar。"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "請先安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後再試。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "請先安裝 Codex CLI(npm i -g @openai/codex)後再試。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "請先安裝 Gemini CLI(npm i -g @google/gemini-cli)後再試。"; +"Installed: %@" = "已安裝:%@"; +"JetBrains AI is ready" = "JetBrains AI 已就緒"; +"JetBrains IDE" = "JetBrains IDE"; +"Keyboard shortcut" = "鍵盤快速鍵"; +"Keychain access" = "鑰匙圈存取"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "進階設定中已停用鑰匙圈存取,因此無法匯入瀏覽器 Cookies。"; +"Kimi cookies are disabled." = "Kimi Cookies 已停用。"; +"Label" = "標籤"; +"Language" = "語言"; +"May your tokens never run out—keep agent limits in view." = "願你的 Token 額度永不耗盡,始終看清代理限額。"; +"Menu bar" = "選單列"; +"Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近限額的供應商。"; +"Menu bar shows percent" = "選單列顯示百分比"; +"Menu content" = "選單內容"; +"Merge Icons" = "合併圖示"; +"MiniMax cookies are disabled." = "MiniMax Cookies 已停用。"; +"Never prompt" = "永不提示"; +"No JetBrains IDE detected" = "未偵測到 JetBrains IDE"; +"No enabled providers available for Overview." = "總覽中沒有可用的已啟用供應商。"; +"No output captured." = "未擷取到輸出。"; +"No providers selected" = "未選擇供應商"; +"No token accounts yet." = "還沒有 Token 帳號。"; +"No writable bin dirs found." = "未找到可寫入的 bin 目錄。"; +"No write access: %@" = "無寫入權限:%@"; +"Obscure email addresses in the menu bar and menu UI." = "在選單列和選單介面中模糊顯示電子郵件地址。"; +"Ollama cookies are disabled." = "Ollama Cookies 已停用。"; +"Only on user action" = "僅在使用者操作時"; +"Open Antigravity to sign in, then refresh CodexBar." = "打開 Antigravity 登入,然後重新整理 CodexBar。"; +"Open menu" = "打開選單"; +"Open token file" = "打開 Token 檔案"; +"OpenCode cookies are disabled." = "OpenCode Cookies 已停用。"; +"Options" = "選項"; +"Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基礎路徑覆寫自動偵測"; +"Overview rows always follow provider order." = "總覽列始終遵循供應商順序。"; +"Overview tab providers" = "總覽頁供應商"; +"Paste a Cookie header captured from the billing page." = "貼上從帳單頁面擷取的 Cookie Header。"; +"Paste a Cookie header from a chatgpt.com request." = "貼上 chatgpt.com 請求中的 Cookie Header。"; +"Paste a Cookie header from a claude.ai request." = "貼上 claude.ai 請求中的 Cookie Header。"; +"Paste a Cookie header from a cursor.com request." = "貼上 cursor.com 請求中的 Cookie Header。"; +"Paste a Cookie header from app.factory.ai." = "貼上來自 app.factory.ai 的 Cookie Header。"; +"Paste a Cookie header or cURL capture from Amp settings." = "貼上從 Amp 設定中取得的 Cookie Header 或 cURL 擷取。"; +"Paste a Cookie header or cURL capture from Ollama settings." = "貼上從 Ollama 設定中取得的 Cookie Header 或 cURL 擷取。"; +"Paste a Cookie header or cURL capture from the Augment dashboard." = "貼上從 Augment 控制台取得的 Cookie Header 或 cURL 擷取。"; +"Paste a Cookie header or cURL capture from the Coding Plan page." = "貼上從 Coding Plan 頁面取得的 Cookie Header 或 cURL 擷取。"; +"Paste a cookie header or the kimi-auth token value." = "貼上 Cookie Header 或 kimi-auth Token 值。"; +"Paste key…" = "貼上金鑰…"; +"Prevents any Keychain access while enabled." = "啟用後會阻止任何鑰匙圈存取。"; +"Quit" = "結束"; +"Quit CodexBar" = "結束 CodexBar"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本機用量日誌,並在選單中顯示今日與最近 30 天花費。"; +"Refresh" = "重新整理"; +"Refresh cadence" = "重新整理頻率"; +"Refreshing" = "重新整理中"; +"Reload" = "重新載入"; +"Remove selected account" = "移除所選帳號"; +"Reorder" = "重新排序"; +"Replace critter bars with provider branding icons and a percentage." = "用供應商品牌圖示和百分比取代小條圖。"; +"Requires authentication via GitHub Device Flow." = "需要透過 GitHub Device Flow 驗證。"; +"Select a provider" = "選擇一個供應商"; +"Select the IDE to monitor" = "選擇要監控的 IDE"; +"Session quota notifications" = "工作階段配額通知"; +"Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 積分與 Claude 額外用量區塊。"; +"Show Debug Settings" = "顯示偵錯設定"; +"Show all token accounts" = "顯示所有 Token 帳號"; +"Show cost summary" = "顯示花費摘要"; +"Show credits + extra usage" = "顯示點數與額外用量"; +"Show most-used provider" = "顯示最常用的供應商"; +"Show reset time as clock" = "以時鐘時間顯示重設時間"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示供應商圖示(否則顯示每週進度線)。"; +"Show usage as used" = "按已用量顯示"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨著你消耗配額而填滿(而不是顯示剩餘量)。"; +"Display reset times as absolute clock values instead of countdowns." = "將重設時間顯示為絕對時鐘時間,而不是倒數計時。"; +"Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu." = "輪詢 OpenAI/Claude 狀態頁以及 Gemini/Antigravity 的 Google Workspace 狀態,並在圖示和選單中顯示事故。"; +"Notifies when the 5-hour session quota hits 0% and when it becomes available again." = "在 5 小時工作階段配額降到 0% 以及恢復可用時發送通知。"; +"Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers." = "停用所有鑰匙圈讀取與寫入。瀏覽器 Cookie 匯入將不可用,請在「供應商」中手動貼上 Cookie Header。"; +"Show usage breakdown, credits history, and code review via chatgpt.com." = "透過 chatgpt.com 顯示用量拆分、點數歷史和程式碼審查。"; +"Sign in again" = "重新登入"; +"Sign in via button below" = "透過下方按鈕登入"; +"Sign in with GitHub" = "使用 GitHub 登入"; +"Source" = "來源"; +"Start at Login" = "登入時啟動"; +"State" = "狀態"; +"Status" = "狀態"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存於 ~/.codexbar/config.json,可在 kimi-k2.ai 產生。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存於 ~/.codexbar/config.json,請貼上你的 MiniMax API 金鑰。"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 用量歷史(8 週),用於個人化節奏預測。"; +"Surprise me" = "給我點驚喜"; +"Switch Account..." = "切換帳號..."; +"Switcher shows icons" = "切換器顯示圖示"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "將 CodexBarCLI 符號連結到 /usr/local/bin 和 /opt/homebrew/bin,命名為 codexbar。"; +"System" = "系統"; +"Trigger the menu bar menu from anywhere." = "可在任意位置觸發選單列選單。"; +"Twitter" = "Twitter"; +"Update Channel" = "更新通道"; +"Update ready, restart now?" = "更新已就緒,是否現在重新啟動?"; +"Updated" = "更新時間"; +"Updates unavailable in this build." = "此建置版本不支援更新。"; +"Usage" = "用量"; +"Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "使用 /usr/bin/security 讀取 Claude 憑證,避免 CodexBar 彈出鑰匙圈提示。"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中國大陸端點使用 BigModel(open.bigmodel.cn)。"; +"Use a single menu bar icon with a provider switcher." = "使用單一選單列圖示並搭配供應商切換器。"; +"Version" = "版本"; +"Version %@" = "版本 %@"; +"Vertex AI Login" = "Vertex AI 登入"; +"Waiting for Authentication..." = "等待驗證中..."; +"Website" = "網站"; +"You can return to the app; authentication finished." = "你可以返回應用程式;驗證已完成。"; +"api" = "api"; +"github api" = "GitHub API"; +"Hide details" = "隱藏詳情"; +"Hover a bar for details" = "將滑鼠懸停在長條上查看詳情"; +"just now" = "剛剛"; +"last fetch failed" = "上次抓取失敗"; +"No limit set for the API key" = "API 金鑰未設定額度"; +"not detected" = "未偵測到"; +"Refreshing..." = "正在重新整理..."; +"Show details" = "顯示詳情"; +"Top: %@" = "Top:%@"; +"usage not fetched yet" = "尚未抓取用量"; +"web" = "網頁"; +"wrk_…" = "wrk_…"; +"%@: %@ credits" = "%@: %@ 點數"; +"© 2025 Peter Steinberger. MIT License." = "© 2025 Peter Steinberger。MIT 授權。"; +"—" = "—"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..24779d3a0 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -5,6 +5,20 @@ import ServiceManagement extension SettingsStore { private static let mergedOverviewSelectionEditedActiveProvidersKey = "mergedOverviewSelectionEditedActiveProviders" + var appLanguage: AppLanguage { + get { + AppLanguage(rawValue: self.defaultsState.appLanguageRaw ?? "") ?? .system + } + set { + self.defaultsState.appLanguageRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: AppLanguage.userDefaultsKey) + } + } + + var appLocale: Locale { + self.appLanguage.locale + } + var refreshFrequency: RefreshFrequency { get { self.defaultsState.refreshFrequency } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 09f3e3caa..5f2132990 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -27,14 +27,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { } var label: String { - switch self { - case .manual: "Manual" - case .oneMinute: "1 min" - case .twoMinutes: "2 min" - case .fiveMinutes: "5 min" - case .fifteenMinutes: "15 min" - case .thirtyMinutes: "30 min" - } + AppStrings.refreshFrequency(self) } } @@ -49,12 +42,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { } var label: String { - switch self { - case .automatic: "Automatic" - case .primary: "Primary" - case .secondary: "Secondary" - case .average: "Average" - } + AppStrings.menuBarMetricPreference(self) } } @@ -157,6 +145,7 @@ final class SettingsStore { extension SettingsStore { private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { + let appLanguageRaw = userDefaults.string(forKey: AppLanguage.userDefaultsKey) let refreshRaw = userDefaults.string(forKey: "refreshFrequency") ?? RefreshFrequency.fiveMinutes.rawValue let refreshFrequency = RefreshFrequency(rawValue: refreshRaw) ?? .fiveMinutes let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false @@ -225,6 +214,7 @@ extension SettingsStore { let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false return SettingsDefaultsState( + appLanguageRaw: appLanguageRaw, refreshFrequency: refreshFrequency, launchAtLogin: launchAtLogin, debugMenuEnabled: debugMenuEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..2fcd17762 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -1,6 +1,7 @@ import Foundation struct SettingsDefaultsState { + var appLanguageRaw: String? var refreshFrequency: RefreshFrequency var launchAtLogin: Bool var debugMenuEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index e92843bc5..e47e0be62 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -208,18 +208,18 @@ extension StatusItemController { return case .missingBinary: self.presentLoginAlert( - title: "Codex CLI not found", - message: "Install the Codex CLI (npm i -g @openai/codex) and try again.") + title: AppStrings.tr("Codex CLI not found"), + message: AppStrings.tr("Install the Codex CLI (npm i -g @openai/codex) and try again.")) case let .launchFailed(message): - self.presentLoginAlert(title: "Could not start codex login", message: message) + self.presentLoginAlert(title: AppStrings.tr("Could not start codex login"), message: message) case .timedOut: self.presentLoginAlert( - title: "Codex login timed out", + title: AppStrings.tr("Codex login timed out"), message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "codex login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) - self.presentLoginAlert(title: "Codex login failed", message: message) + self.presentLoginAlert(title: AppStrings.tr("Codex login failed"), message: message) } } @@ -229,18 +229,18 @@ extension StatusItemController { return case .missingBinary: self.presentLoginAlert( - title: "Claude CLI not found", - message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.") + title: AppStrings.tr("Claude CLI not found"), + message: AppStrings.tr("Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.")) case let .launchFailed(message): - self.presentLoginAlert(title: "Could not start claude /login", message: message) + self.presentLoginAlert(title: AppStrings.tr("Could not start claude /login"), message: message) case .timedOut: self.presentLoginAlert( - title: "Claude login timed out", + title: AppStrings.tr("Claude login timed out"), message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "claude /login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) - self.presentLoginAlert(title: "Claude login failed", message: message) + self.presentLoginAlert(title: AppStrings.tr("Claude login failed"), message: message) } } @@ -288,10 +288,10 @@ extension StatusItemController { nil case .missingBinary: LoginAlertInfo( - title: "Gemini CLI not found", - message: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.") + title: AppStrings.tr("Gemini CLI not found"), + message: AppStrings.tr("Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.")) case let .launchFailed(message): - LoginAlertInfo(title: "Could not open Terminal for Gemini", message: message) + LoginAlertInfo(title: AppStrings.tr("Could not open Terminal for Gemini"), message: message) } } @@ -306,7 +306,7 @@ extension StatusItemController { private func trimmedLoginOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 - if trimmed.isEmpty { return "No output captured." } + if trimmed.isEmpty { return AppStrings.tr("No output captured.") } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: AppStrings.tr("Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -1143,7 +1146,7 @@ extension StatusItemController { @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: AppStrings.tr("Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1153,7 +1156,7 @@ extension StatusItemController { @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: AppStrings.tr("Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1163,7 +1166,7 @@ extension StatusItemController { @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: AppStrings.tr("Usage history (30 days)"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1190,20 +1193,20 @@ extension StatusItemController { let submenu = NSMenu() submenu.delegate = self - let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "") + let titleItem = NSMenuItem(title: AppStrings.tr("MCP details"), action: nil, keyEquivalent: "") titleItem.isEnabled = false submenu.addItem(titleItem) if let window = timeLimit.windowLabel { - let item = NSMenuItem(title: "Window: \(window)", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: AppStrings.fmt("Window: %@", window), action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } if let resetTime = timeLimit.nextResetTime { let reset = self.settings.resetTimeDisplayStyle == .absolute - ? UsageFormatter.resetDescription(from: resetTime) - : UsageFormatter.resetCountdownDescription(from: resetTime) - let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "") + ? AppStrings.resetDescription(from: resetTime) + : AppStrings.resetCountdownDescription(from: resetTime) + let item = NSMenuItem(title: AppStrings.fmt("Resets: %@", reset), action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 92c334231..1dbace812 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -68,7 +68,7 @@ final class ProviderSwitcherView: NSView { Segment( selection: .overview, image: overviewIcon, - title: "Overview"), + title: AppStrings.tr("Overview")), at: 0) } self.segments = segments diff --git a/Sources/CodexBar/UpdateChannel.swift b/Sources/CodexBar/UpdateChannel.swift index aabf87bc6..89e53322a 100644 --- a/Sources/CodexBar/UpdateChannel.swift +++ b/Sources/CodexBar/UpdateChannel.swift @@ -8,21 +8,11 @@ enum UpdateChannel: String, CaseIterable, Codable { static let sparkleBetaChannel = "beta" var displayName: String { - switch self { - case .stable: - "Stable" - case .beta: - "Beta" - } + AppStrings.updateChannel(self) } var description: String { - switch self { - case .stable: - "Receive only stable, production-ready releases." - case .beta: - "Receive stable releases plus beta previews." - } + AppStrings.updateChannelDescription(self) } var allowedSparkleChannels: Set { diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ee0ccaa65..9194252b5 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -31,7 +31,7 @@ struct UsageBreakdownChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No usage breakdown data.") + Text(AppStrings.tr("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -362,17 +362,17 @@ struct UsageBreakdownChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (AppStrings.tr("Hover a bar for details"), nil) } - let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let dayLabel = AppStrings.monthDayString(from: date) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { - return ("\(dayLabel): \(total)", nil) + return (AppStrings.fmt("%@: %@", dayLabel, total), nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) - return ("\(dayLabel): \(used)", first.service) + return (AppStrings.fmt("%@: %@", dayLabel, used), first.service) } let services = day.services @@ -384,6 +384,6 @@ struct UsageBreakdownChartMenuView: View { .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") - return ("\(dayLabel): \(total)", services) + return (AppStrings.fmt("%@: %@", dayLabel, total), services) } } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 94d1ed565..30d057d07 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -12,9 +12,9 @@ enum UsagePaceText { static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return AppStrings.fmt("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return AppStrings.fmt("Pace: %@", detail.leftLabel) } static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { @@ -29,28 +29,30 @@ enum UsagePaceText { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: - return "On pace" + return AppStrings.tr("On pace") case .slightlyAhead, .ahead, .farAhead: - return "\(deltaValue)% in deficit" + return AppStrings.fmt("%d%% in deficit", deltaValue) case .slightlyBehind, .behind, .farBehind: - return "\(deltaValue)% in reserve" + return AppStrings.fmt("%d%% in reserve", deltaValue) } } private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { - etaLabel = "Lasts until reset" + etaLabel = AppStrings.tr("Lasts until reset") } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) - etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)" + etaLabel = etaText == AppStrings.tr("now") + ? AppStrings.tr("Runs out now") + : AppStrings.fmt("Runs out %@", etaText) } else { etaLabel = nil } guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) - let riskLabel = "≈ \(roundedRisk)% run-out risk" + let riskLabel = AppStrings.fmt("≈ %d%% run-out risk", roundedRisk) if let etaLabel { return "\(etaLabel) · \(riskLabel)" } @@ -59,10 +61,7 @@ enum UsagePaceText { private static func durationText(seconds: TimeInterval, now: Date) -> String { let date = now.addingTimeInterval(seconds) - let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) - if countdown == "now" { return "now" } - if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } - return countdown + return AppStrings.resetCountdownDescription(from: date, now: now) } private static func roundedRiskPercent(_ probability: Double) -> Int { diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 522416898..dc1c647e7 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -17,14 +17,7 @@ enum ProviderStatusIndicator: String { } var label: String { - switch self { - case .none: "Operational" - case .minor: "Partial outage" - case .major: "Major outage" - case .critical: "Critical issue" - case .maintenance: "Maintenance" - case .unknown: "Status unknown" - } + AppStrings.providerStatus(self) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift index 4b6505217..f90261748 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift @@ -21,8 +21,8 @@ struct CodexBarSwitcherWidget: Widget { { entry in CodexBarSwitcherWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Switcher") - .description("Usage widget with a provider switcher.") + .configurationDisplayName(WidgetStrings.resource("CodexBar Switcher")) + .description(WidgetStrings.resource("Usage widget with a provider switcher.")) .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } @@ -38,8 +38,8 @@ struct CodexBarUsageWidget: Widget { { entry in CodexBarUsageWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Usage") - .description("Session and weekly usage with credits and costs.") + .configurationDisplayName(WidgetStrings.resource("CodexBar Usage")) + .description(WidgetStrings.resource("Session and weekly usage with credits and costs.")) .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } @@ -55,8 +55,8 @@ struct CodexBarHistoryWidget: Widget { { entry in CodexBarHistoryWidgetView(entry: entry) } - .configurationDisplayName("CodexBar History") - .description("Usage history chart with recent totals.") + .configurationDisplayName(WidgetStrings.resource("CodexBar History")) + .description(WidgetStrings.resource("Usage history chart with recent totals.")) .supportedFamilies([.systemMedium, .systemLarge]) } } @@ -72,8 +72,8 @@ struct CodexBarCompactWidget: Widget { { entry in CodexBarCompactWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Metric") - .description("Compact widget for credits or cost.") + .configurationDisplayName(WidgetStrings.resource("CodexBar Metric")) + .description(WidgetStrings.resource("Compact widget for credits or cost.")) .supportedFamilies([.systemSmall]) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index eb0d00574..e3e030164 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -14,7 +14,7 @@ enum ProviderChoice: String, AppEnum { case kilo case opencode - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: WidgetStrings.resource("Provider")) static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [ .codex: DisplayRepresentation(title: "Codex"), @@ -76,20 +76,20 @@ enum CompactMetric: String, AppEnum { case todayCost case last30DaysCost - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: WidgetStrings.resource("Metric")) static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [ - .credits: DisplayRepresentation(title: "Credits left"), - .todayCost: DisplayRepresentation(title: "Today cost"), - .last30DaysCost: DisplayRepresentation(title: "30d cost"), + .credits: DisplayRepresentation(title: WidgetStrings.resource("Credits left")), + .todayCost: DisplayRepresentation(title: WidgetStrings.resource("Today cost")), + .last30DaysCost: DisplayRepresentation(title: WidgetStrings.resource("30d cost")), ] } struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { - static let title: LocalizedStringResource = "Provider" - static let description = IntentDescription("Select the provider to display in the widget.") + static let title = WidgetStrings.resource("Provider") + static let description = IntentDescription(WidgetStrings.resource("Select the provider to display in the widget.")) - @Parameter(title: "Provider") + @Parameter(title: "Provider", description: "Select the provider to display in the widget.") var provider: ProviderChoice init() { @@ -98,8 +98,8 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { } struct SwitchWidgetProviderIntent: AppIntent { - static let title: LocalizedStringResource = "Switch Provider" - static let description = IntentDescription("Switch the provider shown in the widget.") + static let title = WidgetStrings.resource("Switch Provider") + static let description = IntentDescription(WidgetStrings.resource("Switch the provider shown in the widget.")) @Parameter(title: "Provider") var provider: ProviderChoice @@ -118,13 +118,13 @@ struct SwitchWidgetProviderIntent: AppIntent { } struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { - static let title: LocalizedStringResource = "Provider + Metric" - static let description = IntentDescription("Select the provider and metric to display.") + static let title = WidgetStrings.resource("Provider + Metric") + static let description = IntentDescription(WidgetStrings.resource("Select the provider and metric to display.")) - @Parameter(title: "Provider") + @Parameter(title: "Provider", description: "Select the provider to display in the widget.") var provider: ProviderChoice - @Parameter(title: "Metric") + @Parameter(title: "Metric", description: "Select the metric to display in the widget.") var metric: CompactMetric init() { @@ -261,8 +261,16 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { enum WidgetPreviewData { static func snapshot() -> WidgetSnapshot { - let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") - let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") + let primary = RateWindow( + usedPercent: 35, + windowMinutes: nil, + resetsAt: nil, + resetDescription: WidgetStrings.tr("Resets in 4h")) + let secondary = RateWindow( + usedPercent: 60, + windowMinutes: nil, + resetsAt: nil, + resetDescription: WidgetStrings.tr("Resets in 3d")) let entry = WidgetSnapshot.ProviderEntry( provider: .codex, updatedAt: Date(), diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index fbb8c5d9c..b15b10e8e 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -33,10 +33,10 @@ struct CodexBarUsageWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + Text(WidgetStrings.tr("Open CodexBar")) .font(.body) .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") + Text(WidgetStrings.tr("Usage data will appear once the app refreshes.")) .font(.caption) .foregroundStyle(.secondary) } @@ -63,10 +63,10 @@ struct CodexBarHistoryWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + Text(WidgetStrings.tr("Open CodexBar")) .font(.body) .fontWeight(.semibold) - Text("Usage history will appear after a refresh.") + Text(WidgetStrings.tr("Usage history will appear after a refresh.")) .font(.caption) .foregroundStyle(.secondary) } @@ -92,10 +92,10 @@ struct CodexBarCompactWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + Text(WidgetStrings.tr("Open CodexBar")) .font(.body) .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") + Text(WidgetStrings.tr("Usage data will appear once the app refreshes.")) .font(.caption) .foregroundStyle(.secondary) } @@ -143,10 +143,10 @@ struct CodexBarSwitcherWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + Text(WidgetStrings.tr("Open CodexBar")) .font(.caption) .foregroundStyle(.secondary) - Text("Usage data appears after a refresh.") + Text(WidgetStrings.tr("Usage data appears after a refresh.")) .font(.caption2) .foregroundStyle(.secondary) } @@ -182,15 +182,15 @@ private struct CompactMetricView: View { switch self.metric { case .credits: let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" - return (value, "Credits left", nil) + return (value, WidgetStrings.tr("Credits left"), nil) case .todayCost: let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + return (value, WidgetStrings.tr("Today cost"), detail) case .last30DaysCost: let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + return (value, WidgetStrings.tr("30d cost"), detail) } } } @@ -289,16 +289,16 @@ private struct SwitcherSmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: WidgetStrings.tr("Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } @@ -312,19 +312,19 @@ private struct SwitcherMediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: WidgetStrings.tr("Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: WidgetStrings.tr("Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) } } @@ -337,29 +337,29 @@ private struct SwitcherLargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: WidgetStrings.tr("Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: WidgetStrings.tr("Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + title: WidgetStrings.tr("Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: WidgetStrings.tr("30d"), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) @@ -378,16 +378,16 @@ private struct SmallUsageView: View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: WidgetStrings.tr("Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } @@ -403,19 +403,19 @@ private struct MediumUsageView: View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: WidgetStrings.tr("Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: WidgetStrings.tr("Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) } } @@ -430,29 +430,29 @@ private struct LargeUsageView: View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: WidgetStrings.tr(ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: WidgetStrings.tr("Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: WidgetStrings.tr("Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + title: WidgetStrings.tr("Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: WidgetStrings.tr("30d"), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) @@ -476,10 +476,10 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: WidgetStrings.tr("Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: WidgetStrings.tr("30d"), value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) } } @@ -658,7 +658,7 @@ enum WidgetFormat { formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)" - return "\(raw) tokens" + return WidgetStrings.fmt("%@ tokens", raw) } static func relativeDate(_ date: Date) -> String { diff --git a/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings b/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..5d2399270 --- /dev/null +++ b/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"30d" = "30d"; +"30d cost" = "30d cost"; +"Code review" = "Code review"; +"Credits" = "Credits"; +"Credits left" = "Credits left"; +"Metric" = "Metric"; +"Open CodexBar" = "Open CodexBar"; +"Provider" = "Provider"; +"Provider + Metric" = "Provider + Metric"; +"Resets in 3d" = "Resets in 3d"; +"Resets in 4h" = "Resets in 4h"; +"Session" = "Session"; +"Select the provider and metric to display." = "Select the provider and metric to display."; +"Select the provider to display in the widget." = "Select the provider to display in the widget."; +"Session and weekly usage with credits and costs." = "Session and weekly usage with credits and costs."; +"Switch Provider" = "Switch Provider"; +"Switch the provider shown in the widget." = "Switch the provider shown in the widget."; +"Today" = "Today"; +"Today cost" = "Today cost"; +"CodexBar History" = "CodexBar History"; +"CodexBar Metric" = "CodexBar Metric"; +"CodexBar Switcher" = "CodexBar Switcher"; +"CodexBar Usage" = "CodexBar Usage"; +"Compact widget for credits or cost." = "Compact widget for credits or cost."; +"Usage data appears after a refresh." = "Usage data appears after a refresh."; +"Usage data will appear once the app refreshes." = "Usage data will appear once the app refreshes."; +"Usage history chart with recent totals." = "Usage history chart with recent totals."; +"Usage history will appear after a refresh." = "Usage history will appear after a refresh."; +"Usage widget with a provider switcher." = "Usage widget with a provider switcher."; +"Weekly" = "Weekly"; diff --git a/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..8cfb84c37 --- /dev/null +++ b/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"30d" = "30 天"; +"30d cost" = "30 天花费"; +"Code review" = "代码审查"; +"Credits" = "积分"; +"Credits left" = "剩余积分"; +"Metric" = "指标"; +"Open CodexBar" = "打开 CodexBar"; +"Provider" = "提供商"; +"Provider + Metric" = "提供商 + 指标"; +"Resets in 3d" = "3 天后重置"; +"Resets in 4h" = "4 小时后重置"; +"Session" = "会话"; +"Select the provider and metric to display." = "选择要在小组件中显示的提供商和指标。"; +"Select the provider to display in the widget." = "选择要在小组件中显示的提供商。"; +"Session and weekly usage with credits and costs." = "显示会话和每周使用情况,以及积分和花费。"; +"Switch Provider" = "切换提供商"; +"Switch the provider shown in the widget." = "切换小组件中显示的提供商。"; +"Today" = "今天"; +"Today cost" = "今日花费"; +"CodexBar History" = "CodexBar 历史"; +"CodexBar Metric" = "CodexBar 指标"; +"CodexBar Switcher" = "CodexBar 切换器"; +"CodexBar Usage" = "CodexBar 使用情况"; +"Compact widget for credits or cost." = "用于显示积分或花费的紧凑小组件。"; +"Usage data appears after a refresh." = "刷新后会显示使用数据。"; +"Usage data will appear once the app refreshes." = "应用刷新后会显示使用数据。"; +"Usage history chart with recent totals." = "显示最近汇总数据的使用历史图表。"; +"Usage history will appear after a refresh." = "刷新后会显示使用历史。"; +"Usage widget with a provider switcher." = "带提供商切换器的用量小组件。"; +"Weekly" = "每周"; diff --git a/Sources/CodexBarWidget/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBarWidget/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 000000000..05ac25875 --- /dev/null +++ b/Sources/CodexBarWidget/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"30d" = "30 天"; +"30d cost" = "30 天花費"; +"Code review" = "程式碼審查"; +"Credits" = "點數"; +"Credits left" = "剩餘點數"; +"Metric" = "指標"; +"Open CodexBar" = "打開 CodexBar"; +"Provider" = "供應商"; +"Provider + Metric" = "供應商 + 指標"; +"Resets in 3d" = "3 天後重設"; +"Resets in 4h" = "4 小時後重設"; +"Session" = "工作階段"; +"Select the provider and metric to display." = "選擇要在小工具中顯示的供應商和指標。"; +"Select the provider to display in the widget." = "選擇要在小工具中顯示的供應商。"; +"Session and weekly usage with credits and costs." = "顯示工作階段與每週使用情況,以及點數和花費。"; +"Switch Provider" = "切換供應商"; +"Switch the provider shown in the widget." = "切換小工具中顯示的供應商。"; +"Today" = "今天"; +"Today cost" = "今日花費"; +"CodexBar History" = "CodexBar 歷史"; +"CodexBar Metric" = "CodexBar 指標"; +"CodexBar Switcher" = "CodexBar 切換器"; +"CodexBar Usage" = "CodexBar 使用情況"; +"Compact widget for credits or cost." = "用於顯示點數或花費的精簡小工具。"; +"Usage data appears after a refresh." = "重新整理後會顯示使用資料。"; +"Usage data will appear once the app refreshes." = "應用程式重新整理後會顯示使用資料。"; +"Usage history chart with recent totals." = "顯示近期彙總資料的使用歷史圖表。"; +"Usage history will appear after a refresh." = "重新整理後會顯示使用歷史。"; +"Usage widget with a provider switcher." = "帶供應商切換器的使用情況小工具。"; +"Weekly" = "每週"; diff --git a/Sources/CodexBarWidget/WidgetStrings.swift b/Sources/CodexBarWidget/WidgetStrings.swift new file mode 100644 index 000000000..de1d9ff90 --- /dev/null +++ b/Sources/CodexBarWidget/WidgetStrings.swift @@ -0,0 +1,17 @@ +import Foundation + +enum WidgetStrings { + private static let table = "Localizable" + + static func tr(_ key: String) -> String { + Bundle.module.localizedString(forKey: key, value: key, table: self.table) + } + + static func resource(_ key: String) -> LocalizedStringResource { + LocalizedStringResource(stringLiteral: self.tr(key)) + } + + static func fmt(_ key: String, _ args: CVarArg...) -> String { + String(format: self.tr(key), locale: .current, arguments: args) + } +} diff --git a/Tests/CodexBarTests/AppStringsLocalizationTests.swift b/Tests/CodexBarTests/AppStringsLocalizationTests.swift new file mode 100644 index 000000000..03aa14c3b --- /dev/null +++ b/Tests/CodexBarTests/AppStringsLocalizationTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AppStringsLocalizationTests { + @Test + func `selected language resolves localized strings and fallback`() { + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect(AppStrings.tr("General") == "通用") + #expect(AppStrings.tr("Language") == "语言") + #expect(AppStrings.tr("__missing_translation_key__") == "__missing_translation_key__") + } + + AppStrings.withTestingLanguage(.traditionalChinese) { + #expect(AppStrings.tr("General") == "一般") + #expect(AppStrings.tr("Language") == "語言") + } + + AppStrings.withTestingLanguage(.english) { + #expect(AppStrings.tr("General") == "General") + #expect(AppStrings.tr("Language") == "Language") + } + } + + @Test + func `relative strings follow selected language`() { + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect(Date().relativeDescription(now: Date()) == "刚刚") + } + + AppStrings.withTestingLanguage(.english) { + #expect(Date().relativeDescription(now: Date()) == "just now") + } + } + + @Test + func `month day formatting follows selected language`() throws { + let date = try #require(Calendar(identifier: .gregorian).date(from: DateComponents( + year: 2025, + month: 1, + day: 5))) + + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect(AppStrings.monthDayString(from: date).contains("月")) + } + + AppStrings.withTestingLanguage(.english) { + #expect(AppStrings.monthDayString(from: date).contains("Jan")) + } + } + + @Test + func `status source and dashboard helpers localize simplified chinese`() { + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect( + AppStrings.localizedProviderStatusDescription( + "Partially Degraded Service", + indicator: .minor) == "部分故障") + #expect(AppStrings.localizedSourceLabel("web") == "网页") + #expect(AppStrings.tr("Weekly") == "每周") + #expect(AppStrings.tr("Monthly") == "每月") + #expect( + AppStrings.localizedOpenAIDashboardError( + "OpenAI dashboard data not found. Body sample: 跳至内容") == + "未找到 OpenAI 仪表盘数据。页面片段:跳至内容") + } + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 052bd0dad..77fdb2bb0 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -62,7 +62,7 @@ struct MenuCardModelTests { #expect(model.metrics.count == 2) #expect(model.metrics.first?.percent == 78) #expect(model.planText == "Plus") - #expect(model.subtitleText.hasPrefix("Updated")) + #expect(model.subtitleText.hasPrefix(AppStrings.tr("Updated"))) #expect(model.progressColor != Color.clear) #expect(model.metrics[1].resetText?.isEmpty == false) } @@ -119,10 +119,10 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.first?.title == "Session") + #expect(model.metrics.first?.title == AppStrings.tr("Session")) #expect(model.metrics.first?.percent == 22) - #expect(model.metrics.first?.percentLabel.contains("used") == true) - #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 27 }) + #expect(model.metrics.first?.percentLabel.contains(AppStrings.tr("used")) == true) + #expect(model.metrics.contains { $0.title == AppStrings.tr("Code review") && $0.percent == 27 }) } @Test @@ -169,7 +169,7 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) + #expect(model.metrics.contains { $0.title == AppStrings.tr("Code review") && $0.percent == 73 }) } @Test @@ -212,7 +212,7 @@ struct MenuCardModelTests { now: now)) #expect(model.metrics.count == 1) - #expect(model.metrics.first?.title == "Session") + #expect(model.metrics.first?.title == AppStrings.tr("Session")) #expect(model.planText == "Max") } diff --git a/Tests/CodexBarTests/MenuDescriptorKiloTests.swift b/Tests/CodexBarTests/MenuDescriptorKiloTests.swift index a3dba9cb3..97bdc53e6 100644 --- a/Tests/CodexBarTests/MenuDescriptorKiloTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorKiloTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct MenuDescriptorKiloTests { @Test func `kilo credits detail does not render as reset line`() throws { @@ -109,7 +110,7 @@ struct MenuDescriptorKiloTests { return text } - #expect(textLines.contains(where: { $0.contains("Resets") })) + #expect(textLines.contains(where: { $0.contains(AppStrings.tr("Resets")) })) #expect(textLines.contains("$0.00 / $19.00 (+ $9.50 bonus)")) } @@ -161,7 +162,60 @@ struct MenuDescriptorKiloTests { return text } - #expect(textLines.contains("Activity: Auto top-up: off")) - #expect(!textLines.contains("Plan: Auto top-up: off")) + #expect(textLines.contains(AppStrings.fmt("Activity: %@", "Auto top-up: off"))) + #expect(!textLines.contains(AppStrings.fmt("Plan: %@", "Auto top-up: off"))) + } + + @Test + func `kilo activity label localizes with selected language`() throws { + try AppStrings.withTestingLanguage(.simplifiedChinese) { + let suite = "MenuDescriptorKiloTests-kilo-activity-language" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "0/0 credits"), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Auto top-up: off")) + store._setSnapshotForTesting(snapshot, provider: .kilo) + + let descriptor = MenuDescriptor.build( + provider: .kilo, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(AppStrings.fmt("Activity: %@", "Auto top-up: off"))) + #expect(!textLines.contains("Activity: Auto top-up: off")) + } } } diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index b71448ef1..080978bca 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct PreferencesPaneSmokeTests { @Test func `builds preference panes with default settings`() { @@ -43,6 +44,73 @@ struct PreferencesPaneSmokeTests { _ = AboutPane(updater: DisabledUpdaterController()).body } + @Test + func `builds preference panes for each supported app language`() { + let languages = AppLanguage.allCases + + for language in languages { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-\(language.rawValue)") + settings.appLanguage = language + let store = Self.makeUsageStore(settings: settings) + let selection = PreferencesSelection() + + _ = PreferencesView( + settings: settings, + store: store, + updater: DisabledUpdaterController(), + selection: selection).body + + #expect(settings.appLanguage == language) + let expectedPrefix = language == .system ? Locale.autoupdatingCurrent.identifier : language.rawValue + #expect(settings.appLocale.identifier.hasPrefix(expectedPrefix)) + #expect(AppLanguage.allCases.map(\.displayName) == ["System", "English", "简体中文", "繁體中文"]) + + switch language { + case .system: + #expect(!PreferencesView._test_visibleTabTitles(debugMenuEnabled: false).isEmpty) + case .english: + AppStrings.withTestingLanguage(.english) { + #expect(PreferencesView._test_visibleTabTitles(debugMenuEnabled: false) == [ + "General", + "Providers", + "Display", + "Advanced", + "About", + ]) + #expect(AppStrings.tr("System") == "System") + #expect(AppStrings.tr("Language") == "Language") + #expect(AppStrings.tr("Quit CodexBar") == "Quit CodexBar") + } + case .simplifiedChinese: + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect(PreferencesView._test_visibleTabTitles(debugMenuEnabled: false) == [ + "通用", + "提供商", + "显示", + "高级", + "关于", + ]) + #expect(AppStrings.tr("System") == "系统") + #expect(AppStrings.tr("Language") == "语言") + #expect(AppStrings.tr("Quit CodexBar") == "退出 CodexBar") + } + case .traditionalChinese: + AppStrings.withTestingLanguage(.traditionalChinese) { + #expect(PreferencesView._test_visibleTabTitles(debugMenuEnabled: false) == [ + "一般", + "供應商", + "顯示", + "進階", + "關於", + ]) + #expect(AppStrings.tr("System") == "系統") + #expect(AppStrings.tr("Language") == "語言") + #expect(AppStrings.tr("Quit CodexBar") == "結束 CodexBar") + } + } + } + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 08ba985b4..bea007d74 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct ProvidersPaneCoverageTests { @Test func `exercises providers pane views`() { @@ -25,8 +26,8 @@ struct ProvidersPaneCoverageTests { MenuBarMetricPreference.primary.rawValue, ]) #expect(picker?.options.map(\.title) == [ - "Automatic", - "Primary (API key limit)", + AppStrings.tr("Automatic"), + AppStrings.tr("Primary (API key limit)"), ]) } @@ -34,7 +35,7 @@ struct ProvidersPaneCoverageTests { func `provider detail plan row formats open router as balance`() { let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") - #expect(row?.label == "Balance") + #expect(row?.label == AppStrings.tr("Balance")) #expect(row?.value == "$4.61") } @@ -42,10 +43,30 @@ struct ProvidersPaneCoverageTests { func `provider detail plan row keeps plan label for non open router`() { let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") - #expect(row?.label == "Plan") + #expect(row?.label == AppStrings.tr("Plan")) #expect(row?.value == "Pro") } + @Test + func `provider subtitle follows selected language`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-language") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + AppStrings.withTestingLanguage(.simplifiedChinese) { + #expect(pane.providerSubtitle(.codex).contains(AppStrings.tr("usage not fetched yet"))) + + store._setErrorForTesting("boom", provider: .codex) + #expect(pane.providerSubtitle(.codex).contains(AppStrings.tr("last fetch failed"))) + + store._setErrorForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + provider: .codex) + #expect(pane.providerSubtitle(.codex).contains(AppStrings.tr("just now"))) + } + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 7fedb5e7d..a4c41fbfc 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -59,6 +59,22 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `app language persists across store reload`() throws { + let suite = "SettingsStoreCoverageTests-app-language" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + first.appLanguage = .traditionalChinese + #expect(defaults.string(forKey: AppLanguage.userDefaultsKey) == AppLanguage.traditionalChinese.rawValue) + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.appLanguage == .traditionalChinese) + #expect(second.appLocale.identifier.hasPrefix("zh-Hant")) + } + @Test func `token account mutations apply side effects`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index a8b1fe705..cd4e17081 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -6,6 +6,63 @@ import Testing @MainActor struct SettingsStoreTests { + @Test + func `default app language is system`() throws { + let suite = "SettingsStoreTests-default-language" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.appLanguage == .system) + } + + @Test + func `persists app language across instances`() throws { + let suite = "SettingsStoreTests-app-language-persist" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + storeA.appLanguage = .traditionalChinese + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.appLanguage == .traditionalChinese) + } + + @Test + func `invalid app language raw value falls back to system`() throws { + let suite = "SettingsStoreTests-app-language-invalid" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("fr", forKey: AppLanguage.userDefaultsKey) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.appLanguage == .system) + } + @Test func `default refresh frequency is five minutes`() throws { let suite = "SettingsStoreTests-default" diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 017e591d0..8a755364c 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct StatusMenuTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false @@ -126,6 +127,41 @@ struct StatusMenuTests { #expect(controller.lastMenuProvider == expectedResolved) } + @Test + func `menu actions follow selected language`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let expectedTitles: [(AppLanguage, String, String, String)] = [ + (.english, "Settings...", "About CodexBar", "Quit"), + (.simplifiedChinese, "设置...", "关于 CodexBar", "退出"), + (.traditionalChinese, "設定...", "關於 CodexBar", "結束"), + ] + + for (language, settingsTitle, aboutTitle, quitTitle) in expectedTitles { + AppStrings.withTestingLanguage(language) { + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let titles = menu.items.map(\.title) + #expect(titles.contains(settingsTitle)) + #expect(titles.contains(aboutTitle)) + #expect(titles.contains(quitTitle)) + } + } + } + @Test func `merged menu refresh uses resolved enabled provider when persisted selection is disabled`() { self.disableMenuCardsForTesting() @@ -406,13 +442,13 @@ struct StatusMenuTests { controller.menuWillOpen(menu) let titles = Set(menu.items.map(\.title)) - #expect(!titles.contains("Add Account...")) - #expect(!titles.contains("Switch Account...")) - #expect(!titles.contains("Usage Dashboard")) - #expect(!titles.contains("Status Page")) - #expect(titles.contains("Settings...")) - #expect(titles.contains("About CodexBar")) - #expect(titles.contains("Quit")) + #expect(!titles.contains(AppStrings.tr("Add Account..."))) + #expect(!titles.contains(AppStrings.tr("Switch Account..."))) + #expect(!titles.contains(AppStrings.tr("Usage Dashboard"))) + #expect(!titles.contains(AppStrings.tr("Status Page"))) + #expect(titles.contains(AppStrings.tr("Settings..."))) + #expect(titles.contains(AppStrings.tr("About CodexBar"))) + #expect(titles.contains(AppStrings.tr("Quit"))) } @Test @@ -496,8 +532,8 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let titles = Set(menu.items.map(\.title)) - #expect(!titles.contains("Credits history")) - #expect(!titles.contains("Usage breakdown")) + #expect(!titles.contains(AppStrings.tr("Credits history"))) + #expect(!titles.contains(AppStrings.tr("Usage breakdown"))) } @Test @@ -883,12 +919,13 @@ extension StatusMenuTests { let ids = self.representedIDs(in: menu) let switcherButtons = self.switcherButtons(in: menu) #expect(switcherButtons.count == store.enabledProvidersForDisplay().count) - #expect(switcherButtons.contains(where: { $0.title == "Overview" }) == false) + #expect(switcherButtons.contains(where: { $0.title == AppStrings.tr("Overview") }) == false) #expect(switcherButtons.contains(where: { $0.state == .on && $0.tag == 0 })) #expect(ids.contains("menuCard")) #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) #expect(ids.contains("overviewEmptyState") == false) - #expect(menu.items.contains(where: { $0.title == "No providers selected for Overview." }) == false) + #expect(menu.items + .contains(where: { $0.title == AppStrings.tr("No providers selected for Overview.") }) == false) } @Test diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 987f2d1e8..ebb03af44 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -3,68 +3,96 @@ import Foundation import Testing @testable import CodexBar +@MainActor +@Suite(.serialized) struct UsagePaceTextTests { @Test func `weekly pace detail provides left right labels`() throws { - let now = Date(timeIntervalSince1970: 0) - let window = RateWindow( - usedPercent: 50, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(4 * 24 * 3600), - resetDescription: nil) - let pace = try #require(UsagePace.weekly(window: window, now: now)) + try AppStrings.withTestingLanguage(.english) { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) - #expect(detail.leftLabel == "7% in deficit") - #expect(detail.rightLabel == "Runs out in 3d") + #expect(detail.leftLabel == "7% in deficit") + #expect(detail.rightLabel == "Runs out in 3d") + } } @Test func `weekly pace detail reports lasts until reset`() throws { - let now = Date(timeIntervalSince1970: 0) - let window = RateWindow( - usedPercent: 10, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(4 * 24 * 3600), - resetDescription: nil) - let pace = try #require(UsagePace.weekly(window: window, now: now)) + try AppStrings.withTestingLanguage(.english) { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) - #expect(detail.leftLabel == "33% in reserve") - #expect(detail.rightLabel == "Lasts until reset") + #expect(detail.leftLabel == "33% in reserve") + #expect(detail.rightLabel == "Lasts until reset") + } } @Test func `weekly pace summary formats single line text`() throws { - let now = Date(timeIntervalSince1970: 0) - let window = RateWindow( - usedPercent: 50, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(4 * 24 * 3600), - resetDescription: nil) - let pace = try #require(UsagePace.weekly(window: window, now: now)) + try AppStrings.withTestingLanguage(.english) { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) - let summary = UsagePaceText.weeklySummary(pace: pace, now: now) + let summary = UsagePaceText.weeklySummary(pace: pace, now: now) - #expect(summary == "Pace: 7% in deficit · Runs out in 3d") + #expect(summary == "Pace: 7% in deficit · Runs out in 3d") + } } @Test func `weekly pace detail formats rounded risk when available`() { - let now = Date(timeIntervalSince1970: 0) - let pace = UsagePace( - stage: .ahead, - deltaPercent: 8, - expectedUsedPercent: 42, - actualUsedPercent: 50, - etaSeconds: 2 * 24 * 3600, - willLastToReset: false, - runOutProbability: 0.683) + AppStrings.withTestingLanguage(.english) { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .ahead, + deltaPercent: 8, + expectedUsedPercent: 42, + actualUsedPercent: 50, + etaSeconds: 2 * 24 * 3600, + willLastToReset: false, + runOutProbability: 0.683) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) - #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") + #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") + } + } + + @Test + func `weekly pace detail localizes for simplified chinese`() throws { + try AppStrings.withTestingLanguage(.simplifiedChinese) { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + + #expect(detail.leftLabel == "预留 33%") + #expect(detail.rightLabel == "可撑到重置") + } } }