From 7214ede5eb83400a4a08a6b85f0d7d4177d2b241 Mon Sep 17 00:00:00 2001 From: ShawnRn Date: Mon, 16 Feb 2026 21:01:52 +0800 Subject: [PATCH] Complete zh-Hans localization coverage --- Package.swift | 1 + .../CodexBar/CostHistoryChartMenuView.swift | 12 +- .../CodexBar/Date+RelativeDescription.swift | 2 +- Sources/CodexBar/Localization.swift | 12 + Sources/CodexBar/MenuBarDisplayMode.swift | 12 +- Sources/CodexBar/MenuCardView.swift | 74 +++-- Sources/CodexBar/MenuContent.swift | 10 +- Sources/CodexBar/MenuDescriptor.swift | 34 +- ...penAICreditsPurchaseWindowController.swift | 2 +- Sources/CodexBar/PreferencesAboutPane.swift | 16 +- .../CodexBar/PreferencesAdvancedPane.swift | 49 ++- Sources/CodexBar/PreferencesComponents.swift | 10 +- Sources/CodexBar/PreferencesDisplayPane.swift | 42 +-- Sources/CodexBar/PreferencesGeneralPane.swift | 45 ++- .../PreferencesProviderDetailView.swift | 54 ++-- .../PreferencesProviderErrorView.swift | 4 +- .../PreferencesProviderSettingsRows.swift | 46 +-- .../PreferencesProviderSidebarView.swift | 8 +- .../CodexBar/PreferencesProvidersPane.swift | 21 +- Sources/CodexBar/PreferencesView.swift | 12 +- .../AugmentProviderImplementation.swift | 4 +- .../Claude/ClaudeProviderImplementation.swift | 2 +- .../Codex/CodexProviderImplementation.swift | 4 +- .../CopilotProviderImplementation.swift | 2 +- .../Cursor/CursorProviderImplementation.swift | 2 +- .../Kimi/KimiProviderImplementation.swift | 2 +- .../OpenCodeProviderImplementation.swift | 2 +- .../Shared/ProviderPresentation.swift | 2 +- .../SyntheticProviderImplementation.swift | 2 +- .../Zai/ZaiProviderImplementation.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 291 ++++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 291 ++++++++++++++++++ .../CodexBar/SessionQuotaNotifications.swift | 8 +- Sources/CodexBar/SettingsStore.swift | 20 +- .../StatusItemController+Actions.swift | 4 +- .../CodexBar/StatusItemController+Menu.swift | 20 +- Sources/CodexBar/UpdateChannel.swift | 8 +- Sources/CodexBar/UsagePaceText.swift | 18 +- .../Providers/Cursor/CursorStatusProbe.swift | 6 +- .../Factory/FactoryStatusProbe.swift | 6 +- .../Providers/Gemini/GeminiStatusProbe.swift | 14 +- .../JetBrains/JetBrainsStatusProbe.swift | 18 +- Sources/CodexBarCore/UsageFetcher.swift | 6 +- Sources/CodexBarCore/UsageFormatter.swift | 50 +-- 44 files changed, 924 insertions(+), 326 deletions(-) create mode 100644 Sources/CodexBar/Localization.swift create mode 100644 Sources/CodexBar/Resources/en.lproj/Localizable.strings create mode 100644 Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings diff --git a/Package.swift b/Package.swift index 83cbfca65..ad0c827e4 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), ], diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef..3fa5d0b71 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(L10n.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(L10n.format("Total (30d): %@", UsageFormatter.usdString(total))) .font(.caption) .foregroundStyle(.secondary) } @@ -291,17 +291,17 @@ struct CostHistoryChartMenuView: View { let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (L10n.tr("Hover a bar for details"), nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + let primary = L10n.format("%@: %@ · %@ tokens", dayLabel, cost, UsageFormatter.tokenCountString(tokens)) let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } - let primary = "\(dayLabel): \(cost)" + let primary = L10n.format("%@: %@", dayLabel, cost) let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } @@ -321,6 +321,6 @@ struct CostHistoryChartMenuView: View { .prefix(3) .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + return L10n.format("Top: %@", parts.joined(separator: " · ")) } } diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index 7356f9671..241379756 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -14,7 +14,7 @@ extension Date { func relativeDescription(now: Date = .now) -> String { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { - return "just now" + return L10n.tr("just now") } return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) } diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift new file mode 100644 index 000000000..8295f082d --- /dev/null +++ b/Sources/CodexBar/Localization.swift @@ -0,0 +1,12 @@ +import Foundation + +enum L10n { + static func tr(_ key: String) -> String { + NSLocalizedString(key, bundle: .module, comment: "") + } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + let format = Self.tr(key) + return String(format: format, locale: .current, arguments: arguments) + } +} diff --git a/Sources/CodexBar/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift index 8daa30ccf..347e9147f 100644 --- a/Sources/CodexBar/MenuBarDisplayMode.swift +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -12,17 +12,17 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { var label: String { switch self { - case .percent: "Percent" - case .pace: "Pace" - case .both: "Both" + case .percent: L10n.tr("Percent") + case .pace: L10n.tr("Pace") + case .both: L10n.tr("Both") } } 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%)" + case .percent: L10n.tr("Show remaining/used percentage (e.g. 45%)") + case .pace: L10n.tr("Show pace indicator (e.g. +5%)") + case .both: L10n.tr("Show both percentage and pace (e.g. 45% · +5%)") } } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 89a930dc1..9eb23e6ba 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: L10n.tr("left") + case .used: L10n.tr("used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: L10n.tr("Usage remaining") + case .used: L10n.tr("Usage used") } } } @@ -37,7 +37,13 @@ struct UsageMenuCardView: View { let paceOnTop: Bool var percentLabel: String { - String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) + let percentText = String(format: "%.0f%%", self.percent) + switch self.percentStyle { + case .left: + return L10n.format("%@ left", percentText) + case .used: + return L10n.format("%@ used", percentText) + } } } @@ -135,7 +141,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -267,7 +273,7 @@ private struct CopyIconButton: View { .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) - .accessibilityLabel(self.didCopy ? "Copied" : "Copy error") + .accessibilityLabel(self.didCopy ? L10n.tr("Copied") : L10n.tr("Copy error")) } private func copyToPasteboard() { @@ -290,12 +296,12 @@ private struct ProviderCostContent: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Extra usage spent") + accessibilityLabel: L10n.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(L10n.format("%d%% used", Int(min(100, max(0, self.section.percentUsed)).rounded()))) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } @@ -460,19 +466,19 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + return L10n.format("%@ tokens", scale) } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(L10n.tr("Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: L10n.tr("Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -513,7 +519,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -621,7 +627,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 + ? L10n.tr("No usage yet") + : nil return UsageMenuCardView.Model( providerName: input.metadata.displayName, @@ -687,14 +695,14 @@ extension UsageMenuCardView.Model { } if isRefreshing, snapshot == nil { - return ("Refreshing...", .loading) + return (L10n.tr("Refreshing..."), .loading) } if let updated = snapshot?.updatedAt { return (UsageFormatter.updatedString(from: updated), .info) } - return ("Not fetched yet", .info) + return (L10n.tr("Not fetched yet"), .info) } private struct RedactedText { @@ -756,7 +764,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "primary", - title: input.metadata.sessionLabel, + title: L10n.tr(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -784,7 +792,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: L10n.tr(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -797,7 +805,7 @@ extension UsageMenuCardView.Model { if input.metadata.supportsOpus, let opus = snapshot.tertiary { metrics.append(Metric( id: "tertiary", - title: input.metadata.opusLabel ?? "Sonnet", + title: L10n.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), @@ -812,7 +820,7 @@ extension UsageMenuCardView.Model { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", - title: "Code review", + title: L10n.tr("Code review"), percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, @@ -835,7 +843,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 L10n.format("%@ / %@ (%@ remaining)", currentStr, usageStr, remainingStr) } return nil @@ -881,13 +889,13 @@ extension UsageMenuCardView.Model { if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } - return metadata.creditsHint + return L10n.tr(metadata.creditsHint) } 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 L10n.tr(error) } private static func tokenUsageSection( @@ -900,24 +908,24 @@ extension UsageMenuCardView.Model { guard enabled else { return nil } guard let snapshot else { return nil } - let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? L10n.tr("—") let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + return L10n.format("Today: %@ · %@ tokens", sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + return L10n.format("Today: %@", sessionCost) }() - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? L10n.tr("—") let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + return L10n.format("Last 30 days: %@ · %@ tokens", monthCost, monthTokens) } - return "Last 30 days: \(monthCost)" + return L10n.format("Last 30 days: %@", monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -940,22 +948,22 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = L10n.tr("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = L10n.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 = cost.period.map(L10n.tr) ?? L10n.tr("This month") return ProviderCostSection( title: title, percentUsed: percentUsed, - spendLine: "\(periodLabel): \(used) / \(limit)") + spendLine: L10n.format("%@: %@ / %@", periodLabel, used, limit)) } private static func clamped(_ value: Double) -> Double { diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index fa41695f5..78d09910d 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -42,11 +42,11 @@ struct MenuContent: View { case let .text(text, style): switch style { case .headline: - Text(text).font(.headline) + Text(L10n.tr(text)).font(.headline) case .primary: - Text(text) + Text(L10n.tr(text)) case .secondary: - Text(text).foregroundStyle(.secondary).font(.footnote) + Text(L10n.tr(text)).foregroundStyle(.secondary).font(.footnote) } case let .action(title, action): Button { @@ -57,11 +57,11 @@ struct MenuContent: View { Image(systemName: icon) .imageScale(.medium) .frame(width: 18, alignment: .center) - Text(title) + Text(L10n.tr(title)) } .foregroundStyle(.primary) } else { - Text(title) + Text(L10n.tr(title)) } } .buttonStyle(.plain) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 495cbd7ef..a1ba02b85 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -86,7 +86,7 @@ struct MenuDescriptor { sections.append(accountSection) } } else { - sections.append(Section(entries: [.text("No usage configured.", .secondary)])) + sections.append(Section(entries: [.text(L10n.tr("No usage configured."), .secondary)])) } } @@ -128,7 +128,7 @@ struct MenuDescriptor { } Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: L10n.tr(meta.sessionLabel), window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -147,7 +147,7 @@ struct MenuDescriptor { }() Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: L10n.tr(meta.weeklyLabel), window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, @@ -159,7 +159,7 @@ struct MenuDescriptor { if meta.supportsOpus, let opus = snap.tertiary { Self.appendRateWindow( entries: &entries, - title: meta.opusLabel ?? "Sonnet", + title: meta.opusLabel ?? L10n.tr("Sonnet"), window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -169,11 +169,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(L10n.format("Quota: %@ / %@", used, limit), .primary)) } } } else { - entries.append(.text("No usage yet", .secondary)) + entries.append(.text(L10n.tr("No usage yet"), .secondary)) } let usageContext = ProviderMenuUsageContext( @@ -221,19 +221,19 @@ struct MenuDescriptor { let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) if let emailText, !emailText.isEmpty { - entries.append(.text("Account: \(redactedEmail)", .secondary)) + entries.append(.text(L10n.format("Account: %@", redactedEmail), .secondary)) } if let planText, !planText.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(planText))", .secondary)) + entries.append(.text(L10n.format("Plan: %@", AccountFormatter.plan(planText)), .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(L10n.format("Account: %@", redacted), .secondary)) } if planText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan))", .secondary)) + entries.append(.text(L10n.format("Plan: %@", AccountFormatter.plan(fallbackPlan)), .secondary)) } } @@ -281,7 +281,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 ? L10n.tr("Switch Account...") : L10n.tr("Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -297,10 +297,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L10n.tr("Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L10n.tr("Status Page"), .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -313,12 +313,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(L10n.tr("Update ready, restart now?"), .installUpdate)) } entries.append(contentsOf: [ - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L10n.tr("Settings..."), .settings), + .action(L10n.tr("About CodexBar"), .about), + .action(L10n.tr("Quit"), .quit), ]) return Section(entries: entries) } diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift index 99ec8eef6..c58c388ac 100644 --- a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift +++ b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift @@ -420,7 +420,7 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - window.title = "Buy Credits" + window.title = L10n.tr("Buy Credits") window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] window.contentView = container diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 16e27189e..94e30f046 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -51,14 +51,14 @@ struct AboutPane: View { VStack(spacing: 2) { Text("CodexBar") .font(.title3).bold() - Text("Version \(self.versionString)") + Text(L10n.format("Version %@", self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(L10n.format("Built %@", buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(L10n.tr("May your tokens never run out—keep agent limits in view.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -80,12 +80,12 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle(L10n.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(L10n.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(L10n.tr("Check for Updates…")) { self.updater.checkForUpdates(nil) } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? L10n.tr("Updates unavailable in this build.")) .foregroundStyle(.secondary) } - Text("© 2025 Peter Steinberger. MIT License.") + Text(L10n.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..3fadc6759 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(L10n.tr("Keyboard shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(L10n.tr("Open menu")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(L10n.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(L10n.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(L10n.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: L10n.tr("Show Debug Settings"), + subtitle: L10n.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: L10n.tr("Surprise me"), + subtitle: L10n.tr("Check if you like your agents having some fun up there."), binding: self.$settings.randomBlinkEnabled) } @@ -70,22 +70,21 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Hide personal information", - subtitle: "Obscure email addresses in the menu bar and menu UI.", + title: L10n.tr("Hide personal information"), + subtitle: L10n.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. - """) { + title: L10n.tr("Keychain access"), + caption: L10n.tr( + "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.", + title: L10n.tr("Disable Keychain access"), + subtitle: L10n.tr("Prevents any Keychain access while enabled."), binding: self.$settings.debugDisableKeychainAccess) } } @@ -105,7 +104,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 = L10n.tr("CodexBarCLI not found in app bundle.") return } @@ -119,29 +118,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(L10n.format("No write access: %@", dir)) continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + results.append(L10n.format("Installed: %@", dir)) } else { - results.append("Exists: \(dir)") + results.append(L10n.format("Exists: %@", dir)) } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + results.append(L10n.format("Installed: %@", dir)) } catch { - results.append("Failed: \(dir)") + results.append(L10n.format("Failed: %@", dir)) } } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? L10n.tr("No writable bin dirs found.") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesComponents.swift b/Sources/CodexBar/PreferencesComponents.swift index d0fb56a0d..fdd25d1ab 100644 --- a/Sources/CodexBar/PreferencesComponents.swift +++ b/Sources/CodexBar/PreferencesComponents.swift @@ -10,13 +10,13 @@ struct PreferenceToggleRow: View { var body: some View { VStack(alignment: .leading, spacing: 5.4) { Toggle(isOn: self.$binding) { - Text(self.title) + Text(L10n.tr(self.title)) .font(.body) } .toggleStyle(.checkbox) if let subtitle, !subtitle.isEmpty { - Text(subtitle) + Text(L10n.tr(subtitle)) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -47,11 +47,11 @@ struct SettingsSection: View { var body: some View { VStack(alignment: .leading, spacing: 10) { if let title, !title.isEmpty { - Text(title) + Text(L10n.tr(title)) .font(.subheadline.weight(.semibold)) } if let caption { - Text(caption) + Text(L10n.tr(caption)) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -77,7 +77,7 @@ struct AboutLinkRow: View { } label: { HStack(spacing: 8) { Image(systemName: self.icon) - Text(self.title) + Text(L10n.tr(self.title)) .underline(self.hovering, color: .accentColor) } .frame(maxWidth: .infinity) diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27ee..f2d672b9d 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -8,40 +8,40 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + Text(L10n.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: L10n.tr("Merge Icons"), + subtitle: L10n.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: L10n.tr("Switcher shows icons"), + subtitle: L10n.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: L10n.tr("Show most-used provider"), + subtitle: L10n.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: L10n.tr("Menu bar shows percent"), + subtitle: L10n.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(L10n.tr("Display mode")) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + Text(L10n.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(L10n.tr("Display mode"), selection: self.$settings.menuBarDisplayMode) { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -57,25 +57,25 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + Text(L10n.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: L10n.tr("Show usage as used"), + subtitle: L10n.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: L10n.tr("Show reset time as clock"), + subtitle: L10n.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: L10n.tr("Show credits + extra usage"), + subtitle: L10n.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: L10n.tr("Show all token accounts"), + subtitle: L10n.tr("Stack token accounts in the menu (otherwise show an account switcher bar)."), binding: self.$settings.showAllTokenAccountsInMenu) } } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..d5707a21a 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,20 +11,20 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(L10n.tr("System")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: L10n.tr("Start at Login"), + subtitle: L10n.tr("Automatically opens CodexBar when you start your Mac."), binding: self.$settings.launchAtLogin) } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(L10n.tr("Usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +32,18 @@ struct GeneralPane: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { - Text("Show cost summary") + Text(L10n.tr("Show cost summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(L10n.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(L10n.tr("Auto-refresh: hourly · Timeout: 10m")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,21 +57,21 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(L10n.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(L10n.tr("Refresh cadence")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(L10n.tr("How often CodexBar polls providers in the background.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker(L10n.tr("Refresh cadence"), selection: self.$settings.refreshFrequency) { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -81,20 +81,19 @@ struct GeneralPane: View { .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { - Text("Auto-refresh is off; use the menu's Refresh command.") + Text(L10n.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: L10n.tr("Check provider status"), + subtitle: L10n.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: L10n.tr("Session quota notifications"), + subtitle: L10n.tr("Notifies when the 5-hour session quota hits 0% and when it becomes available again."), binding: self.$settings.sessionQuotaNotificationsEnabled) } @@ -103,7 +102,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(L10n.tr("Quit CodexBar")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +118,7 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + return Text("\(name): \(L10n.tr("unsupported"))") .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,7 +132,7 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + return Text("\(name): \(L10n.tr("fetching"))…\(elapsed)") .font(.footnote) .foregroundStyle(.tertiary) } @@ -154,11 +153,11 @@ struct GeneralPane: View { let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + return Text("\(name): \(L10n.format("last attempt %@", when))") .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + return Text("\(name): \(L10n.tr("no data yet"))") .font(.footnote) .foregroundStyle(.tertiary) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f8b843240..8234b62da 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -38,14 +38,14 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: L10n.format("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: L10n.tr("Settings")) { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } @@ -61,7 +61,7 @@ struct ProviderDetailView: View { } if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: "Options") { + ProviderSettingsSection(title: L10n.tr("Options")) { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } @@ -82,26 +82,26 @@ struct ProviderDetailView: View { } private var detailLabelWidth: CGFloat { - var infoLabels = ["State", "Source", "Version", "Updated"] + var infoLabels = [L10n.tr("State"), L10n.tr("Source"), L10n.tr("Version"), L10n.tr("Updated")] if self.store.status(for: self.provider) != nil { - infoLabels.append("Status") + infoLabels.append(L10n.tr("Status")) } if !self.model.email.isEmpty { - infoLabels.append("Account") + infoLabels.append(L10n.tr("Account")) } if let plan = self.model.planText, !plan.isEmpty { - infoLabels.append("Plan") + infoLabels.append(L10n.tr("Plan")) } var metricLabels = self.model.metrics.map(\.title) if self.model.creditsText != nil { - metricLabels.append("Credits") + metricLabels.append(L10n.tr("Credits")) } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { - metricLabels.append("Cost") + metricLabels.append(L10n.tr("Cost")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -147,7 +147,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(L10n.tr("Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -206,32 +206,32 @@ 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 = L10n.tr(self.store.sourceLabel(for: self.provider)) + let version = self.store.version(for: self.provider) ?? L10n.tr("not detected") let updated = self.updatedText let email = self.model.email let plan = self.model.planText ?? "" - let enabledText = self.isEnabled ? "Enabled" : "Disabled" + let enabledText = self.isEnabled ? L10n.tr("Enabled") : L10n.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: L10n.tr("State"), value: enabledText, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: L10n.tr("Source"), value: source, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: L10n.tr("Version"), value: version, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: L10n.tr("Updated"), value: updated, labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( - label: "Status", + label: L10n.tr("Status"), value: status.description ?? status.indicator.label, labelWidth: self.labelWidth) } if !email.isEmpty { - ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: L10n.tr("Account"), value: email, labelWidth: self.labelWidth) } if !plan.isEmpty { - ProviderDetailInfoRow(label: "Plan", value: plan, labelWidth: self.labelWidth) + ProviderDetailInfoRow(label: L10n.tr("Plan"), value: plan, labelWidth: self.labelWidth) } } .font(.footnote) @@ -243,9 +243,9 @@ private struct ProviderDetailInfoGrid: View { return UsageFormatter.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { - return "Refreshing" + return L10n.tr("Refreshing") } - return "Not fetched yet" + return L10n.tr("Not fetched yet") } } @@ -273,7 +273,7 @@ struct ProviderMetricsInlineView: View { var body: some View { ProviderSettingsSection( - title: "Usage", + title: L10n.tr("Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -294,7 +294,7 @@ struct ProviderMetricsInlineView: View { if let credits = self.model.creditsText { ProviderMetricInlineTextRow( - title: "Credits", + title: L10n.tr("Credits"), value: credits, labelWidth: self.labelWidth) } @@ -308,7 +308,7 @@ struct ProviderMetricsInlineView: View { if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( - title: "Cost", + title: L10n.tr("Cost"), value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( @@ -322,9 +322,9 @@ struct ProviderMetricsInlineView: View { private var placeholderText: String { if !self.isEnabled { - return "Disabled — no recent data" + return L10n.tr("Disabled — no recent data") } - return self.model.placeholder ?? "No usage yet" + return self.model.placeholder ?? L10n.tr("No usage yet") } } diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 55d45fcbb..b0163b86a 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(L10n.tr("Copy error")) } Text(self.display.preview) @@ -36,7 +36,7 @@ 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() } + Button(self.isExpanded ? L10n.tr("Hide details") : L10n.tr("Show details")) { self.isExpanded.toggle() } .buttonStyle(.link) .font(.footnote) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..edaf45174 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -23,7 +23,7 @@ struct ProviderSettingsSection: View { var body: some View { VStack(alignment: .leading, spacing: self.spacing) { - Text(self.title) + Text(L10n.tr(self.title)) .font(.headline) self.content() } @@ -41,9 +41,9 @@ struct ProviderSettingsToggleRowView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text(self.toggle.title) + Text(L10n.tr(self.toggle.title)) .font(.subheadline.weight(.semibold)) - Text(self.toggle.subtitle) + Text(L10n.tr(self.toggle.subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -56,7 +56,7 @@ struct ProviderSettingsToggleRowView: View { if self.toggle.binding.wrappedValue { if let status = self.toggle.statusText?(), !status.isEmpty { - Text(status) + Text(L10n.tr(status)) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(4) @@ -67,7 +67,7 @@ struct ProviderSettingsToggleRowView: View { if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in - Button(action.title) { + Button(L10n.tr(action.title)) { Task { @MainActor in await action.perform() } @@ -101,13 +101,13 @@ struct ProviderSettingsPickerRowView: View { let isEnabled = self.picker.isEnabled?() ?? true VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(self.picker.title) + Text(L10n.tr(self.picker.title)) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) Picker("", selection: self.picker.binding) { ForEach(self.picker.options) { option in - Text(option.title).tag(option.id) + Text(L10n.tr(option.title)).tag(option.id) } } .labelsHidden() @@ -115,7 +115,7 @@ struct ProviderSettingsPickerRowView: View { .controlSize(.small) if let trailingText = self.picker.trailingText?(), !trailingText.isEmpty { - Text(trailingText) + Text(L10n.tr(trailingText)) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) @@ -128,7 +128,7 @@ struct ProviderSettingsPickerRowView: View { let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle if !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(subtitle) + Text(L10n.tr(subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -157,11 +157,11 @@ struct ProviderSettingsFieldRowView: View { if hasHeader { VStack(alignment: .leading, spacing: 4) { if !trimmedTitle.isEmpty { - Text(trimmedTitle) + Text(L10n.tr(trimmedTitle)) .font(.subheadline.weight(.semibold)) } if !trimmedSubtitle.isEmpty { - Text(trimmedSubtitle) + Text(L10n.tr(trimmedSubtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -171,12 +171,12 @@ struct ProviderSettingsFieldRowView: View { switch self.field.kind { case .plain: - TextField(self.field.placeholder ?? "", text: self.field.binding) + TextField(self.field.placeholder.map { L10n.tr($0) } ?? "", text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } case .secure: - SecureField(self.field.placeholder ?? "", text: self.field.binding) + SecureField(self.field.placeholder.map { L10n.tr($0) } ?? "", text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } @@ -186,7 +186,7 @@ struct ProviderSettingsFieldRowView: View { if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in - Button(action.title) { + Button(L10n.tr(action.title)) { Task { @MainActor in await action.perform() } @@ -208,11 +208,11 @@ struct ProviderSettingsTokenAccountsRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) + Text(L10n.tr(self.descriptor.title)) .font(.subheadline.weight(.semibold)) if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(self.descriptor.subtitle) + Text(L10n.tr(self.descriptor.subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -220,7 +220,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(L10n.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(L10n.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(L10n.tr("Label"), text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) + SecureField(L10n.tr(self.descriptor.placeholder), text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(L10n.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(L10n.tr("Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(L10n.tr("Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7..af84f6ec9 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(L10n.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 "\(L10n.format("Disabled — %@", String(first)))\n\(rest)" } - return "Disabled — \(self.subtitle)" + return L10n.format("Disabled — %@", self.subtitle) } } @@ -135,7 +135,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(L10n.tr("Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c08711077..6a7595ec3 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -50,7 +50,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(L10n.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(L10n.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 = L10n.tr("last fetch failed") } else { - usageText = "usage not fetched yet" + usageText = L10n.tr("usage not fetched yet") } let presentationContext = ProviderPresentationContext( @@ -267,23 +267,24 @@ struct ProvidersPane: View { let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L10n.tr("Automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: "\(L10n.tr("Primary")) (\(L10n.tr(metadata.sessionLabel)))"), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: "\(L10n.tr("Secondary")) (\(L10n.tr(metadata.weeklyLabel)))"), ] if supportsAverage { options.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: + "\(L10n.tr("Average")) (\(L10n.tr(metadata.sessionLabel)) + \(L10n.tr(metadata.weeklyLabel)))")) } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: L10n.tr("Menu bar metric"), + subtitle: L10n.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 39413302c..1a09f0f08 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -34,28 +34,28 @@ 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(L10n.tr("General"), systemImage: "gearshape") } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + .tabItem { Label(L10n.tr("Providers"), systemImage: "square.grid.2x2") } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { Label(L10n.tr("Display"), systemImage: "eye") } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { Label(L10n.tr("Advanced"), systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { Label(L10n.tr("About"), systemImage: "info.circle") } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) - .tabItem { Label("Debug", systemImage: "ladybug") } + .tabItem { Label(L10n.tr("Debug"), systemImage: "ladybug") } .tag(PreferencesTab.debug) } } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift index c1529bd58..2cf56214c 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift @@ -83,14 +83,14 @@ struct AugmentProviderImplementation: ProviderImplementation { @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) { - entries.append(.action("Refresh Session", .refreshAugmentSession)) + entries.append(.action(L10n.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)", + L10n.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 956bd8f08..ae71fb1c0 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -10,7 +10,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in - var versionText = context.store.version(for: context.provider) ?? "not detected" + var versionText = context.store.version(for: context.provider) ?? L10n.tr("not detected") if let parenRange = versionText.range(of: "(") { versionText = versionText[.. ProviderPresentation { ProviderPresentation { context in - context.store.version(for: context.provider) ?? "not detected" + context.store.version(for: context.provider) ?? L10n.tr("not detected") } } @@ -176,7 +176,7 @@ struct CodexProviderImplementation: ProviderImplementation { } } else { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint - entries.append(.text(hint, .secondary)) + entries.append(.text(L10n.tr(hint), .secondary)) } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f..fa3150fa7 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 L10n.tr("github api") } } @MainActor diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 48db614f9..eb497e4f3 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 L10n.tr("web") } } @MainActor diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index d48511963..677cd76c4 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 L10n.tr("web") } } @MainActor diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift index 5e069f0a1..def23d656 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 L10n.tr("web") } } @MainActor diff --git a/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift b/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift index 70fe3504f..9abcd3f4b 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift @@ -6,7 +6,7 @@ struct ProviderPresentation { @MainActor static func standardDetailLine(context: ProviderPresentationContext) -> String { - let versionText = context.store.version(for: context.provider) ?? "not detected" + let versionText = context.store.version(for: context.provider) ?? L10n.tr("not detected") return "\(context.metadata.cliName) \(versionText)" } } diff --git a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift index dcef3eb67..ab1b9770f 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 L10n.tr("api") } } @MainActor diff --git a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift index d4bc64a9f..8407b8a61 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 L10n.tr("api") } } @MainActor diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..8a95fe526 --- /dev/null +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -0,0 +1,291 @@ +"General" = "General"; +"Providers" = "Providers"; +"Display" = "Display"; +"Advanced" = "Advanced"; +"About" = "About"; +"Debug" = "Debug"; +"System" = "System"; +"Start at Login" = "Start at Login"; +"Automatically opens CodexBar when you start your Mac." = "Automatically opens CodexBar when you start your Mac."; +"Usage" = "Usage"; +"Show cost summary" = "Show cost summary"; +"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."; +"Auto-refresh: hourly · Timeout: 10m" = "Auto-refresh: hourly · Timeout: 10m"; +"unsupported" = "unsupported"; +"fetching" = "fetching"; +"last attempt %@" = "last attempt %@"; +"no data yet" = "no data yet"; +"Automation" = "Automation"; +"Refresh cadence" = "Refresh cadence"; +"How often CodexBar polls providers in the background." = "How often CodexBar polls providers in the background."; +"Auto-refresh is off; use the menu's Refresh command." = "Auto-refresh is off; use the menu's Refresh command."; +"Check provider status" = "Check provider status"; +"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."; +"Session quota notifications" = "Session quota notifications"; +"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."; +"Quit CodexBar" = "Quit CodexBar"; +"Menu bar" = "Menu bar"; +"Merge Icons" = "Merge Icons"; +"Use a single menu bar icon with a provider switcher." = "Use a single menu bar icon with a provider switcher."; +"Switcher shows icons" = "Switcher shows icons"; +"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 most-used provider" = "Show most-used provider"; +"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"; +"Replace critter bars with provider branding icons and a percentage." = "Replace critter bars with provider branding icons and a percentage."; +"Display mode" = "Display mode"; +"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)."; +"Menu content" = "Menu content"; +"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)."; +"Show reset time as clock" = "Show reset time as clock"; +"Display reset times as absolute clock values instead of countdowns." = "Display reset times as absolute clock values instead of countdowns."; +"Show credits + extra usage" = "Show credits + extra usage"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Show Codex Credits and Claude Extra usage sections in the menu."; +"Show all token accounts" = "Show all token accounts"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"Keyboard shortcut" = "Keyboard shortcut"; +"Open menu" = "Open menu"; +"Trigger the menu bar menu from anywhere." = "Trigger the menu bar menu from anywhere."; +"Install CLI" = "Install CLI"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"Show Debug Settings" = "Show Debug Settings"; +"Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; +"Surprise me" = "Surprise me"; +"Check if you like your agents having some fun up there." = "Check if you like your agents having some fun up there."; +"Hide personal information" = "Hide personal information"; +"Obscure email addresses in the menu bar and menu UI." = "Obscure email addresses in the menu bar and menu UI."; +"Keychain access" = "Keychain access"; +"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."; +"Disable Keychain access" = "Disable Keychain access"; +"Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled."; +"CodexBarCLI not found in app bundle." = "CodexBarCLI not found in app bundle."; +"No write access: %@" = "No write access: %@"; +"Installed: %@" = "Installed: %@"; +"Exists: %@" = "Exists: %@"; +"Failed: %@" = "Failed: %@"; +"No writable bin dirs found." = "No writable bin dirs found."; +"Version %@" = "Version %@"; +"Built %@" = "Built %@"; +"May your tokens never run out—keep agent limits in view." = "May your tokens never run out—keep agent limits in view."; +"Check for updates automatically" = "Check for updates automatically"; +"Update Channel" = "Update Channel"; +"Check for Updates…" = "Check for Updates…"; +"Updates unavailable in this build." = "Updates unavailable in this build."; +"© 2025 Peter Steinberger. MIT License." = "© 2025 Peter Steinberger. MIT License."; +"Stable" = "Stable"; +"Beta" = "Beta"; +"Receive only stable, production-ready releases." = "Receive only stable, production-ready releases."; +"Receive stable releases plus beta previews." = "Receive stable releases plus beta previews."; +"Percent" = "Percent"; +"Pace" = "Pace"; +"Both" = "Both"; +"Show remaining/used percentage (e.g. 45%)" = "Show remaining/used percentage (e.g. 45%)"; +"Show pace indicator (e.g. +5%)" = "Show pace indicator (e.g. +5%)"; +"Show both percentage and pace (e.g. 45% · +5%)" = "Show both percentage and pace (e.g. 45% · +5%)"; +"Manual" = "Manual"; +"Automatic" = "Automatic"; +"Primary" = "Primary"; +"Secondary" = "Secondary"; +"Average" = "Average"; +"No usage configured." = "No usage configured."; +"No usage yet" = "No usage yet"; +"Account: %@" = "Account: %@"; +"Plan: %@" = "Plan: %@"; +"Add Account..." = "Add Account..."; +"Switch Account..." = "Switch Account..."; +"Usage Dashboard" = "Usage Dashboard"; +"Status Page" = "Status Page"; +"Update ready, restart now?" = "Update ready, restart now?"; +"Settings..." = "Settings..."; +"About CodexBar" = "About CodexBar"; +"Quit" = "Quit"; +"No token accounts yet." = "No token accounts yet."; +"Remove selected account" = "Remove selected account"; +"Label" = "Label"; +"Add" = "Add"; +"Open token file" = "Open token file"; +"Reload" = "Reload"; +"Settings" = "Settings"; +"Options" = "Options"; +"State" = "State"; +"Source" = "Source"; +"Version" = "Version"; +"Updated" = "Updated"; +"Status" = "Status"; +"Account" = "Account"; +"Plan" = "Plan"; +"Enabled" = "Enabled"; +"Disabled" = "Disabled"; +"not detected" = "not detected"; +"Refreshing" = "Refreshing"; +"Not fetched yet" = "Not fetched yet"; +"Credits" = "Credits"; +"Cost" = "Cost"; +"Disabled — no recent data" = "Disabled — no recent data"; +"Select a provider" = "Select a provider"; +"Cancel" = "Cancel"; +"Copy error" = "Copy error"; +"Hide details" = "Hide details"; +"Show details" = "Show details"; +"Refresh" = "Refresh"; +"Last %@ fetch failed:" = "Last %@ fetch failed:"; +"just now" = "just now"; +"Pace: %@" = "Pace: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"On pace" = "On pace"; +"%d%% in deficit" = "%d%% in deficit"; +"%d%% in reserve" = "%d%% in reserve"; +"Lasts until reset" = "Lasts until reset"; +"Runs out now" = "Runs out now"; +"Runs out in %@" = "Runs out in %@"; +"now" = "now"; +"left" = "left"; +"used" = "used"; +"Usage remaining" = "Usage remaining"; +"Usage used" = "Usage used"; +"Copied" = "Copied"; +"Extra usage spent" = "Extra usage spent"; +"%d%% used" = "%d%% used"; +"last fetch failed" = "last fetch failed"; +"usage not fetched yet" = "usage not fetched yet"; +"Menu bar metric" = "Menu bar metric"; +"Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; +"Sonnet" = "Sonnet"; +"Quota: %@ / %@" = "Quota: %@ / %@"; +"%@ session depleted" = "%@ session depleted"; +"0% left. Will notify when it's available again." = "0% left. Will notify when it's available again."; +"%@ session restored" = "%@ session restored"; +"Session quota is available again." = "Session quota is available again."; +"%@ login successful" = "%@ login successful"; +"You can return to the app; authentication finished." = "You can return to the app; authentication finished."; +"1 min" = "1 min"; +"2 min" = "2 min"; +"5 min" = "5 min"; +"15 min" = "15 min"; +"30 min" = "30 min"; +"Disabled — %@" = "Disabled — %@"; +"Drag to reorder" = "Drag to reorder"; +"Reorder" = "Reorder"; +"api" = "api"; +"web" = "web"; +"github api" = "github api"; +"Cached: %@ • %@" = "Cached: %@ • %@"; +"Credits unavailable; keep Codex running to refresh." = "Credits unavailable; keep Codex running to refresh."; +"Usage source" = "Usage source"; +"OpenAI web extras" = "OpenAI web extras"; +"Show usage breakdown, credits history, and code review via chatgpt.com." = "Show usage breakdown, credits history, and code review via chatgpt.com."; +"OpenAI cookies" = "OpenAI cookies"; +"Automatic imports browser cookies for dashboard extras." = "Automatic imports browser cookies for dashboard extras."; +"Paste a Cookie header from a chatgpt.com request." = "Paste a Cookie header from a chatgpt.com request."; +"Disable OpenAI dashboard cookie usage." = "Disable OpenAI dashboard cookie usage."; +"Cookie source" = "Cookie source"; +"Automatic imports browser cookies." = "Automatic imports browser cookies."; +"Automatic imports browser cookies and local storage tokens." = "Automatic imports browser cookies and local storage tokens."; +"Automatic imports browser cookies and WorkOS tokens." = "Automatic imports browser cookies and WorkOS tokens."; +"Automatic imports browser cookies from opencode.ai." = "Automatic imports browser cookies from opencode.ai."; +"Automatic imports browser cookies for the web API." = "Automatic imports browser cookies for the web API."; +"Automatic imports browser cookies or stored sessions." = "Automatic imports browser cookies or stored sessions."; +"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 captured from the billing page." = "Paste a Cookie header captured from the billing page."; +"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."; +"OpenCode cookies are disabled." = "OpenCode cookies are disabled."; +"Factory cookies are disabled." = "Factory cookies are disabled."; +"MiniMax cookies are disabled." = "MiniMax cookies are disabled."; +"Kimi cookies are disabled." = "Kimi cookies are disabled."; +"Claude cookies are disabled." = "Claude cookies are disabled."; +"Cursor cookies are disabled." = "Cursor cookies are disabled."; +"Augment cookies are disabled." = "Augment cookies are disabled."; +"Amp cookies are disabled." = "Amp cookies are disabled."; +"Ollama cookies are disabled." = "Ollama cookies are disabled."; +"Never prompt" = "Never prompt"; +"Only on user action" = "Only on user action"; +"Always allow prompts" = "Always allow prompts"; +"Keychain prompt policy" = "Keychain prompt policy"; +"Controls whether Claude OAuth may trigger macOS Keychain prompts." = "Controls whether Claude OAuth may trigger macOS Keychain prompts."; +"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."; +"Controls Claude OAuth Keychain prompts. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "Controls Claude OAuth Keychain prompts. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed."; +"Claude cookies" = "Claude cookies"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."; +"API region" = "API region"; +"API key" = "API key"; +"Open API Keys" = "Open API Keys"; +"Open Warp Settings" = "Open Warp Settings"; +"Open Amp Settings" = "Open Amp Settings"; +"Open Ollama Settings" = "Open Ollama Settings"; +"Open Console" = "Open Console"; +"Workspace ID" = "Workspace ID"; +"Optional override if workspace lookup fails." = "Optional override if workspace lookup fails."; +"JetBrains IDE" = "JetBrains IDE"; +"Auto-detect" = "Auto-detect"; +"Select the IDE to monitor" = "Select the IDE to monitor"; +"Custom Path" = "Custom Path"; +"Override auto-detection with a custom IDE base path" = "Override auto-detection with a custom IDE base path"; +"GitHub Login" = "GitHub Login"; +"Requires authentication via GitHub Device Flow." = "Requires authentication via GitHub Device Flow."; +"Sign in via button below" = "Sign in via button below"; +"Sign in with GitHub" = "Sign in with GitHub"; +"Sign in again" = "Sign in again"; +"Menu bar metric" = "Menu bar metric"; +"Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain access is disabled in Advanced, so browser cookie import is unavailable."; +"Auto" = "Auto"; +"Off" = "Off"; +"auto" = "auto"; +"oauth" = "oauth"; +"openai-web" = "openai-web"; +"Updated just now" = "Updated just now"; +"Updated %@" = "Updated %@"; +"Updated %dm ago" = "Updated %dm ago"; +"Updated %dh ago" = "Updated %dh ago"; +"%@ left" = "%@ left"; +"%@ used" = "%@ used"; +"Session" = "Session"; +"Weekly" = "Weekly"; +"Refreshing..." = "Refreshing..."; +"Credits remaining" = "Credits remaining"; +"%@ tokens" = "%@ tokens"; +"Code review" = "Code review"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ remaining)"; +"Today: %@" = "Today: %@"; +"Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; +"Last 30 days: %@" = "Last 30 days: %@"; +"Last 30 days: %@ · %@ tokens" = "Last 30 days: %@ · %@ tokens"; +"Quota usage" = "Quota usage"; +"Extra usage" = "Extra usage"; +"This month" = "This month"; +"%@: %@ / %@" = "%@: %@ / %@"; +"Buy Credits" = "Buy Credits"; +"Buy Credits..." = "Buy Credits..."; +"Credits history" = "Credits history"; +"Usage breakdown" = "Usage breakdown"; +"Usage history (30 days)" = "Usage history (30 days)"; +"No Codex sessions found yet. Run at least one Codex prompt first." = "No Codex sessions found yet. Run at least one Codex prompt first."; +"Found sessions, but no rate limit events yet." = "Found sessions, but no rate limit events yet."; +"Could not parse Codex session log." = "Could not parse Codex session log."; +"in %dd %dh" = "in %dd %dh"; +"in %dd" = "in %dd"; +"in %dh %dm" = "in %dh %dm"; +"in %dh" = "in %dh"; +"in %dm" = "in %dm"; +"tomorrow, %@" = "tomorrow, %@"; +"Resets" = "Resets"; +"Resets %@" = "Resets %@"; +"%@ · %@ · %@ credits" = "%@ · %@ · %@ credits"; +"%@ — %@: %@" = "%@ — %@: %@"; +"Expired" = "Expired"; +"Resets soon" = "Resets soon"; +"Refresh Session" = "Refresh Session"; +"Open Augment (Log Out & Back In)" = "Open Augment (Log Out & Back In)"; +"No cost history data." = "No cost history data."; +"Total (30d): %@" = "Total (30d): %@"; +"Hover a bar for details" = "Hover a bar for details"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@" = "%@: %@"; +"Top: %@" = "Top: %@"; 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..192a4f5ed --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,291 @@ +"General" = "通用"; +"Providers" = "提供商"; +"Display" = "显示"; +"Advanced" = "高级"; +"About" = "关于"; +"Debug" = "调试"; +"System" = "系统"; +"Start at Login" = "开机启动"; +"Automatically opens CodexBar when you start your Mac." = "启动 Mac 时自动打开 CodexBar。"; +"Usage" = "用量"; +"Show cost summary" = "显示成本汇总"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "读取本地用量日志,在菜单中显示今日与近 30 天成本。"; +"Auto-refresh: hourly · Timeout: 10m" = "自动刷新:每小时 · 超时:10 分钟"; +"unsupported" = "不支持"; +"fetching" = "获取中"; +"last attempt %@" = "上次尝试 %@"; +"no data yet" = "暂无数据"; +"Automation" = "自动化"; +"Refresh cadence" = "刷新频率"; +"How often CodexBar polls providers in the background." = "CodexBar 在后台轮询提供商的频率。"; +"Auto-refresh is off; use the menu's Refresh command." = "已关闭自动刷新;请使用菜单中的“刷新”命令。"; +"Check provider status" = "检查提供商状态"; +"Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu." = "轮询 OpenAI/Claude 状态页及 Google Workspace(Gemini/Antigravity),并在图标与菜单中提示故障。"; +"Session quota notifications" = "会话配额通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes available again." = "当 5 小时会话配额降至 0% 及恢复可用时发送通知。"; +"Quit CodexBar" = "退出 CodexBar"; +"Menu bar" = "菜单栏"; +"Merge Icons" = "合并图标"; +"Use a single menu bar icon with a provider switcher." = "使用单个菜单栏图标并提供服务切换器。"; +"Switcher shows icons" = "切换器显示图标"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切换器中显示提供商图标(否则显示每周进度线)。"; +"Show most-used provider" = "显示使用最多的提供商"; +"Menu bar auto-shows the provider closest to its rate limit." = "菜单栏自动显示最接近限额的提供商。"; +"Menu bar shows percent" = "菜单栏显示百分比"; +"Replace critter bars with provider branding icons and a percentage." = "用提供商品牌图标和百分比替代条形图。"; +"Display mode" = "显示模式"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "选择菜单栏显示内容(Pace 显示实际与预期用量差)。"; +"Menu content" = "菜单内容"; +"Show usage as used" = "按已用显示用量"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "进度条随配额消耗填充(而不是显示剩余)。"; +"Show reset time as clock" = "重置时间显示为时钟"; +"Display reset times as absolute clock values instead of countdowns." = "将重置时间显示为绝对时刻而非倒计时。"; +"Show credits + extra usage" = "显示积分与额外用量"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在菜单中显示 Codex 积分与 Claude 额外用量区块。"; +"Show all token accounts" = "显示所有 Token 账号"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在菜单中堆叠显示 Token 账号(否则显示账号切换条)。"; +"Keyboard shortcut" = "快捷键"; +"Open menu" = "打开菜单"; +"Trigger the menu bar menu from anywhere." = "在任意位置触发菜单栏菜单。"; +"Install CLI" = "安装 CLI"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "将 CodexBarCLI 软链接到 /usr/local/bin 和 /opt/homebrew/bin,命名为 codexbar。"; +"Show Debug Settings" = "显示调试设置"; +"Expose troubleshooting tools in the Debug tab." = "在“调试”标签页中显示排障工具。"; +"Surprise me" = "给我点惊喜"; +"Check if you like your agents having some fun up there." = "看看你是否喜欢让代理在上面玩点花样。"; +"Hide personal information" = "隐藏个人信息"; +"Obscure email addresses in the menu bar and menu UI." = "在菜单栏和菜单界面中隐藏邮箱地址。"; +"Keychain access" = "钥匙串访问"; +"Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers." = "禁用所有钥匙串读写。将无法导入浏览器 Cookie;请在“提供商”中手动粘贴 Cookie Header。"; +"Disable Keychain access" = "禁用钥匙串访问"; +"Prevents any Keychain access while enabled." = "启用后禁止任何钥匙串访问。"; +"CodexBarCLI not found in app bundle." = "在应用包中未找到 CodexBarCLI。"; +"No write access: %@" = "无写入权限:%@"; +"Installed: %@" = "已安装:%@"; +"Exists: %@" = "已存在:%@"; +"Failed: %@" = "失败:%@"; +"No writable bin dirs found." = "未找到可写的 bin 目录。"; +"Version %@" = "版本 %@"; +"Built %@" = "构建于 %@"; +"May your tokens never run out—keep agent limits in view." = "愿你的 token 永不见底,代理限额尽在掌握。"; +"Check for updates automatically" = "自动检查更新"; +"Update Channel" = "更新通道"; +"Check for Updates…" = "检查更新…"; +"Updates unavailable in this build." = "此构建版本不支持更新。"; +"© 2025 Peter Steinberger. MIT License." = "© 2025 Peter Steinberger。MIT 许可证。"; +"Stable" = "稳定版"; +"Beta" = "测试版"; +"Receive only stable, production-ready releases." = "仅接收稳定、可用于生产的版本。"; +"Receive stable releases plus beta previews." = "接收稳定版及测试预览版。"; +"Percent" = "百分比"; +"Pace" = "节奏"; +"Both" = "两者"; +"Show remaining/used percentage (e.g. 45%)" = "显示剩余/已用百分比(如 45%)"; +"Show pace indicator (e.g. +5%)" = "显示节奏指示(如 +5%)"; +"Show both percentage and pace (e.g. 45% · +5%)" = "同时显示百分比与节奏(如 45% · +5%)"; +"Manual" = "手动"; +"Automatic" = "自动"; +"Primary" = "主窗口"; +"Secondary" = "次窗口"; +"Average" = "平均"; +"No usage configured." = "尚未配置用量来源。"; +"No usage yet" = "暂无用量"; +"Account: %@" = "账号:%@"; +"Plan: %@" = "套餐:%@"; +"Add Account..." = "添加账号..."; +"Switch Account..." = "切换账号..."; +"Usage Dashboard" = "用量面板"; +"Status Page" = "状态页面"; +"Update ready, restart now?" = "更新已就绪,现在重启吗?"; +"Settings..." = "设置..."; +"About CodexBar" = "关于 CodexBar"; +"Quit" = "退出"; +"No token accounts yet." = "还没有 Token 账号。"; +"Remove selected account" = "删除所选账号"; +"Label" = "标签"; +"Add" = "添加"; +"Open token file" = "打开 Token 文件"; +"Reload" = "重新加载"; +"Settings" = "设置"; +"Options" = "选项"; +"State" = "状态"; +"Source" = "来源"; +"Version" = "版本"; +"Updated" = "更新时间"; +"Status" = "状态"; +"Account" = "账号"; +"Plan" = "套餐"; +"Enabled" = "已启用"; +"Disabled" = "已禁用"; +"not detected" = "未检测到"; +"Refreshing" = "刷新中"; +"Not fetched yet" = "尚未获取"; +"Credits" = "积分"; +"Cost" = "成本"; +"Disabled — no recent data" = "已禁用 — 无近期数据"; +"Select a provider" = "请选择一个提供商"; +"Cancel" = "取消"; +"Copy error" = "复制错误"; +"Hide details" = "隐藏详情"; +"Show details" = "显示详情"; +"Refresh" = "刷新"; +"Last %@ fetch failed:" = "%@ 最近一次拉取失败:"; +"just now" = "刚刚"; +"Pace: %@" = "节奏:%@"; +"Pace: %@ · %@" = "节奏:%@ · %@"; +"On pace" = "节奏正常"; +"%d%% in deficit" = "落后 %d%%"; +"%d%% in reserve" = "富余 %d%%"; +"Lasts until reset" = "可持续至重置"; +"Runs out now" = "现在耗尽"; +"Runs out in %@" = "%@ 后耗尽"; +"now" = "现在"; +"left" = "剩余"; +"used" = "已用"; +"Usage remaining" = "剩余用量"; +"Usage used" = "已用用量"; +"Copied" = "已复制"; +"Extra usage spent" = "额外用量消耗"; +"%d%% used" = "已用 %d%%"; +"last fetch failed" = "最近拉取失败"; +"usage not fetched yet" = "用量尚未获取"; +"Menu bar metric" = "菜单栏指标"; +"Choose which window drives the menu bar percent." = "选择由哪个窗口驱动菜单栏百分比。"; +"Sonnet" = "Sonnet"; +"Quota: %@ / %@" = "配额:%@ / %@"; +"%@ session depleted" = "%@ 配额已耗尽"; +"0% left. Will notify when it's available again." = "剩余 0%,恢复可用时会再次通知。"; +"%@ session restored" = "%@ 会话已恢复"; +"Session quota is available again." = "会话配额已恢复可用。"; +"%@ login successful" = "%@ 登录成功"; +"You can return to the app; authentication finished." = "你可以返回应用,认证已完成。"; +"1 min" = "1 分钟"; +"2 min" = "2 分钟"; +"5 min" = "5 分钟"; +"15 min" = "15 分钟"; +"30 min" = "30 分钟"; +"Disabled — %@" = "已禁用 — %@"; +"Drag to reorder" = "拖动以重新排序"; +"Reorder" = "重新排序"; +"api" = "API"; +"web" = "网页"; +"github api" = "GitHub API"; +"Cached: %@ • %@" = "已缓存:%@ • %@"; +"Credits unavailable; keep Codex running to refresh." = "积分暂不可用;保持 Codex 运行后会自动刷新。"; +"Usage source" = "用量来源"; +"OpenAI web extras" = "OpenAI 网页扩展"; +"Show usage breakdown, credits history, and code review via chatgpt.com." = "通过 chatgpt.com 显示用量拆分、积分历史和代码审查。"; +"OpenAI cookies" = "OpenAI Cookie"; +"Automatic imports browser cookies for dashboard extras." = "自动导入浏览器 Cookie 以获取面板扩展数据。"; +"Paste a Cookie header from a chatgpt.com request." = "粘贴来自 chatgpt.com 请求的 Cookie Header。"; +"Disable OpenAI dashboard cookie usage." = "禁用 OpenAI 面板 Cookie 用法。"; +"Cookie source" = "Cookie 来源"; +"Automatic imports browser cookies." = "自动导入浏览器 Cookie。"; +"Automatic imports browser cookies and local storage tokens." = "自动导入浏览器 Cookie 和本地存储 token。"; +"Automatic imports browser cookies and WorkOS tokens." = "自动导入浏览器 Cookie 和 WorkOS token。"; +"Automatic imports browser cookies from opencode.ai." = "自动导入来自 opencode.ai 的浏览器 Cookie。"; +"Automatic imports browser cookies for the web API." = "自动导入浏览器 Cookie 以访问 Web API。"; +"Automatic imports browser cookies or stored sessions." = "自动导入浏览器 Cookie 或已保存会话。"; +"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 captured from the billing page." = "粘贴从账单页面抓取的 Cookie Header。"; +"Paste a Cookie header or cURL capture from Amp settings." = "粘贴 Cookie Header 或来自 Amp 设置页的 cURL 抓包。"; +"Paste a Cookie header or cURL capture from Ollama settings." = "粘贴 Cookie Header 或来自 Ollama 设置页的 cURL 抓包。"; +"Paste a Cookie header or cURL capture from the Augment dashboard." = "粘贴 Cookie Header 或来自 Augment 面板的 cURL 抓包。"; +"Paste a Cookie header or cURL capture from the Coding Plan page." = "粘贴 Cookie Header 或来自 Coding Plan 页面的 cURL 抓包。"; +"Paste a cookie header or the kimi-auth token value." = "粘贴 Cookie Header 或 kimi-auth token 值。"; +"OpenCode cookies are disabled." = "OpenCode Cookie 已禁用。"; +"Factory cookies are disabled." = "Factory Cookie 已禁用。"; +"MiniMax cookies are disabled." = "MiniMax Cookie 已禁用。"; +"Kimi cookies are disabled." = "Kimi Cookie 已禁用。"; +"Claude cookies are disabled." = "Claude Cookie 已禁用。"; +"Cursor cookies are disabled." = "Cursor Cookie 已禁用。"; +"Augment cookies are disabled." = "Augment Cookie 已禁用。"; +"Amp cookies are disabled." = "Amp Cookie 已禁用。"; +"Ollama cookies are disabled." = "Ollama Cookie 已禁用。"; +"Never prompt" = "从不提示"; +"Only on user action" = "仅在用户操作时提示"; +"Always allow prompts" = "始终允许提示"; +"Keychain prompt policy" = "钥匙串提示策略"; +"Controls whether Claude OAuth may trigger macOS Keychain prompts." = "控制 Claude OAuth 是否可触发 macOS 钥匙串提示。"; +"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "高级设置中已禁用全局钥匙串访问,因此此设置当前不生效。"; +"Controls Claude OAuth Keychain prompts. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "控制 Claude OAuth 的钥匙串提示。选择“从不提示”可能导致 OAuth 不可用;需要时请使用 Web/CLI。"; +"Claude cookies" = "Claude Cookie"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中国大陆节点请使用 BigModel(open.bigmodel.cn)。"; +"API region" = "API 区域"; +"API key" = "API 密钥"; +"Open API Keys" = "打开 API 密钥页"; +"Open Warp Settings" = "打开 Warp 设置"; +"Open Amp Settings" = "打开 Amp 设置"; +"Open Ollama Settings" = "打开 Ollama 设置"; +"Open Console" = "打开控制台"; +"Workspace ID" = "Workspace ID"; +"Optional override if workspace lookup fails." = "当工作区自动识别失败时可手动覆盖。"; +"JetBrains IDE" = "JetBrains IDE"; +"Auto-detect" = "自动检测"; +"Select the IDE to monitor" = "选择要监控的 IDE"; +"Custom Path" = "自定义路径"; +"Override auto-detection with a custom IDE base path" = "使用自定义 IDE 基路径覆盖自动检测"; +"GitHub Login" = "GitHub 登录"; +"Requires authentication via GitHub Device Flow." = "需要通过 GitHub Device Flow 完成认证。"; +"Sign in via button below" = "请使用下方按钮登录"; +"Sign in with GitHub" = "使用 GitHub 登录"; +"Sign in again" = "重新登录"; +"Menu bar metric" = "菜单栏指标"; +"Choose which window drives the menu bar percent." = "选择由哪个窗口驱动菜单栏百分比。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "高级设置中已禁用钥匙串访问,因此无法导入浏览器 Cookie。"; +"Auto" = "自动"; +"Off" = "关闭"; +"auto" = "自动"; +"oauth" = "OAuth"; +"openai-web" = "OpenAI 网页"; +"Updated just now" = "刚刚更新"; +"Updated %@" = "更新于 %@"; +"Updated %dm ago" = "%d 分钟前更新"; +"Updated %dh ago" = "%d 小时前更新"; +"%@ left" = "剩余 %@"; +"%@ used" = "已用 %@"; +"Session" = "会话"; +"Weekly" = "每周"; +"Refreshing..." = "刷新中..."; +"Credits remaining" = "剩余积分"; +"%@ tokens" = "%@ tokens"; +"Code review" = "代码审查"; +"%@ / %@ (%@ remaining)" = "%@ / %@(剩余 %@)"; +"Today: %@" = "今日:%@"; +"Today: %@ · %@ tokens" = "今日:%@ · %@ tokens"; +"Last 30 days: %@" = "近 30 天:%@"; +"Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ tokens"; +"Quota usage" = "配额用量"; +"Extra usage" = "额外用量"; +"This month" = "本月"; +"%@: %@ / %@" = "%@:%@ / %@"; +"Buy Credits" = "购买积分"; +"Buy Credits..." = "购买积分..."; +"Credits history" = "积分历史"; +"Usage breakdown" = "用量拆分"; +"Usage history (30 days)" = "用量历史(30 天)"; +"No Codex sessions found yet. Run at least one Codex prompt first." = "尚未发现 Codex 会话,请先至少运行一次 Codex 提示。"; +"Found sessions, but no rate limit events yet." = "已发现会话,但尚未出现速率限制事件。"; +"Could not parse Codex session log." = "无法解析 Codex 会话日志。"; +"in %dd %dh" = "%d 天 %d 小时后"; +"in %dd" = "%d 天后"; +"in %dh %dm" = "%d 小时 %d 分钟后"; +"in %dh" = "%d 小时后"; +"in %dm" = "%d 分钟后"; +"tomorrow, %@" = "明天 %@"; +"Resets" = "重置"; +"Resets %@" = "将于 %@重置"; +"%@ · %@ · %@ credits" = "%@ · %@ · %@ 积分"; +"%@ — %@: %@" = "%@ — %@:%@"; +"Expired" = "已过期"; +"Resets soon" = "即将重置"; +"Refresh Session" = "刷新会话"; +"Open Augment (Log Out & Back In)" = "打开 Augment(退出并重新登录)"; +"No cost history data." = "暂无成本历史数据。"; +"Total (30d): %@" = "总计(30 天):%@"; +"Hover a bar for details" = "悬停柱状图查看详情"; +"%@: %@ · %@ tokens" = "%@:%@ · %@ tokens"; +"%@: %@" = "%@:%@"; +"Top: %@" = "Top:%@"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 2672a8c01..f9207b8e5 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -44,9 +44,13 @@ final class SessionQuotaNotifier { case .none: ("", "") case .depleted: - ("\(providerName) session depleted", "0% left. Will notify when it's available again.") + ( + L10n.format("%@ session depleted", providerName), + L10n.tr("0% left. Will notify when it's available again.")) case .restored: - ("\(providerName) session restored", "Session quota is available again.") + ( + L10n.format("%@ session restored", providerName), + L10n.tr("Session quota is available again.")) } let providerText = provider.rawValue diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index ecf6bbd6c..7ef0ae581 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -28,12 +28,12 @@ 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" + case .manual: L10n.tr("Manual") + case .oneMinute: L10n.tr("1 min") + case .twoMinutes: L10n.tr("2 min") + case .fiveMinutes: L10n.tr("5 min") + case .fifteenMinutes: L10n.tr("15 min") + case .thirtyMinutes: L10n.tr("30 min") } } } @@ -50,10 +50,10 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { var label: String { switch self { - case .automatic: "Automatic" - case .primary: "Primary" - case .secondary: "Secondary" - case .average: "Average" + case .automatic: L10n.tr("Automatic") + case .primary: L10n.tr("Primary") + case .secondary: L10n.tr("Secondary") + case .average: L10n.tr("Average") } } } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index efe63ffd8..839bae75c 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -314,8 +314,8 @@ extension StatusItemController { func postLoginNotification(for provider: UsageProvider) { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let title = "\(name) login successful" - let body = "You can return to the app; authentication finished." + let title = L10n.format("%@ login successful", name) + let body = L10n.tr("You can return to the app; authentication finished.") AppNotifications.shared.post(idPrefix: "login-\(provider.rawValue)", title: title, body: body) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..be964a1fb 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -369,21 +369,23 @@ extension StatusItemController { for entry in section.entries { switch entry { case let .text(text, style): - let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") + let localizedText = L10n.tr(text) + let item = NSMenuItem(title: localizedText, action: nil, keyEquivalent: "") item.isEnabled = false if style == .headline { let font = NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) - item.attributedTitle = NSAttributedString(string: text, attributes: [.font: font]) + item.attributedTitle = NSAttributedString(string: localizedText, attributes: [.font: font]) } else if style == .secondary { let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) item.attributedTitle = NSAttributedString( - string: text, + string: localizedText, attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]) } menu.addItem(item) case let .action(title, action): let (selector, represented) = self.selector(for: action) - let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") + let localizedTitle = L10n.tr(title) + let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented if let iconName = action.systemImageName, @@ -397,7 +399,7 @@ extension StatusItemController { let subtitle = self.switchAccountSubtitle(for: targetProvider) { item.isEnabled = false - self.applySubtitle(subtitle, to: item, title: title) + self.applySubtitle(L10n.tr(subtitle), to: item, title: localizedTitle) } menu.addItem(item) case .divider: @@ -924,7 +926,7 @@ extension StatusItemController { } private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem(title: L10n.tr("Buy Credits..."), action: #selector(self.openCreditsPurchase), keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -937,7 +939,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: L10n.tr("Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -947,7 +949,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: L10n.tr("Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -957,7 +959,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: L10n.tr("Usage history (30 days)"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) diff --git a/Sources/CodexBar/UpdateChannel.swift b/Sources/CodexBar/UpdateChannel.swift index d2b0a74df..861c96e01 100644 --- a/Sources/CodexBar/UpdateChannel.swift +++ b/Sources/CodexBar/UpdateChannel.swift @@ -10,18 +10,18 @@ enum UpdateChannel: String, CaseIterable, Codable, Sendable { var displayName: String { switch self { case .stable: - "Stable" + L10n.tr("Stable") case .beta: - "Beta" + L10n.tr("Beta") } } var description: String { switch self { case .stable: - "Receive only stable, production-ready releases." + L10n.tr("Receive only stable, production-ready releases.") case .beta: - "Receive stable releases plus beta previews." + L10n.tr("Receive stable releases plus beta previews.") } } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 920e38ef9..8e6ed0b79 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -14,9 +14,9 @@ enum UsagePaceText { static func weeklySummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { guard let detail = weeklyDetail(provider: provider, window: window, now: now) else { return nil } if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L10n.format("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L10n.format("Pace: %@", detail.leftLabel) } static func weeklyDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { @@ -32,26 +32,26 @@ enum UsagePaceText { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: - return "On pace" + return L10n.tr("On pace") case .slightlyAhead, .ahead, .farAhead: - return "\(deltaValue)% in deficit" + return L10n.format("%d%% in deficit", deltaValue) case .slightlyBehind, .behind, .farBehind: - return "\(deltaValue)% in reserve" + return L10n.format("%d%% in reserve", deltaValue) } } private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { - if pace.willLastToReset { return "Lasts until reset" } + if pace.willLastToReset { return L10n.tr("Lasts until reset") } guard let etaSeconds = pace.etaSeconds else { return nil } let etaText = Self.durationText(seconds: etaSeconds, now: now) - if etaText == "now" { return "Runs out now" } - return "Runs out in \(etaText)" + if etaText == L10n.tr("now") { return L10n.tr("Runs out now") } + return L10n.format("Runs out in %@", etaText) } 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 == "now" { return L10n.tr("now") } if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } return countdown } diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 84cca3c3d..3df53e885 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -318,10 +318,8 @@ public struct CursorStatusSnapshot: Sendable { } private static func formatResetDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d 'at' h:mma" - formatter.locale = Locale(identifier: "en_US_POSIX") - return "Resets " + formatter.string(from: date) + let text = date.formatted(date: .abbreviated, time: .shortened) + return String(format: NSLocalizedString("Resets %@", comment: ""), text) } private static func formatMembershipType(_ type: String) -> String { diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index b4994a1d1..f6210d295 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -384,10 +384,8 @@ public struct FactoryStatusSnapshot: Sendable { } private static func formatResetDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d 'at' h:mma" - formatter.locale = Locale(identifier: "en_US_POSIX") - return "Resets " + formatter.string(from: date) + let text = date.formatted(date: .abbreviated, time: .shortened) + return String(format: NSLocalizedString("Resets %@", comment: ""), text) } } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 509cb03e2..b59a59014 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -731,24 +731,18 @@ public struct GeminiStatusProbe: Sendable { private static func formatResetTime(_ isoString: String) -> String { guard let resetDate = parseResetTime(isoString) else { - return "Resets soon" + return NSLocalizedString("Resets soon", comment: "") } let now = Date() let interval = resetDate.timeIntervalSince(now) if interval <= 0 { - return "Resets soon" + return NSLocalizedString("Resets soon", comment: "") } - let hours = Int(interval / 3600) - let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - - if hours > 0 { - return "Resets in \(hours)h \(minutes)m" - } else { - return "Resets in \(minutes)m" - } + let countdown = UsageFormatter.resetCountdownDescription(from: resetDate, now: now) + return String(format: NSLocalizedString("Resets %@", comment: ""), countdown) } // MARK: - Legacy CLI parsing (kept for fallback) diff --git a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift index b9e02dff9..fef82fa61 100644 --- a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift @@ -85,20 +85,10 @@ public struct JetBrainsStatusSnapshot: Sendable { guard let date else { return nil } let now = Date() let interval = date.timeIntervalSince(now) - guard interval > 0 else { return "Expired" } - - let hours = Int(interval / 3600) - let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - - if hours > 24 { - let days = hours / 24 - let remainingHours = hours % 24 - return "Resets in \(days)d \(remainingHours)h" - } else if hours > 0 { - return "Resets in \(hours)h \(minutes)m" - } else { - return "Resets in \(minutes)m" - } + guard interval > 0 else { return NSLocalizedString("Expired", comment: "") } + + let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) + return String(format: NSLocalizedString("Resets %@", comment: ""), countdown) } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..8ff7ce8c5 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -207,11 +207,11 @@ public enum UsageError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .noSessions: - "No Codex sessions found yet. Run at least one Codex prompt first." + NSLocalizedString("No Codex sessions found yet. Run at least one Codex prompt first.", comment: "") case .noRateLimitsFound: - "Found sessions, but no rate limit events yet." + NSLocalizedString("Found sessions, but no rate limit events yet.", comment: "") case .decodeFailed: - "Could not parse Codex session log." + NSLocalizedString("Could not parse Codex session log.", comment: "") } } } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d43569..cd1df792b 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -9,13 +9,14 @@ public enum UsageFormatter { public 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 ? "used" : "left" - return String(format: "%.0f%% %@", clamped, suffix) + let percentText = String(format: "%.0f%%", clamped) + let key = showUsed ? "%@ used" : "%@ left" + return String(format: NSLocalizedString(key, comment: ""), percentText) } public static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String { let seconds = max(0, date.timeIntervalSince(now)) - if seconds < 1 { return "now" } + if seconds < 1 { return NSLocalizedString("now", comment: "") } let totalMinutes = max(1, Int(ceil(seconds / 60.0))) let days = totalMinutes / (24 * 60) @@ -23,14 +24,14 @@ public enum UsageFormatter { let minutes = totalMinutes % 60 if days > 0 { - if hours > 0 { return "in \(days)d \(hours)h" } - return "in \(days)d" + if hours > 0 { return String(format: NSLocalizedString("in %dd %dh", comment: ""), days, hours) } + return String(format: NSLocalizedString("in %dd", comment: ""), days) } if hours > 0 { - if minutes > 0 { return "in \(hours)h \(minutes)m" } - return "in \(hours)h" + if minutes > 0 { return String(format: NSLocalizedString("in %dh %dm", comment: ""), hours, minutes) } + return String(format: NSLocalizedString("in %dh", comment: ""), hours) } - return "in \(totalMinutes)m" + return String(format: NSLocalizedString("in %dm", comment: ""), totalMinutes) } public static func resetDescription(from date: Date, now: Date = .init()) -> String { @@ -42,7 +43,7 @@ public enum UsageFormatter { if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + return String(format: NSLocalizedString("tomorrow, %@", comment: ""), date.formatted(date: .omitted, time: .shortened)) } return date.formatted(date: .abbreviated, time: .shortened) } @@ -56,14 +57,15 @@ public enum UsageFormatter { let text = style == .countdown ? self.resetCountdownDescription(from: date, now: now) : self.resetDescription(from: date, now: now) - return "Resets \(text)" + return String(format: NSLocalizedString("Resets %@", comment: ""), text) } if let desc = window.resetDescription { let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if trimmed.lowercased().hasPrefix("resets") { return trimmed } - return "Resets \(trimmed)" + let localizedResets = NSLocalizedString("Resets", comment: "") + if trimmed.lowercased().hasPrefix("resets") || trimmed.hasPrefix(localizedResets) { return trimmed } + return String(format: NSLocalizedString("Resets %@", comment: ""), trimmed) } return nil } @@ -71,24 +73,24 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return NSLocalizedString("Updated just now", comment: "") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return String(format: NSLocalizedString("Updated %@", comment: ""), rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return String(format: NSLocalizedString("Updated %dm ago", comment: ""), minutes) } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return String(format: NSLocalizedString("Updated %dh ago", comment: ""), wholeHours) #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return String(format: NSLocalizedString("Updated %@", comment: ""), date.formatted(date: .omitted, time: .shortened)) } } @@ -99,7 +101,7 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return String(format: NSLocalizedString("%@ left", comment: ""), formatted) } /// Formats a USD value with proper negative handling and thousand separators. @@ -152,7 +154,11 @@ public enum UsageFormatter { number.numberStyle = .decimal number.maximumFractionDigits = 2 let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" - return "\(formatter.string(from: event.date)) · \(event.service) · \(credits) credits" + return String( + format: NSLocalizedString("%@ · %@ · %@ credits", comment: ""), + formatter.string(from: event.date), + event.service, + credits) } public static func creditEventCompact(_ event: CreditEvent) -> String { @@ -162,7 +168,11 @@ public enum UsageFormatter { number.numberStyle = .decimal number.maximumFractionDigits = 2 let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" - return "\(formatter.string(from: event.date)) — \(event.service): \(credits)" + return String( + format: NSLocalizedString("%@ — %@: %@", comment: ""), + formatter.string(from: event.date), + event.service, + credits) } public static func creditShort(_ value: Double) -> String {