From 5f37f0f804d260a5ca061c38bdf9380ea02899f3 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 20:29:33 -0300 Subject: [PATCH] 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",