From 5f37f0f804d260a5ca061c38bdf9380ea02899f3 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 20:29:33 -0300 Subject: [PATCH 01/29] chore(i18n): add missing en.json keys for translator, cli-tools, memory, onboarding Adds 58 missing keys identified by the new dashboard audit script: - cliTools: 18 custom CLI builder keys (CustomCliCard) - translator: 24 keys covering stream transformer, live monitor, test bench - memory: 12 health/pagination/dialog keys - onboarding.tier: 8 keys for the tier tour walkthrough Also adds scripts/i18n/audit-dashboard-pages.mjs which scans all dashboard pages, reports t() calls referencing missing en.json keys, and flags candidate hardcoded JSX/attribute strings. --- .gitignore | 3 + scripts/i18n/audit-dashboard-pages.mjs | 284 +++++++++++++++++++++++++ src/i18n/messages/en.json | 90 +++++++- 3 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 scripts/i18n/audit-dashboard-pages.mjs diff --git a/.gitignore b/.gitignore index c4d3037cb6..23659f5073 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ http-client.private.env.json # Feature-triage ephemeral artifact (regenerated each run) _ideia/_triage.json + +# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs) +scripts/i18n/_audit.json diff --git a/scripts/i18n/audit-dashboard-pages.mjs b/scripts/i18n/audit-dashboard-pages.mjs new file mode 100644 index 0000000000..cefbced5b9 --- /dev/null +++ b/scripts/i18n/audit-dashboard-pages.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/** + * Audit every dashboard page for: + * 1. Hardcoded user-visible strings in JSX (any language) + * 2. t("...") calls that reference keys NOT present in en.json + * + * Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout. + * + * Scope: src/app/(dashboard)/**\/*.tsx + */ +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, dirname, relative, basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "..", ".."); +const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json"); +const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)"); +const OUT_PATH = join(__dirname, "_audit.json"); + +const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8")); + +/** + * Deep-convert a parsed JSON object into a Map tree. + * Using Map sidesteps prototype-pollution concerns when keys are + * derived from source-code scraping (CWE-915). + */ +function toMapTree(value) { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(toMapTree); + const m = new Map(); + for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v)); + return m; +} +const enJson = toMapTree(enJsonRaw); + +/** Walk a directory recursively and return all .tsx files */ +function walkTsx(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) out.push(...walkTsx(full)); + else if (entry.endsWith(".tsx")) out.push(full); + } + return out; +} + +/** Walk a dotted path through the Map tree, returning the leaf or undefined */ +function walkPath(parts) { + let cursor = enJson; + for (const p of parts) { + if (!(cursor instanceof Map) || !cursor.has(p)) return undefined; + cursor = cursor.get(p); + } + return cursor; +} + +/** Check if a dotted key exists, supporting dotted namespaces too */ +function keyExists(namespace, key) { + const nsParts = namespace ? namespace.split(".") : []; + const keyParts = key.split("."); + return walkPath([...nsParts, ...keyParts]) !== undefined; +} + +/** Extract useTranslations("ns") and getTranslations("ns") calls */ +function extractNamespaces(source) { + const namespaces = []; + const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g; + for (const m of source.matchAll(reNs)) { + namespaces.push(m[1]); + } + // Without arg: bare, key must be fully qualified + if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null); + return namespaces; +} + +/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */ +function extractTCalls(source) { + const out = new Set(); + // t("..."), t('...') — match only quoted string literals (skip template literals) + const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g; + for (const m of source.matchAll(re)) out.add(m[1]); + // translateOrFallback("key", fallback) — also t("key") style + for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) { + out.add(m[1]); + } + return [...out]; +} + +/** + * Find candidate hardcoded user-visible strings in JSX. + * + * Patterns we detect: + * A. JSX text: >Some text< (must contain a letter, length >= 2) + * B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..." + * C. Toast/error literals: toast.error("...") toast.success("...") + * + * We DELIBERATELY skip: + * - strings inside {t(...)} or {tOrFallback(...)} + * - strings inside import statements + * - strings inside CSS class names (className/style) + * - URLs (start with /, http, mailto:, #) + * - All-uppercase/snake constants (CONSTANT_VAR) + * - pure-symbol strings (no letters) + * - lonely-word JSX text that is a console.log / pino call argument + */ +// TS / JS noise that the naive regex picks up between two `>` chars +const TS_TYPE_NOISE = new Set([ + "Promise", + "Record", + "Map", + "Set", + "Array", + "ReadonlyArray", + "Partial", + "Pick", + "Omit", + "Required", + "Readonly", + "Awaited", + "ReturnType", + "Parameters", + "void | Promise", + "Promise | undefined", +]); + +function findHardcodedStrings(source) { + const findings = []; + const lines = source.split("\n"); + + // (A) JSX text between > and < — fragile but good enough for an audit pass + let lineIdx = 0; + for (const line of lines) { + lineIdx++; + // Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines) + if ( + /^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line) + ) + continue; + // Skip lines that are *clearly* TypeScript signatures + if ( + /:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*/g, "__ARROW__") + .replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags + .replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like or + + let m; + const re = />([^<>{}\n]+)=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue; + // pure number or simple variable.member with no spaces + if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue; + findings.push({ kind: "jsx-text", line: lineIdx, value: raw }); + } + } + + // (B) JSX attributes that are user-visible + const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g; + lineIdx = 0; + for (const line of lines) { + lineIdx++; + if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue; + let m; + const re = new RegExp(attrRe.source, "g"); + while ((m = re.exec(line)) !== null) { + const value = m[2].trim(); + if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue; + if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key + findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value }); + } + } + + // (C) toast.* and Error() arguments that look like user copy (length > 5, has a space) + lineIdx = 0; + const callRe = + /\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g; + for (const line of lines) { + lineIdx++; + let m; + const re = new RegExp(callRe.source, "g"); + while ((m = re.exec(line)) !== null) { + findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() }); + } + } + + return findings; +} + +/** Find t() calls whose key does NOT exist in en.json */ +function findMissingKeys(namespaces, tKeys) { + const missing = []; + for (const key of tKeys) { + // A key may itself contain dots — handle namespace dotted into key + let found = false; + for (const ns of namespaces) { + if (keyExists(ns, key)) { + found = true; + break; + } + } + // Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations) + if (!found && key.includes(".")) { + const [maybeNs, ...rest] = key.split("."); + if (keyExists(maybeNs, rest.join("."))) found = true; + } + if (!found) missing.push(key); + } + return missing; +} + +const files = walkTsx(DASHBOARD_ROOT); +const report = []; + +for (const file of files) { + const rel = relative(REPO_ROOT, file); + const src = readFileSync(file, "utf8"); + const namespaces = extractNamespaces(src); + const tCalls = extractTCalls(src); + const hardcoded = findHardcodedStrings(src); + const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls); + // Only include files that have ANY user-visible content + if (!namespaces.length && !tCalls.length && !hardcoded.length) continue; + report.push({ + file: rel, + namespaces, + tCallCount: tCalls.length, + missingKeys: missing, + hardcodedCount: hardcoded.length, + hardcoded, + }); +} + +writeFileSync(OUT_PATH, JSON.stringify(report, null, 2)); + +// Human summary +let totalMissing = 0; +let totalHardcoded = 0; +console.log("# i18n audit — dashboard pages\n"); +console.log(`Scanned ${report.length} files with user-visible content.\n`); +const onlyIssues = report + .map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length })) + .filter((r) => r.total > 0) + .sort((a, b) => b.total - a.total); + +for (const r of onlyIssues) { + totalMissing += r.missingKeys.length; + totalHardcoded += r.hardcoded.length; + console.log(`\n## ${r.file}`); + console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`); + if (r.missingKeys.length) { + console.log(` ✗ missing keys (${r.missingKeys.length}):`); + for (const k of r.missingKeys) console.log(` - ${k}`); + } + if (r.hardcoded.length) { + console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`); + for (const h of r.hardcoded.slice(0, 30)) { + console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`); + } + if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`); + } +} + +console.log(`\n# Summary`); +console.log(`Files with issues: ${onlyIssues.length}`); +console.log(`Missing keys total: ${totalMissing}`); +console.log(`Hardcoded strings: ${totalHardcoded}`); +console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index fa112858e9..cd8b9d5104 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1640,7 +1640,25 @@ "mitmClientsTab": "MITM Clients", "customCliTab": "Custom CLI", "toolCategories": "Tool Categories", - "visibleToolsCount": "{count} tools available" + "visibleToolsCount": "{count} tools available", + "customCliBuilderTitle": "OpenAI-compatible CLI builder", + "customCliBuilderDescription": "Generate env vars and JSON snippets for any CLI or SDK that accepts an OpenAI-compatible base URL, API key, and model ID.", + "customCliNoModels": "Connect at least one provider to populate the model selectors.", + "customCliNameLabel": "CLI name", + "customCliNamePlaceholder": "e.g. My Team CLI", + "customCliDefaultModelLabel": "Default model", + "customCliDefaultModelHelp": "Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string.", + "customCliKeyHelper": "For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys.", + "customCliAliasMappingsLabel": "Alias mappings", + "customCliAliasMappingsHelp": "Optional helper aliases for wrapper scripts or config files that want stable shorthand names.", + "customCliAddAlias": "Add alias", + "customCliNoMappings": "No alias mappings yet. Add one if your wrapper or team scripts use stable short names.", + "customCliAliasPlaceholder": "e.g. review", + "customCliTargetModelLabel": "Target model", + "customCliEndpointHintLabel": "How to wire the endpoint", + "customCliEndpointHint": "Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.", + "customCliEnvBlockTitle": "Env / shell snippet", + "customCliJsonBlockTitle": "Provider JSON block" }, "combos": { "title": "Combos", @@ -2528,7 +2546,19 @@ "episodic": "Episodic", "procedural": "Procedural", "semantic": "Semantic", - "a": "A" + "a": "A", + "pipelineOk": "Pipeline OK ({latencyMs}ms)", + "pipelineError": "Pipeline error", + "healthUnknown": "Health unknown", + "checkingHealth": "Checking…", + "checkHealth": "Check health", + "pageInfo": "Page {page} of {totalPages} ({total} total)", + "previous": "Previous", + "next": "Next", + "cancel": "Cancel", + "save": "Save", + "keyPlaceholder": "e.g. user.preferences.theme", + "contentPlaceholder": "Value or JSON content to remember" }, "skills": { "title": "Skills", @@ -2825,7 +2855,23 @@ "failedAddProvider": "Failed to add provider. Try again.", "connectionError": "Connection error. Please try again.", "provider": "Provider", - "apiKeyHelp": "An API key is a password for AI services. Get one from your provider's website (e.g., platform.openai.com, console.anthropic.com)." + "apiKeyHelp": "An API key is a password for AI services. Get one from your provider's website (e.g., platform.openai.com, console.anthropic.com).", + "tier": { + "subtitle": "OmniRoute organises providers into three tiers so routing prefers the most reliable, lowest-cost path first.", + "tier1": { + "label": "Premium clients", + "description": "First-class CLIs with native auth flows and reasoning models." + }, + "tier2": { + "label": "Cost-optimised", + "description": "Cheap, high-throughput providers used for everyday traffic." + }, + "tier3": { + "label": "Fallback & specialty", + "description": "Locally-hosted or specialty endpoints used as fallbacks." + }, + "configure": "Configure providers" + } }, "providers": { "title": "Providers", @@ -4426,6 +4472,16 @@ }, "streaming": { "prompt": "Tell me a short story about a robot learning to paint." + }, + "vision": { + "system": "You are an assistant that describes images precisely.", + "userPrompt": "What is shown in this image?", + "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/600px-PNG_transparency_demonstration_1.png" + }, + "schemaCoercion": { + "userPrompt": "Look up the weather for Tokyo using metric units and include the hourly breakdown.", + "toolDescription": "Fetch weather for a city with structured options.", + "cityDescription": "The city to query, e.g. 'Tokyo' or 'New York'." } }, "openaiCompatibleLabel": "OpenAI Compatible", @@ -4471,7 +4527,33 @@ "eventSourceTranslatorPage": "• Translator page (Chat Tester, Test Bench)", "eventSourceMainPipeline": "• Main request pipeline (CLI/IDE/API traffic)", "liveMonitorDescriptionPrefix": "Shows translation events as API calls flow through OmniRoute. Events come from the in-memory buffer (resets on restart). Use", - "liveMonitorDescriptionSuffix": ", or external API calls to generate events." + "liveMonitorDescriptionSuffix": ", or external API calls to generate events.", + "streamTransformer": "Stream Transformer", + "modeDescriptionStreamTransformer": "Run chat completions SSE streams through the Responses transformer.", + "streamTransformerTitle": "Responses Stream Transformer", + "streamTransformerDescription": "Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client.", + "loadTextSample": "Load text sample", + "loadToolSample": "Load tool-call sample", + "transformToResponses": "Transform to Responses", + "rawChatSseInput": "Raw chat completions SSE", + "transformedResponsesSse": "Transformed Responses API SSE", + "noResultsYet": "No results yet", + "transformedEvents": "Transformed events", + "uniqueEventTypes": "Unique event types", + "inputLines": "Input lines", + "outputLines": "Output lines", + "transformedEventTimeline": "Transformed event timeline", + "transformerTimelineHint": "Run the transformer to inspect emitted response.output_* events in order.", + "eventType": "Event type", + "eventPreview": "Preview", + "comboRouted": "Combo routed", + "uniqueEndpoints": "Unique endpoints", + "routeDetails": "Route details", + "comboBadge": "Combo", + "routeEndpointLabel": "Endpoint", + "routeConnectionLabel": "Connection", + "scenarioVision": "Vision (image understanding)", + "scenarioSchemaCoercion": "Schema coercion (structured output)" }, "usage": { "title": "Usage", From 8d34f4c6506066caf253edc5b3df8b367b7449c8 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 21:21:46 -0300 Subject: [PATCH 02/29] chore(i18n): replace hardcoded UI text with t() calls across dashboard (round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagents refactored 8 high-impact dashboard pages, replacing 81 of the 407 hardcoded English/PT strings flagged by the audit with proper useTranslations() lookups. Added 73 corresponding keys to en.json across the home, apiManager, providers, settings, and usage namespaces. Pages affected: - BudgetTab (27 → 0) - HomePageClient (2 → 0) - RoutingTab (25 → 7) - ResilienceTab (38 → 18) - SystemStorageTab (42 → 21) - providers/[id] (17 → 15) - ApiManagerPageClient (14 → 13) - OneproxyTab (13 → 10) Also adds two helper scripts: - scripts/i18n/extract-keys-from-diff.mjs — extracts new keys from git diff - scripts/i18n/merge-keys.mjs — merges a pending-keys JSON into en.json Remaining hardcoded strings will be addressed in follow-up rounds. --- .gitignore | 1 + scripts/i18n/extract-keys-from-diff.mjs | 130 ++++++++++++++++++ scripts/i18n/merge-keys.mjs | 38 +++++ .../(dashboard)/dashboard/HomePageClient.tsx | 4 +- .../api-manager/ApiManagerPageClient.tsx | 2 +- .../dashboard/providers/[id]/page.tsx | 4 +- .../settings/components/OneproxyTab.tsx | 6 +- .../settings/components/ResilienceTab.tsx | 47 ++++--- .../settings/components/RoutingTab.tsx | 38 ++--- .../settings/components/SystemStorageTab.tsx | 42 +++--- .../dashboard/usage/components/BudgetTab.tsx | 31 +++-- src/i18n/messages/en.json | 83 ++++++++++- 12 files changed, 340 insertions(+), 86 deletions(-) create mode 100644 scripts/i18n/extract-keys-from-diff.mjs create mode 100644 scripts/i18n/merge-keys.mjs diff --git a/.gitignore b/.gitignore index 23659f5073..13d4236f33 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ _ideia/_triage.json # i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs) scripts/i18n/_audit.json +scripts/i18n/_pending-keys.json diff --git a/scripts/i18n/extract-keys-from-diff.mjs b/scripts/i18n/extract-keys-from-diff.mjs new file mode 100644 index 0000000000..7d0aece27e --- /dev/null +++ b/scripts/i18n/extract-keys-from-diff.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * Extract NEW i18n keys created by subagents from git diff. + * + * For each modified .tsx file, walk the unified diff and pair "-" lines + * (containing literal English text) with their following "+" lines + * (now using `t("key")`). When we find a stable pairing, record the key + * and its original English value. + * + * The output is a per-namespace map ready to merge into en.json. + */ +import { execSync } from "node:child_process"; + +const diff = execSync('git diff --unified=0 "src/app/(dashboard)"', { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, +}); + +const blocks = diff.split(/^diff --git /m).slice(1); + +const allPairs = []; // { file, removed, added } + +for (const block of blocks) { + const headLine = block.split("\n")[0]; + const fileMatch = headLine.match(/b\/(.+?)$/); + const file = fileMatch ? fileMatch[1] : "?"; + const lines = block.split("\n"); + + // Walk in groups: consecutive "-" lines, then consecutive "+" lines. + // Pair them positionally (removed[i] ↔ added[i]). + let removed = []; + let added = []; + function flush() { + const n = Math.min(removed.length, added.length); + for (let i = 0; i < n; i++) allPairs.push({ file, removed: removed[i], added: added[i] }); + // If removed.length > added.length, pair remaining removed with last added (multi-string in one new line) + if (removed.length > added.length && added.length > 0) { + for (let i = added.length; i < removed.length; i++) { + allPairs.push({ file, removed: removed[i], added: added[added.length - 1] }); + } + } + removed = []; + added = []; + } + for (const line of lines) { + if (line.startsWith("---") || line.startsWith("+++")) continue; + if (line.startsWith("-")) { + if (added.length) flush(); + removed.push(line.slice(1)); + } else if (line.startsWith("+")) { + added.push(line.slice(1)); + } else { + flush(); + } + } + flush(); +} + +/** Patterns inside JSX or attribute strings */ +const T_CALL_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g; +const JSX_TEXT_RE = />([^<>{}\n]+) { value, file } + +function recordKey(key, value, file) { + const trimmed = value.trim(); + if (!trimmed) return; + // Ignore if value contains JSX expression syntax — those were dynamic + if (/^\{|\}$/.test(trimmed)) return; + if (!newKeys.has(key)) { + newKeys.set(key, { value: trimmed, file }); + } +} + +for (const { file, removed, added } of allPairs) { + // Extract every t("xxx") key from the "added" line + const tKeys = [...added.matchAll(T_CALL_RE)].map((m) => m[1]); + if (!tKeys.length) continue; + + // Extract candidate strings from the "removed" line: JSX text + attr values + const candidates = []; + for (const m of removed.matchAll(JSX_TEXT_RE)) candidates.push(m[1]); + for (const m of removed.matchAll(ATTR_RE)) candidates.push(m[1]); + + // Direct strings without surrounding markup (rare) + const stripped = removed + .replace(/<[^<>]+>/g, "") + .replace(/\bt\([^)]*\)/g, "") + .trim(); + if (stripped && /[A-Za-z]/.test(stripped)) candidates.push(stripped); + + // For each tKey in added, try to align with the candidate at the same index. + // The agents typically replaced strings in the same left-to-right order. + for (let i = 0; i < tKeys.length; i++) { + const candidate = candidates[i] ?? candidates[candidates.length - 1]; + if (!candidate) continue; + recordKey(tKeys[i], candidate, file); + } +} + +// Group by file's primary namespace via useTranslations() call in file +import { readFileSync } from "node:fs"; +function inferNamespaceForFile(file) { + try { + const src = readFileSync(file, "utf8"); + const m = src.match(/useTranslations\s*\(\s*["']([^"']+)["']\s*\)/); + return m?.[1] ?? "common"; + } catch { + return "common"; + } +} + +const byNamespace = new Map(); +for (const [key, { value, file }] of newKeys) { + const ns = inferNamespaceForFile(file); + if (!byNamespace.has(ns)) byNamespace.set(ns, []); + byNamespace.get(ns).push({ key, value }); +} + +for (const [ns, items] of byNamespace) { + console.log(`\nNEW_KEYS_FOR_NAMESPACE: ${ns}`); + console.log("{"); + for (const { key, value } of items) { + // Escape value for JSON + console.log(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`); + } + console.log("}"); +} +console.log(`\nTotal extracted: ${newKeys.size} keys across ${byNamespace.size} namespaces`); diff --git a/scripts/i18n/merge-keys.mjs b/scripts/i18n/merge-keys.mjs new file mode 100644 index 0000000000..a2eaf43a7d --- /dev/null +++ b/scripts/i18n/merge-keys.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Merge keys from scripts/i18n/_pending-keys.json into src/i18n/messages/en.json + * + * Format of _pending-keys.json: + * { "namespace": { "key": "value", ... }, ... } + * + * Keys are appended to the END of each namespace block. Existing keys with + * the same name are preserved (we never overwrite). + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const SRC = "src/i18n/messages/en.json"; +const PENDING = "scripts/i18n/_pending-keys.json"; + +const enJson = JSON.parse(readFileSync(SRC, "utf8")); +const pending = JSON.parse(readFileSync(PENDING, "utf8")); + +let added = 0; +let skipped = 0; +for (const [ns, keys] of Object.entries(pending)) { + if (!Object.prototype.hasOwnProperty.call(enJson, ns)) { + console.warn(`! namespace missing in en.json: ${ns} — skipping`); + continue; + } + const target = enJson[ns]; + for (const [k, v] of Object.entries(keys)) { + if (Object.prototype.hasOwnProperty.call(target, k)) { + skipped++; + continue; + } + target[k] = v; + added++; + } +} + +writeFileSync(SRC, JSON.stringify(enJson, null, 2) + "\n"); +console.log(`✓ merged ${added} new keys (${skipped} skipped — already present)`); diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 5c6ad0a89f..7afcfe02a9 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -603,7 +603,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { check_circle {updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}

-

Reloading page automatically...

+

{t("reloadingPageAutomatically")}

)} @@ -788,7 +788,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
-

Provider Topology

+

{t("providerTopology")}

Connected providers routing through OmniRoute in real time

diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 8e2a146cbb..6227c3ad9a 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -1279,7 +1279,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Max Sessions Limit (T08) */}
-

Max Active Sessions

+

{t("maxActiveSessions")}

0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.

diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 3ca261e16f..bfc3c06f35 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3440,13 +3440,13 @@ export default function ProviderDetailPage() {

{t("connections")}

{providerId === "codex" && ( -
+
diff --git a/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx index b9b16520f6..c313af7dbf 100644 --- a/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx @@ -140,7 +140,7 @@ export default function OneproxyTab() {
-

1proxy Free Proxy Marketplace

+

{t("oneproxyTitle")}

Fetch and rotate free validated proxies from the 1proxy community platform

@@ -173,7 +173,7 @@ export default function OneproxyTab() {
{stats.total}
-
Total Proxies
+
{t("oneproxyTotalProxies")}
{stats.active}
@@ -183,7 +183,7 @@ export default function OneproxyTab() {
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
-
Avg Quality
+
{t("oneproxyAvgQuality")}
diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index bcc92aaeed..ab9434e51e 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -62,16 +62,17 @@ function SectionDescription({ trigger: string; effect: string; }) { + const t = useTranslations("settings"); return (
- Scope: {scope} + {t("resilienceScope")} {scope}
- Trigger: {trigger} + {t("resilienceTrigger")} {trigger}
- Effect: {effect} + {t("resilienceEffect")} {effect}
); @@ -203,6 +204,7 @@ function RequestQueueCard({ onSave: (next: RequestQueueSettings) => Promise; saving: boolean; }) { + const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -216,7 +218,7 @@ function RequestQueueCard({
speed -

Request Queue & Rate

+

{t("resilienceRequestQueueTitle")}

@@ -256,13 +258,13 @@ function RequestQueueCard({ } /> setDraft((prev) => ({ ...prev, requestsPerMinute }))} /> @@ -270,7 +272,7 @@ function RequestQueueCard({ } /> @@ -278,7 +280,7 @@ function RequestQueueCard({ } />
-
Auto-enable for API-key providers
+
+ {t("resilienceAutoEnableApiKeyProviders")} +
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
-
Requests per minute
+
{t("resilienceRequestsPerMinute")}
{value.requestsPerMinute}
-
Minimum time between requests
+
{t("resilienceMinTimeBetweenRequests")}
{formatMs(value.minTimeBetweenRequestsMs)}
-
Concurrent requests
+
{t("resilienceConcurrentRequests")}
{value.concurrentRequests}
-
Maximum queue wait time
+
{t("resilienceMaxQueueWaitTime")}
{formatMs(value.maxWaitMs)}
@@ -333,6 +337,7 @@ function ConnectionCooldownCard({ onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise; saving: boolean; }) { + const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -347,7 +352,7 @@ function ConnectionCooldownCard({ {editing ? ( <> @@ -368,7 +373,9 @@ function ConnectionCooldownCard({ />

diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 06a3fc578e..5f65d8fc1c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -303,6 +303,7 @@ function StringListEditor({ onChange: (next: string[]) => void; disabled?: boolean; }) { + const t = useTranslations("settings"); return (

{label} @@ -324,7 +325,7 @@ function StringListEditor({ size="sm" icon="close" disabled={disabled} - aria-label="Remove entry" + aria-label={t("routingRemoveEntry")} onClick={() => { const next = [...items]; next.splice(idx, 1); @@ -356,6 +357,7 @@ function OpEditor({ onChange: (next: any) => void; disabled?: boolean; }) { + const t = useTranslations("settings"); const updateField = (field: string, value: any) => onChange({ ...op, [field]: value }); const kind = op?.kind as TransformOpKind | undefined; const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null; @@ -376,14 +378,14 @@ function OpEditor({ return wrap(
updateField("needles", next)} disabled={disabled} /> updateField("caseSensitive", c)} @@ -396,14 +398,14 @@ function OpEditor({ return wrap(
updateField("prefixes", next)} disabled={disabled} /> updateField("caseSensitive", c)} @@ -416,21 +418,21 @@ function OpEditor({ return wrap(
updateField("match", e.target.value)} /> updateField("replacement", e.target.value)} /> updateField("allOccurrences", c)} @@ -443,21 +445,21 @@ function OpEditor({ return wrap(
updateField("pattern", e.target.value)} /> updateField("flags", e.target.value)} /> updateField("needles", next)} @@ -480,7 +482,7 @@ function OpEditor({ return wrap(
- +