mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Merge pull request #22 from awizemann/claude/pedantic-kare-1edf13
feat(i18n): enable String Catalog + locale-aware numeric formatters
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
Scarf uses Apple's modern **String Catalog** workflow. Source strings are auto-extracted from `Text("…")` and `String(localized: …)` literals into [`scarf/scarf/Localizable.xcstrings`](../scarf/Localizable.xcstrings) at build time (when built in Xcode.app; `xcodebuild` alone emits per-source `.stringsdata` but does not merge back into the catalog). Info.plist keys are localized via [`scarf/scarf/InfoPlist.xcstrings`](../scarf/InfoPlist.xcstrings).
|
||||
|
||||
## Languages
|
||||
|
||||
| Locale | Status |
|
||||
|---|---|
|
||||
| `en` (English) | Base / source |
|
||||
| `zh-Hans` (Simplified Chinese) | Region enabled, translations pending |
|
||||
| `de` (German) | Region enabled, translations pending |
|
||||
| `fr` (French) | Region enabled, translations pending |
|
||||
|
||||
Canadian French users are served by base `fr`. `fr-CA` will be added only if a concrete Québec-specific bug is reported.
|
||||
|
||||
## Adding a new language
|
||||
|
||||
1. Xcode → Project → Info → Localizations → `+` (add locale).
|
||||
2. Ensure the locale code is also listed in `knownRegions` of `scarf.xcodeproj/project.pbxproj`.
|
||||
3. Open `Localizable.xcstrings` in Xcode; the new locale appears as an empty column — translate or use Xcode's AI suggestions.
|
||||
4. Repeat for `InfoPlist.xcstrings` (microphone usage, etc.).
|
||||
5. Smoke-test via scheme language override (Edit Scheme → Run → App Language).
|
||||
|
||||
## Adding translations (AI-first workflow)
|
||||
|
||||
For the three supported non-English locales we use Xcode's built-in AI translation:
|
||||
|
||||
1. Open `Localizable.xcstrings` in Xcode.
|
||||
2. Select untranslated rows for a locale → right-click → **Translate** (Xcode 26+ provides GPT-backed suggestions with context from the surrounding code comment).
|
||||
3. Review each suggestion before marking **Translated**.
|
||||
4. For terms that should NOT translate (proper nouns like *Scarf*, *Hermes*, *Anthropic*; env var names; file paths), wrap the source site in `Text(verbatim: "…")` so the key never hits the catalog.
|
||||
|
||||
## Guardrails when writing new UI code
|
||||
|
||||
`Text("literal")` auto-localizes. These patterns **silently leak English** and need explicit handling:
|
||||
|
||||
| Pattern | Fix |
|
||||
|---|---|
|
||||
| `Text(someStringVar)` | `Text(LocalizedStringResource("key"))` or pass a `LocalizedStringKey` down the view tree |
|
||||
| `"Hello " + name` | `String(localized: "Hello \(name)")` |
|
||||
| `String(format: "$%.2f", cost)` | `cost.formatted(.currency(code: "USD").precision(.fractionLength(2)))` |
|
||||
| `String(format: "%.1f MB", size)` | `Int64(size).formatted(.byteCount(style: .file))` |
|
||||
| `String(format: "%.1fM", n)` | `n.formatted(.number.notation(.compactName))` |
|
||||
| Custom `DateFormatter` with fixed `dateFormat` | `date.formatted(.dateTime.month().day().year())` |
|
||||
| `.help(stringVar)` | Compute a `LocalizedStringKey` or use `.help(Text(…))` |
|
||||
| `Button(stringVar)` | `Button(LocalizedStringResource("key")) { … }` |
|
||||
|
||||
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
|
||||
|
||||
## Outstanding audit follow-ups
|
||||
|
||||
The [initial i18n PR](#) enabled the catalog infrastructure and migrated clear locale-bug formatter sites. The following patterns remain in the codebase and should be refactored **before** translations ship in Phase 2 — otherwise their English copy will leak through regardless of locale:
|
||||
|
||||
### Category A — UI copy held in a `String` variable (needs `LocalizedStringResource`)
|
||||
|
||||
High-priority (ternary UI copy, visible status chrome):
|
||||
|
||||
- `Features/Health/Views/HealthView.swift:135` — `"Hermes Running"` / `"Hermes Stopped"` ternary
|
||||
- `Features/Chat/Views/ChatView.swift:125` — `"Active"` fallback
|
||||
- `Features/Chat/Views/ChatView.swift:241` — `"Voice On" / "Voice Off"`
|
||||
- `Features/Chat/Views/ChatView.swift:256` — `"TTS On" / "TTS Off"`
|
||||
- `Features/Chat/Views/ChatView.swift:271` — `"Recording..." / "Push to Talk"`
|
||||
- `Features/MCPServers/Views/MCPServerTestResultView.swift:13` — `"Test passed" / "Test failed"`
|
||||
- `Features/Profiles/Views/ProfilesView.swift:145` — `"Active profile" / "Inactive"`
|
||||
- `Features/Platforms/Views/PlatformSetup/SignalSetupView.swift:54` — `"signal-cli is available on PATH"` / not-found message
|
||||
- `Features/QuickCommands/Views/QuickCommandsView.swift:148` — `"Add Quick Command"` / `"Edit /\(name)"` ternary
|
||||
- `Features/MCPServers/Views/MCPServersView.swift:131` — `.help("\(count) tools")` vs `"Test failed"` ternary
|
||||
- `Features/MCPServers/Views/MCPServerDetailView.swift:157, 185` — masked-value placeholder
|
||||
|
||||
Medium-priority (enum `.rawValue` → display):
|
||||
|
||||
- `Features/Logs/Views/LogsView.swift:30,38,48,69` — file / component / level display
|
||||
- `Features/Projects/Views/ProjectsView.swift:153` — tab display
|
||||
- `Features/Skills/Views/SkillsView.swift:37` — tab display
|
||||
- `Features/Insights/Views/InsightsView.swift:40, 201` — period picker, day-of-week names (see also Category C)
|
||||
- `Features/CredentialPools/Views/CredentialPoolsView.swift:265` — type display
|
||||
- `Features/Activity/Views/ActivityView.swift:117` — kind display
|
||||
|
||||
Lower-priority (displayName passthroughs from services that could be wrapped once at the source):
|
||||
|
||||
- `Features/Platforms/Views/PlatformsView.swift:43, 91`, `Features/Tools/Views/ToolsView.swift:46` — `platform.displayName`
|
||||
- `Features/Gateway/Views/GatewayView.swift:105` — `platform.name.capitalized`
|
||||
- `Features/MCPServers/Views/*` — `transport.displayName`, preset names / descriptions
|
||||
- `Features/Servers/Views/ServerSwitcherToolbar.swift:40`, `ManageServersView.swift:96` — `server.displayName`
|
||||
|
||||
### Category B — Composite format strings with translatable suffixes
|
||||
|
||||
- `Features/Settings/Views/Components/ModelPickerSheet.swift:105` — `ctx + " ctx"` — literal " ctx" needs keying
|
||||
- `Features/Insights/Views/InsightsView.swift:93` — `formatTokens(...) + " tokens"` — literal " tokens" needs keying
|
||||
- `Features/MCPServers/Views/MCPServerTestResultView.swift:15` — `"%.1fs · %d tools"` — needs splitting into a `String(localized: "\(elapsed)s · \(count) tools")`
|
||||
- `Features/Insights/Views/InsightsView.swift:167` — `"%.1f%%"` — needs `.formatted(.percent)` after verifying the input range (0…1 vs 0…100)
|
||||
|
||||
### Category C — Hard-coded day / month name arrays
|
||||
|
||||
- `Features/Insights/Views/InsightsView.swift:196` — `["Mon", "Tue", …]` literal — use `Calendar.current.shortWeekdaySymbols` (locale-aware).
|
||||
|
||||
### Category D — `.help(stringVar)` sites
|
||||
|
||||
- `Features/Servers/Views/ConnectionStatusPill.swift:42` — `.help(tooltip)`; refactor `tooltip` to return `LocalizedStringKey` / `LocalizedStringResource`.
|
||||
|
||||
### Non-blocking (intentional verbatim)
|
||||
|
||||
The following are correct as-is because they pass user data or machine-readable content through to the UI:
|
||||
|
||||
- Session titles, message content, memory / skill / YAML file contents, log lines, shell commands, file paths, session IDs, model IDs, credential sources, URL strings.
|
||||
|
||||
If we later need to badge these (e.g. "(empty)" placeholder), the badge itself becomes a localizable key while the data passthrough stays verbatim.
|
||||
@@ -214,6 +214,9 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
de,
|
||||
fr,
|
||||
);
|
||||
mainGroup = 534959372F7B83B600BD31AD;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
|
||||
@@ -86,8 +86,8 @@ enum WidgetValue: Codable, Sendable, Hashable {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(format: "%.1f", n)
|
||||
? Int(n).formatted(.number)
|
||||
: n.formatted(.number.precision(.fractionLength(1)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
return String(format: "$%.2f / $%.2f", input, output)
|
||||
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
|
||||
return "\(input.formatted(currency)) / \(output.formatted(currency))"
|
||||
}
|
||||
|
||||
/// Display-friendly context window ("200K", "1M", etc.).
|
||||
|
||||
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
|
||||
}
|
||||
|
||||
if let cost = session.displayCostUSD {
|
||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ struct DashboardView: View {
|
||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||
if cost > 0 {
|
||||
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||
StatCard(label: "Cost", value: cost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ struct SessionRow: View {
|
||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||
if let cost = session.displayCostUSD, cost > 0 {
|
||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||
Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
@@ -61,10 +61,10 @@ struct InsightsView: View {
|
||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||
InsightCard(label: "Total Cost", value: viewModel.totalCost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
|
||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : (Double(viewModel.totalMessages) / Double(viewModel.sessions.count)).formatted(.number.precision(.fractionLength(1))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +159,7 @@ final class SessionsViewModel {
|
||||
let dbPath = context.paths.stateDB
|
||||
let fileSize: String
|
||||
if let stat = context.makeTransport().stat(dbPath) {
|
||||
let size = Double(stat.size)
|
||||
if size >= FileSizeUnit.megabyte {
|
||||
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
|
||||
} else {
|
||||
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
|
||||
}
|
||||
fileSize = Int64(stat.size).formatted(.byteCount(style: .file))
|
||||
} else {
|
||||
fileSize = "unknown"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ struct SessionDetailView: View {
|
||||
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||
}
|
||||
if let cost = session.displayCostUSD {
|
||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||
}
|
||||
if let date = session.startedAt {
|
||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||
|
||||
@@ -224,7 +224,7 @@ struct DoubleStepperRow: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(String(format: "%.2f", value))
|
||||
Text(value.formatted(.number.precision(.fractionLength(2))))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Stepper("", value: Binding(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"NSMicrophoneUsageDescription" : {
|
||||
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Scarf uses the microphone for Hermes voice chat."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user