diff --git a/CHANGELOG.md b/CHANGELOG.md index a63788d6a..835df05fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - **OpenCode's session-level fallback now bills reasoning tokens at the output rate.** When none of a session's messages produce a priced call, codeburn falls back to the totals on OpenCode's `session` row, and that path handed `tokens_output` alone to the pricing table while still reporting `tokens_reasoning` separately, so a reasoning model's thinking showed up in the token breakdown but was never charged (4,000 reasoning tokens on Sonnet 4 left a $0.11 session priced at $0.05). OpenCode itself charges reasoning at the output rate and codeburn's per-message path already did; the fallback and the per-message path now both go through `billableOutputTokens`, the same rule the cache-rehydration path uses, so the three can't drift apart again. Displayed token totals are unchanged. (#1334) ### Fixed (menubar) +- **The localization catalog test can now see argument-order drift, and every multi-argument key spells its slots positionally.** The catalog comparison matched specifier lists, so a key whose placeholders are all `%@` passed even when the Chinese value bound the arguments into the wrong slots — exactly the drift that shipped in three early-reset keys and was only caught by rendering real data bilingually. All 42 multi-argument keys in both tables now use positional specifiers (`%1$@`, `%2$lld`, …), the parity check compares slot to verb so Chinese word order stays free while each slot keeps its type, and a new test fails on any multi-argument key that is not positional. The source scanner also stopped being blind to three shapes that put copy on screen looking like machinery: a ternary (`Text(flag ? "A" : "B")`), a concatenation (`.help(prefix + " suffix")`), and a literal bound to a `let` and passed by name — `Text(verbatim:)` stays the explicit opt-out, and the four provider-name fallbacks the new rules surfaced in HeatmapSection now route through the catalog as verbatim entries. (#1331) - **A quota window that resets now refreshes now.** The menubar's 30s tick compared the reset times it already holds — Claude's 5-hour and weekly windows, Codex's two windows, every Capacity Dock provider's — against the previous tick, and forces a refresh for the provider whose window just rolled over instead of leaving the pre-reset percentage on screen for up to five minutes. Each reset instant fires once; everything else keeps the existing cadence. - **The popover's quota warning can be read in light mode, and says which limit it is warning about.** The banner under the tagline painted its text in the same system yellow, orange or red as its 12% pill, so in light mode warning text measured 1.2:1 to 1.4:1 against the pill, and even red reached only 3.0:1; in dark mode red fell to 3.3:1. The pill keeps its hue, and the text and its glyph now take a per-appearance colour for each severity, a deep amber, rust or brick in light mode and a lifted orange or red in dark mode, that measures 5.3:1 or better everywhere. A test holds every severity at WCAG AA (4.5:1) against the pill composited over both ends of the popover's background in both appearances. The sentence also named no window. Its figure is the provider's worst window, so "Claude 70% of quota used" meant the 5-hour limit while Claude's own panel read weekly 34%, and it looked wrong. It now reads `Claude · 5-hour 71% · resets in 3h 12m`, with the countdown worded exactly as the Capacity Dock words it and one line per provider when several are warning. The countdown is left off when the provider reports no reset time or the reset has already passed. "Over limit" is kept for a window at or past 100%, where it used to cover anything from 90%. The flame's colour and the providers that trigger the banner are unchanged. diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 16f5b8b47..98941553d 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1393,7 +1393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM let menubarPeriod = store.menubarPeriod if let shortfall = store.menubarBadgeDeviceShortfall { button.toolTip = L( - "CodeBurn %@ · %lld of %lld devices reporting", + "CodeBurn %1$@ · %2$lld of %3$lld devices reporting", menubarPeriod.menubarMetricLabel, shortfall.reachable, shortfall.total @@ -1740,7 +1740,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM private func contextMenuUsageSummary() -> String { guard let current = store.todayPayload?.current else { return L("Today · no usage yet") } let calls = current.calls == 1 ? L("1 call") : L("%lld calls", current.calls) - return L("Today · %@ · %@", current.cost.asCurrency(), calls) + return L("Today · %1$@ · %2$@", current.cost.asCurrency(), calls) } private var settingsWindowController: NSWindowController? @@ -1821,13 +1821,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { alert.messageText = L("Update Available") let header = L( - "%@ is available (you have %@).", + "%1$@ is available (you have %2$@).", AppVersion.display(latest), AppVersion.display(updateChecker.currentVersion) ) if updateChecker.cliTooOldForUpdate { alert.informativeText = L( - "%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force", + "%1$@ Your codeburn CLI is too old to install it. First run:\n\n%2$@\n\nthen:\n\ncodeburn menubar --force", header, updateChecker.cliUpdateCommand ) diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift index ddbf7db6c..2f89b477a 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift @@ -120,8 +120,8 @@ enum CodexBankedResetPresentation { if let grant = credits.latestGrant, let grantedAt = grant.grantedAt { let type = resetTypeLabel(grant.resetType) parts.append(grantedAt > now - ? L("next %@ lands %@", type, compactAge(of: grantedAt, now: now)) - : L("latest %@ granted %@", type, compactAge(of: grantedAt, now: now))) + ? L("next %1$@ lands %2$@", type, compactAge(of: grantedAt, now: now)) + : L("latest %1$@ granted %2$@", type, compactAge(of: grantedAt, now: now))) } if let expiry = credits.nextExpiresAt { parts.append(L("next expires %@", compactAge(of: expiry, now: now))) @@ -147,8 +147,8 @@ enum CodexBankedResetPresentation { var body: String if let grantedAt = grant.grantedAt { body = grantedAt > now - ? L("A %@ lands %@.", type, compactAge(of: grantedAt, now: now)) - : L("A %@ was added to your account %@.", type, compactAge(of: grantedAt, now: now)) + ? L("A %1$@ lands %2$@.", type, compactAge(of: grantedAt, now: now)) + : L("A %1$@ was added to your account %2$@.", type, compactAge(of: grantedAt, now: now)) } else { body = L("A %@ was added to your account.", type) } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index c51a7e20f..b68f652f8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -147,7 +147,7 @@ struct CodexUsage: Sendable, Equatable { } // The two figures are already grouped by the formatter above, so // they are substituted formatted and only the sentence is translated. - let base = L("Monthly usage limit · %@ / %@ credits", text(used), text(limit)) + let base = L("Monthly usage limit · %1$@ / %2$@ credits", text(used), text(limit)) return reached ? L("%@ · limit reached", base) : base } diff --git a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift index 36fa313a5..398b04a1d 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift @@ -77,7 +77,7 @@ enum CopilotQuotaPresentation { // The host is an API hostname and the plan comes from GitHub; both are // substituted verbatim, only the sentence around them is translated. guard let plan, !plan.isEmpty else { return L("Live quota tracked from %@.", host) } - return L("Plan: %@. Live quota tracked from %@.", plan, host) + return L("Plan: %1$@. Live quota tracked from %2$@.", plan, host) } static func settingsNotConnectedDetail(explicitlyDisconnected: Bool) -> String { diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index 274e6d5a9..0a74552a5 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -72,7 +72,7 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { switch signal { case .resetMovedForward: return L( - "%@'s %@ reset %@ early. %@", + "%1$@'s %2$@ reset %3$@ early. %4$@", providerName, EarlyQuotaResetFormat.limitName(windowName), lead, back ) // The reset time did not move: the vendor emptied the counter inside the @@ -80,7 +80,7 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { // here would promise a whole new window that is not coming. case .usageDropped: return L( - "%@ cleared your %@ %@ before its reset. %@", + "%1$@ cleared your %2$@ %3$@ before its reset. %4$@", providerName, EarlyQuotaResetFormat.usageName(windowName), lead, back ) } @@ -92,13 +92,13 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { switch signal { case .resetMovedForward: return L( - "%@ reset %@ early", + "%1$@ reset %2$@ early", EarlyQuotaResetFormat.capitalizedFirst(EarlyQuotaResetFormat.limitName(windowName)), lead ) case .usageDropped: let usage = EarlyQuotaResetFormat.usageName(windowName) - return L("%@ cleared, %@ before reset", EarlyQuotaResetFormat.capitalizedFirst(usage), lead) + return L("%1$@ cleared, %2$@ before reset", EarlyQuotaResetFormat.capitalizedFirst(usage), lead) } } @@ -111,12 +111,12 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { switch signal { case .resetMovedForward: return L( - "%@ reset this %@ %@ before its scheduled time. %@", + "%1$@ reset this %2$@ %3$@ before its scheduled time. %4$@", providerName, EarlyQuotaResetFormat.limitName(windowName), lead, available ) case .usageDropped: return L( - "%@ cleared this %@ %@ before the window's scheduled reset, which has not moved. %@", + "%1$@ cleared this %2$@ %3$@ before the window's scheduled reset, which has not moved. %4$@", providerName, EarlyQuotaResetFormat.usageName(windowName), lead, available ) } @@ -271,13 +271,13 @@ enum EarlyQuotaResetHistory { let noun = EarlyQuotaResetFormat.windowNoun(windowName) let lead = EarlyQuotaResetFormat.approximateLead(seconds: typicalEarlyBySeconds) if earlyResets == 1 && observedResets == 1 { - return L("Last %@ reset came ~%@ early", noun, lead) + return L("Last %1$@ reset came ~%2$@ early", noun, lead) } if earlyResets == observedResets { - return L("Last %lld %@ resets came ~%@ early", earlyResets, noun, lead) + return L("Last %1$lld %2$@ resets came ~%3$@ early", earlyResets, noun, lead) } return L( - "%lld of the last %lld %@ resets came ~%@ early", + "%1$lld of the last %2$lld %3$@ resets came ~%4$@ early", earlyResets, observedResets, noun, lead ) } @@ -370,7 +370,7 @@ enum EarlyQuotaResetFormat { let hours = Int((seconds / 3600).rounded()) guard hours >= 24 else { return L("%lldh", hours) } let rest = hours % 24 - return rest == 0 ? L("%lldd", hours / 24) : L("%lldd %lldh", hours / 24, rest) + return rest == 0 ? L("%lldd", hours / 24) : L("%1$lldd %2$lldh", hours / 24, rest) } /// "18h", "2d" — rounded, for a pattern that is only ever approximate. diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaCrossing.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaCrossing.swift index cf1c5a011..4d2f763de 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaCrossing.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaCrossing.swift @@ -43,8 +43,8 @@ struct QuotaCrossingEvent: Equatable, Sendable { var notificationTitle: String { switch level { - case .warning: L("%@ · %@ at 80%%", providerName, windowLabel) - case .limit: L("%@ · %@ limit reached", providerName, windowLabel) + case .warning: L("%1$@ · %2$@ at 80%%", providerName, windowLabel) + case .limit: L("%1$@ · %2$@ limit reached", providerName, windowLabel) } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift index 39012bc28..bb0248fd6 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift @@ -23,8 +23,8 @@ enum QuotaPacePresentation { let hours = minutes / 60 let days = hours / 24 // Same three keys the popover's reset countdown uses. - if days > 0 { return L("%lldd %lldh", days, hours % 24) } - if hours > 0 { return L("%lldh %lldm", hours, minutes % 60) } + if days > 0 { return L("%1$lldd %2$lldh", days, hours % 24) } + if hours > 0 { return L("%1$lldh %2$lldm", hours, minutes % 60) } return L("%lldm", minutes) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index cd186a0db..6a3ca9987 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -152,8 +152,8 @@ extension QuotaSummary.Window { let hours = minutes / 60 let days = hours / 24 // d/h/m are unit abbreviations; zh-Hans uses 天/小时/分. - if days > 0 { return L("%lldd %lldh", days, hours % 24) } - if hours > 0 { return L("%lldh %lldm", hours, minutes % 60) } + if days > 0 { return L("%1$lldd %2$lldh", days, hours % 24) } + if hours > 0 { return L("%1$lldh %2$lldm", hours, minutes % 60) } return L("%lldm", minutes) } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaWarningPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaWarningPresentation.swift index 4927a7fc9..6d0242879 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaWarningPresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaWarningPresentation.swift @@ -95,7 +95,7 @@ enum QuotaWarningPresentation { // "Over limit" only once the window really is. Severity turns `.danger` // at 90%, and "over limit (93%)" is not true. var text = warning.percent >= 100 - ? L("%@ over limit (%lld%%)", subject, percent) + ? L("%1$@ over limit (%2$lld%%)", subject, percent) : "\(subject) \(percent)%" if let countdown = resetCountdown(warning.resetsAt, now: now) { text += " · " + L("resets in %@", countdown) diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 21e07a9ec..20ffeb424 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -291,7 +291,7 @@ final class UpdateChecker { isUpdating = false updateFailureStage = .cliUpdate updateError = L( - "Could not find the package manager for %@. Run “%@” manually, then try again.", + "Could not find the package manager for %1$@. Run “%2$@” manually, then try again.", cliPath.isEmpty ? L("the CLI") : cliPath, cliUpdateCommand ) @@ -362,7 +362,7 @@ final class UpdateChecker { if cliTooOldForUpdate { updateFailureStage = .menubarUpdate updateError = L( - "Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again.", + "Your codeburn CLI (%1$@) is too old to update the menubar. Run “%2$@” first, then try again.", AppVersion.display(installedCliVersion ?? ""), cliUpdateCommand ) diff --git a/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift b/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift index 6e1a6c6cc..ad3fba009 100644 --- a/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift +++ b/mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift @@ -333,8 +333,8 @@ enum MenubarRowFormatter { let hours = minutes / 60 let days = hours / 24 // d/h/m are unit abbreviations; zh-Hans uses 天/小时/分. - if days > 0 { return L("%lldd %lldh", days, hours % 24) } - if hours > 0 { return L("%lldh %lldm", hours, minutes % 60) } + if days > 0 { return L("%1$lldd %2$lldh", days, hours % 24) } + if hours > 0 { return L("%1$lldh %2$lldm", hours, minutes % 60) } return L("%lldm", minutes) } diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings index 0c7f2ae51..6869efe99 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -88,8 +88,8 @@ "Reconnect" = "Reconnect"; "Add API Key" = "Add API Key"; "now" = "now"; -"%lldd %lldh" = "%lldd %lldh"; -"%lldh %lldm" = "%lldh %lldm"; +"%1$lldd %2$lldh" = "%1$lldd %2$lldh"; +"%1$lldh %2$lldm" = "%1$lldh %2$lldm"; "%lldm" = "%lldm"; /* MARK: Usage refresh cadence */ @@ -165,10 +165,10 @@ "App and CLI %@ updates are ready. Click to install." = "App and CLI %@ updates are ready. Click to install."; "Click to install the update." = "Click to install the update."; "CodeBurn CLI %@ available" = "CodeBurn CLI %@ available"; -"Could not find the package manager for %@. Run “%@” manually, then try again." = "Could not find the package manager for %@. Run “%@” manually, then try again."; +"Could not find the package manager for %1$@. Run “%2$@” manually, then try again." = "Could not find the package manager for %1$@. Run “%2$@” manually, then try again."; "the CLI" = "the CLI"; "CLI update failed (exit %lld)" = "CLI update failed (exit %lld)"; -"Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again." = "Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again."; +"Your codeburn CLI (%1$@) is too old to update the menubar. Run “%2$@” first, then try again." = "Your codeburn CLI (%1$@) is too old to update the menubar. Run “%2$@” first, then try again."; "Update failed (exit %lld)" = "Update failed (exit %lld)"; "GitHub returned HTTP %lld." = "GitHub returned HTTP %lld."; "No mac-v release with a menubar zip and checksum was found." = "No mac-v release with a menubar zip and checksum was found."; @@ -178,7 +178,7 @@ /* MARK: Status-item menu, tooltip and update alerts */ "%@ credits" = "%@ credits"; -"CodeBurn %@ · %lld of %lld devices reporting" = "CodeBurn %@ · %lld of %lld devices reporting"; +"CodeBurn %1$@ · %2$lld of %3$lld devices reporting" = "CodeBurn %1$@ · %2$lld of %3$lld devices reporting"; "Settings…" = "Settings…"; "Capacity Dock Settings…" = "Capacity Dock Settings…"; "Refresh Now" = "Refresh Now"; @@ -188,11 +188,11 @@ "Today · no usage yet" = "Today · no usage yet"; "1 call" = "1 call"; "%lld calls" = "%lld calls"; -"Today · %@ · %@" = "Today · %@ · %@"; +"Today · %1$@ · %2$@" = "Today · %1$@ · %2$@"; "CodeBurn Settings" = "CodeBurn Settings"; "Update Available" = "Update Available"; -"%@ is available (you have %@)." = "%@ is available (you have %@)."; -"%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force" = "%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force"; +"%1$@ is available (you have %2$@)." = "%1$@ is available (you have %2$@)."; +"%1$@ Your codeburn CLI is too old to install it. First run:\n\n%2$@\n\nthen:\n\ncodeburn menubar --force" = "%1$@ Your codeburn CLI is too old to install it. First run:\n\n%2$@\n\nthen:\n\ncodeburn menubar --force"; "%@ Run:\n\ncodeburn menubar --force" = "%@ Run:\n\ncodeburn menubar --force"; "Up to Date" = "Up to Date"; "You're on the latest version (%@)." = "You're on the latest version (%@)."; @@ -201,12 +201,12 @@ /* MARK: Popover chrome, header, footer and banners */ "This total may be incomplete." = "This total may be incomplete."; "Claude config" = "Claude config"; -"No %@ data for %@" = "No %@ data for %@"; +"No %1$@ data for %2$@" = "No %1$@ data for %2$@"; "Couldn't load %@" = "Couldn't load %@"; "Retry" = "Retry"; "Loading %@…" = "Loading %@…"; "Your AI Bill, Itemized" = "Your AI Bill, Itemized"; -"%@ over limit (%lld%%)" = "%@ over limit (%lld%%)"; +"%1$@ over limit (%2$lld%%)" = "%1$@ over limit (%2$lld%%)"; "resets in %@" = "resets in %@"; "Change accent color" = "Change accent color"; "CLI %@ available" = "CLI %@ available"; @@ -228,7 +228,7 @@ "Combined unavailable · showing local" = "Combined unavailable · showing local"; "Combined · %@" = "Combined · %@"; "Saved %@ with local models" = "Saved %@ with local models"; -"%lld of %lld devices" = "%lld of %lld devices"; +"%1$lld of %2$lld devices" = "%1$lld of %2$lld devices"; "%@ · local" = "%@ · local"; /* MARK: Period strip and date picker */ @@ -284,19 +284,19 @@ "Spend up %lld%% vs prior 7 days" = "Spend up %lld%% vs prior 7 days"; "Cache hit only %lld%% — paying for cold prompts" = "Cache hit only %lld%% — paying for cold prompts"; "%lld%% one-shot — lots of iteration" = "%lld%% one-shot — lots of iteration"; -"On pace for %@ this month (+%lld%% vs last)" = "On pace for %@ this month (+%lld%% vs last)"; +"On pace for %1$@ this month (+%2$lld%% vs last)" = "On pace for %1$@ this month (+%2$lld%% vs last)"; "What's working" = "What's working"; "What to improve" = "What to improve"; "Risks" = "Risks"; /* MARK: Insight tabs (trend, calendar, forecast, pulse, stats, optimize, plan) */ "Last %lld days" = "Last %lld days"; -"%@%% vs prior %lldd" = "%@%% vs prior %lldd"; +"%1$@%% vs prior %2$lldd" = "%1$@%% vs prior %2$lldd"; "Avg/day" = "Avg/day"; "Peak" = "Peak"; "Yesterday" = "Yesterday"; "%@ tokens" = "%@ tokens"; -"%@ on %@" = "%@ on %@"; +"%1$@ on %2$@" = "%1$@ on %2$@"; "Daily activity" = "Daily activity"; "%lld active days" = "%lld active days"; "Peak day" = "Peak day"; @@ -313,18 +313,18 @@ "No tracked usage" = "No tracked usage"; "%@: future day" = "%@: future day"; "%@: no tracked usage" = "%@: no tracked usage"; -"%@: %@, %lld calls, %@ tokens" = "%@: %@, %lld calls, %@ tokens"; +"%1$@: %2$@, %3$lld calls, %4$@ tokens" = "%1$@: %2$@, %3$lld calls, %4$@ tokens"; "Month-to-date" = "Month-to-date"; "On pace for" = "On pace for"; "Avg/day (this wk)" = "Avg/day (this wk)"; "Last 7d" = "Last 7d"; "no prior month" = "no prior month"; -"%@%% vs last month (%@)" = "%@%% vs last month (%@)"; +"%1$@%% vs last month (%2$@)" = "%1$@%% vs last month (%2$@)"; "Cache hit" = "Cache hit"; "Cost / session" = "Cost / session"; "Cost/edit" = "Cost/edit"; -"Save ~%@ / ~%@ tokens · 1 finding" = "Save ~%@ / ~%@ tokens · 1 finding"; -"Save ~%@ / ~%@ tokens · %lld findings" = "Save ~%@ / ~%@ tokens · %lld findings"; +"Save ~%1$@ / ~%2$@ tokens · 1 finding" = "Save ~%1$@ / ~%2$@ tokens · 1 finding"; +"Save ~%1$@ / ~%2$@ tokens · %3$lld findings" = "Save ~%1$@ / ~%2$@ tokens · %3$lld findings"; "Favorite model" = "Favorite model"; "Active days (month)" = "Active days (month)"; "Most active day" = "Most active day"; @@ -335,7 +335,7 @@ "Tracked spend (last %lld days)" = "Tracked spend (last %lld days)"; "Costliest session" = "Costliest session"; "Retry tax" = "Retry tax"; -"%lld retries across %lld edits" = "%lld retries across %lld edits"; +"%1$lld retries across %2$lld edits" = "%1$lld retries across %2$lld edits"; "%@ ret/edit" = "%@ ret/edit"; "Hides session details" = "Hides session details"; "Shows session details" = "Shows session details"; @@ -347,7 +347,7 @@ "%lld%% of spend" = "%lld%% of spend"; "could be optimized" = "could be optimized"; "Routing waste" = "Routing waste"; -"vs %@ @ %@/edit" = "vs %@ @ %@/edit"; +"vs %1$@ @ %2$@/edit" = "vs %1$@ @ %2$@/edit"; "Connect Claude subscription" = "Connect Claude subscription"; "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." = "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically."; "Reading Claude credentials..." = "Reading Claude credentials..."; @@ -407,9 +407,9 @@ "On pace: %@ at reset" = "On pace: %@ at reset"; "%@%% in deficit" = "%@%% in deficit"; "%@%% in reserve" = "%@%% in reserve"; -"%@ · hits 100%% %@" = "%@ · hits 100%% %@"; -"%@ · %@ at reset" = "%@ · %@ at reset"; -"On pace: %@ at reset · hits 100%% %@" = "On pace: %@ at reset · hits 100%% %@"; +"%1$@ · hits 100%% %2$@" = "%1$@ · hits 100%% %2$@"; +"%1$@ · %2$@ at reset" = "%1$@ · %2$@ at reset"; +"On pace: %1$@ at reset · hits 100%% %2$@" = "On pace: %1$@ at reset · hits 100%% %2$@"; "Based on last cycle: %@" = "Based on last cycle: %@"; "in %lldm" = "in %lldm"; "in %lldh" = "in %lldh"; @@ -430,6 +430,10 @@ provider's own label or the generic horizon, both left verbatim. */ "Show %@ usage" = "Show %@ usage"; "none running" = "none running"; +"Kimi Code" = "Kimi Code"; +"Gemini" = "Gemini"; +"Copilot" = "Copilot"; +"Antigravity" = "Antigravity"; "1 running" = "1 running"; "%lld running" = "%lld running"; "%@ left" = "%@ left"; @@ -439,7 +443,7 @@ "Open in %@" = "Open in %@"; "Bring %@ to front" = "Bring %@ to front"; "burned" = "burned"; -"today %@ of %@" = "today %@ of %@"; +"today %1$@ of %2$@" = "today %1$@ of %2$@"; "no budget set" = "no budget set"; "Refreshing…" = "Refreshing…"; "Last known usage · refreshing" = "Last known usage · refreshing"; @@ -560,7 +564,7 @@ "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are used only to read your Copilot quota." = "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are used only to read your Copilot quota."; "Couldn't load Copilot quota" = "Couldn't load Copilot quota"; "Live quota tracked from %@." = "Live quota tracked from %@."; -"Plan: %@. Live quota tracked from %@." = "Plan: %@. Live quota tracked from %@."; +"Plan: %1$@. Live quota tracked from %2$@." = "Plan: %1$@. Live quota tracked from %2$@."; "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect."; "GitHub rate-limited; auto-retrying." = "GitHub rate-limited; auto-retrying."; "Looking for a GitHub token on this Mac." = "Looking for a GitHub token on this Mac."; @@ -632,7 +636,7 @@ /* MARK: Codex credit limit */ "Monthly usage limit" = "Monthly usage limit"; "Monthly usage limit · limit reached" = "Monthly usage limit · limit reached"; -"Monthly usage limit · %@ / %@ credits" = "Monthly usage limit · %@ / %@ credits"; +"Monthly usage limit · %1$@ / %2$@ credits" = "Monthly usage limit · %1$@ / %2$@ credits"; "%@ · limit reached" = "%@ · limit reached"; /* MARK: Added on rebase onto main (#1306, #1313, #1314, #1328, #1329) */ @@ -663,16 +667,16 @@ "%@ quota reset early" = "%@ quota reset early"; "%@ quota cleared early" = "%@ quota cleared early"; "You're back to %lld%%." = "You're back to %lld%%."; -"%@'s %@ reset %@ early. %@" = "%@'s %@ reset %@ early. %@"; -"%@ cleared your %@ %@ before its reset. %@" = "%@ cleared your %@ %@ before its reset. %@"; -"%@ reset %@ early" = "%@ reset %@ early"; -"%@ cleared, %@ before reset" = "%@ cleared, %@ before reset"; +"%1$@'s %2$@ reset %3$@ early. %4$@" = "%1$@'s %2$@ reset %3$@ early. %4$@"; +"%1$@ cleared your %2$@ %3$@ before its reset. %4$@" = "%1$@ cleared your %2$@ %3$@ before its reset. %4$@"; +"%1$@ reset %2$@ early" = "%1$@ reset %2$@ early"; +"%1$@ cleared, %2$@ before reset" = "%1$@ cleared, %2$@ before reset"; "%lld%% of it was available when CodeBurn noticed." = "%lld%% of it was available when CodeBurn noticed."; -"%@ reset this %@ %@ before its scheduled time. %@" = "%@ reset this %@ %@ before its scheduled time. %@"; -"%@ cleared this %@ %@ before the window's scheduled reset, which has not moved. %@" = "%@ cleared this %@ %@ before the window's scheduled reset, which has not moved. %@"; -"Last %@ reset came ~%@ early" = "Last %@ reset came ~%@ early"; -"Last %lld %@ resets came ~%@ early" = "Last %lld %@ resets came ~%@ early"; -"%lld of the last %lld %@ resets came ~%@ early" = "%lld of the last %lld %@ resets came ~%@ early"; +"%1$@ reset this %2$@ %3$@ before its scheduled time. %4$@" = "%1$@ reset this %2$@ %3$@ before its scheduled time. %4$@"; +"%1$@ cleared this %2$@ %3$@ before the window's scheduled reset, which has not moved. %4$@" = "%1$@ cleared this %2$@ %3$@ before the window's scheduled reset, which has not moved. %4$@"; +"Last %1$@ reset came ~%2$@ early" = "Last %1$@ reset came ~%2$@ early"; +"Last %1$lld %2$@ resets came ~%3$@ early" = "Last %1$lld %2$@ resets came ~%3$@ early"; +"%1$lld of the last %2$lld %3$@ resets came ~%4$@ early" = "%1$lld of the last %2$lld %3$@ resets came ~%4$@ early"; "%lldh" = "%lldh"; "5-hour limit" = "5-hour limit"; "weekly limit" = "weekly limit"; @@ -689,11 +693,11 @@ /* MARK: Codex banked limit resets (#725) */ "%lld available" = "%lld available"; "%lld usable now" = "%lld usable now"; -"next %@ lands %@" = "next %@ lands %@"; -"latest %@ granted %@" = "latest %@ granted %@"; +"next %1$@ lands %2$@" = "next %1$@ lands %2$@"; +"latest %1$@ granted %2$@" = "latest %1$@ granted %2$@"; "next expires %@" = "next expires %@"; -"A %@ lands %@." = "A %@ lands %@."; -"A %@ was added to your account %@." = "A %@ was added to your account %@."; +"A %1$@ lands %2$@." = "A %1$@ lands %2$@."; +"A %1$@ was added to your account %2$@." = "A %1$@ was added to your account %2$@."; "A %@ was added to your account." = "A %@ was added to your account."; "You have %lld available to use." = "You have %lld available to use."; "Codex banked a limit reset" = "Codex banked a limit reset"; @@ -710,6 +714,6 @@ "Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure." = "Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure."; /* MARK: Quota crossing notifications */ -"%@ · %@ at 80%%" = "%@ · %@ at 80%%"; -"%@ · %@ limit reached" = "%@ · %@ limit reached"; -"Quota crossings (%lld%% and %lld%%)" = "Quota crossings (%lld%% and %lld%%)"; +"%1$@ · %2$@ at 80%%" = "%1$@ · %2$@ at 80%%"; +"%1$@ · %2$@ limit reached" = "%1$@ · %2$@ limit reached"; +"Quota crossings (%1$lld%% and %2$lld%%)" = "Quota crossings (%1$lld%% and %2$lld%%)"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings index cef5b8b73..e499a10d5 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -82,8 +82,8 @@ "Reconnect" = "重新连接"; "Add API Key" = "添加 API 密钥"; "now" = "现在"; -"%lldd %lldh" = "%lld 天 %lld 小时"; -"%lldh %lldm" = "%lld 小时 %lld 分"; +"%1$lldd %2$lldh" = "%1$lld 天 %2$lld 小时"; +"%1$lldh %2$lldm" = "%1$lld 小时 %2$lld 分"; "%lldm" = "%lld 分"; /* MARK: Usage refresh cadence */ @@ -159,10 +159,10 @@ "App and CLI %@ updates are ready. Click to install." = "应用与命令行工具 %@ 更新已就绪。点按以安装。"; "Click to install the update." = "点按以安装更新。"; "CodeBurn CLI %@ available" = "CodeBurn 命令行工具 %@ 可用"; -"Could not find the package manager for %@. Run “%@” manually, then try again." = "找不到 %@ 对应的包管理器。请手动运行“%@”,然后重试。"; +"Could not find the package manager for %1$@. Run “%2$@” manually, then try again." = "找不到 %1$@ 对应的包管理器。请手动运行“%2$@”,然后重试。"; "the CLI" = "命令行工具"; "CLI update failed (exit %lld)" = "命令行工具更新失败(退出码 %lld)"; -"Your codeburn CLI (%@) is too old to update the menubar. Run “%@” first, then try again." = "你的 codeburn 命令行工具(%@)版本过旧,无法更新菜单栏应用。请先运行“%@”,然后重试。"; +"Your codeburn CLI (%1$@) is too old to update the menubar. Run “%2$@” first, then try again." = "你的 codeburn 命令行工具(%1$@)版本过旧,无法更新菜单栏应用。请先运行“%2$@”,然后重试。"; "Update failed (exit %lld)" = "更新失败(退出码 %lld)"; "GitHub returned HTTP %lld." = "GitHub 返回 HTTP %lld。"; "No mac-v release with a menubar zip and checksum was found." = "未找到包含菜单栏 zip 与校验和的 mac-v 版本。"; @@ -172,7 +172,7 @@ /* MARK: Status-item menu, tooltip and update alerts */ "%@ credits" = "%@ 点数"; -"CodeBurn %@ · %lld of %lld devices reporting" = "CodeBurn %@ · %lld/%lld 台设备已上报"; +"CodeBurn %1$@ · %2$lld of %3$lld devices reporting" = "CodeBurn %1$@ · %2$lld/%3$lld 台设备已上报"; "Settings…" = "设置…"; "Capacity Dock Settings…" = "容量 Dock 设置…"; "Refresh Now" = "立即刷新"; @@ -182,11 +182,11 @@ "Today · no usage yet" = "今天 · 暂无用量"; "1 call" = "1 次调用"; "%lld calls" = "%lld 次调用"; -"Today · %@ · %@" = "今天 · %@ · %@"; +"Today · %1$@ · %2$@" = "今天 · %1$@ · %2$@"; "CodeBurn Settings" = "CodeBurn 设置"; "Update Available" = "有可用更新"; -"%@ is available (you have %@)." = "%@ 已发布(你当前为 %@)。"; -"%@ Your codeburn CLI is too old to install it. First run:\n\n%@\n\nthen:\n\ncodeburn menubar --force" = "%@ 你的 codeburn 命令行工具版本过旧,无法安装。请先运行:\n\n%@\n\n然后:\n\ncodeburn menubar --force"; +"%1$@ is available (you have %2$@)." = "%1$@ 已发布(你当前为 %2$@)。"; +"%1$@ Your codeburn CLI is too old to install it. First run:\n\n%2$@\n\nthen:\n\ncodeburn menubar --force" = "%1$@ 你的 codeburn 命令行工具版本过旧,无法安装。请先运行:\n\n%2$@\n\n然后:\n\ncodeburn menubar --force"; "%@ Run:\n\ncodeburn menubar --force" = "%@ 运行:\n\ncodeburn menubar --force"; "Up to Date" = "已是最新版本"; "You're on the latest version (%@)." = "你已使用最新版本(%@)。"; @@ -195,12 +195,12 @@ /* MARK: Popover chrome, header, footer and banners */ "This total may be incomplete." = "此总计可能不完整。"; "Claude config" = "Claude 配置"; -"No %@ data for %@" = "%@ 在%@没有数据"; +"No %1$@ data for %2$@" = "%1$@ 在%2$@没有数据"; "Couldn't load %@" = "无法载入%@"; "Retry" = "重试"; "Loading %@…" = "正在载入%@…"; "Your AI Bill, Itemized" = "你的 AI 账单,逐项明细"; -"%@ over limit (%lld%%)" = "%@ 已超限(%lld%%)"; +"%1$@ over limit (%2$lld%%)" = "%1$@ 已超限(%2$lld%%)"; "resets in %@" = "%@后重置"; "Change accent color" = "更改主题色"; "CLI %@ available" = "命令行工具 %@ 可用"; @@ -222,7 +222,7 @@ "Combined unavailable · showing local" = "合并数据不可用 · 显示本机数据"; "Combined · %@" = "合并 · %@"; "Saved %@ with local models" = "使用本地模型节省了 %@"; -"%lld of %lld devices" = "%lld/%lld 台设备"; +"%1$lld of %2$lld devices" = "%1$lld/%2$lld 台设备"; "%@ · local" = "%@ · 本机"; /* MARK: Period strip and date picker */ @@ -278,19 +278,19 @@ "Spend up %lld%% vs prior 7 days" = "支出较前 7 天上升 %lld%%"; "Cache hit only %lld%% — paying for cold prompts" = "缓存命中率仅 %lld%% — 正在为冷提示付费"; "%lld%% one-shot — lots of iteration" = "一次通过率 %lld%% — 反复修改较多"; -"On pace for %@ this month (+%lld%% vs last)" = "按当前速度本月将达 %@(较上月 +%lld%%)"; +"On pace for %1$@ this month (+%2$lld%% vs last)" = "按当前速度本月将达 %1$@(较上月 +%2$lld%%)"; "What's working" = "做得好的"; "What to improve" = "可以改进的"; "Risks" = "风险"; /* MARK: Insight tabs (trend, calendar, forecast, pulse, stats, optimize, plan) */ "Last %lld days" = "最近 %lld 天"; -"%@%% vs prior %lldd" = "%@%% 较前 %lld 天"; +"%1$@%% vs prior %2$lldd" = "%1$@%% 较前 %2$lld 天"; "Avg/day" = "日均"; "Peak" = "峰值"; "Yesterday" = "昨天"; "%@ tokens" = "%@ Token"; -"%@ on %@" = "%@(%@)"; +"%1$@ on %2$@" = "%1$@(%2$@)"; "Daily activity" = "每日活动"; "%lld active days" = "%lld 个活跃日"; "Peak day" = "峰值日"; @@ -307,18 +307,18 @@ "No tracked usage" = "无跟踪到的用量"; "%@: future day" = "%@:未来日期"; "%@: no tracked usage" = "%@:无跟踪到的用量"; -"%@: %@, %lld calls, %@ tokens" = "%@:%@,%lld 次调用,%@ Token"; +"%1$@: %2$@, %3$lld calls, %4$@ tokens" = "%1$@:%2$@,%3$lld 次调用,%4$@ Token"; "Month-to-date" = "本月至今"; "On pace for" = "预计本月"; "Avg/day (this wk)" = "日均(本周)"; "Last 7d" = "最近 7 天"; "no prior month" = "无上月数据"; -"%@%% vs last month (%@)" = "%@%% 较上月(%@)"; +"%1$@%% vs last month (%2$@)" = "%1$@%% 较上月(%2$@)"; "Cache hit" = "缓存命中"; "Cost / session" = "每会话花费"; "Cost/edit" = "每次编辑花费"; -"Save ~%@ / ~%@ tokens · 1 finding" = "可省约 %@ / 约 %@ Token · 1 项建议"; -"Save ~%@ / ~%@ tokens · %lld findings" = "可省约 %@ / 约 %@ Token · %lld 项建议"; +"Save ~%1$@ / ~%2$@ tokens · 1 finding" = "可省约 %1$@ / 约 %2$@ Token · 1 项建议"; +"Save ~%1$@ / ~%2$@ tokens · %3$lld findings" = "可省约 %1$@ / 约 %2$@ Token · %3$lld 项建议"; "Favorite model" = "最常用模型"; "Active days (month)" = "活跃天数(本月)"; "Most active day" = "最活跃的一天"; @@ -329,7 +329,7 @@ "Tracked spend (last %lld days)" = "已跟踪支出(最近 %lld 天)"; "Costliest session" = "花费最高的会话"; "Retry tax" = "重试成本"; -"%lld retries across %lld edits" = "%lld 次重试,涉及 %lld 次编辑"; +"%1$lld retries across %2$lld edits" = "%1$lld 次重试,涉及 %2$lld 次编辑"; "%@ ret/edit" = "%@ 次重试/编辑"; "Hides session details" = "隐藏会话详情"; "Shows session details" = "显示会话详情"; @@ -341,7 +341,7 @@ "%lld%% of spend" = "占支出 %lld%%"; "could be optimized" = "可以优化"; "Routing waste" = "路由浪费"; -"vs %@ @ %@/edit" = "对比 %@(%@/次编辑)"; +"vs %1$@ @ %2$@/edit" = "对比 %1$@(%2$@/次编辑)"; "Connect Claude subscription" = "连接 Claude 订阅"; "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." = "CodeBurn 只会读取一次你的 Claude Code 凭证,macOS 会请求授权。之后实时配额条会显示在 Claude 标签旁并自动更新。"; "Reading Claude credentials..." = "正在读取 Claude 凭证…"; @@ -401,9 +401,9 @@ "On pace: %@ at reset" = "符合节奏:重置时 %@"; "%@%% in deficit" = "超出 %@%%"; "%@%% in reserve" = "结余 %@%%"; -"%@ · hits 100%% %@" = "%@ · %@达到 100%%"; -"%@ · %@ at reset" = "%@ · 重置时 %@"; -"On pace: %@ at reset · hits 100%% %@" = "符合节奏:重置时 %@ · %@达到 100%%"; +"%1$@ · hits 100%% %2$@" = "%1$@ · %2$@达到 100%%"; +"%1$@ · %2$@ at reset" = "%1$@ · 重置时 %2$@"; +"On pace: %1$@ at reset · hits 100%% %2$@" = "符合节奏:重置时 %1$@ · %2$@达到 100%%"; "Based on last cycle: %@" = "按上一周期估算:%@"; "in %lldm" = "%lld 分钟后"; "in %lldh" = "%lld 小时后"; @@ -424,13 +424,18 @@ "Show %@ usage" = "显示 %@ 用量"; "none running" = "无运行中"; "1 running" = "1 个运行中"; +/* 服务商/计划名按目录规则在两种语言中原样保留。 */ +"Kimi Code" = "Kimi Code"; +"Gemini" = "Gemini"; +"Copilot" = "Copilot"; +"Antigravity" = "Antigravity"; "%lld running" = "%lld 个运行中"; "%@ left" = "剩余 %@"; /* 运行中会话行的提示。%@ 为终端应用自身的名称,保持原样。 */ "Open in %@" = "在 %@ 中打开"; "Bring %@ to front" = "将 %@ 切换到前台"; "burned" = "已消耗"; -"today %@ of %@" = "今天 %@ / %@"; +"today %1$@ of %2$@" = "今天 %1$@ / %2$@"; "no budget set" = "未设置预算"; "Refreshing…" = "正在刷新…"; "Last known usage · refreshing" = "最近已知用量 · 正在刷新"; @@ -551,7 +556,7 @@ "Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are used only to read your Copilot quota." = "可选,仅当这台 Mac 上没有其他已登录来源时才需要。一个具有 \"Plan: Read-only\" 权限的细粒度个人访问令牌即可。主机请保持为 github.com,除非该令牌是在 GitHub Enterprise Cloud 租户上签发的;若是,请填入该租户的主机(.ghe.com),这样令牌就绝不会发送到 api.github.com。两者都保存在 CodeBurn 自己的钥匙串项目中,且仅用于读取你的 Copilot 配额。"; "Couldn't load Copilot quota" = "无法载入 Copilot 配额"; "Live quota tracked from %@." = "实时配额来自 %@。"; -"Plan: %@. Live quota tracked from %@." = "套餐:%@。实时配额来自 %@。"; +"Plan: %1$@. Live quota tracked from %2$@." = "套餐:%1$@。实时配额来自 %2$@。"; "Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect." = "请通过 Copilot 命令行工具、编辑器的 Copilot 插件或 gh auth login 重新登录,然后点按“重新连接”。"; "GitHub rate-limited; auto-retrying." = "GitHub 已限流,正在自动重试。"; "Looking for a GitHub token on this Mac." = "正在这台 Mac 上查找 GitHub 令牌。"; @@ -622,7 +627,7 @@ /* MARK: Codex credit limit */ "Monthly usage limit" = "每月用量上限"; "Monthly usage limit · limit reached" = "每月用量上限 · 已达上限"; -"Monthly usage limit · %@ / %@ credits" = "每月用量上限 · %@ / %@ 点数"; +"Monthly usage limit · %1$@ / %2$@ credits" = "每月用量上限 · %1$@ / %2$@ 点数"; "%@ · limit reached" = "%@ · 已达上限"; /* MARK: Added on rebase onto main (#1306, #1313, #1314, #1328, #1329) */ @@ -653,16 +658,16 @@ "%@ quota reset early" = "%@ 配额已提前重置"; "%@ quota cleared early" = "%@ 配额已提前清零"; "You're back to %lld%%." = "你已恢复到 %lld%%。"; -"%@'s %@ reset %@ early. %@" = "%@ 的%@提前 %@重置。%@"; -"%@ cleared your %@ %@ before its reset. %@" = "%@ 清空了你的%@,比其重置提前 %@。%@"; -"%@ reset %@ early" = "%@提前 %@重置"; -"%@ cleared, %@ before reset" = "%@已清空,比重置提前 %@"; +"%1$@'s %2$@ reset %3$@ early. %4$@" = "%1$@ 的%2$@提前 %3$@重置。%4$@"; +"%1$@ cleared your %2$@ %3$@ before its reset. %4$@" = "%1$@ 清空了你的%2$@,比其重置提前 %3$@。%4$@"; +"%1$@ reset %2$@ early" = "%1$@提前 %2$@重置"; +"%1$@ cleared, %2$@ before reset" = "%1$@已清空,比重置提前 %2$@"; "%lld%% of it was available when CodeBurn noticed." = "CodeBurn 发现时其中有 %lld%% 可用。"; -"%@ reset this %@ %@ before its scheduled time. %@" = "%@ 将这个%@提前 %@ 重置,早于其预定时间。%@"; -"%@ cleared this %@ %@ before the window's scheduled reset, which has not moved. %@" = "%@ 清空了这个%@,比该窗口的预定重置时间早 %@,而重置时间并未改变。%@"; -"Last %@ reset came ~%@ early" = "上次%@重置提前了约 %@"; -"Last %lld %@ resets came ~%@ early" = "最近 %lld 次%@重置提前了约 %@"; -"%lld of the last %lld %@ resets came ~%@ early" = "最近 %lld/%lld 次%@重置提前了约 %@"; +"%1$@ reset this %2$@ %3$@ before its scheduled time. %4$@" = "%1$@ 将这个%2$@提前 %3$@ 重置,早于其预定时间。%4$@"; +"%1$@ cleared this %2$@ %3$@ before the window's scheduled reset, which has not moved. %4$@" = "%1$@ 清空了这个%2$@,比该窗口的预定重置时间早 %3$@,而重置时间并未改变。%4$@"; +"Last %1$@ reset came ~%2$@ early" = "上次%1$@重置提前了约 %2$@"; +"Last %1$lld %2$@ resets came ~%3$@ early" = "最近 %1$lld 次%2$@重置提前了约 %3$@"; +"%1$lld of the last %2$lld %3$@ resets came ~%4$@ early" = "最近 %1$lld/%2$lld 次%3$@重置提前了约 %4$@"; "%lldh" = "%lld 小时"; "5-hour limit" = "5 小时限额"; "weekly limit" = "每周限额"; @@ -679,11 +684,11 @@ /* MARK: Codex banked limit resets (#725) */ "%lld available" = "%lld 个可用"; "%lld usable now" = "%lld 个现在可用"; -"next %@ lands %@" = "下一次%@将于 %@到账"; -"latest %@ granted %@" = "最近一次%@于 %@发放"; +"next %1$@ lands %2$@" = "下一次%1$@将于 %2$@到账"; +"latest %1$@ granted %2$@" = "最近一次%1$@于 %2$@发放"; "next expires %@" = "下一个将于 %@过期"; -"A %@ lands %@." = "一次%@将于 %@到账。"; -"A %@ was added to your account %@." = "%@已于 %@添加到你的账户。"; +"A %1$@ lands %2$@." = "一次%1$@将于 %2$@到账。"; +"A %1$@ was added to your account %2$@." = "%1$@已于 %2$@添加到你的账户。"; "A %@ was added to your account." = "%@已添加到你的账户。"; "You have %lld available to use." = "你有 %lld 个可以使用。"; "Codex banked a limit reset" = "Codex 存入了一次限额重置"; @@ -700,6 +705,6 @@ "Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure." = "今天从该服务商提示缓存中复用的输入 Token。不是新的输入(arrow.down),也不是缓存写入,并且已按缓存读取费率计入已消耗金额。"; /* MARK: Quota crossing notifications */ -"%@ · %@ at 80%%" = "%@ · %@ 已达 80%%"; -"%@ · %@ limit reached" = "%@ · %@ 已达上限"; -"Quota crossings (%lld%% and %lld%%)" = "配额跨越(%lld%% 与 %lld%%)"; +"%1$@ · %2$@ at 80%%" = "%1$@ · %2$@ 已达 80%%"; +"%1$@ · %2$@ limit reached" = "%1$@ · %2$@ 已达上限"; +"Quota crossings (%1$lld%% and %2$lld%%)" = "配额跨越(%1$lld%% 与 %2$lld%%)"; diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index dfbb311ad..543cba023 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -1264,7 +1264,7 @@ struct CapacityDockDetailView: View { private func budgetLine() -> some View { let spend = store.capacityDockToday?.cost ?? 0 let budget = store.activeDailyBudget - Text(budget > 0 ? L("today %@ of %@", spend.asUSD(), budget.asUSD()) : L("no budget set")) + Text(budget > 0 ? L("today %1$@ of %2$@", spend.asUSD(), budget.asUSD()) : L("no budget set")) .font(.system(size: 11)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.6)) diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift index 8fb95e35a..7c7608205 100644 --- a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift @@ -192,7 +192,7 @@ private struct TipItem: Identifiable { } if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 { risks.append(TipItem( - text: L("On pace for %@ this month (+%lld%% vs last)", projected.asCompactCurrency(), Int(((projected - prevMonth) / prevMonth) * 100)), + text: L("On pace for %1$@ this month (+%2$lld%% vs last)", projected.asCompactCurrency(), Int(((projected - prevMonth) / prevMonth) * 100)), trailing: nil )) } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 39f842318..79d320429 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -182,7 +182,7 @@ private struct TrendInsight: View { HStack(spacing: 3) { Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.system(size: 9, weight: .bold)) - Text(L("%@%% vs prior %lldd", (delta >= 0 ? "+" : "") + String(format: "%.0f", delta), dayCount)) + Text(L("%1$@%% vs prior %2$lldd", (delta >= 0 ? "+" : "") + String(format: "%.0f", delta), dayCount)) .font(.system(size: 10.5)) .monospacedDigit() } @@ -218,7 +218,7 @@ private struct TrendInsight: View { private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String { guard let peak, metric(peak) > 0 else { return "—" } - return L("%@ on %@", formatValue(metric(peak), useTokens: useTokens), shortDate(peak.date)) + return L("%1$@ on %2$@", formatValue(metric(peak), useTokens: useTokens), shortDate(peak.date)) } private func formatTokens(_ n: Double) -> String { @@ -772,7 +772,7 @@ struct ContributionDay: Identifiable, Equatable { if isFuture { return L("%@: future day", prettyDate(date)) } if cost <= 0 && calls == 0 { return L("%@: no tracked usage", prettyDate(date)) } return L( - "%@: %@, %lld calls, %@ tokens", + "%1$@: %2$@, %3$lld calls, %4$@ tokens", prettyDate(date), cost.asCompactCurrency(), calls, @@ -859,7 +859,7 @@ func buildContributionWeeks( let total = active.reduce(0.0) { $0 + $1.cost } let avg = active.isEmpty ? 0 : total / Double(active.count) let peak = active.max(by: { $0.cost < $1.cost }) - let peakLabel = peak.map { L("%@ on %@", $0.cost.asCompactCurrency(), shortContributionDate($0.date)) } ?? "—" + let peakLabel = peak.map { L("%1$@ on %2$@", $0.cost.asCompactCurrency(), shortContributionDate($0.date)) } ?? "—" var streak = 0 for day in days.reversed() { @@ -949,7 +949,7 @@ private struct ForecastInsight: View { guard previous > 0 else { return L("no prior month") } let diff = ((projection - previous) / previous) * 100 let sign = diff >= 0 ? "+" : "" - return L("%@%% vs last month (%@)", sign + String(format: "%.0f", diff), previous.asCompactCurrency()) + return L("%1$@%% vs last month (%2$@)", sign + String(format: "%.0f", diff), previous.asCompactCurrency()) } } @@ -1189,8 +1189,8 @@ private struct OptimizeSavingsBadge: View { let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx) let tokensLabel = formatTokens(tokens) return findingCount == 1 - ? L("Save ~%@ / ~%@ tokens · 1 finding", savingsUSD.asCompactCurrency(), tokensLabel) - : L("Save ~%@ / ~%@ tokens · %lld findings", savingsUSD.asCompactCurrency(), tokensLabel, findingCount) + ? L("Save ~%1$@ / ~%2$@ tokens · 1 finding", savingsUSD.asCompactCurrency(), tokensLabel) + : L("Save ~%1$@ / ~%2$@ tokens · %3$lld findings", savingsUSD.asCompactCurrency(), tokensLabel, findingCount) } private func openOptimize() { @@ -1312,7 +1312,7 @@ private struct RetryTaxSection: View { } } - Text(L("%lld retries across %lld edits", retryTax.retries, retryTax.editTurns)) + Text(L("%1$lld retries across %2$lld edits", retryTax.retries, retryTax.editTurns)) .font(.system(size: 9.5)) .foregroundStyle(.quaternary) @@ -1625,7 +1625,7 @@ private struct RoutingWasteSection: View { } if !routingWaste.baselineModel.isEmpty { - Text(L("vs %@ @ %@/edit", routingWaste.baselineModel, routingWaste.baselineCostPerEdit.asCompactCurrency())) + Text(L("vs %1$@ @ %2$@/edit", routingWaste.baselineModel, routingWaste.baselineCostPerEdit.asCompactCurrency())) .font(.system(size: 9.5)) .foregroundStyle(.quaternary) } @@ -2301,7 +2301,7 @@ private struct KimiPlanInsight: View { private func loadedBody(usage: KimiUsage, idle: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text(usage.plan ?? "Kimi Code") + Text(usage.plan ?? L("Kimi Code")) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() @@ -2412,7 +2412,7 @@ private struct GeminiPlanInsight: View { private func loadedBody(usage: GeminiUsage, idle: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text(usage.plan ?? "Gemini") + Text(usage.plan ?? L("Gemini")) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() @@ -2513,7 +2513,7 @@ private struct CopilotPlanInsight: View { private func loadedBody(usage: CopilotUsage, idle: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text(usage.plan ?? "Copilot") + Text(usage.plan ?? L("Copilot")) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() @@ -2595,7 +2595,7 @@ private struct AntigravityPlanInsight: View { private func loadedBody(usage: AntigravityUsage, idle: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text(usage.plan ?? "Antigravity") + Text(usage.plan ?? L("Antigravity")) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() @@ -2732,14 +2732,14 @@ private struct ProjectionCaption: View { : L("%@%% in reserve", String(format: "%.0f", -delta)) if projection.compact { return stage } if projection.willOverflow, let hit = projection.hitsLimitAt { - return L("%@ · hits 100%% %@", stage, relativeReset(hit)) + return L("%1$@ · hits 100%% %2$@", stage, relativeReset(hit)) } - return L("%@ · %@ at reset", stage, projected) + return L("%1$@ · %2$@ at reset", stage, projected) } switch projection.source { case .linear: if projection.willOverflow, let hit = projection.hitsLimitAt { - return L("On pace: %@ at reset · hits 100%% %@", projected, relativeReset(hit)) + return L("On pace: %1$@ at reset · hits 100%% %2$@", projected, relativeReset(hit)) } return L("On pace: %@ at reset", projected) case .historicalBaseline: diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index cb3f08a7e..6109ba1ac 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -211,7 +211,7 @@ private struct CombinedDeviceBreakdown: View { HStack(spacing: 4) { Image(systemName: "desktopcomputer") .font(.system(size: 10)) - Text(L("%lld of %lld devices", usage.combined.reachableCount, usage.combined.deviceCount)) + Text(L("%1$lld of %2$lld devices", usage.combined.reachableCount, usage.combined.deviceCount)) .font(.system(size: 11, weight: .medium)) } .foregroundStyle(.secondary) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 913a708ac..c2595e004 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -250,7 +250,7 @@ private struct EmptyProviderState: View { Image(systemName: "tray") .font(.system(size: 26)) .foregroundStyle(.tertiary) - Text(L("No %@ data for %@", provider.displayLabel, periodLabel)) + Text(L("No %1$@ data for %2$@", provider.displayLabel, periodLabel)) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 9b5d124ba..e44d39aa1 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -558,7 +558,7 @@ private struct GeneralSettingsTab: View { Text(L("Posts a notification when a provider resets a usage limit before its scheduled time, so you know the capacity is back. The Capacity Dock shows the same notice for 12 hours either way.")) .font(.system(size: 11)) .foregroundStyle(.secondary) - Toggle(L("Quota crossings (%lld%% and %lld%%)", 80, 100), isOn: $notifyAboutQuotaCrossings) + Toggle(L("Quota crossings (%1$lld%% and %2$lld%%)", 80, 100), isOn: $notifyAboutQuotaCrossings) } Section(L("Terminal")) { diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift index afc61dba5..56b27a2da 100644 --- a/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationCatalogTests.swift @@ -35,7 +35,8 @@ struct LocalizationCatalogTests { ) } - /// Specifier occurrences in order, `%%` included. + /// Specifier occurrences in order, `%%` included. Positional specifiers + /// (`%1$@`) keep their index inside the token; bare ones (`%@`) stay bare. static func specifiers(in value: String) -> [String] { var found: [String] = [] var rest = Substring(value) @@ -53,6 +54,12 @@ struct LocalizationCatalogTests { token.append(rest[cursor]) cursor = rest.index(after: cursor) } + // A `$` right after the digits makes this a positional specifier: + // the index is part of the contract, so it stays in the token. + if cursor < rest.endIndex, rest[cursor] == "$" { + token.append("$") + cursor = rest.index(after: cursor) + } while cursor < rest.endIndex, "lhqLzjt".contains(rest[cursor]) { token.append(rest[cursor]) cursor = rest.index(after: cursor) @@ -68,11 +75,32 @@ struct LocalizationCatalogTests { } /// The specifiers that consume an argument. `String(format:)` binds these - /// positionally, so their order is part of the contract between locales. + /// by slot, so slot agreement is part of the contract between locales. static func arguments(in value: String) -> [String] { specifiers(in: value).filter { $0 != "%%" } } + /// Slot number → verb for every argument specifier. A positional specifier + /// (`%2$lld`) names its own slot; a bare one (`%@`) takes the next slot in + /// order of appearance, which is how `String(format:)` binds it. + static func argumentSlots(in value: String) -> [Int: String] { + var slots: [Int: String] = [:] + var next = 1 + for token in arguments(in: value) { + if let dollar = token.firstIndex(of: "$") { + let digits = token[token.index(after: token.startIndex).. Int { specifiers(in: value).filter { $0 == "%%" }.count } @@ -142,22 +170,43 @@ struct LocalizationCatalogTests { // MARK: - Format specifiers - @Test("argument specifiers match in count and order across locales") + @Test("every argument slot agrees on its verb across locales") func argumentSpecifierParity() throws { let en = try Self.table("en") let zh = try Self.table("zh-Hans") for key in en.keys.sorted() { guard let english = en[key], let chinese = zh[key] else { continue } - let expected = Self.arguments(in: english) - let actual = Self.arguments(in: chinese) + let expected = Self.argumentSlots(in: english) + let actual = Self.argumentSlots(in: chinese) #expect( expected == actual, - "specifier mismatch for \(key.debugDescription): en \(expected) vs zh-Hans \(actual). String(format:) binds positionally, so a reorder or a dropped specifier is a wrong value or a crash." + "specifier mismatch for \(key.debugDescription): en \(expected) vs zh-Hans \(actual). String(format:) binds by slot, so a slot with a different verb is a wrong value or a crash." ) } } + /// A key whose arguments are all the same type (`%@ … %@`) cannot be checked + /// for order by comparing specifier lists: the slots bind positionally, so + /// Chinese word order may legally reorder them, and only positional + /// specifiers say which argument landed where. Swapped slots in exactly + /// this shape shipped once (#1331); this pins the positional form so the + /// slot comparison above has something explicit to compare. + @Test("keys with two or more arguments use positional specifiers in every locale") + func multiArgumentKeysArePositional() throws { + for localization in ["en", "zh-Hans"] { + for (key, value) in try Self.table(localization) { + let args = Self.arguments(in: value) + guard args.count >= 2 else { continue } + let bare = args.filter { !$0.contains("$") } + #expect( + bare.isEmpty, + "\(key.debugDescription) in \(localization) has \(args.count) arguments but \(bare.debugDescription) are bare: use %1$…, %2$… so each slot is explicit and checkable" + ) + } + } + } + @Test("literal percent signs survive translation") func literalPercentParity() throws { let en = try Self.table("en") @@ -230,13 +279,13 @@ struct LocalizationCatalogTests { @Test("percent-bearing quota copy formats correctly in both locales") func quotaCopyResolves() throws { - let englishOverLimit = try Self.localized("%@ over limit (%lld%%)", "en", "Claude", 105) + let englishOverLimit = try Self.localized("%1$@ over limit (%2$lld%%)", "en", "Claude", 105) #expect(englishOverLimit == "Claude over limit (105%)") - let chineseOverLimit = try Self.localized("%@ over limit (%lld%%)", "zh-Hans", "Claude", 105) + let chineseOverLimit = try Self.localized("%1$@ over limit (%2$lld%%)", "zh-Hans", "Claude", 105) #expect(chineseOverLimit == "Claude 已超限(105%)") - let countdown = try Self.localized("%lldh %lldm", "zh-Hans", 2, 11) + let countdown = try Self.localized("%1$lldh %2$lldm", "zh-Hans", 2, 11) #expect(countdown == "2 小时 11 分") // The quota warning banner's reset clause wraps that same countdown. diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift index 680b499e3..628e6afb9 100644 --- a/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationCoverageTests.swift @@ -249,6 +249,76 @@ struct LocalizationCoverageTests { ) } + // MARK: - Expression arguments (#1331) + // + // A literal that is not the argument's first token used to be invisible: + // the first-character check read `Text(flag ? "A" : "B")` as machinery and + // moved on. The three shapes below all shipped copy that way. + + @Test("ternary branches are copy, not control flow") + func flagsTernaryBranches() { + let source = """ + Text(isEarly ? "Came early" : "On time") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Came early", "On time"] + ) + } + + @Test("a literal concatenated onto a variable is copy") + func flagsConcatenatedLiterals() { + let source = """ + view.help(prefix + " and counting") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == [" and counting"] + ) + } + + @Test("a let-bound literal passed to a call site is reported; a bound L(…) is not") + func tracesLetBoundLiterals() { + let bare = """ + let tip = "Quota resets at midnight" + view.help(tip) + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: bare, fileName: "V.swift") + .map(\.literal) == ["Quota resets at midnight"] + ) + + let routed = """ + let tip = L("Quota resets at midnight") + view.help(tip) + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: routed, fileName: "V.swift").isEmpty + ) + } + + @Test("Text(verbatim:) is the explicit do-not-localize opt-out") + func acceptsVerbatim() { + let source = """ + Text(verbatim: "· \\(name)") + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift").isEmpty + ) + } + + @Test("literals inside a call's closures are reported once, by their own call site") + func doesNotDoubleReportExpressions() { + let source = """ + Button(action: save) { Text("Save now") } + Section(header: Text("Sessions")) { } + """ + #expect( + LocalizationSourceScanner.unroutedLiterals(inSource: source, fileName: "V.swift") + .map(\.literal) == ["Save now", "Sessions"] + ) + } + @Test("a URL inside a literal does not read as a comment") func survivesURLsInLiterals() { let source = """ diff --git a/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift b/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift index c13e1cf07..ce4f23a4c 100644 --- a/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift +++ b/mac/Tests/CodeBurnMenubarTests/LocalizationSourceScanner.swift @@ -132,6 +132,7 @@ enum LocalizationSourceScanner { static func unroutedLiterals(inSource source: String, fileName: String) -> [Finding] { let code = Array(strippingComments(source)) var findings: [Finding] = [] + let bindings = literalBindings(in: code) for callSite in userFacingCallSites { let needle = Array(callSite) @@ -157,6 +158,21 @@ enum LocalizationSourceScanner { literal: literal.value ) ) + } else if cursor < code.count, code[cursor] != "\"" { + // The argument is an expression, not a bare literal: a + // ternary (`Text(flag ? "A" : "B")`), a concatenation + // (`.help(prefix + " suffix")`), or a variable holding a + // literal bound earlier in the file (#1331). Those all put + // copy on screen while looking like machinery to the + // first-character check above. + findings += unroutedExpressionLiterals( + after: cursor, + at: index, + callSite: callSite, + in: code, + bindings: bindings, + fileName: fileName + ) } index += needle.count } @@ -166,6 +182,206 @@ enum LocalizationSourceScanner { return findings.sorted { ($0.line, $0.literal) < ($1.line, $1.literal) } } + // MARK: - Expression arguments (#1331) + + /// A `let`/`var` whose right-hand side is a single string literal (or a + /// single `L("…")`), collected per file so a call site handed a bare + /// identifier can be traced back to the copy it carries. + struct LiteralBinding { + let name: String + let literal: String + /// True when the bound literal already sits inside `L(…)`. + let routed: Bool + } + + /// The first `let`/`var name =` binding per identifier in the file. Swift + /// allows shadowing, so this is an approximation — but the shape the issue + /// names (one literal, one use in a `.help`) is exactly the file-local one, + /// and a cross-file constant is out of reach for a source scan anyway. + static func literalBindings(in code: [Character]) -> [String: LiteralBinding] { + var bindings: [String: LiteralBinding] = [:] + for keyword in ["let ", "var "] { + let needle = Array(keyword) + var index = 0 + while index + needle.count <= code.count { + guard Array(code[index..<(index + needle.count)]) == needle, + startsAWord(needle, at: index, in: code) else { + index += 1 + continue + } + var cursor = index + needle.count + while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } + var name = "" + while cursor < code.count, code[cursor].isLetter || code[cursor].isNumber || code[cursor] == "_" { + name.append(code[cursor]) + cursor += 1 + } + while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } + guard !name.isEmpty, cursor < code.count, code[cursor] == "=" else { + index += needle.count + continue + } + cursor += 1 + while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } + if cursor + 1 < code.count, code[cursor] == "L", code[cursor + 1] == "(" { + cursor += 2 + while cursor < code.count, code[cursor].isWhitespace { cursor += 1 } + if cursor < code.count, code[cursor] == "\"", + let literal = stringLiteral(in: code, startingAt: cursor) { + if bindings[name] == nil { + bindings[name] = LiteralBinding(name: name, literal: unescaped(literal.value), routed: true) + } + } + } else if cursor < code.count, code[cursor] == "\"", + let literal = stringLiteral(in: code, startingAt: cursor) { + if bindings[name] == nil { + bindings[name] = LiteralBinding(name: name, literal: unescaped(literal.value), routed: false) + } + } + index += needle.count + } + } + return bindings + } + + /// The extent of one call argument: from just after the open parenthesis to + /// the enclosing close (or a top-level comma, since only the first + /// argument of each call site is copy). Bracket-balanced so closures and + /// nested calls inside the argument do not end it early. + static func argumentSpan(in code: [Character], from start: Int) -> Range { + var depth = 0 + var index = start + while index < code.count { + switch code[index] { + case "(", "[", "{": + depth += 1 + case ")", "]", "}": + depth -= 1 + if depth < 0 { return start..