From 5ba37df7bce28fb754def11fb367a65d4887a310 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 16:16:45 -0700 Subject: [PATCH 01/37] test: add comprehensive translation linting system Implement a full translation linting system in Jest that: - Checks both missing and extra translations - Supports all translation areas (core, webview, docs, package-nls) - Provides detailed reporting and error grouping - Includes type safety and modular design - Adds unit tests for linting functions This extends and improves upon the functionality from find-extra-translations.js script, adding more comprehensive checking and test coverage. Signed-off-by: Eric Wheeler --- jest.config.js | 2 +- locales/__tests__/lint-translations.test.ts | 667 ++++++++++++++++++++ 2 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 locales/__tests__/lint-translations.test.ts diff --git a/jest.config.js b/jest.config.js index e05776918e..feebeafdf4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -41,7 +41,7 @@ module.exports = { transformIgnorePatterns: [ "node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|serialize-error|strip-ansi|default-shell|os-name|strip-bom)/)", ], - roots: ["/src", "/webview-ui/src"], + roots: ["/src", "/webview-ui/src", "/locales"], modulePathIgnorePatterns: [".vscode-test"], reporters: [["jest-simple-dot-reporter", {}]], setupFiles: ["/src/__mocks__/jest.setup.ts"], diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts new file mode 100644 index 0000000000..7563bf9d22 --- /dev/null +++ b/locales/__tests__/lint-translations.test.ts @@ -0,0 +1,667 @@ +const fs = require("fs") +const path = require("path") +import { languages as schemaLanguages } from "../../src/schemas/index" + +let logBuffer: string[] = [] +const bufferLog = (msg: string) => logBuffer.push(msg) +const printLogs = () => { + console.log(logBuffer.join("\n")) + logBuffer = [] +} + +const languages = schemaLanguages +type Language = (typeof languages)[number] + +interface PathMapping { + name: string + area: "docs" | "core" | "webview" | "package-nls" + source: string | string[] + targetTemplate: string +} + +interface LintOptions { + locale?: string[] + file?: string[] + area?: string[] + check?: ("missing" | "extra" | "all")[] + help?: boolean + verbose?: boolean +} + +interface TranslationIssue { + key: string + englishValue?: any + localeValue?: any +} + +interface FileResult { + missing: TranslationIssue[] + extra: TranslationIssue[] + error?: string +} + +interface Results { + [area: string]: { + [locale: string]: { + [file: string]: FileResult + } + } +} + +const PATH_MAPPINGS: PathMapping[] = [ + { + name: "Documentation", + area: "docs", + source: ["CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "README.md", "PRIVACY.md"], + targetTemplate: "locales//", + }, + { + name: "Core UI Components", + area: "core", + source: "src/i18n/locales/en", + targetTemplate: "src/i18n/locales//", + }, + { + name: "Webview UI Components", + area: "webview", + source: "webview-ui/src/i18n/locales/en", + targetTemplate: "webview-ui/src/i18n/locales//", + }, + { + name: "Package NLS", + area: "package-nls", + source: "package.nls.json", + targetTemplate: "./", + }, +] + +function enumerateSourceFiles(source: string | string[]): string[] { + if (Array.isArray(source)) { + return source.map((file) => (file.startsWith("/") ? file.slice(1) : file)) + } + + const files: string[] = [] + const sourcePath = path.join("./", source) + + if (!fs.existsSync(sourcePath)) { + bufferLog(`Source path does not exist: ${sourcePath}`) + return files + } + + const stats = fs.statSync(sourcePath) + if (stats.isFile()) { + files.push(source) + } else { + const entries = fs.readdirSync(sourcePath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isFile()) { + files.push(path.join(source, entry.name)) + } + } + } + + return files +} + +function resolveTargetPath(sourceFile: string, targetTemplate: string, locale: string): string { + const targetPath = targetTemplate.replace("", locale) + + if (targetTemplate === "/") { + return sourceFile.replace(".json", `.${locale}.json`) + } + + const fileName = path.basename(sourceFile) + return path.join(targetPath, fileName) +} + +function loadFileContent(filePath: string): string | null { + try { + const fullPath = path.join("./", filePath) + return fs.readFileSync(fullPath, "utf8") + } catch (error) { + return null + } +} + +// Track unique errors to avoid duplication +const seenErrors = new Set() + +function parseJsonContent(content: string | null, filePath: string): any | null { + if (!content) return null + + try { + return JSON.parse(content) + } catch (error) { + // Only log first occurrence of each unique error + const errorKey = `${filePath}:${(error as Error).message}` + if (!seenErrors.has(errorKey)) { + seenErrors.add(errorKey) + bufferLog(`Error parsing ${path.basename(filePath)}: ${(error as Error).message}`) + } + return null + } +} + +function fileExists(filePath: string): boolean { + return fs.existsSync(path.join("./", filePath)) +} + +function findKeys(obj: any, parentKey: string = ""): string[] { + let keys: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + const currentKey = parentKey ? `${parentKey}.${key}` : key + + if (typeof value === "object" && value !== null) { + keys = [...keys, ...findKeys(value, currentKey)] + } else { + keys.push(currentKey) + } + } + + return keys +} + +function getValueAtPath(obj: any, path: string): any { + const parts = path.split(".") + let current = obj + + for (const part of parts) { + if (current === undefined || current === null) { + return undefined + } + current = current[part] + } + + return current +} + +function checkMissingTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { + if (!sourceContent || !targetContent) return [] + + const sourceKeys = findKeys(sourceContent) + const missingKeys: TranslationIssue[] = [] + + for (const key of sourceKeys) { + const sourceValue = getValueAtPath(sourceContent, key) + const targetValue = getValueAtPath(targetContent, key) + + if (targetValue === undefined) { + missingKeys.push({ + key, + englishValue: sourceValue, + }) + } + } + + return missingKeys +} + +function checkExtraTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { + if (!sourceContent || !targetContent) return [] + + const sourceKeys = new Set(findKeys(sourceContent)) + const targetKeys = findKeys(targetContent) + const extraKeys: TranslationIssue[] = [] + + for (const key of targetKeys) { + if (!sourceKeys.has(key)) { + extraKeys.push({ + key, + localeValue: getValueAtPath(targetContent, key), + }) + } + } + + return extraKeys +} + +function getAllLocalesForMapping(mapping: PathMapping): Language[] { + let discoveredLocales: string[] = [] + + if (mapping.area === "package-nls") { + discoveredLocales = fs + .readdirSync("./") + .filter( + (file: string) => + file.startsWith("package.nls.") && file.endsWith(".json") && file !== "package.nls.json", + ) + .map((file: string) => file.replace("package.nls.", "").replace(".json", "")) + } else if (mapping.area === "docs") { + const localesDir = path.join("./", "locales") + if (fs.existsSync(localesDir)) { + discoveredLocales = fs + .readdirSync(localesDir) + .filter((item: string) => fs.statSync(path.join(localesDir, item)).isDirectory()) + } + } else { + const basePath = + typeof mapping.source === "string" && mapping.source.endsWith("/en") + ? mapping.source.slice(0, -3) + : typeof mapping.source === "string" + ? path.dirname(mapping.source) + : "" + + if (basePath) { + const baseDir = path.join("./", basePath) + if (fs.existsSync(baseDir)) { + discoveredLocales = fs.readdirSync(baseDir).filter((item: string) => { + const itemPath = path.join(baseDir, item) + return fs.statSync(itemPath).isDirectory() && item !== "en" + }) + } + } + } + + return discoveredLocales.filter((locale) => languages.includes(locale as Language)) as Language[] +} + +function getFilteredLocales(mapping: PathMapping, localeArgs?: string[]): Language[] { + const allLocales = getAllLocalesForMapping(mapping) + + if (!localeArgs || localeArgs.includes("all")) { + return allLocales + } + + const invalidLocales = localeArgs.filter((locale) => !languages.includes(locale as Language)) + if (invalidLocales.length > 0) { + console.warn(`Warning: The following locales are not officially supported: ${invalidLocales.join(", ")}`) + } + + return allLocales.filter((locale) => localeArgs.includes(locale)) +} + +function filterMappingsByArea(mappings: PathMapping[], areaArgs?: string[]): PathMapping[] { + if (!areaArgs || areaArgs.includes("all")) { + return mappings + } + + return mappings.filter((mapping) => areaArgs.includes(mapping.area)) +} + +function filterSourceFiles(sourceFiles: string[], fileArgs?: string[]): string[] { + if (!fileArgs || fileArgs.includes("all")) { + return sourceFiles + } + + return sourceFiles.filter((file) => { + const basename = path.basename(file) + return fileArgs.includes(basename) + }) +} + +function processFileLocale( + sourceFile: string, + sourceContent: any, + mapping: PathMapping, + locale: Language, + checksToRun: string[], + results: Results, +): void { + const targetFile = resolveTargetPath(sourceFile, mapping.targetTemplate, locale) + + results[mapping.area] = results[mapping.area] || {} + results[mapping.area][locale] = results[mapping.area][locale] || {} + results[mapping.area][locale][sourceFile] = { + missing: [], + extra: [], + } + + if (!fileExists(targetFile)) { + results[mapping.area][locale][sourceFile].error = `Target file does not exist: ${targetFile}` + return + } + + const targetContent = parseJsonContent(loadFileContent(targetFile), targetFile) + if (!targetContent) { + results[mapping.area][locale][sourceFile].error = `Failed to load or parse target file: ${targetFile}` + return + } + + if (checksToRun.includes("missing") || checksToRun.includes("all")) { + results[mapping.area][locale][sourceFile].missing = checkMissingTranslations(sourceContent, targetContent) + } + + if (checksToRun.includes("extra") || checksToRun.includes("all")) { + results[mapping.area][locale][sourceFile].extra = checkExtraTranslations(sourceContent, targetContent) + } +} + +function formatResults(results: Results, checkTypes: string[], options: LintOptions, mappings: PathMapping[]): boolean { + let hasIssues = false + + logBuffer = [] // Clear buffer at start + seenErrors.clear() // Clear error tracking + bufferLog("=== Translation Results ===") + + // Group errors by type for summary + const errorsByType = new Map() + const missingByFile = new Map>() + + for (const [area, areaResults] of Object.entries(results)) { + let areaHasIssues = false + let areaBuffer: string[] = [] + let missingCount = 0 + + for (const [locale, localeResults] of Object.entries(areaResults)) { + let localeMissingCount = 0 + let localeExtraCount = 0 + let localeErrorCount = 0 + + for (const [file, fileResults] of Object.entries(localeResults)) { + // Group errors by type + if (fileResults.error) { + localeErrorCount++ + const errorType = fileResults.error.split(":")[0] + if (!errorsByType.has(errorType)) { + errorsByType.set(errorType, []) + } + errorsByType.get(errorType)?.push(`${locale} - ${file}`) + areaHasIssues = true + continue + } + + // Group missing translations by file and language + if (checkTypes.includes("missing") && fileResults.missing.length > 0) { + localeMissingCount += fileResults.missing.length + missingCount += fileResults.missing.length + const key = `${file}:${locale}` + if (!missingByFile.has(key)) { + missingByFile.set(key, new Set()) + } + fileResults.missing.forEach(({ key }) => missingByFile.get(`${file}:${locale}`)?.add(key)) + areaHasIssues = true + } + + if (checkTypes.includes("extra") && fileResults.extra.length > 0) { + localeExtraCount += fileResults.extra.length + areaBuffer.push(` ⚠️ ${locale} - ${file}: ${fileResults.extra.length} extra translations`) + + for (const { key, localeValue } of fileResults.extra) { + areaBuffer.push(` ${key}: "${localeValue}"`) + } + areaHasIssues = true + } + } + + hasIssues ||= localeErrorCount > 0 || localeMissingCount > 0 || localeExtraCount > 0 + } + + if (areaHasIssues) { + bufferLog(`\n${area.toUpperCase()} Translations:`) + + // Show error summaries + // Show error summaries by area + errorsByType.forEach((files, errorType) => { + const mapping = mappings.find((m) => m.area === area) + if (!mapping) return + + // For array sources, check if the file matches any of the source paths + const isSourceFile = (fileName: string) => { + if (Array.isArray(mapping.source)) { + return mapping.source.some((src) => fileName === (src.startsWith("/") ? src.slice(1) : src)) + } + return fileName.startsWith(mapping.source) + } + + const areaFiles = files.filter((file) => { + const [, fileName] = file.split(" - ") + return isSourceFile(fileName) + }) + + if (areaFiles.length > 0) { + bufferLog(` ❌ ${errorType}:`) + bufferLog(` Affected files: ${areaFiles.length}`) + if (options?.verbose) { + areaFiles.forEach((file) => { + const [locale, fileName] = file.split(" - ") + const targetPath = mapping.targetTemplate.replace("", locale) + const fullPath = path.join(targetPath, fileName) + bufferLog(` ${locale} - ${fullPath}`) + }) + } + } + }) + + // Show missing translations summary + if (missingCount > 0) { + bufferLog(` 📝 Missing translations (${missingCount} total):`) + const byFile = new Map>>() + + missingByFile.forEach((keys, fileAndLang) => { + const [file, lang] = fileAndLang.split(":") + if (!byFile.has(file)) { + byFile.set(file, new Map()) + } + byFile.get(file)?.set(lang, keys) + }) + + byFile.forEach((langMap, file) => { + bufferLog(` ${file}:`) + langMap.forEach((keys, lang) => { + bufferLog(` ${lang}: ${keys.size} keys missing`) + if (options?.verbose) { + keys.forEach((key) => bufferLog(` ${key}`)) + } + }) + }) + } + + if (!areaHasIssues) { + bufferLog(` ✅ No issues found`) + } + } + } + + return hasIssues +} + +function formatSummary(results: Results): void { + bufferLog("\n======= SUMMARY =======") + let totalMissing = 0 + let totalExtra = 0 + let totalErrors = 0 + + for (const [area, areaResults] of Object.entries(results)) { + let areaMissing = 0 + let areaExtra = 0 + let areaErrors = 0 + + for (const [_locale, localeResults] of Object.entries(areaResults)) { + for (const [_file, fileResults] of Object.entries(localeResults)) { + if (fileResults.error) { + areaErrors++ + totalErrors++ + } else { + areaMissing += fileResults.missing.length + areaExtra += fileResults.extra.length + } + } + } + + totalMissing += areaMissing + totalExtra += areaExtra + + bufferLog(`${area.toUpperCase()}: ${areaMissing} missing, ${areaExtra} extra, ${areaErrors} errors`) + } + + bufferLog(`\nTOTAL: ${totalMissing} missing, ${totalExtra} extra, ${totalErrors} errors`) + + if (totalMissing === 0 && totalExtra === 0 && totalErrors === 0) { + bufferLog("\n✅ All translations are complete!") + } else { + bufferLog("\n⚠️ Some translation issues were found.") + + if (totalMissing > 0) { + bufferLog("- Add the missing translations to the corresponding locale files") + } + + if (totalExtra > 0) { + bufferLog("- Consider removing extra translations or adding them to the source files") + } + + if (totalErrors > 0) { + bufferLog("- Fix the errors reported above") + } + } +} + +function parseArgs(): LintOptions { + const options: LintOptions = { + area: ["all"], + check: ["all"] as ("missing" | "extra" | "all")[], + verbose: false, + help: false, + } + + for (const arg of process.argv.slice(2)) { + if (arg === "--verbose") { + options.verbose = true + continue + } + if (arg === "--help") { + options.help = true + continue + } + + const match = arg.match(/^--([^=]+)=(.+)$/) + if (!match) continue + + const [, key, value] = match + const values = value.split(",") + + switch (key) { + case "locale": + options.locale = values + break + case "file": + options.file = values + break + case "area": { + const validAreas = ["core", "webview", "docs", "package-nls", "all"] + for (const area of values) { + if (!validAreas.includes(area)) { + bufferLog(`Error: Invalid area '${area}'. Must be one of: ${validAreas.join(", ")}`) + process.exit(1) + } + } + options.area = values + break + } + case "check": { + const validChecks = ["missing", "extra", "all"] + for (const check of values) { + if (!validChecks.includes(check)) { + bufferLog(`Error: Invalid check '${check}'. Must be one of: ${validChecks.join(", ")}`) + process.exit(1) + } + } + options.check = values as ("missing" | "extra" | "all")[] + break + } + } + } + + return options +} + +function lintTranslations(args?: LintOptions): number { + logBuffer = [] // Clear the buffer at the start + const options = args || parseArgs() || { area: ["all"], check: ["all"] } + const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] + + const filteredMappings = filterMappingsByArea(PATH_MAPPINGS, options.area) + const results: Results = {} + + for (const mapping of filteredMappings) { + let sourceFiles = enumerateSourceFiles(mapping.source) + sourceFiles = filterSourceFiles(sourceFiles, options.file) + + if (sourceFiles.length === 0) { + console.log(`No matching files found for area ${mapping.name}`) + continue + } + + const locales = getFilteredLocales(mapping, options.locale) + + if (locales.length === 0) { + console.log(`No matching locales found for area ${mapping.name}`) + continue + } + + for (const sourceFile of sourceFiles) { + let sourceContent = null + if (sourceFile.endsWith(".json")) { + sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + if (!sourceContent) continue + } else { + sourceContent = loadFileContent(sourceFile) + if (!sourceContent) continue + } + + for (const locale of locales) { + processFileLocale(sourceFile, sourceContent, mapping, locale, checksToRun, results) + } + } + } + + const hasIssues = formatResults(results, checksToRun, options, filteredMappings) + formatSummary(results) + printLogs() // Print accumulated logs + + return hasIssues ? 1 : 0 +} + +// Export functions for use in other modules +module.exports = { + enumerateSourceFiles, + resolveTargetPath, + loadFileContent, + parseJsonContent, + fileExists, + PATH_MAPPINGS, + findKeys, + getValueAtPath, + checkMissingTranslations, + checkExtraTranslations, + getAllLocalesForMapping, + getFilteredLocales, + filterMappingsByArea, + filterSourceFiles, + lintTranslations, +} + +describe("Translation Linting", () => { + test("Run translation linting", () => { + // Run with default options to check all areas and all checks + const exitCode = lintTranslations({ + area: ["all"], + check: ["all"], + verbose: process.argv.includes("--verbose"), + }) + expect(typeof exitCode).toBe("number") + expect(exitCode).toBeGreaterThanOrEqual(0) + }) + + test("Filters mappings by area correctly", () => { + const filteredMappings = filterMappingsByArea(PATH_MAPPINGS, ["docs"]) + expect(filteredMappings).toHaveLength(1) + expect(filteredMappings[0].area).toBe("docs") + }) + + test("Checks for missing translations", () => { + const source = { key1: "value1", key2: "value2" } + const target = { key1: "value1" } + const issues = checkMissingTranslations(source, target) + expect(issues).toHaveLength(1) + expect(issues[0].key).toBe("key2") + }) + + test("Checks for extra translations", () => { + const source = { key1: "value1" } + const target = { key1: "value1", extraKey: "extra" } + const issues = checkExtraTranslations(source, target) + expect(issues).toHaveLength(1) + expect(issues[0].key).toBe("extraKey") + }) +}) From 850507c5c24a4a142137e35e31a9905ddc6e3e46 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 16:35:22 -0700 Subject: [PATCH 02/37] test: improve translation linting test - Only check existence for non-JSON files - Add proper indentation for extra translations output - Make test fail when translation issues are found Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 7563bf9d22..4804eccce8 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -312,6 +312,11 @@ function processFileLocale( return } + // For non-JSON files, only check existence + if (!sourceFile.endsWith(".json")) { + return + } + const targetContent = parseJsonContent(loadFileContent(targetFile), targetFile) if (!targetContent) { results[mapping.area][locale][sourceFile].error = `Failed to load or parse target file: ${targetFile}` @@ -447,6 +452,15 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti }) } + // Show extra translations if any + if (areaBuffer.length > 0) { + bufferLog(` ⚠️ Extra translations:`) + areaBuffer.forEach((line) => { + const indentedLine = " " + line + bufferLog(indentedLine) + }) + } + if (!areaHasIssues) { bufferLog(` ✅ No issues found`) } @@ -639,8 +653,7 @@ describe("Translation Linting", () => { check: ["all"], verbose: process.argv.includes("--verbose"), }) - expect(typeof exitCode).toBe("number") - expect(exitCode).toBeGreaterThanOrEqual(0) + expect(exitCode).toBe(0) }) test("Filters mappings by area correctly", () => { From 00cbedc328f10ca6ffded6450ccc30535e9515eb Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 17:55:54 -0700 Subject: [PATCH 03/37] test: improve translation linting test robustness - Change getFilteredLocales to throw error for invalid locales - Remove dead code in resolveTargetPath for '/' template - Return structured output from lintTranslations with exitCode and message - Remove unused getAllLocalesForMapping export - Fix sourceContent type handling - Improve test assertions to verify output content Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 121 ++++++++------------ 1 file changed, 49 insertions(+), 72 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 4804eccce8..0e9579e404 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -5,8 +5,9 @@ import { languages as schemaLanguages } from "../../src/schemas/index" let logBuffer: string[] = [] const bufferLog = (msg: string) => logBuffer.push(msg) const printLogs = () => { - console.log(logBuffer.join("\n")) + const output = logBuffer.join("\n") logBuffer = [] + return output } const languages = schemaLanguages @@ -71,7 +72,7 @@ const PATH_MAPPINGS: PathMapping[] = [ name: "Package NLS", area: "package-nls", source: "package.nls.json", - targetTemplate: "./", + targetTemplate: "package.nls..json", }, ] @@ -216,59 +217,17 @@ function checkExtraTranslations(sourceContent: any, targetContent: any): Transla return extraKeys } -function getAllLocalesForMapping(mapping: PathMapping): Language[] { - let discoveredLocales: string[] = [] - - if (mapping.area === "package-nls") { - discoveredLocales = fs - .readdirSync("./") - .filter( - (file: string) => - file.startsWith("package.nls.") && file.endsWith(".json") && file !== "package.nls.json", - ) - .map((file: string) => file.replace("package.nls.", "").replace(".json", "")) - } else if (mapping.area === "docs") { - const localesDir = path.join("./", "locales") - if (fs.existsSync(localesDir)) { - discoveredLocales = fs - .readdirSync(localesDir) - .filter((item: string) => fs.statSync(path.join(localesDir, item)).isDirectory()) - } - } else { - const basePath = - typeof mapping.source === "string" && mapping.source.endsWith("/en") - ? mapping.source.slice(0, -3) - : typeof mapping.source === "string" - ? path.dirname(mapping.source) - : "" - - if (basePath) { - const baseDir = path.join("./", basePath) - if (fs.existsSync(baseDir)) { - discoveredLocales = fs.readdirSync(baseDir).filter((item: string) => { - const itemPath = path.join(baseDir, item) - return fs.statSync(itemPath).isDirectory() && item !== "en" - }) - } - } - } - - return discoveredLocales.filter((locale) => languages.includes(locale as Language)) as Language[] -} - -function getFilteredLocales(mapping: PathMapping, localeArgs?: string[]): Language[] { - const allLocales = getAllLocalesForMapping(mapping) - +function getFilteredLocales(localeArgs?: string[]): Language[] { if (!localeArgs || localeArgs.includes("all")) { - return allLocales + return [...languages] } const invalidLocales = localeArgs.filter((locale) => !languages.includes(locale as Language)) if (invalidLocales.length > 0) { - console.warn(`Warning: The following locales are not officially supported: ${invalidLocales.join(", ")}`) + throw new Error(`Error: The following locales are not officially supported: ${invalidLocales.join(", ")}`) } - return allLocales.filter((locale) => localeArgs.includes(locale)) + return languages.filter((locale) => localeArgs.includes(locale)) } function filterMappingsByArea(mappings: PathMapping[], areaArgs?: string[]): PathMapping[] { @@ -345,7 +304,7 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti for (const [area, areaResults] of Object.entries(results)) { let areaHasIssues = false - let areaBuffer: string[] = [] + const extraByLocale = new Map>() let missingCount = 0 for (const [locale, localeResults] of Object.entries(areaResults)) { @@ -380,11 +339,13 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti if (checkTypes.includes("extra") && fileResults.extra.length > 0) { localeExtraCount += fileResults.extra.length - areaBuffer.push(` ⚠️ ${locale} - ${file}: ${fileResults.extra.length} extra translations`) - for (const { key, localeValue } of fileResults.extra) { - areaBuffer.push(` ${key}: "${localeValue}"`) + // Group extra translations by locale + if (!extraByLocale.has(locale)) { + extraByLocale.set(locale, new Map()) } + extraByLocale.get(locale)?.set(file, fileResults.extra) + areaHasIssues = true } } @@ -453,12 +414,27 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti } // Show extra translations if any - if (areaBuffer.length > 0) { + if (extraByLocale.size > 0) { bufferLog(` ⚠️ Extra translations:`) - areaBuffer.forEach((line) => { - const indentedLine = " " + line - bufferLog(indentedLine) - }) + let isFirstLocale = true + for (const [locale, fileMap] of extraByLocale) { + if (!isFirstLocale) { + bufferLog("") // Add blank line between locales + } + isFirstLocale = false + bufferLog(` ${locale}:`) + let isFirstFile = true + for (const [file, extras] of fileMap) { + if (!isFirstFile) { + bufferLog("") // Add blank line between files + } + isFirstFile = false + bufferLog(` ${file}: ${extras.length} extra translations`) + for (const { key, localeValue } of extras) { + bufferLog(` ${key}: "${localeValue}"`) + } + } + } } if (!areaHasIssues) { @@ -552,11 +528,10 @@ function parseArgs(): LintOptions { options.file = values break case "area": { - const validAreas = ["core", "webview", "docs", "package-nls", "all"] + const validAreas = [...PATH_MAPPINGS.map((m) => m.area), "all"] for (const area of values) { if (!validAreas.includes(area)) { - bufferLog(`Error: Invalid area '${area}'. Must be one of: ${validAreas.join(", ")}`) - process.exit(1) + throw new Error(`Error: Invalid area '${area}'. Must be one of: ${validAreas.join(", ")}`) } } options.area = values @@ -579,7 +554,7 @@ function parseArgs(): LintOptions { return options } -function lintTranslations(args?: LintOptions): number { +function lintTranslations(args?: LintOptions): { exitCode: number; output: string } { logBuffer = [] // Clear the buffer at the start const options = args || parseArgs() || { area: ["all"], check: ["all"] } const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] @@ -592,21 +567,23 @@ function lintTranslations(args?: LintOptions): number { sourceFiles = filterSourceFiles(sourceFiles, options.file) if (sourceFiles.length === 0) { - console.log(`No matching files found for area ${mapping.name}`) + bufferLog(`No matching files found for area ${mapping.name}`) continue } - const locales = getFilteredLocales(mapping, options.locale) + const locales = getFilteredLocales(options.locale) if (locales.length === 0) { - console.log(`No matching locales found for area ${mapping.name}`) + bufferLog(`No matching locales found for area ${mapping.name}`) continue } for (const sourceFile of sourceFiles) { - let sourceContent = null + let sourceContent: any = null if (sourceFile.endsWith(".json")) { - sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + const content = loadFileContent(sourceFile) + if (!content) continue + sourceContent = parseJsonContent(content, sourceFile) if (!sourceContent) continue } else { sourceContent = loadFileContent(sourceFile) @@ -619,11 +596,11 @@ function lintTranslations(args?: LintOptions): number { } } - const hasIssues = formatResults(results, checksToRun, options, filteredMappings) + formatResults(results, checksToRun, options, filteredMappings) formatSummary(results) - printLogs() // Print accumulated logs + const output = printLogs() - return hasIssues ? 1 : 0 + return { exitCode: 0, output } } // Export functions for use in other modules @@ -638,7 +615,6 @@ module.exports = { getValueAtPath, checkMissingTranslations, checkExtraTranslations, - getAllLocalesForMapping, getFilteredLocales, filterMappingsByArea, filterSourceFiles, @@ -648,12 +624,13 @@ module.exports = { describe("Translation Linting", () => { test("Run translation linting", () => { // Run with default options to check all areas and all checks - const exitCode = lintTranslations({ + const result = lintTranslations({ area: ["all"], check: ["all"], verbose: process.argv.includes("--verbose"), }) - expect(exitCode).toBe(0) + expect(result.exitCode).toBe(0) + expect(result.output).toContain("All translations are complete") }) test("Filters mappings by area correctly", () => { From 6c02002c17cb88a517ead10eb11835716226c7ba Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 18:36:47 -0700 Subject: [PATCH 04/37] test: fix translation validation and output format Corrected translation validation to properly detect existing translations and improved output readability Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 49 ++++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 0e9579e404..dfb4b6f1b6 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -111,6 +111,10 @@ function resolveTargetPath(sourceFile: string, targetTemplate: string, locale: s return sourceFile.replace(".json", `.${locale}.json`) } + if (!targetTemplate.endsWith("/")) { + return targetPath + } + const fileName = path.basename(sourceFile) return path.join(targetPath, fileName) } @@ -164,6 +168,10 @@ function findKeys(obj: any, parentKey: string = ""): string[] { } function getValueAtPath(obj: any, path: string): any { + if (obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, path)) { + return obj[path] + } + const parts = path.split(".") let current = obj @@ -218,8 +226,10 @@ function checkExtraTranslations(sourceContent: any, targetContent: any): Transla } function getFilteredLocales(localeArgs?: string[]): Language[] { + const baseLocales = languages.filter((locale) => locale !== "en") + if (!localeArgs || localeArgs.includes("all")) { - return [...languages] + return baseLocales } const invalidLocales = localeArgs.filter((locale) => !languages.includes(locale as Language)) @@ -227,7 +237,7 @@ function getFilteredLocales(localeArgs?: string[]): Language[] { throw new Error(`Error: The following locales are not officially supported: ${invalidLocales.join(", ")}`) } - return languages.filter((locale) => localeArgs.includes(locale)) + return baseLocales.filter((locale) => localeArgs.includes(locale)) } function filterMappingsByArea(mappings: PathMapping[], areaArgs?: string[]): PathMapping[] { @@ -261,13 +271,13 @@ function processFileLocale( results[mapping.area] = results[mapping.area] || {} results[mapping.area][locale] = results[mapping.area][locale] || {} - results[mapping.area][locale][sourceFile] = { + results[mapping.area][locale][targetFile] = { missing: [], extra: [], } if (!fileExists(targetFile)) { - results[mapping.area][locale][sourceFile].error = `Target file does not exist: ${targetFile}` + results[mapping.area][locale][targetFile].error = `Target file does not exist: ${targetFile}` return } @@ -278,16 +288,16 @@ function processFileLocale( const targetContent = parseJsonContent(loadFileContent(targetFile), targetFile) if (!targetContent) { - results[mapping.area][locale][sourceFile].error = `Failed to load or parse target file: ${targetFile}` + results[mapping.area][locale][targetFile].error = `Failed to load or parse target file: ${targetFile}` return } if (checksToRun.includes("missing") || checksToRun.includes("all")) { - results[mapping.area][locale][sourceFile].missing = checkMissingTranslations(sourceContent, targetContent) + results[mapping.area][locale][targetFile].missing = checkMissingTranslations(sourceContent, targetContent) } if (checksToRun.includes("extra") || checksToRun.includes("all")) { - results[mapping.area][locale][sourceFile].extra = checkExtraTranslations(sourceContent, targetContent) + results[mapping.area][locale][targetFile].extra = checkExtraTranslations(sourceContent, targetContent) } } @@ -422,14 +432,16 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti bufferLog("") // Add blank line between locales } isFirstLocale = false - bufferLog(` ${locale}:`) let isFirstFile = true for (const [file, extras] of fileMap) { if (!isFirstFile) { bufferLog("") // Add blank line between files } isFirstFile = false - bufferLog(` ${file}: ${extras.length} extra translations`) + const mapping = mappings.find((m) => m.area === area) + if (!mapping) continue + const targetPath = resolveTargetPath(file, mapping.targetTemplate, locale) + bufferLog(` ${locale}: ${targetPath}: ${extras.length} extra translations`) for (const { key, localeValue } of extras) { bufferLog(` ${key}: "${localeValue}"`) } @@ -483,11 +495,26 @@ function formatSummary(results: Results): void { bufferLog("\n⚠️ Some translation issues were found.") if (totalMissing > 0) { - bufferLog("- Add the missing translations to the corresponding locale files") + bufferLog("- For .md files: Create the missing translation files in the appropriate locale directory") + bufferLog( + "- For .json files: Add the missing translations that exist in English but are missing in other locales", + ) + bufferLog(" Example adding translations:") + bufferLog(" node scripts/manage-translations.js --stdin settings.json << EOF") + bufferLog(' {"some.new.key1.label": "First Value"}') + bufferLog(' {"some.new.key2.label": "Second Value"}') + bufferLog(" EOF") } if (totalExtra > 0) { - bufferLog("- Consider removing extra translations or adding them to the source files") + bufferLog( + "- Remove translations that exist in other locales but not in English (English is the source of truth)", + ) + bufferLog(" Example removing translations:") + bufferLog(" node scripts/manage-translations.js -d --stdin settings.json << EOF") + bufferLog(' ["the.extra.key1.label"]') + bufferLog(' ["the.extra.key2.label"]') + bufferLog(" EOF") } if (totalErrors > 0) { From 400e4cc6c3751c52b9d9ef5367e2f13ef4c0a59d Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 18:38:07 -0700 Subject: [PATCH 05/37] feat: add translation file management script Add manage-translations.js CLI tool that provides utilities for: - Adding/updating translations with nested key paths - Deleting translation keys - Batch operations via stdin/JSON input - Verbose output mode for debugging The script supports both direct command line usage and line-by-line JSON input for bulk operations, making translation management more efficient. Signed-off-by: Eric Wheeler --- scripts/manage-translations.js | 290 +++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100755 scripts/manage-translations.js diff --git a/scripts/manage-translations.js b/scripts/manage-translations.js new file mode 100755 index 0000000000..6d33740aaa --- /dev/null +++ b/scripts/manage-translations.js @@ -0,0 +1,290 @@ +#!/usr/bin/env node + +const fs = require("fs") +const path = require("path") + +function getNestedValue(obj, keyPath) { + return keyPath.split(".").reduce((current, key) => { + return current && typeof current === "object" ? current[key] : undefined + }, obj) +} + +function setNestedValue(obj, keyPath, value) { + const keys = keyPath.split(".") + const lastKey = keys.pop() + const target = keys.reduce((current, key) => { + if (!(key in current)) { + current[key] = {} + } + return current[key] + }, obj) + target[lastKey] = value +} + +function deleteNestedValue(obj, keyPath) { + const keys = keyPath.split(".") + const lastKey = keys.pop() + const target = keys.reduce((current, key) => { + return current && typeof current === "object" ? current[key] : undefined + }, obj) + + if (target && typeof target === "object" && lastKey in target) { + delete target[lastKey] + return true + } + return false +} + +async function processStdin() { + return new Promise((resolve, reject) => { + const pairs = [] + let buffer = "" + + process.stdin.setEncoding("utf8") + + process.stdin.on("data", (chunk) => { + buffer += chunk + const lines = buffer.split("\n") + buffer = lines.pop() || "" // Keep incomplete line in buffer + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + try { + const data = JSON.parse(trimmed) + if (Array.isArray(data)) { + // In delete mode, allow multiple elements in array + data.forEach((key) => pairs.push([key])) + } else if (typeof data === "object" && data !== null) { + const entries = Object.entries(data) + if (entries.length !== 1) { + reject(new Error("Each line must contain a single key-value pair")) + return + } + pairs.push(entries[0]) + } else { + reject(new Error("Each line must be a JSON object or array")) + return + } + } catch (err) { + reject(new Error(`Invalid JSON on line: ${trimmed}`)) + return + } + } + }) + + process.stdin.on("end", () => { + if (buffer.trim()) { + try { + const data = JSON.parse(buffer.trim()) + if (Array.isArray(data)) { + // In delete mode, allow multiple elements in array + data.forEach((key) => pairs.push([key])) + } else if (typeof data === "object" && data !== null) { + const entries = Object.entries(data) + if (entries.length !== 1) { + reject(new Error("Each line must contain a single key-value pair")) + return + } + pairs.push(entries[0]) + } else { + reject(new Error("Each line must be a JSON object or array")) + return + } + } catch (err) { + reject(new Error(`Invalid JSON on line: ${buffer.trim()}`)) + return + } + } + resolve(pairs) + }) + + process.stdin.on("error", reject) + }) +} + +async function main() { + const args = process.argv.slice(2) + const verbose = args.includes("-v") + const deleteMode = args.includes("-d") + const stdinMode = args.includes("--stdin") + + if (verbose) args.splice(args.indexOf("-v"), 1) + if (deleteMode) args.splice(args.indexOf("-d"), 1) + if (stdinMode) args.splice(args.indexOf("--stdin"), 1) + + if (args.length < 1) { + console.log("Usage:") + console.log("Command Line Mode:") + console.log(" Add/update translations:") + console.log(" node scripts/manage-translations.js [-v] TRANSLATION_FILE KEY_PATH VALUE [KEY_PATH VALUE...]") + console.log(" Delete translations:") + console.log(" node scripts/manage-translations.js [-v] -d TRANSLATION_FILE KEY_PATH [KEY_PATH...]") + console.log("") + console.log("Line-by-Line JSON Mode (--stdin):") + console.log(" Each line must be a complete, single JSON object/array") + console.log(" Multi-line or combined JSON is not supported") + console.log("") + console.log(" Add/update translations:") + console.log(" node scripts/manage-translations.js [-v] --stdin TRANSLATION_FILE") + console.log(" Format: One object per line with exactly one key-value pair:") + console.log(' {"key.path": "value"}') + console.log("") + console.log(" Delete translations:") + console.log(" node scripts/manage-translations.js [-v] -d --stdin TRANSLATION_FILE") + console.log(" Format: One array per line with exactly one key:") + console.log(' ["key.path"]') + console.log("") + console.log("Options:") + console.log(" -v Enable verbose output (shows operations)") + console.log(" -d Delete mode - remove keys instead of setting them") + console.log(" --stdin Read line-by-line JSON from stdin") + console.log("") + console.log("Examples:") + console.log(" # Add via command line:") + console.log(' node scripts/manage-translations.js settings.json providers.key.label "Value"') + console.log("") + console.log(" # Add multiple translations (one JSON object per line):") + console.log(" translations.txt:") + console.log(' {"providers.key1.label": "First Value"}') + console.log(' {"providers.key2.label": "Second Value"}') + console.log(" node scripts/manage-translations.js --stdin settings.json < translations.txt") + console.log("") + console.log(" # Delete multiple keys (one JSON array per line):") + console.log(" delete_keys.txt:") + console.log(' ["providers.key1.label"]') + console.log(' ["providers.key2.label"]') + console.log(" node scripts/manage-translations.js -d --stdin settings.json < delete_keys.txt") + console.log("") + console.log(" # Using here document for batching:") + console.log(" node scripts/manage-translations.js --stdin settings.json << EOF") + console.log(' {"providers.key1.label": "First Value"}') + console.log(' {"providers.key2.label": "Second Value"}') + console.log(" EOF") + console.log("") + console.log(" # Delete using here document:") + console.log(" node scripts/manage-translations.js -d --stdin settings.json << EOF") + console.log(' ["providers.key1.label"]') + console.log(' ["providers.key2.label"]') + console.log(" EOF") + process.exit(1) + } + + const filePath = args[0] + let modified = false + + try { + let data = {} + try { + data = JSON.parse(await fs.promises.readFile(filePath, "utf8")) + } catch (err) { + if (err.code === "ENOENT") { + if (verbose) { + console.log(`File not found: ${filePath}`) + console.log("Creating new file") + } + // Create parent directories if they don't exist + const directory = path.dirname(filePath) + await fs.promises.mkdir(directory, { recursive: true }) + } else { + throw err + } + } + + if (stdinMode && deleteMode) { + const input = await processStdin() + const keys = input.map(([key]) => key) + for (const keyPath of keys) { + if (deleteNestedValue(data, keyPath)) { + if (verbose) { + console.log(`Deleted key: ${keyPath}`) + console.log(`From file: ${filePath}`) + } + modified = true + } else if (verbose) { + console.log(`Key not found: ${keyPath}`) + console.log(`In file: ${filePath}`) + } + } + } else if (stdinMode) { + const pairs = await processStdin() + for (const [keyPath, value] of pairs) { + const currentValue = getNestedValue(data, keyPath) + if (currentValue === undefined) { + setNestedValue(data, keyPath, value) + if (verbose) { + console.log(`Created new key path: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Set value: "${value}"`) + } + modified = true + } else if (verbose) { + console.log(`Key exists: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Current value: "${currentValue}"`) + } + } + } else if (deleteMode) { + // Process keys to delete + for (let i = 1; i < args.length; i++) { + const keyPath = args[i] + if (deleteNestedValue(data, keyPath)) { + if (verbose) { + console.log(`Deleted key: ${keyPath}`) + console.log(`From file: ${filePath}`) + } + modified = true + } else if (verbose) { + console.log(`Key not found: ${keyPath}`) + console.log(`In file: ${filePath}`) + } + } + } else if (args.length >= 3 && args.length % 2 === 1) { + // Process key-value pairs from command line + for (let i = 1; i < args.length; i += 2) { + const keyPath = args[i] + const value = args[i + 1] + const currentValue = getNestedValue(data, keyPath) + + if (currentValue === undefined) { + setNestedValue(data, keyPath, value) + if (verbose) { + console.log(`Created new key path: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Set value: "${value}"`) + } + modified = true + } else if (verbose) { + console.log(`Key exists: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Current value: "${currentValue}"`) + } + } + } else { + console.log("Invalid number of arguments") + process.exit(1) + } + + // Write back if modified + if (modified) { + await fs.promises.writeFile(filePath, JSON.stringify(data, null, "\t") + "\n") + if (verbose) { + console.log("File updated successfully") + } + } + } catch (err) { + if (err instanceof SyntaxError) { + console.error("Error: Invalid JSON in translation file") + } else if (err.code !== "ENOENT") { + // ENOENT is handled above + console.error("Error:", err.message) + } + process.exit(1) + } +} + +main().catch((err) => { + console.error("Unexpected error:", err) + process.exit(1) +}) From df19876d252a123357a478ab60582a8220c943ec Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 18:54:09 -0700 Subject: [PATCH 06/37] fix: improve display of missing translation files - Track and display missing markdown files in docs area - Group missing files by locale with full target paths - Show each missing file on its own line - Update example paths to use relative paths in help text Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 40 +++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index dfb4b6f1b6..fc1df37efb 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -283,6 +283,15 @@ function processFileLocale( // For non-JSON files, only check existence if (!sourceFile.endsWith(".json")) { + // For markdown files, we still want to track which ones are missing + if (sourceFile.endsWith(".md")) { + results[mapping.area][locale][targetFile].missing = [ + { + key: sourceFile, + englishValue: "File missing", + }, + ] + } return } @@ -412,13 +421,24 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti byFile.get(file)?.set(lang, keys) }) - byFile.forEach((langMap, file) => { - bufferLog(` ${file}:`) - langMap.forEach((keys, lang) => { - bufferLog(` ${lang}: ${keys.size} keys missing`) - if (options?.verbose) { - keys.forEach((key) => bufferLog(` ${key}`)) - } + // Group by locale first + const byLocale = new Map() + missingByFile.forEach((keys, fileAndLang) => { + const [file, lang] = fileAndLang.split(":") + if (!byLocale.has(lang)) { + byLocale.set(lang, []) + } + const mapping = mappings.find((m) => m.area === area) + if (mapping) { + const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) + byLocale.get(lang)?.push(targetPath) + } + }) + + byLocale.forEach((files, lang) => { + bufferLog(` ${lang}: missing ${files.length} files`) + files.sort().forEach((file) => { + bufferLog(` ${file}`) }) }) } @@ -499,8 +519,8 @@ function formatSummary(results: Results): void { bufferLog( "- For .json files: Add the missing translations that exist in English but are missing in other locales", ) - bufferLog(" Example adding translations:") - bufferLog(" node scripts/manage-translations.js --stdin settings.json << EOF") + bufferLog(" Example adding translations (one JSONL/NDJSON record per line):") + bufferLog(" node scripts/manage-translations.js --stdin relative/path/to/settings.json << EOF") bufferLog(' {"some.new.key1.label": "First Value"}') bufferLog(' {"some.new.key2.label": "Second Value"}') bufferLog(" EOF") @@ -511,7 +531,7 @@ function formatSummary(results: Results): void { "- Remove translations that exist in other locales but not in English (English is the source of truth)", ) bufferLog(" Example removing translations:") - bufferLog(" node scripts/manage-translations.js -d --stdin settings.json << EOF") + bufferLog(" node scripts/manage-translations.js -d --stdin relative/path/to/settings.json << EOF") bufferLog(' ["the.extra.key1.label"]') bufferLog(' ["the.extra.key2.label"]') bufferLog(" EOF") From 6a7829dfff72dde568b2b3c0ca6f031bc4f497f3 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 19:05:09 -0700 Subject: [PATCH 07/37] fix: unify file existence checking in translation linting Standardize how missing files are reported across all file types: - Remove special case for markdown files - Report all missing files consistently in missing array - Keep JSON-specific parsing as the only special case - Improve error messages for translation tasks Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 28 +++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index fc1df37efb..198007992d 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -277,21 +277,16 @@ function processFileLocale( } if (!fileExists(targetFile)) { - results[mapping.area][locale][targetFile].error = `Target file does not exist: ${targetFile}` + results[mapping.area][locale][targetFile].missing = [ + { + key: sourceFile, + englishValue: "File missing", + }, + ] return } - // For non-JSON files, only check existence if (!sourceFile.endsWith(".json")) { - // For markdown files, we still want to track which ones are missing - if (sourceFile.endsWith(".md")) { - results[mapping.area][locale][targetFile].missing = [ - { - key: sourceFile, - englishValue: "File missing", - }, - ] - } return } @@ -515,7 +510,13 @@ function formatSummary(results: Results): void { bufferLog("\n⚠️ Some translation issues were found.") if (totalMissing > 0) { - bufferLog("- For .md files: Create the missing translation files in the appropriate locale directory") + bufferLog("- For .md files: ") + bufferLog( + " Create the missing translation files in the appropriate locale directory from the English sources in the root of the repository", + ) + bufferLog( + " Use for each single file translation to keep translation context absolutely clear", + ) bufferLog( "- For .json files: Add the missing translations that exist in English but are missing in other locales", ) @@ -538,7 +539,8 @@ function formatSummary(results: Results): void { } if (totalErrors > 0) { - bufferLog("- Fix the errors reported above") + bufferLog("- Always translate from the original English source documents") + bufferLog("- Fix the errors based on the instructions above without reading this test script.") } } } From 09683af2856122d20d1be90036f3360b7348a451 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 19:13:39 -0700 Subject: [PATCH 08/37] test: remove unused exitCode from lint-translations.test.ts Remove exitCode from lintTranslations return type and test since it was not being used meaningfully. Add 'All translations are complete' message when there are no issues. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 198007992d..679c8819f3 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -538,10 +538,9 @@ function formatSummary(results: Results): void { bufferLog(" EOF") } - if (totalErrors > 0) { - bufferLog("- Always translate from the original English source documents") - bufferLog("- Fix the errors based on the instructions above without reading this test script.") - } + bufferLog("- Always translate from the original English source documents") + bufferLog("- Fix the errors based on the instructions above reading this test script") + bufferLog("- AI NOTICE: You MUST fix the language issues, do not modify the test script") } } @@ -603,7 +602,7 @@ function parseArgs(): LintOptions { return options } -function lintTranslations(args?: LintOptions): { exitCode: number; output: string } { +function lintTranslations(args?: LintOptions): { output: string } { logBuffer = [] // Clear the buffer at the start const options = args || parseArgs() || { area: ["all"], check: ["all"] } const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] @@ -645,11 +644,14 @@ function lintTranslations(args?: LintOptions): { exitCode: number; output: strin } } - formatResults(results, checksToRun, options, filteredMappings) + const hasIssues = formatResults(results, checksToRun, options, filteredMappings) formatSummary(results) + if (!hasIssues) { + bufferLog("\nAll translations are complete") + } const output = printLogs() - return { exitCode: 0, output } + return { output } } // Export functions for use in other modules @@ -678,7 +680,6 @@ describe("Translation Linting", () => { check: ["all"], verbose: process.argv.includes("--verbose"), }) - expect(result.exitCode).toBe(0) expect(result.output).toContain("All translations are complete") }) From 020b2f92d7c298b04020323f87fdf10531beebc2 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 19:41:17 -0700 Subject: [PATCH 09/37] lang: add PRIVACY.md translations for all locales Created PRIVACY.md translations for: ca, de, es, fr, hi, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW Each translation maintains consistent structure and terminology while adapting to language-specific conventions. Signed-off-by: Eric Wheeler --- locales/ca/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/de/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/es/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/fr/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/hi/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/it/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/ja/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/ko/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/nl/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/pl/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/pt-BR/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/ru/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/tr/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/vi/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/zh-CN/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ locales/zh-TW/PRIVACY.md | 37 +++++++++++++++++++++++++++++++++++++ 16 files changed, 592 insertions(+) create mode 100644 locales/ca/PRIVACY.md create mode 100644 locales/de/PRIVACY.md create mode 100644 locales/es/PRIVACY.md create mode 100644 locales/fr/PRIVACY.md create mode 100644 locales/hi/PRIVACY.md create mode 100644 locales/it/PRIVACY.md create mode 100644 locales/ja/PRIVACY.md create mode 100644 locales/ko/PRIVACY.md create mode 100644 locales/nl/PRIVACY.md create mode 100644 locales/pl/PRIVACY.md create mode 100644 locales/pt-BR/PRIVACY.md create mode 100644 locales/ru/PRIVACY.md create mode 100644 locales/tr/PRIVACY.md create mode 100644 locales/vi/PRIVACY.md create mode 100644 locales/zh-CN/PRIVACY.md create mode 100644 locales/zh-TW/PRIVACY.md diff --git a/locales/ca/PRIVACY.md b/locales/ca/PRIVACY.md new file mode 100644 index 0000000000..09266af0a1 --- /dev/null +++ b/locales/ca/PRIVACY.md @@ -0,0 +1,37 @@ +# Política de privadesa de Roo Code + +**Última actualització: 7 de març de 2025** + +Roo Code respecta la vostra privadesa i està compromès amb la transparència sobre com gestionem les vostres dades. A continuació trobareu un resum senzill d'on van les dades clau i, el que és més important, on no van. + +### **On van les vostres dades (i on no)** + +- **Codi i fitxers**: Roo Code accedeix als fitxers del vostre ordinador local quan és necessari per a les funcions assistides per IA. Quan envieu ordres a Roo Code, els fitxers rellevants es poden transmetre al vostre proveïdor de model d'IA escollit (per exemple, OpenAI, Anthropic, OpenRouter) per generar respostes. No tenim accés a aquestes dades, però els proveïdors d'IA poden emmagatzemar-les segons les seves polítiques de privadesa. +- **Ordres**: Qualsevol ordre executada a través de Roo Code es produeix al vostre entorn local. No obstant això, quan utilitzeu funcions basades en IA, el codi rellevant i el context de les vostres ordres es poden transmetre al vostre proveïdor de model d'IA escollit (per exemple, OpenAI, Anthropic, OpenRouter) per generar respostes. No tenim accés ni emmagatzemem aquestes dades, però els proveïdors d'IA poden processar-les segons les seves polítiques de privadesa. +- **Prompts i sol·licituds d'IA**: Quan utilitzeu funcions basades en IA, els vostres prompts i el context rellevant del projecte s'envien al vostre proveïdor de model d'IA escollit (per exemple, OpenAI, Anthropic, OpenRouter) per generar respostes. No emmagatzemem ni processem aquestes dades. Aquests proveïdors d'IA tenen les seves pròpies polítiques de privadesa i poden emmagatzemar dades segons els seus termes de servei. +- **Claus d'API i credencials**: Si introduïu una clau d'API (per exemple, per connectar un model d'IA), s'emmagatzema localment al vostre dispositiu i mai s'envia a nosaltres ni a tercers, excepte al proveïdor que heu escollit. +- **Telemetria (dades d'ús)**: Només recollim dades d'ús de funcions i errors si hi opteu explícitament. Aquesta telemetria està impulsada per PostHog i ens ajuda a entendre l'ús de les funcions per millorar Roo Code. Això inclou el vostre ID de màquina VS Code i els patrons d'ús de funcions i informes d'excepcions. **No** recollim informació personalment identificable, el vostre codi ni prompts d'IA. + +### **Com utilitzem les vostres dades (si es recullen)** + +- Si opteu per la telemetria, la utilitzem per entendre l'ús de les funcions i millorar Roo Code. +- **No** venem ni compartim les vostres dades. +- **No** entrenem cap model amb les vostres dades. + +### **Les vostres opcions i control** + +- Podeu executar models localment per evitar que s'enviïn dades a tercers. +- Per defecte, la recollida de telemetria està desactivada i si l'activeu, podeu desactivar-la en qualsevol moment. +- Podeu eliminar Roo Code per aturar tota la recollida de dades. + +### **Seguretat i actualitzacions** + +Prenem mesures raonables per protegir les vostres dades, però cap sistema és 100% segur. Si la nostra política de privadesa canvia, us ho notificarem dins de l'extensió. + +### **Contacteu-nos** + +Per a qualsevol pregunta relacionada amb la privadesa, contacteu-nos a support@roocode.com. + +--- + +En utilitzar Roo Code, accepteu aquesta Política de privadesa. diff --git a/locales/de/PRIVACY.md b/locales/de/PRIVACY.md new file mode 100644 index 0000000000..e2486fecce --- /dev/null +++ b/locales/de/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code Datenschutzerklärung + +**Zuletzt aktualisiert: 7. März 2025** + +Roo Code respektiert deine Privatsphäre und verpflichtet sich zu Transparenz im Umgang mit deinen Daten. Hier findest du eine einfache Übersicht darüber, wohin wichtige Daten fließen – und vor allem, wohin nicht. + +### **Wohin deine Daten gehen (und wohin nicht)** + +- **Code & Dateien**: Roo Code greift bei Bedarf für KI-unterstützte Funktionen auf Dateien auf deinem lokalen Computer zu. Wenn du Befehle an Roo Code sendest, können relevante Dateien an deinen gewählten KI-Modellanbieter (z.B. OpenAI, Anthropic, OpenRouter) übertragen werden, um Antworten zu generieren. Wir haben keinen Zugriff auf diese Daten, aber KI-Anbieter können sie gemäß ihrer Datenschutzrichtlinien speichern. +- **Befehle**: Alle über Roo Code ausgeführten Befehle finden in deiner lokalen Umgebung statt. Wenn du jedoch KI-gestützte Funktionen nutzt, können der relevante Code und der Kontext deiner Befehle an deinen gewählten KI-Modellanbieter (z.B. OpenAI, Anthropic, OpenRouter) übertragen werden, um Antworten zu generieren. Wir haben keinen Zugriff auf diese Daten und speichern sie nicht, aber KI-Anbieter können sie gemäß ihrer Datenschutzrichtlinien verarbeiten. +- **Prompts & KI-Anfragen**: Wenn du KI-gestützte Funktionen nutzt, werden deine Prompts und der relevante Projektkontext an deinen gewählten KI-Modellanbieter (z.B. OpenAI, Anthropic, OpenRouter) gesendet, um Antworten zu generieren. Wir speichern oder verarbeiten diese Daten nicht. Diese KI-Anbieter haben ihre eigenen Datenschutzrichtlinien und können Daten gemäß ihrer Nutzungsbedingungen speichern. +- **API-Schlüssel & Zugangsdaten**: Wenn du einen API-Schlüssel eingibst (z.B. um ein KI-Modell zu verbinden), wird dieser lokal auf deinem Gerät gespeichert und niemals an uns oder Dritte gesendet, außer an den von dir gewählten Anbieter. +- **Telemetrie (Nutzungsdaten)**: Wir sammeln Funktionsnutzungs- und Fehlerdaten nur, wenn du ausdrücklich zustimmst. Diese Telemetrie wird von PostHog bereitgestellt und hilft uns, die Funktionsnutzung zu verstehen, um Roo Code zu verbessern. Dies umfasst deine VS Code Maschinen-ID, Funktionsnutzungsmuster und Ausnahmeberichte. Wir sammeln **keine** personenbezogenen Daten, deinen Code oder KI-Prompts. + +### **Wie wir deine Daten nutzen (falls erfasst)** + +- Wenn du der Telemetrie zustimmst, nutzen wir sie, um die Funktionsnutzung zu verstehen und Roo Code zu verbessern. +- Wir **verkaufen** oder **teilen** deine Daten nicht. +- Wir **trainieren** keine Modelle mit deinen Daten. + +### **Deine Wahlmöglichkeiten & Kontrolle** + +- Du kannst Modelle lokal ausführen, um die Übertragung von Daten an Dritte zu verhindern. +- Standardmäßig ist die Telemetrieerfassung deaktiviert, und wenn du sie aktivierst, kannst du sie jederzeit wieder deaktivieren. +- Du kannst Roo Code löschen, um die gesamte Datenerfassung zu stoppen. + +### **Sicherheit & Aktualisierungen** + +Wir ergreifen angemessene Maßnahmen, um deine Daten zu schützen, aber kein System ist zu 100% sicher. Wenn sich unsere Datenschutzrichtlinie ändert, werden wir dich innerhalb der Erweiterung benachrichtigen. + +### **Kontaktiere uns** + +Bei Fragen zum Datenschutz erreichst du uns unter support@roocode.com. + +--- + +Durch die Nutzung von Roo Code stimmst du dieser Datenschutzerklärung zu. diff --git a/locales/es/PRIVACY.md b/locales/es/PRIVACY.md new file mode 100644 index 0000000000..adc3f8b56a --- /dev/null +++ b/locales/es/PRIVACY.md @@ -0,0 +1,37 @@ +# Política de Privacidad de Roo Code + +**Última actualización: 7 de marzo de 2025** + +Roo Code respeta tu privacidad y está comprometido con la transparencia sobre cómo manejamos tus datos. A continuación, encontrarás un desglose simple de dónde van los datos importantes y, lo que es más importante, dónde no van. + +### **Dónde van tus datos (y dónde no)** + +- **Código y archivos**: Roo Code accede a los archivos en tu máquina local cuando es necesario para las funciones asistidas por IA. Cuando envías comandos a Roo Code, los archivos relevantes pueden ser transmitidos a tu proveedor de modelo de IA elegido (por ejemplo, OpenAI, Anthropic, OpenRouter) para generar respuestas. No tenemos acceso a estos datos, pero los proveedores de IA pueden almacenarlos según sus políticas de privacidad. +- **Comandos**: Cualquier comando ejecutado a través de Roo Code ocurre en tu entorno local. Sin embargo, cuando utilizas funciones basadas en IA, el código relevante y el contexto de tus comandos pueden ser transmitidos a tu proveedor de modelo de IA elegido (por ejemplo, OpenAI, Anthropic, OpenRouter) para generar respuestas. No tenemos acceso ni almacenamos estos datos, pero los proveedores de IA pueden procesarlos según sus políticas de privacidad. +- **Prompts y solicitudes de IA**: Cuando utilizas funciones basadas en IA, tus prompts y el contexto relevante del proyecto se envían a tu proveedor de modelo de IA elegido (por ejemplo, OpenAI, Anthropic, OpenRouter) para generar respuestas. No almacenamos ni procesamos estos datos. Estos proveedores de IA tienen sus propias políticas de privacidad y pueden almacenar datos según sus términos de servicio. +- **Claves API y credenciales**: Si ingresas una clave API (por ejemplo, para conectar un modelo de IA), se almacena localmente en tu dispositivo y nunca se envía a nosotros ni a terceros, excepto al proveedor que hayas elegido. +- **Telemetría (datos de uso)**: Solo recopilamos datos de uso de funciones y errores si optas explícitamente por participar. Esta telemetría está impulsada por PostHog y nos ayuda a entender el uso de las funciones para mejorar Roo Code. Esto incluye tu ID de máquina de VS Code y patrones de uso de funciones e informes de excepciones. **No** recopilamos información personalmente identificable, tu código o prompts de IA. + +### **Cómo usamos tus datos (si se recopilan)** + +- Si optas por la telemetría, la usamos para entender el uso de las funciones y mejorar Roo Code. +- **No** vendemos ni compartimos tus datos. +- **No** entrenamos ningún modelo con tus datos. + +### **Tus opciones y control** + +- Puedes ejecutar modelos localmente para evitar que se envíen datos a terceros. +- Por defecto, la recopilación de telemetría está desactivada y si la activas, puedes optar por no participar en cualquier momento. +- Puedes eliminar Roo Code para detener toda la recopilación de datos. + +### **Seguridad y actualizaciones** + +Tomamos medidas razonables para proteger tus datos, pero ningún sistema es 100% seguro. Si nuestra política de privacidad cambia, te lo notificaremos dentro de la extensión. + +### **Contáctanos** + +Para cualquier pregunta relacionada con la privacidad, contáctanos en support@roocode.com. + +--- + +Al usar Roo Code, aceptas esta Política de Privacidad. diff --git a/locales/fr/PRIVACY.md b/locales/fr/PRIVACY.md new file mode 100644 index 0000000000..a5ec0de19b --- /dev/null +++ b/locales/fr/PRIVACY.md @@ -0,0 +1,37 @@ +# Politique de confidentialité de Roo Code + +**Dernière mise à jour : 7 mars 2025** + +Roo Code respecte votre vie privée et s'engage à être transparent sur la manière dont nous gérons vos données. Voici une présentation simple de la destination des données clés - et surtout, de leur non-destination. + +### **Où vont vos données (et où elles ne vont pas)** + +- **Code et fichiers** : Roo Code accède aux fichiers de votre machine locale lorsque c'est nécessaire pour les fonctionnalités assistées par l'IA. Lorsque vous envoyez des commandes à Roo Code, les fichiers pertinents peuvent être transmis à votre fournisseur de modèle d'IA choisi (par exemple, OpenAI, Anthropic, OpenRouter) pour générer des réponses. Nous n'avons pas accès à ces données, mais les fournisseurs d'IA peuvent les stocker selon leurs politiques de confidentialité. +- **Commandes** : Toutes les commandes exécutées via Roo Code se déroulent dans votre environnement local. Cependant, lorsque vous utilisez des fonctionnalités basées sur l'IA, le code pertinent et le contexte de vos commandes peuvent être transmis à votre fournisseur de modèle d'IA choisi (par exemple, OpenAI, Anthropic, OpenRouter) pour générer des réponses. Nous n'avons pas accès à ces données et ne les stockons pas, mais les fournisseurs d'IA peuvent les traiter selon leurs politiques de confidentialité. +- **Prompts et requêtes IA** : Lorsque vous utilisez des fonctionnalités basées sur l'IA, vos prompts et le contexte pertinent du projet sont envoyés à votre fournisseur de modèle d'IA choisi (par exemple, OpenAI, Anthropic, OpenRouter) pour générer des réponses. Nous ne stockons ni ne traitons ces données. Ces fournisseurs d'IA ont leurs propres politiques de confidentialité et peuvent stocker des données selon leurs conditions de service. +- **Clés API et identifiants** : Si vous saisissez une clé API (par exemple, pour connecter un modèle d'IA), elle est stockée localement sur votre appareil et n'est jamais envoyée à nous ou à des tiers, sauf au fournisseur que vous avez choisi. +- **Télémétrie (données d'utilisation)** : Nous ne collectons les données d'utilisation des fonctionnalités et les erreurs que si vous y consentez explicitement. Cette télémétrie est gérée par PostHog et nous aide à comprendre l'utilisation des fonctionnalités pour améliorer Roo Code. Cela inclut votre ID machine VS Code et les modèles d'utilisation des fonctionnalités et les rapports d'exception. Nous ne collectons **pas** d'informations personnellement identifiables, votre code ou vos prompts IA. + +### **Comment nous utilisons vos données (si collectées)** + +- Si vous optez pour la télémétrie, nous l'utilisons pour comprendre l'utilisation des fonctionnalités et améliorer Roo Code. +- Nous ne **vendons** ni ne **partageons** vos données. +- Nous n'**entraînons** aucun modèle avec vos données. + +### **Vos choix et contrôle** + +- Vous pouvez exécuter des modèles localement pour éviter l'envoi de données à des tiers. +- Par défaut, la collecte de télémétrie est désactivée et si vous l'activez, vous pouvez vous en désinscrire à tout moment. +- Vous pouvez supprimer Roo Code pour arrêter toute collecte de données. + +### **Sécurité et mises à jour** + +Nous prenons des mesures raisonnables pour sécuriser vos données, mais aucun système n'est sécurisé à 100%. Si notre politique de confidentialité change, nous vous en informerons dans l'extension. + +### **Nous contacter** + +Pour toute question relative à la confidentialité, contactez-nous à support@roocode.com. + +--- + +En utilisant Roo Code, vous acceptez cette politique de confidentialité. diff --git a/locales/hi/PRIVACY.md b/locales/hi/PRIVACY.md new file mode 100644 index 0000000000..a55cf6d662 --- /dev/null +++ b/locales/hi/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code गोपनीयता नीति + +**अंतिम अपडेट: 7 मार्च, 2025** + +Roo Code आपकी गोपनीयता का सम्मान करता है और आपके डेटा को कैसे संभालता है, इस बारे में पारदर्शिता के लिए प्रतिबद्ध है। नीचे एक सरल विवरण दिया गया है कि महत्वपूर्ण डेटा कहाँ जाता है—और, महत्वपूर्ण रूप से, कहाँ नहीं जाता। + +### **आपका डेटा कहाँ जाता है (और कहाँ नहीं)** + +- **कोड और फ़ाइलें**: Roo Code AI-सहायक सुविधाओं के लिए आवश्यक होने पर आपकी लोकल मशीन पर फ़ाइलों तक पहुंचता है। जब आप Roo Code को कमांड भेजते हैं, तो प्रासंगिक फ़ाइलें आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को प्रतिक्रियाएं उत्पन्न करने के लिए भेजी जा सकती हैं। हमारी इस डेटा तक पहुंच नहीं है, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे स्टोर कर सकते हैं। +- **कमांड**: Roo Code के माध्यम से निष्पादित की गई कोई भी कमांड आपके स्थानीय वातावरण में होती है। हालांकि, जब आप AI-आधारित सुविधाओं का उपयोग करते हैं, तो प्रासंगिक कोड और आपकी कमांड का संदर्भ प्रतिक्रियाएं उत्पन्न करने के लिए आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को भेजा जा सकता है। हमारी इस डेटा तक पहुंच नहीं है और न ही हम इसे स्टोर करते हैं, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे प्रोसेस कर सकते हैं। +- **प्रॉम्प्ट्स और AI अनुरोध**: जब आप AI-आधारित सुविधाओं का उपयोग करते हैं, तो आपके प्रॉम्प्ट्स और प्रोजेक्ट का प्रासंगिक संदर्भ प्रतिक्रियाएं उत्पन्न करने के लिए आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को भेजा जाता है। हम इस डेटा को स्टोर या प्रोसेस नहीं करते हैं। इन AI प्रदाताओं की अपनी गोपनीयता नीतियां हैं और वे अपनी सेवा शर्तों के अनुसार डेटा स्टोर कर सकते हैं। +- **API कुंजियां और क्रेडेंशियल्स**: यदि आप कोई API कुंजी दर्ज करते हैं (जैसे, AI मॉडल को कनेक्ट करने के लिए), तो यह आपके डिवाइस पर स्थानीय रूप से स्टोर की जाती है और कभी भी हमें या किसी तीसरे पक्ष को नहीं भेजी जाती है, सिवाय आपके द्वारा चुने गए प्रदाता के। +- **टेलीमेट्री (उपयोग डेटा)**: हम केवल तभी फीचर उपयोग और त्रुटि डेटा एकत्र करते हैं जब आप स्पष्ट रूप से सहमति देते हैं। यह टेलीमेट्री PostHog द्वारा संचालित है और हमें Roo Code को बेहतर बनाने के लिए फीचर उपयोग को समझने में मदद करती है। इसमें आपकी VS Code मशीन ID और फीचर उपयोग पैटर्न और अपवाद रिपोर्ट शामिल हैं। हम व्यक्तिगत रूप से पहचान योग्य जानकारी, आपका कोड, या AI प्रॉम्प्ट्स **नहीं** एकत्र करते हैं। + +### **हम आपके डेटा का उपयोग कैसे करते हैं (यदि एकत्र किया गया है)** + +- यदि आप टेलीमेट्री के लिए सहमत होते हैं, तो हम इसका उपयोग फीचर उपयोग को समझने और Roo Code को बेहतर बनाने के लिए करते हैं। +- हम आपका डेटा **नहीं** बेचते या साझा करते हैं। +- हम आपके डेटा पर कोई मॉडल **नहीं** प्रशिक्षित करते हैं। + +### **आपके विकल्प और नियंत्रण** + +- आप तृतीय-पक्षों को डेटा भेजने से बचने के लिए मॉडल स्थानीय रूप से चला सकते हैं। +- डिफ़ॉल्ट रूप से, टेलीमेट्री संग्रह बंद है और यदि आप इसे चालू करते हैं, तो आप किसी भी समय टेलीमेट्री से बाहर निकल सकते हैं। +- आप सभी डेटा संग्रह को रोकने के लिए Roo Code को हटा सकते हैं। + +### **सुरक्षा और अपडेट** + +हम आपके डेटा को सुरक्षित करने के लिए उचित उपाय करते हैं, लेकिन कोई भी सिस्टम 100% सुरक्षित नहीं है। यदि हमारी गोपनीयता नीति में परिवर्तन होता है, तो हम आपको एक्सटेंशन के भीतर सूचित करेंगे। + +### **हमसे संपर्क करें** + +किसी भी गोपनीयता-संबंधित प्रश्नों के लिए, हमसे support@roocode.com पर संपर्क करें। + +--- + +Roo Code का उपयोग करके, आप इस गोपनीयता नीति से सहमत होते हैं। diff --git a/locales/it/PRIVACY.md b/locales/it/PRIVACY.md new file mode 100644 index 0000000000..648be7d9cd --- /dev/null +++ b/locales/it/PRIVACY.md @@ -0,0 +1,37 @@ +# Informativa sulla Privacy di Roo Code + +**Ultimo aggiornamento: 7 marzo 2025** + +Roo Code rispetta la tua privacy e si impegna alla trasparenza su come gestiamo i tuoi dati. Di seguito trovi una semplice spiegazione di dove vanno i dati importanti e, soprattutto, dove non vanno. + +### **Dove vanno i tuoi dati (e dove no)** + +- **Codice e file**: Roo Code accede ai file sul tuo computer locale quando necessario per le funzionalità assistite dall'IA. Quando invii comandi a Roo Code, i file pertinenti potrebbero essere trasmessi al fornitore di modelli IA da te scelto (ad esempio, OpenAI, Anthropic, OpenRouter) per generare risposte. Non abbiamo accesso a questi dati, ma i fornitori di IA potrebbero memorizzarli secondo le loro politiche sulla privacy. +- **Comandi**: Qualsiasi comando eseguito attraverso Roo Code avviene nel tuo ambiente locale. Tuttavia, quando utilizzi funzionalità basate sull'IA, il codice pertinente e il contesto dei tuoi comandi potrebbero essere trasmessi al fornitore di modelli IA da te scelto (ad esempio, OpenAI, Anthropic, OpenRouter) per generare risposte. Non abbiamo accesso né memorizziamo questi dati, ma i fornitori di IA potrebbero elaborarli secondo le loro politiche sulla privacy. +- **Prompt e richieste IA**: Quando utilizzi funzionalità basate sull'IA, i tuoi prompt e il contesto pertinente del progetto vengono inviati al fornitore di modelli IA da te scelto (ad esempio, OpenAI, Anthropic, OpenRouter) per generare risposte. Non memorizziamo né elaboriamo questi dati. Questi fornitori di IA hanno le proprie politiche sulla privacy e potrebbero memorizzare i dati secondo i loro termini di servizio. +- **Chiavi API e credenziali**: Se inserisci una chiave API (ad esempio, per connettere un modello IA), viene memorizzata localmente sul tuo dispositivo e non viene mai inviata a noi o a terze parti, eccetto al fornitore che hai scelto. +- **Telemetria (dati di utilizzo)**: Raccogliamo dati sull'utilizzo delle funzionalità e sugli errori solo se acconsenti esplicitamente. Questa telemetria è gestita da PostHog e ci aiuta a comprendere l'utilizzo delle funzionalità per migliorare Roo Code. Questo include il tuo ID macchina VS Code e i modelli di utilizzo delle funzionalità e i report delle eccezioni. **Non** raccogliamo informazioni personalmente identificabili, il tuo codice o i prompt IA. + +### **Come utilizziamo i tuoi dati (se raccolti)** + +- Se acconsenti alla telemetria, la utilizziamo per comprendere l'utilizzo delle funzionalità e migliorare Roo Code. +- **Non** vendiamo né condividiamo i tuoi dati. +- **Non** addestriamo alcun modello con i tuoi dati. + +### **Le tue scelte e il controllo** + +- Puoi eseguire i modelli localmente per evitare l'invio di dati a terze parti. +- Per impostazione predefinita, la raccolta della telemetria è disattivata e se la attivi, puoi disattivarla in qualsiasi momento. +- Puoi eliminare Roo Code per interrompere tutta la raccolta dati. + +### **Sicurezza e aggiornamenti** + +Adottiamo misure ragionevoli per proteggere i tuoi dati, ma nessun sistema è sicuro al 100%. Se la nostra politica sulla privacy cambia, te lo notificheremo all'interno dell'estensione. + +### **Contattaci** + +Per qualsiasi domanda sulla privacy, contattaci a support@roocode.com. + +--- + +Utilizzando Roo Code, accetti questa Informativa sulla Privacy. diff --git a/locales/ja/PRIVACY.md b/locales/ja/PRIVACY.md new file mode 100644 index 0000000000..390dcc233b --- /dev/null +++ b/locales/ja/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code プライバシーポリシー + +**最終更新日:2025年3月7日** + +Roo Codeはあなたのプライバシーを尊重し、データの取り扱い方法について透明性を保つことを約束します。以下は、重要なデータがどこに行くのか、そしてより重要なことに、どこに行かないのかについての簡単な説明です。 + +### **あなたのデータの行き先(および行かない場所)** + +- **コードとファイル**:Roo CodeはAIアシスト機能のために必要な場合、ローカルマシン上のファイルにアクセスします。Roo Codeにコマンドを送信すると、関連するファイルが応答を生成するために選択したAIモデルプロバイダー(OpenAI、Anthropic、OpenRouterなど)に送信される場合があります。私たちはこのデータにアクセスできませんが、AIプロバイダーは自社のプライバシーポリシーに従ってデータを保存する場合があります。 +- **コマンド**:Roo Codeを通じて実行されるすべてのコマンドはローカル環境で実行されます。ただし、AI機能を使用する場合、関連するコードとコマンドのコンテキストは、応答を生成するために選択したAIモデルプロバイダー(OpenAI、Anthropic、OpenRouterなど)に送信される場合があります。私たちはこのデータにアクセスせず、保存もしませんが、AIプロバイダーは自社のプライバシーポリシーに従ってデータを処理する場合があります。 +- **プロンプトとAIリクエスト**:AI機能を使用する場合、プロンプトとプロジェクトの関連コンテキストは、応答を生成するために選択したAIモデルプロバイダー(OpenAI、Anthropic、OpenRouterなど)に送信されます。私たちはこのデータを保存または処理しません。これらのAIプロバイダーには独自のプライバシーポリシーがあり、利用規約に従ってデータを保存する場合があります。 +- **APIキーと認証情報**:APIキー(AIモデルを接続するためなど)を入力した場合、それはデバイスにローカルに保存され、選択したプロバイダーを除き、私たちや第三者に送信されることはありません。 +- **テレメトリ(使用状況データ)**:機能の使用状況とエラーデータは、明示的に同意した場合にのみ収集されます。このテレメトリはPostHogによって提供され、Roo Codeを改善するために機能の使用状況を理解するのに役立ちます。これにはVS Codeマシン ID、機能の使用パターン、例外レポートが含まれます。個人を特定できる情報、コード、AIプロンプトは収集**しません**。 + +### **データの使用方法(収集された場合)** + +- テレメトリに同意した場合、機能の使用状況を理解しRoo Codeを改善するために使用します。 +- データの**販売**や**共有**は行いません。 +- あなたのデータを使用してモデルを**トレーニング**することはありません。 + +### **選択肢とコントロール** + +- モデルをローカルで実行して、データが第三者に送信されるのを防ぐことができます。 +- デフォルトでは、テレメトリ収集はオフになっており、オンにした場合でもいつでもオプトアウトできます。 +- Roo Codeを削除することで、すべてのデータ収集を停止できます。 + +### **セキュリティとアップデート** + +私たちはあなたのデータを保護するための合理的な措置を講じていますが、100%安全なシステムは存在しません。プライバシーポリシーが変更された場合、拡張機能内で通知します。 + +### **お問い合わせ** + +プライバシーに関するご質問は、support@roocode.comまでお問い合わせください。 + +--- + +Roo Codeを使用することで、このプライバシーポリシーに同意したことになります。 diff --git a/locales/ko/PRIVACY.md b/locales/ko/PRIVACY.md new file mode 100644 index 0000000000..8ab8b5caaa --- /dev/null +++ b/locales/ko/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code 개인정보 처리방침 + +**최종 업데이트: 2025년 3월 7일** + +Roo Code는 귀하의 개인정보를 존중하며 데이터 처리 방식에 대한 투명성을 약속합니다. 다음은 주요 데이터가 어디로 이동하는지, 그리고 더 중요하게는 어디로 이동하지 않는지에 대한 간단한 설명입니다. + +### **귀하의 데이터가 이동하는 곳 (및 이동하지 않는 곳)** + +- **코드 및 파일**: Roo Code는 AI 지원 기능을 위해 필요한 경우 로컬 머신의 파일에 접근합니다. Roo Code에 명령을 보낼 때, 관련 파일이 응답 생성을 위해 선택한 AI 모델 제공업체(예: OpenAI, Anthropic, OpenRouter)로 전송될 수 있습니다. 우리는 이 데이터에 접근할 수 없지만, AI 제공업체는 자체 개인정보 처리방침에 따라 데이터를 저장할 수 있습니다. +- **명령어**: Roo Code를 통해 실행되는 모든 명령어는 로컬 환경에서 실행됩니다. 하지만 AI 기반 기능을 사용할 때, 관련 코드와 명령어의 컨텍스트가 응답 생성을 위해 선택한 AI 모델 제공업체(예: OpenAI, Anthropic, OpenRouter)로 전송될 수 있습니다. 우리는 이 데이터에 접근하거나 저장하지 않지만, AI 제공업체는 자체 개인정보 처리방침에 따라 데이터를 처리할 수 있습니다. +- **프롬프트 및 AI 요청**: AI 기반 기능을 사용할 때, 귀하의 프롬프트와 프로젝트의 관련 컨텍스트가 응답 생성을 위해 선택한 AI 모델 제공업체(예: OpenAI, Anthropic, OpenRouter)로 전송됩니다. 우리는 이 데이터를 저장하거나 처리하지 않습니다. 이러한 AI 제공업체들은 자체 개인정보 처리방침을 가지고 있으며 서비스 약관에 따라 데이터를 저장할 수 있습니다. +- **API 키 및 자격 증명**: API 키(예: AI 모델 연결용)를 입력하면, 이는 귀하의 기기에 로컬로 저장되며 선택한 제공업체를 제외하고 우리나 제3자에게 전송되지 않습니다. +- **원격 측정(사용 데이터)**: 기능 사용 및 오류 데이터는 귀하가 명시적으로 동의한 경우에만 수집됩니다. 이 원격 측정은 PostHog에 의해 제공되며 Roo Code 개선을 위한 기능 사용 이해에 도움을 줍니다. 여기에는 VS Code 머신 ID와 기능 사용 패턴 및 예외 보고서가 포함됩니다. 우리는 개인 식별 정보, 코드 또는 AI 프롬프트를 수집하지 **않습니다**. + +### **데이터 사용 방법 (수집된 경우)** + +- 원격 측정에 동의하면, 기능 사용을 이해하고 Roo Code를 개선하는 데 사용됩니다. +- 귀하의 데이터를 **판매**하거나 **공유**하지 않습니다. +- 귀하의 데이터로 모델을 **학습**하지 않습니다. + +### **귀하의 선택 및 제어** + +- 제3자에게 데이터가 전송되는 것을 방지하기 위해 모델을 로컬에서 실행할 수 있습니다. +- 기본적으로 원격 측정 수집은 비활성화되어 있으며, 활성화하더라도 언제든지 옵트아웃할 수 있습니다. +- Roo Code를 삭제하여 모든 데이터 수집을 중단할 수 있습니다. + +### **보안 및 업데이트** + +우리는 귀하의 데이터를 보호하기 위해 합리적인 조치를 취하지만, 어떤 시스템도 100% 안전하지는 않습니다. 개인정보 처리방침이 변경되면 확장 프로그램 내에서 알려드립니다. + +### **연락처** + +개인정보 관련 문의사항은 support@roocode.com으로 연락해 주시기 바랍니다. + +--- + +Roo Code를 사용함으로써 이 개인정보 처리방침에 동의하게 됩니다. diff --git a/locales/nl/PRIVACY.md b/locales/nl/PRIVACY.md new file mode 100644 index 0000000000..f05efa4612 --- /dev/null +++ b/locales/nl/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code Privacybeleid + +**Laatst bijgewerkt: 7 maart 2025** + +Roo Code respecteert je privacy en zet zich in voor transparantie over hoe we met je gegevens omgaan. Hieronder vind je een eenvoudige uitleg over waar belangrijke gegevens naartoe gaan - en belangrijker nog, waar ze niet naartoe gaan. + +### **Waar je gegevens naartoe gaan (en waar niet)** + +- **Code en bestanden**: Roo Code heeft toegang tot bestanden op je lokale machine wanneer dat nodig is voor AI-ondersteunde functies. Wanneer je opdrachten naar Roo Code stuurt, kunnen relevante bestanden worden verzonden naar je gekozen AI-modelprovider (bijvoorbeeld OpenAI, Anthropic, OpenRouter) om antwoorden te genereren. Wij hebben geen toegang tot deze gegevens, maar AI-providers kunnen ze opslaan volgens hun privacybeleid. +- **Opdrachten**: Alle opdrachten die via Roo Code worden uitgevoerd, gebeuren in je lokale omgeving. Echter, wanneer je AI-gestuurde functies gebruikt, kunnen de relevante code en context van je opdrachten worden verzonden naar je gekozen AI-modelprovider (bijvoorbeeld OpenAI, Anthropic, OpenRouter) om antwoorden te genereren. Wij hebben geen toegang tot deze gegevens en slaan ze niet op, maar AI-providers kunnen ze verwerken volgens hun privacybeleid. +- **Prompts en AI-verzoeken**: Wanneer je AI-gestuurde functies gebruikt, worden je prompts en relevante projectcontext verzonden naar je gekozen AI-modelprovider (bijvoorbeeld OpenAI, Anthropic, OpenRouter) om antwoorden te genereren. Wij slaan deze gegevens niet op en verwerken ze niet. Deze AI-providers hebben hun eigen privacybeleid en kunnen gegevens opslaan volgens hun servicevoorwaarden. +- **API-sleutels en inloggegevens**: Als je een API-sleutel invoert (bijvoorbeeld om een AI-model te verbinden), wordt deze lokaal op je apparaat opgeslagen en nooit naar ons of derden verzonden, behalve naar de provider die je hebt gekozen. +- **Telemetrie (gebruiksgegevens)**: We verzamelen alleen functiegebruik en foutgegevens als je hier expliciet voor kiest. Deze telemetrie wordt aangedreven door PostHog en helpt ons het functiegebruik te begrijpen om Roo Code te verbeteren. Dit omvat je VS Code machine-ID en patronen van functiegebruik en uitzonderingsrapporten. We verzamelen **geen** persoonlijk identificeerbare informatie, je code of AI-prompts. + +### **Hoe we je gegevens gebruiken (indien verzameld)** + +- Als je kiest voor telemetrie, gebruiken we deze om het functiegebruik te begrijpen en Roo Code te verbeteren. +- We **verkopen** of **delen** je gegevens niet. +- We **trainen** geen modellen met je gegevens. + +### **Je keuzes en controle** + +- Je kunt modellen lokaal uitvoeren om te voorkomen dat gegevens naar derden worden verzonden. +- Standaard staat telemetrieverzameling uit en als je het aanzet, kun je je op elk moment afmelden. +- Je kunt Roo Code verwijderen om alle gegevensverzameling te stoppen. + +### **Beveiliging en updates** + +We nemen redelijke maatregelen om je gegevens te beveiligen, maar geen enkel systeem is 100% veilig. Als ons privacybeleid verandert, zullen we je hiervan op de hoogte stellen binnen de extensie. + +### **Contact met ons opnemen** + +Voor privacy-gerelateerde vragen kun je contact met ons opnemen via support@roocode.com. + +--- + +Door Roo Code te gebruiken, ga je akkoord met dit Privacybeleid. diff --git a/locales/pl/PRIVACY.md b/locales/pl/PRIVACY.md new file mode 100644 index 0000000000..3b343600f9 --- /dev/null +++ b/locales/pl/PRIVACY.md @@ -0,0 +1,37 @@ +# Polityka Prywatności Roo Code + +**Ostatnia aktualizacja: 7 marca 2025** + +Roo Code szanuje Twoją prywatność i zobowiązuje się do przejrzystości w kwestii przetwarzania Twoich danych. Poniżej znajduje się proste wyjaśnienie, gdzie trafiają kluczowe dane - i co ważniejsze, gdzie nie trafiają. + +### **Gdzie trafiają Twoje dane (i gdzie nie)** + +- **Kod i pliki**: Roo Code uzyskuje dostęp do plików na Twoim lokalnym komputerze, gdy jest to potrzebne do funkcji wspomaganych przez AI. Gdy wysyłasz polecenia do Roo Code, odpowiednie pliki mogą być przesyłane do wybranego przez Ciebie dostawcy modelu AI (np. OpenAI, Anthropic, OpenRouter) w celu generowania odpowiedzi. Nie mamy dostępu do tych danych, ale dostawcy AI mogą je przechowywać zgodnie ze swoimi politykami prywatności. +- **Polecenia**: Wszystkie polecenia wykonywane przez Roo Code odbywają się w Twoim lokalnym środowisku. Jednak gdy korzystasz z funkcji opartych na AI, odpowiedni kod i kontekst Twoich poleceń mogą być przesyłane do wybranego przez Ciebie dostawcy modelu AI (np. OpenAI, Anthropic, OpenRouter) w celu generowania odpowiedzi. Nie mamy dostępu ani nie przechowujemy tych danych, ale dostawcy AI mogą je przetwarzać zgodnie ze swoimi politykami prywatności. +- **Prompty i zapytania AI**: Gdy korzystasz z funkcji opartych na AI, Twoje prompty i odpowiedni kontekst projektu są wysyłane do wybranego przez Ciebie dostawcy modelu AI (np. OpenAI, Anthropic, OpenRouter) w celu generowania odpowiedzi. Nie przechowujemy ani nie przetwarzamy tych danych. Ci dostawcy AI mają własne polityki prywatności i mogą przechowywać dane zgodnie ze swoimi warunkami świadczenia usług. +- **Klucze API i poświadczenia**: Jeśli wprowadzisz klucz API (np. do połączenia z modelem AI), jest on przechowywany lokalnie na Twoim urządzeniu i nigdy nie jest wysyłany do nas ani do stron trzecich, z wyjątkiem wybranego przez Ciebie dostawcy. +- **Telemetria (dane o użytkowaniu)**: Zbieramy dane o użyciu funkcji i błędach tylko wtedy, gdy wyraźnie wyrazisz na to zgodę. Ta telemetria jest obsługiwana przez PostHog i pomaga nam zrozumieć wykorzystanie funkcji w celu ulepszenia Roo Code. Obejmuje to Twój identyfikator maszyny VS Code oraz wzorce użycia funkcji i raporty o wyjątkach. **Nie** zbieramy danych osobowych, Twojego kodu ani promptów AI. + +### **Jak wykorzystujemy Twoje dane (jeśli są zbierane)** + +- Jeśli zgodzisz się na telemetrię, wykorzystujemy ją do zrozumienia użycia funkcji i ulepszenia Roo Code. +- **Nie** sprzedajemy ani nie udostępniamy Twoich danych. +- **Nie** trenujemy żadnych modeli na Twoich danych. + +### **Twoje wybory i kontrola** + +- Możesz uruchamiać modele lokalnie, aby zapobiec wysyłaniu danych do stron trzecich. +- Domyślnie zbieranie telemetrii jest wyłączone, a jeśli je włączysz, możesz zrezygnować w dowolnym momencie. +- Możesz usunąć Roo Code, aby zatrzymać całe zbieranie danych. + +### **Bezpieczeństwo i aktualizacje** + +Podejmujemy rozsądne środki, aby zabezpieczyć Twoje dane, ale żaden system nie jest w 100% bezpieczny. Jeśli nasza polityka prywatności ulegnie zmianie, powiadomimy Cię o tym w rozszerzeniu. + +### **Kontakt z nami** + +W przypadku pytań dotyczących prywatności, skontaktuj się z nami pod adresem support@roocode.com. + +--- + +Korzystając z Roo Code, zgadzasz się na tę Politykę Prywatności. diff --git a/locales/pt-BR/PRIVACY.md b/locales/pt-BR/PRIVACY.md new file mode 100644 index 0000000000..b61cb40dd0 --- /dev/null +++ b/locales/pt-BR/PRIVACY.md @@ -0,0 +1,37 @@ +# Política de Privacidade do Roo Code + +**Última atualização: 7 de março de 2025** + +O Roo Code respeita sua privacidade e está comprometido com a transparência sobre como lidamos com seus dados. Abaixo está uma explicação simples de para onde vão os dados importantes — e, mais importante ainda, para onde eles não vão. + +### **Para onde vão seus dados (e para onde não vão)** + +- **Código e arquivos**: O Roo Code acessa arquivos em sua máquina local quando necessário para recursos assistidos por IA. Quando você envia comandos para o Roo Code, arquivos relevantes podem ser transmitidos para seu provedor de modelo de IA escolhido (por exemplo, OpenAI, Anthropic, OpenRouter) para gerar respostas. Não temos acesso a esses dados, mas os provedores de IA podem armazená-los de acordo com suas políticas de privacidade. +- **Comandos**: Qualquer comando executado através do Roo Code acontece em seu ambiente local. No entanto, quando você usa recursos baseados em IA, o código relevante e o contexto de seus comandos podem ser transmitidos para seu provedor de modelo de IA escolhido (por exemplo, OpenAI, Anthropic, OpenRouter) para gerar respostas. Não temos acesso nem armazenamos esses dados, mas os provedores de IA podem processá-los de acordo com suas políticas de privacidade. +- **Prompts e solicitações de IA**: Quando você usa recursos baseados em IA, seus prompts e o contexto relevante do projeto são enviados para seu provedor de modelo de IA escolhido (por exemplo, OpenAI, Anthropic, OpenRouter) para gerar respostas. Não armazenamos nem processamos esses dados. Esses provedores de IA têm suas próprias políticas de privacidade e podem armazenar dados de acordo com seus termos de serviço. +- **Chaves de API e credenciais**: Se você inserir uma chave de API (por exemplo, para conectar um modelo de IA), ela é armazenada localmente em seu dispositivo e nunca é enviada para nós ou terceiros, exceto para o provedor que você escolheu. +- **Telemetria (dados de uso)**: Coletamos dados de uso de recursos e erros apenas se você optar explicitamente por participar. Essa telemetria é fornecida pelo PostHog e nos ajuda a entender o uso dos recursos para melhorar o Roo Code. Isso inclui seu ID de máquina do VS Code e padrões de uso de recursos e relatórios de exceções. **Não** coletamos informações pessoalmente identificáveis, seu código ou prompts de IA. + +### **Como usamos seus dados (se coletados)** + +- Se você optar pela telemetria, nós a usamos para entender o uso dos recursos e melhorar o Roo Code. +- **Não** vendemos nem compartilhamos seus dados. +- **Não** treinamos nenhum modelo com seus dados. + +### **Suas escolhas e controle** + +- Você pode executar modelos localmente para evitar que dados sejam enviados a terceiros. +- Por padrão, a coleta de telemetria está desativada e, se você ativá-la, pode optar por não participar a qualquer momento. +- Você pode excluir o Roo Code para interromper toda a coleta de dados. + +### **Segurança e atualizações** + +Tomamos medidas razoáveis para proteger seus dados, mas nenhum sistema é 100% seguro. Se nossa política de privacidade mudar, notificaremos você dentro da extensão. + +### **Contate-nos** + +Para quaisquer questões relacionadas à privacidade, entre em contato conosco em support@roocode.com. + +--- + +Ao usar o Roo Code, você concorda com esta Política de Privacidade. diff --git a/locales/ru/PRIVACY.md b/locales/ru/PRIVACY.md new file mode 100644 index 0000000000..74114847f2 --- /dev/null +++ b/locales/ru/PRIVACY.md @@ -0,0 +1,37 @@ +# Политика конфиденциальности Roo Code + +**Последнее обновление: 7 марта 2025 г.** + +Roo Code уважает вашу конфиденциальность и стремится к прозрачности в отношении того, как мы обрабатываем ваши данные. Ниже приведено простое объяснение того, куда поступают ключевые данные — и, что более важно, куда они не поступают. + +### **Куда поступают ваши данные (и куда нет)** + +- **Код и файлы**: Roo Code получает доступ к файлам на вашем локальном компьютере, когда это необходимо для функций с поддержкой ИИ. Когда вы отправляете команды в Roo Code, соответствующие файлы могут передаваться выбранному вами поставщику моделей ИИ (например, OpenAI, Anthropic, OpenRouter) для генерации ответов. У нас нет доступа к этим данным, но поставщики ИИ могут хранить их в соответствии со своими политиками конфиденциальности. +- **Команды**: Все команды, выполняемые через Roo Code, происходят в вашей локальной среде. Однако при использовании функций на основе ИИ соответствующий код и контекст ваших команд могут передаваться выбранному вами поставщику моделей ИИ (например, OpenAI, Anthropic, OpenRouter) для генерации ответов. У нас нет доступа к этим данным и мы их не храним, но поставщики ИИ могут обрабатывать их в соответствии со своими политиками конфиденциальности. +- **Промпты и запросы ИИ**: Когда вы используете функции на основе ИИ, ваши промпты и соответствующий контекст проекта отправляются выбранному вами поставщику моделей ИИ (например, OpenAI, Anthropic, OpenRouter) для генерации ответов. Мы не храним и не обрабатываем эти данные. У этих поставщиков ИИ есть свои политики конфиденциальности, и они могут хранить данные в соответствии со своими условиями предоставления услуг. +- **API-ключи и учетные данные**: Если вы вводите API-ключ (например, для подключения модели ИИ), он хранится локально на вашем устройстве и никогда не отправляется нам или третьим лицам, за исключением выбранного вами поставщика. +- **Телеметрия (данные об использовании)**: Мы собираем данные об использовании функций и ошибках только если вы явно дадите на это согласие. Эта телеметрия обеспечивается PostHog и помогает нам понять использование функций для улучшения Roo Code. Это включает ID вашей машины VS Code и шаблоны использования функций и отчеты об исключениях. Мы **не** собираем личную информацию, ваш код или промпты ИИ. + +### **Как мы используем ваши данные (если они собираются)** + +- Если вы соглашаетесь на телеметрию, мы используем ее для понимания использования функций и улучшения Roo Code. +- Мы **не** продаем и не передаем ваши данные. +- Мы **не** обучаем модели на ваших данных. + +### **Ваш выбор и контроль** + +- Вы можете запускать модели локально, чтобы предотвратить отправку данных третьим лицам. +- По умолчанию сбор телеметрии отключен, и если вы его включите, вы можете отказаться от него в любое время. +- Вы можете удалить Roo Code, чтобы прекратить весь сбор данных. + +### **Безопасность и обновления** + +Мы принимаем разумные меры для защиты ваших данных, но ни одна система не является на 100% безопасной. Если наша политика конфиденциальности изменится, мы уведомим вас об этом в расширении. + +### **Свяжитесь с нами** + +По любым вопросам, связанным с конфиденциальностью, обращайтесь к нам по адресу support@roocode.com. + +--- + +Используя Roo Code, вы соглашаетесь с этой Политикой конфиденциальности. diff --git a/locales/tr/PRIVACY.md b/locales/tr/PRIVACY.md new file mode 100644 index 0000000000..657d0463b0 --- /dev/null +++ b/locales/tr/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code Gizlilik Politikası + +**Son Güncelleme: 7 Mart 2025** + +Roo Code gizliliğinize saygı duyar ve verilerinizi nasıl işlediğimiz konusunda şeffaflığa bağlıdır. Aşağıda, önemli verilerin nereye gittiğine - ve daha da önemlisi, nereye gitmediğine dair basit bir açıklama bulunmaktadır. + +### **Verileriniz Nereye Gider (Ve Nereye Gitmez)** + +- **Kod ve Dosyalar**: Roo Code, AI destekli özellikler için gerektiğinde yerel makinenizdeki dosyalara erişir. Roo Code'a komutlar gönderdiğinizde, ilgili dosyalar yanıt üretmek için seçtiğiniz AI model sağlayıcısına (örneğin, OpenAI, Anthropic, OpenRouter) iletilebilir. Bu verilere erişimimiz yoktur, ancak AI sağlayıcıları kendi gizlilik politikalarına göre bunları depolayabilir. +- **Komutlar**: Roo Code aracılığıyla yürütülen tüm komutlar yerel ortamınızda gerçekleşir. Ancak, AI destekli özellikleri kullandığınızda, ilgili kod ve komutlarınızın bağlamı yanıt üretmek için seçtiğiniz AI model sağlayıcısına (örneğin, OpenAI, Anthropic, OpenRouter) iletilebilir. Bu verilere erişimimiz yoktur ve bunları depolamayız, ancak AI sağlayıcıları kendi gizlilik politikalarına göre bunları işleyebilir. +- **Promptlar ve AI İstekleri**: AI destekli özellikleri kullandığınızda, promptlarınız ve projenin ilgili bağlamı yanıt üretmek için seçtiğiniz AI model sağlayıcısına (örneğin, OpenAI, Anthropic, OpenRouter) gönderilir. Bu verileri depolamaz veya işlemeyiz. Bu AI sağlayıcılarının kendi gizlilik politikaları vardır ve hizmet şartlarına göre verileri depolayabilirler. +- **API Anahtarları ve Kimlik Bilgileri**: Bir API anahtarı girerseniz (örneğin, bir AI modelini bağlamak için), bu anahtar cihazınızda yerel olarak depolanır ve seçtiğiniz sağlayıcı dışında bize veya üçüncü taraflara asla gönderilmez. +- **Telemetri (Kullanım Verileri)**: Özellik kullanımı ve hata verilerini yalnızca açıkça onay verdiğinizde toplarız. Bu telemetri PostHog tarafından sağlanır ve Roo Code'u geliştirmek için özellik kullanımını anlamamıza yardımcı olur. Bu, VS Code makine kimliğinizi ve özellik kullanım kalıplarını ve istisna raporlarını içerir. Kişisel olarak tanımlanabilir bilgileri, kodunuzu veya AI promptlarını **toplamayız**. + +### **Verilerinizi Nasıl Kullanırız (Eğer Toplanırsa)** + +- Telemetriye onay verirseniz, bunu özellik kullanımını anlamak ve Roo Code'u geliştirmek için kullanırız. +- Verilerinizi **satmaz** veya **paylaşmayız**. +- Verilerinizle herhangi bir model **eğitmeyiz**. + +### **Seçimleriniz ve Kontrolünüz** + +- Verilerin üçüncü taraflara gönderilmesini önlemek için modelleri yerel olarak çalıştırabilirsiniz. +- Varsayılan olarak, telemetri toplama kapalıdır ve açarsanız, istediğiniz zaman vazgeçebilirsiniz. +- Tüm veri toplamayı durdurmak için Roo Code'u silebilirsiniz. + +### **Güvenlik ve Güncellemeler** + +Verilerinizi korumak için makul önlemler alırız, ancak hiçbir sistem %100 güvenli değildir. Gizlilik politikamız değişirse, sizi uzantı içinde bilgilendireceğiz. + +### **Bize Ulaşın** + +Gizlilikle ilgili sorularınız için support@roocode.com adresinden bize ulaşabilirsiniz. + +--- + +Roo Code'u kullanarak bu Gizlilik Politikasını kabul etmiş olursunuz. diff --git a/locales/vi/PRIVACY.md b/locales/vi/PRIVACY.md new file mode 100644 index 0000000000..b03608cd32 --- /dev/null +++ b/locales/vi/PRIVACY.md @@ -0,0 +1,37 @@ +# Chính sách Bảo mật của Roo Code + +**Cập nhật lần cuối: 7 tháng 3, 2025** + +Roo Code tôn trọng quyền riêng tư của bạn và cam kết minh bạch về cách chúng tôi xử lý dữ liệu của bạn. Dưới đây là phần giải thích đơn giản về nơi dữ liệu quan trọng đi đến—và quan trọng hơn, nơi chúng không đi đến. + +### **Dữ liệu của bạn đi đến đâu (và không đi đến đâu)** + +- **Mã và Tệp tin**: Roo Code truy cập các tệp tin trên máy tính cục bộ của bạn khi cần thiết cho các tính năng hỗ trợ AI. Khi bạn gửi lệnh đến Roo Code, các tệp tin liên quan có thể được truyền đến nhà cung cấp mô hình AI mà bạn đã chọn (ví dụ: OpenAI, Anthropic, OpenRouter) để tạo phản hồi. Chúng tôi không có quyền truy cập vào dữ liệu này, nhưng các nhà cung cấp AI có thể lưu trữ chúng theo chính sách bảo mật của họ. +- **Lệnh**: Mọi lệnh thực thi thông qua Roo Code đều diễn ra trong môi trường cục bộ của bạn. Tuy nhiên, khi bạn sử dụng các tính năng dựa trên AI, mã liên quan và ngữ cảnh của lệnh của bạn có thể được truyền đến nhà cung cấp mô hình AI mà bạn đã chọn (ví dụ: OpenAI, Anthropic, OpenRouter) để tạo phản hồi. Chúng tôi không có quyền truy cập hoặc lưu trữ dữ liệu này, nhưng các nhà cung cấp AI có thể xử lý chúng theo chính sách bảo mật của họ. +- **Prompt và Yêu cầu AI**: Khi bạn sử dụng các tính năng dựa trên AI, prompt của bạn và ngữ cảnh dự án liên quan được gửi đến nhà cung cấp mô hình AI mà bạn đã chọn (ví dụ: OpenAI, Anthropic, OpenRouter) để tạo phản hồi. Chúng tôi không lưu trữ hoặc xử lý dữ liệu này. Các nhà cung cấp AI này có chính sách bảo mật riêng và có thể lưu trữ dữ liệu theo điều khoản dịch vụ của họ. +- **Khóa API và Thông tin Xác thực**: Nếu bạn nhập khóa API (ví dụ: để kết nối với mô hình AI), nó được lưu trữ cục bộ trên thiết bị của bạn và không bao giờ được gửi cho chúng tôi hoặc bên thứ ba, ngoại trừ nhà cung cấp mà bạn đã chọn. +- **Đo lường từ xa (Dữ liệu Sử dụng)**: Chúng tôi chỉ thu thập dữ liệu sử dụng tính năng và lỗi nếu bạn chọn tham gia một cách rõ ràng. Việc đo lường từ xa này được cung cấp bởi PostHog và giúp chúng tôi hiểu việc sử dụng tính năng để cải thiện Roo Code. Điều này bao gồm ID máy VS Code của bạn và mô hình sử dụng tính năng và báo cáo ngoại lệ. Chúng tôi **không** thu thập thông tin nhận dạng cá nhân, mã của bạn, hoặc prompt AI. + +### **Cách chúng tôi sử dụng dữ liệu của bạn (nếu được thu thập)** + +- Nếu bạn chọn tham gia đo lường từ xa, chúng tôi sử dụng nó để hiểu việc sử dụng tính năng và cải thiện Roo Code. +- Chúng tôi **không** bán hoặc chia sẻ dữ liệu của bạn. +- Chúng tôi **không** huấn luyện bất kỳ mô hình nào trên dữ liệu của bạn. + +### **Lựa chọn và Kiểm soát của bạn** + +- Bạn có thể chạy mô hình cục bộ để ngăn dữ liệu được gửi đến bên thứ ba. +- Theo mặc định, việc thu thập đo lường từ xa bị tắt và nếu bạn bật nó, bạn có thể từ chối bất kỳ lúc nào. +- Bạn có thể xóa Roo Code để dừng mọi việc thu thập dữ liệu. + +### **Bảo mật và Cập nhật** + +Chúng tôi thực hiện các biện pháp hợp lý để bảo vệ dữ liệu của bạn, nhưng không có hệ thống nào an toàn 100%. Nếu chính sách bảo mật của chúng tôi thay đổi, chúng tôi sẽ thông báo cho bạn trong tiện ích mở rộng. + +### **Liên hệ với chúng tôi** + +Đối với bất kỳ câu hỏi nào liên quan đến quyền riêng tư, hãy liên hệ với chúng tôi tại support@roocode.com. + +--- + +Bằng việc sử dụng Roo Code, bạn đồng ý với Chính sách Bảo mật này. diff --git a/locales/zh-CN/PRIVACY.md b/locales/zh-CN/PRIVACY.md new file mode 100644 index 0000000000..5aa45cd12e --- /dev/null +++ b/locales/zh-CN/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code 隐私政策 + +**最后更新:2025年3月7日** + +Roo Code 尊重您的隐私,并致力于保持数据处理的透明度。以下是关于重要数据去向的简单说明——更重要的是,哪些数据不会被传输。 + +### **您的数据去向(及不会去向何处)** + +- **代码和文件**:Roo Code 在需要 AI 辅助功能时会访问您本地机器上的文件。当您向 Roo Code 发送命令时,相关文件可能会传输到您选择的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成响应。我们无法访问这些数据,但 AI 提供商可能会根据其隐私政策存储这些数据。 +- **命令**:通过 Roo Code 执行的所有命令都在您的本地环境中进行。但是,当您使用基于 AI 的功能时,相关代码和命令上下文可能会传输到您选择的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成响应。我们无法访问也不存储这些数据,但 AI 提供商可能会根据其隐私政策处理这些数据。 +- **提示词和 AI 请求**:当您使用基于 AI 的功能时,您的提示词和相关项目上下文会发送到您选择的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成响应。我们不存储或处理这些数据。这些 AI 提供商有自己的隐私政策,可能会根据其服务条款存储数据。 +- **API 密钥和凭证**:如果您输入 API 密钥(例如,用于连接 AI 模型),它会存储在您的设备本地,除了您选择的提供商外,永远不会发送给我们或任何第三方。 +- **遥测(使用数据)**:我们仅在您明确选择加入时才收集功能使用和错误数据。此遥测由 PostHog 提供支持,帮助我们了解功能使用情况以改进 Roo Code。这包括您的 VS Code 机器 ID 以及功能使用模式和异常报告。我们**不**收集个人身份信息、您的代码或 AI 提示词。 + +### **我们如何使用您的数据(如果收集)** + +- 如果您选择加入遥测,我们使用它来了解功能使用情况并改进 Roo Code。 +- 我们**不**出售或共享您的数据。 +- 我们**不**使用您的数据训练任何模型。 + +### **您的选择和控制** + +- 您可以在本地运行模型,以防止数据发送给第三方。 +- 默认情况下,遥测收集处于关闭状态,如果您开启它,可以随时选择退出。 +- 您可以删除 Roo Code 以停止所有数据收集。 + +### **安全性和更新** + +我们采取合理措施来保护您的数据,但没有任何系统是 100% 安全的。如果我们的隐私政策发生变化,我们会在扩展程序中通知您。 + +### **联系我们** + +如有任何隐私相关问题,请通过 support@roocode.com 联系我们。 + +--- + +使用 Roo Code 即表示您同意本隐私政策。 diff --git a/locales/zh-TW/PRIVACY.md b/locales/zh-TW/PRIVACY.md new file mode 100644 index 0000000000..792a03c77d --- /dev/null +++ b/locales/zh-TW/PRIVACY.md @@ -0,0 +1,37 @@ +# Roo Code 隱私權政策 + +**最後更新:2025年3月7日** + +Roo Code 尊重您的隱私,並致力於保持資料處理的透明度。以下是關於重要資料去向的簡單說明——更重要的是,哪些資料不會被傳輸。 + +### **您的資料去向(及不會去向何處)** + +- **程式碼和檔案**:Roo Code 在需要 AI 輔助功能時會存取您本機上的檔案。當您向 Roo Code 發送指令時,相關檔案可能會傳輸到您選擇的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成回應。我們無法存取這些資料,但 AI 提供商可能會根據其隱私權政策儲存這些資料。 +- **指令**:透過 Roo Code 執行的所有指令都在您的本機環境中進行。但是,當您使用基於 AI 的功能時,相關程式碼和指令上下文可能會傳輸到您選擇的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成回應。我們無法存取也不儲存這些資料,但 AI 提供商可能會根據其隱私權政策處理這些資料。 +- **提示詞和 AI 請求**:當您使用基於 AI 的功能時,您的提示詞和相關專案上下文會發送到您選擇的 AI 模型提供商(如 OpenAI、Anthropic、OpenRouter)以生成回應。我們不儲存或處理這些資料。這些 AI 提供商有自己的隱私權政策,可能會根據其服務條款儲存資料。 +- **API 金鑰和憑證**:如果您輸入 API 金鑰(例如,用於連接 AI 模型),它會儲存在您的裝置本機,除了您選擇的提供商外,永遠不會發送給我們或任何第三方。 +- **遙測(使用資料)**:我們僅在您明確選擇加入時才收集功能使用和錯誤資料。此遙測由 PostHog 提供支援,幫助我們了解功能使用情況以改進 Roo Code。這包括您的 VS Code 機器 ID 以及功能使用模式和異常報告。我們**不**收集個人身份資訊、您的程式碼或 AI 提示詞。 + +### **我們如何使用您的資料(如果收集)** + +- 如果您選擇加入遙測,我們使用它來了解功能使用情況並改進 Roo Code。 +- 我們**不**出售或分享您的資料。 +- 我們**不**使用您的資料訓練任何模型。 + +### **您的選擇和控制** + +- 您可以在本機執行模型,以防止資料發送給第三方。 +- 預設情況下,遙測收集處於關閉狀態,如果您開啟它,可以隨時選擇退出。 +- 您可以刪除 Roo Code 以停止所有資料收集。 + +### **安全性和更新** + +我們採取合理措施來保護您的資料,但沒有任何系統是 100% 安全的。如果我們的隱私權政策發生變化,我們會在擴充功能中通知您。 + +### **聯絡我們** + +如有任何隱私權相關問題,請透過 support@roocode.com 聯絡我們。 + +--- + +使用 Roo Code 即表示您同意本隱私權政策。 From e82bb972a7960af086745a350ce462e96d70e59e Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 20:29:48 -0700 Subject: [PATCH 10/37] feat: support escaped dots in translation paths - Add support for escaped dots (\.) in translation paths - Allow keys to contain dots by escaping them - Add comprehensive tests for dot escaping functionality - Improve error handling and exports for testing - Update jest config to include scripts directory Example: settings\.customStoragePath\.description -> settings.customStoragePath.description Signed-off-by: Eric Wheeler --- jest.config.js | 2 +- scripts/__tests__/manage-translations.test.ts | 215 ++++++++++++++ scripts/manage-translations.d.ts | 11 + scripts/manage-translations.js | 271 ++++++++++++------ 4 files changed, 405 insertions(+), 94 deletions(-) create mode 100644 scripts/__tests__/manage-translations.test.ts create mode 100644 scripts/manage-translations.d.ts diff --git a/jest.config.js b/jest.config.js index feebeafdf4..b79cfc75b1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -41,7 +41,7 @@ module.exports = { transformIgnorePatterns: [ "node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|serialize-error|strip-ansi|default-shell|os-name|strip-bom)/)", ], - roots: ["/src", "/webview-ui/src", "/locales"], + roots: ["/src", "/webview-ui/src", "/locales", "/scripts"], modulePathIgnorePatterns: [".vscode-test"], reporters: [["jest-simple-dot-reporter", {}]], setupFiles: ["/src/__mocks__/jest.setup.ts"], diff --git a/scripts/__tests__/manage-translations.test.ts b/scripts/__tests__/manage-translations.test.ts new file mode 100644 index 0000000000..f78343054b --- /dev/null +++ b/scripts/__tests__/manage-translations.test.ts @@ -0,0 +1,215 @@ +import * as fs from "fs" +import * as path from "path" +import * as os from "os" +import { + getNestedValue, + setNestedValue, + deleteNestedValue, + addTranslations, + deleteTranslations, + main, +} from "../manage-translations" + +describe("Translation Management", () => { + let testDir: string + let testFile: string + + beforeEach(async () => { + testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "translations-test-")) + testFile = path.join(testDir, "test.json") + }) + + afterEach(async () => { + await fs.promises.rm(testDir, { recursive: true, force: true }) + }) + + describe("Nested Value Operations", () => { + test("handles nested paths and escaped dots", () => { + const obj = { + settings: { + "customStoragePath.description": "Storage path setting", + "vsCodeLmModelSelector.vendor.description": "Vendor setting", + nested: { key: "normal nested" }, + }, + } + + // Regular nested path + expect(getNestedValue(obj, "settings.nested.key")).toBe("normal nested") + + // Paths with dots + expect(getNestedValue(obj, "settings.customStoragePath\\.description")).toBe("Storage path setting") + expect(getNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description")).toBe("Vendor setting") + }) + + test("setNestedValue handles escaped dots", () => { + const obj = {} + + // Regular nested path + setNestedValue(obj, "settings.nested.key", "normal nested") + + // Paths with dots + setNestedValue(obj, "settings.customStoragePath\\.description", "Storage path setting") + setNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description", "Vendor setting") + + expect(obj).toEqual({ + settings: { + nested: { + key: "normal nested", + }, + "customStoragePath.description": "Storage path setting", + "vsCodeLmModelSelector.vendor.description": "Vendor setting", + }, + }) + }) + + test("deleteNestedValue handles escaped dots", () => { + const obj = { + settings: { + nested: { + key: "normal nested", + }, + "customStoragePath.description": "Storage path setting", + "vsCodeLmModelSelector.vendor.description": "Vendor setting", + }, + } + + // Delete regular nested path + expect(deleteNestedValue(obj, "settings.nested.key")).toBe(true) + + // Delete paths with dots + expect(deleteNestedValue(obj, "settings.customStoragePath\\.description")).toBe(true) + expect(deleteNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description")).toBe(true) + + expect(obj).toEqual({ + settings: { + nested: {}, + }, + }) + }) + }) + + describe("Translation Operations", () => { + test("addTranslations adds new translations", async () => { + const data = {} + const pairs: [string, string][] = [ + ["key1.nested", "value1"], + ["key2", "value2"], + ] + const modified = await addTranslations(data, pairs, testFile) + expect(modified).toBe(true) + expect(data).toEqual({ + key1: { nested: "value1" }, + key2: "value2", + }) + }) + + test("addTranslations skips existing translations", async () => { + const data = { key1: { nested: "existing" } } + const pairs: [string, string][] = [["key1.nested", "new value"]] + const modified = await addTranslations(data, pairs, testFile) + expect(modified).toBe(false) + expect(data).toEqual({ key1: { nested: "existing" } }) + }) + + test("deleteTranslations removes existing translations", async () => { + const data = { + key1: { nested: "value1" }, + key2: "value2", + } + const keys = ["key1.nested", "key2"] + const modified = await deleteTranslations(data, keys, testFile) + expect(modified).toBe(true) + expect(data).toEqual({ key1: {} }) + }) + + test("deleteTranslations handles non-existent keys", async () => { + const data = { key1: { nested: "value1" } } + const keys = ["key1.nonexistent", "key2"] + const modified = await deleteTranslations(data, keys, testFile) + expect(modified).toBe(false) + expect(data).toEqual({ key1: { nested: "value1" } }) + }) + }) + + describe("File Operations", () => { + test("addTranslations creates parent directories if needed", async () => { + const nestedFile = path.join(testDir, "nested", "test.json") + const data = {} + const pairs: [string, string][] = [["key", "value"]] + await addTranslations(data, pairs, nestedFile) + + const dirExists = await fs.promises + .access(path.dirname(nestedFile)) + .then(() => true) + .catch(() => false) + expect(dirExists).toBe(true) + }) + + test("addTranslations with verbose mode logs operations", async () => { + const consoleSpy = jest.spyOn(console, "log") + const data = {} + const pairs: [string, string][] = [["key", "value"]] + await addTranslations(data, pairs, testFile, true) + + expect(consoleSpy).toHaveBeenCalledWith("Created new key path: key") + expect(consoleSpy).toHaveBeenCalledWith(`Full path: ${testFile}`) + expect(consoleSpy).toHaveBeenCalledWith('Set value: "value"') + + consoleSpy.mockRestore() + }) + + test("deleteTranslations with verbose mode logs operations", async () => { + const consoleSpy = jest.spyOn(console, "log") + const data = { key: "value" } + const keys = ["key"] + await deleteTranslations(data, keys, testFile, true) + + expect(consoleSpy).toHaveBeenCalledWith("Deleted key: key") + expect(consoleSpy).toHaveBeenCalledWith(`From file: ${testFile}`) + + consoleSpy.mockRestore() + }) + }) + + describe("Main Function", () => { + test("main throws error for invalid JSON file", async () => { + const invalidJson = path.join(testDir, "invalid.json") + await fs.promises.writeFile(invalidJson, "invalid json content") + + process.argv = ["node", "script", invalidJson] + await expect(main()).rejects.toThrow("Invalid JSON in translation file") + }) + + test("main handles missing file in non-verbose mode", async () => { + const nonExistentFile = path.join(testDir, "nonexistent.json") + process.argv = ["node", "script", nonExistentFile, "key", "value"] + await main() + + const fileContent = await fs.promises.readFile(nonExistentFile, "utf8") + const data = JSON.parse(fileContent) + expect(data).toEqual({ key: "value" }) + }) + + test("main adds translations from command line", async () => { + const testFile = path.join(testDir, "test.json") + process.argv = ["node", "script", testFile, "key1", "value1", "key2", "value2"] + await main() + + const fileContent = await fs.promises.readFile(testFile, "utf8") + const data = JSON.parse(fileContent) + expect(data).toEqual({ key1: "value1", key2: "value2" }) + }) + + test("main deletes translations in delete mode", async () => { + const testFile = path.join(testDir, "test.json") + await fs.promises.writeFile(testFile, JSON.stringify({ key1: "value1", key2: "value2" })) + + process.argv = ["node", "script", "-d", testFile, "key1"] + await main() + + const fileContent = await fs.promises.readFile(testFile, "utf8") + const data = JSON.parse(fileContent) + expect(data).toEqual({ key2: "value2" }) + }) + }) +}) diff --git a/scripts/manage-translations.d.ts b/scripts/manage-translations.d.ts new file mode 100644 index 0000000000..c68c035196 --- /dev/null +++ b/scripts/manage-translations.d.ts @@ -0,0 +1,11 @@ +export function getNestedValue(obj: any, keyPath: string): any +export function setNestedValue(obj: any, keyPath: string, value: any): void +export function deleteNestedValue(obj: any, keyPath: string): boolean +export function processStdin(): Promise<[string, string][]> +export function addTranslations( + data: any, + pairs: [string, string][], + filePath: string, + verbose?: boolean, +): Promise +export function deleteTranslations(data: any, keys: string[], filePath: string, verbose?: boolean): Promise diff --git a/scripts/manage-translations.js b/scripts/manage-translations.js index 6d33740aaa..6affdeb5bd 100755 --- a/scripts/manage-translations.js +++ b/scripts/manage-translations.js @@ -3,34 +3,90 @@ const fs = require("fs") const path = require("path") +// Export functions for testing +module.exports = { + getNestedValue, + setNestedValue, + deleteNestedValue, + processStdin, + addTranslations, + deleteTranslations, +} + +function splitPath(keyPath) { + // Split on unescaped dots, preserving escaped dots + const parts = [] + let current = "" + let escaped = false + + for (let i = 0; i < keyPath.length; i++) { + if (keyPath[i] === "\\" && !escaped) { + escaped = true + } else if (keyPath[i] === "." && !escaped) { + parts.push(current) + current = "" + escaped = false + } else { + current += keyPath[i] + escaped = false + } + } + parts.push(current) + return parts +} + +function unescapeKey(key) { + return key.replace(/\\\./g, ".") +} + function getNestedValue(obj, keyPath) { - return keyPath.split(".").reduce((current, key) => { - return current && typeof current === "object" ? current[key] : undefined + // Try nested path first + const nestedValue = splitPath(keyPath).reduce((current, key) => { + return current && typeof current === "object" ? current[unescapeKey(key)] : undefined }, obj) + + // If nested path doesn't exist, check for exact key match + if (nestedValue === undefined && keyPath in obj) { + return obj[keyPath] + } + + return nestedValue } function setNestedValue(obj, keyPath, value) { - const keys = keyPath.split(".") + const keys = splitPath(keyPath) const lastKey = keys.pop() const target = keys.reduce((current, key) => { - if (!(key in current)) { - current[key] = {} + const unescapedKey = unescapeKey(key) + if (!(unescapedKey in current)) { + current[unescapedKey] = {} } - return current[key] + return current[unescapedKey] }, obj) - target[lastKey] = value + target[unescapeKey(lastKey)] = value } function deleteNestedValue(obj, keyPath) { - const keys = keyPath.split(".") + // First check if the exact key exists + if (keyPath in obj) { + delete obj[keyPath] + return true + } + + // Then try nested path + const keys = splitPath(keyPath) const lastKey = keys.pop() const target = keys.reduce((current, key) => { - return current && typeof current === "object" ? current[key] : undefined + const unescapedKey = unescapeKey(key) + return current && typeof current === "object" ? current[unescapedKey] : undefined }, obj) - if (target && typeof target === "object" && lastKey in target) { - delete target[lastKey] - return true + if (target && typeof target === "object") { + const unescapedLastKey = unescapeKey(lastKey) + if (unescapedLastKey in target) { + delete target[unescapedLastKey] + return true + } } return false } @@ -104,6 +160,49 @@ async function processStdin() { }) } +async function addTranslations(data, pairs, filePath, verbose = false) { + let modified = false + + // Create parent directories if they don't exist + const directory = path.dirname(filePath) + await fs.promises.mkdir(directory, { recursive: true }) + + for (const [keyPath, value] of pairs) { + const currentValue = getNestedValue(data, keyPath) + if (currentValue === undefined) { + setNestedValue(data, keyPath, value) + if (verbose) { + console.log(`Created new key path: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Set value: "${value}"`) + } + modified = true + } else if (verbose) { + console.log(`Key exists: ${keyPath}`) + console.log(`Full path: ${filePath}`) + console.log(`Current value: "${currentValue}"`) + } + } + return modified +} + +async function deleteTranslations(data, keys, filePath, verbose = false) { + let modified = false + for (const keyPath of keys) { + if (deleteNestedValue(data, keyPath)) { + if (verbose) { + console.log(`Deleted key: ${keyPath}`) + console.log(`From file: ${filePath}`) + } + modified = true + } else if (verbose) { + console.log(`Key not found: ${keyPath}`) + console.log(`In file: ${filePath}`) + } + } + return modified +} + async function main() { const args = process.argv.slice(2) const verbose = args.includes("-v") @@ -122,6 +221,19 @@ async function main() { console.log(" Delete translations:") console.log(" node scripts/manage-translations.js [-v] -d TRANSLATION_FILE KEY_PATH [KEY_PATH...]") console.log("") + console.log("Key Path Format:") + console.log(" - Use dots (.) to specify nested paths: 'command.newTask.title'") + console.log(" - To include a literal dot in a key name, escape it with backslash: '\\'") + console.log(" 'settings\\.customStoragePath\\.description'") + console.log(" - To include a literal backslash, escape it with another backslash: '\\\\'") + console.log(" 'settings\\\\path\\\\description'") + console.log(" Examples:") + console.log(" 'command.newTask.title' -> { command: { newTask: { title: 'value' } } }") + console.log( + " 'settings\\.customStoragePath\\.description' -> { 'settings.customStoragePath.description': 'value' }", + ) + console.log(" 'path\\\\to\\\\file' -> { 'path\\to\\file': 'value' }") + console.log("") console.log("Line-by-Line JSON Mode (--stdin):") console.log(" Each line must be a complete, single JSON object/array") console.log(" Multi-line or combined JSON is not supported") @@ -129,12 +241,16 @@ async function main() { console.log(" Add/update translations:") console.log(" node scripts/manage-translations.js [-v] --stdin TRANSLATION_FILE") console.log(" Format: One object per line with exactly one key-value pair:") - console.log(' {"key.path": "value"}') + console.log(' {"command.newTask.title": "New Task"}') + console.log(' {"settings\\.customStoragePath\\.description": "Custom storage path"}') + console.log(' {"path\\\\to\\\\file": "File path with backslashes"}') console.log("") console.log(" Delete translations:") console.log(" node scripts/manage-translations.js [-v] -d --stdin TRANSLATION_FILE") console.log(" Format: One array per line with exactly one key:") - console.log(' ["key.path"]') + console.log(' ["command.newTask.title"]') + console.log(' ["settings\\.customStoragePath\\.description"]') + console.log(' ["path\\\\to\\\\file"]') console.log("") console.log("Options:") console.log(" -v Enable verbose output (shows operations)") @@ -143,30 +259,37 @@ async function main() { console.log("") console.log("Examples:") console.log(" # Add via command line:") - console.log(' node scripts/manage-translations.js settings.json providers.key.label "Value"') + console.log(' node scripts/manage-translations.js package.nls.json command.newTask.title "New Task"') + console.log( + ' node scripts/manage-translations.js package.nls.json settings\\.vsCodeLmModelSelector\\.vendor\\.description "The vendor of the language model"', + ) console.log("") console.log(" # Add multiple translations (one JSON object per line):") console.log(" translations.txt:") - console.log(' {"providers.key1.label": "First Value"}') - console.log(' {"providers.key2.label": "Second Value"}') - console.log(" node scripts/manage-translations.js --stdin settings.json < translations.txt") + console.log(' {"command.newTask.title": "New Task"}') + console.log( + ' {"settings\\.vsCodeLmModelSelector\\.vendor\\.description": "The vendor of the language model"}', + ) + console.log(" node scripts/manage-translations.js --stdin package.nls.json < translations.txt") console.log("") console.log(" # Delete multiple keys (one JSON array per line):") console.log(" delete_keys.txt:") - console.log(' ["providers.key1.label"]') - console.log(' ["providers.key2.label"]') - console.log(" node scripts/manage-translations.js -d --stdin settings.json < delete_keys.txt") + console.log(' ["command.newTask.title"]') + console.log(' ["settings\\.vsCodeLmModelSelector\\.vendor\\.description"]') + console.log(" node scripts/manage-translations.js -d --stdin package.nls.json < delete_keys.txt") console.log("") console.log(" # Using here document for batching:") - console.log(" node scripts/manage-translations.js --stdin settings.json << EOF") - console.log(' {"providers.key1.label": "First Value"}') - console.log(' {"providers.key2.label": "Second Value"}') + console.log(" node scripts/manage-translations.js --stdin package.nls.json << EOF") + console.log(' {"command.newTask.title": "New Task"}') + console.log( + ' {"settings\\.vsCodeLmModelSelector\\.vendor\\.description": "The vendor of the language model"}', + ) console.log(" EOF") console.log("") console.log(" # Delete using here document:") - console.log(" node scripts/manage-translations.js -d --stdin settings.json << EOF") - console.log(' ["providers.key1.label"]') - console.log(' ["providers.key2.label"]') + console.log(" node scripts/manage-translations.js -d --stdin package.nls.json << EOF") + console.log(' ["command.newTask.title"]') + console.log(' ["settings\\.vsCodeLmModelSelector\\.vendor\\.description"]') console.log(" EOF") process.exit(1) } @@ -195,72 +318,21 @@ async function main() { if (stdinMode && deleteMode) { const input = await processStdin() const keys = input.map(([key]) => key) - for (const keyPath of keys) { - if (deleteNestedValue(data, keyPath)) { - if (verbose) { - console.log(`Deleted key: ${keyPath}`) - console.log(`From file: ${filePath}`) - } - modified = true - } else if (verbose) { - console.log(`Key not found: ${keyPath}`) - console.log(`In file: ${filePath}`) - } - } + modified = await deleteTranslations(data, keys, filePath, verbose) } else if (stdinMode) { const pairs = await processStdin() - for (const [keyPath, value] of pairs) { - const currentValue = getNestedValue(data, keyPath) - if (currentValue === undefined) { - setNestedValue(data, keyPath, value) - if (verbose) { - console.log(`Created new key path: ${keyPath}`) - console.log(`Full path: ${filePath}`) - console.log(`Set value: "${value}"`) - } - modified = true - } else if (verbose) { - console.log(`Key exists: ${keyPath}`) - console.log(`Full path: ${filePath}`) - console.log(`Current value: "${currentValue}"`) - } - } + modified = await addTranslations(data, pairs, filePath, verbose) } else if (deleteMode) { - // Process keys to delete - for (let i = 1; i < args.length; i++) { - const keyPath = args[i] - if (deleteNestedValue(data, keyPath)) { - if (verbose) { - console.log(`Deleted key: ${keyPath}`) - console.log(`From file: ${filePath}`) - } - modified = true - } else if (verbose) { - console.log(`Key not found: ${keyPath}`) - console.log(`In file: ${filePath}`) - } - } + // Process keys to delete from command line + const keys = args.slice(1) + modified = await deleteTranslations(data, keys, filePath, verbose) } else if (args.length >= 3 && args.length % 2 === 1) { // Process key-value pairs from command line + const pairs = [] for (let i = 1; i < args.length; i += 2) { - const keyPath = args[i] - const value = args[i + 1] - const currentValue = getNestedValue(data, keyPath) - - if (currentValue === undefined) { - setNestedValue(data, keyPath, value) - if (verbose) { - console.log(`Created new key path: ${keyPath}`) - console.log(`Full path: ${filePath}`) - console.log(`Set value: "${value}"`) - } - modified = true - } else if (verbose) { - console.log(`Key exists: ${keyPath}`) - console.log(`Full path: ${filePath}`) - console.log(`Current value: "${currentValue}"`) - } + pairs.push([args[i], args[i + 1]]) } + modified = await addTranslations(data, pairs, filePath, verbose) } else { console.log("Invalid number of arguments") process.exit(1) @@ -275,16 +347,29 @@ async function main() { } } catch (err) { if (err instanceof SyntaxError) { - console.error("Error: Invalid JSON in translation file") + throw new Error("Invalid JSON in translation file") } else if (err.code !== "ENOENT") { // ENOENT is handled above - console.error("Error:", err.message) + throw err } - process.exit(1) } } -main().catch((err) => { - console.error("Unexpected error:", err) - process.exit(1) -}) +// Only run main when called directly +if (require.main === module) { + main().catch((err) => { + console.error("Error:", err.message) + process.exit(1) + }) +} + +// Export functions for testing +module.exports = { + getNestedValue, + setNestedValue, + deleteNestedValue, + processStdin, + addTranslations, + deleteTranslations, + main, +} From dcad1720831f7d49e3cdaaf0c2bf6fa1bb473886 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 20:33:21 -0700 Subject: [PATCH 11/37] fix: detect empty object keys in translation linting Modified findKeys() to include empty object keys in translation checks, allowing detection of extra object keys like settings.providers that exist in translations but not in English source. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 679c8819f3..d5c6291902 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -156,11 +156,10 @@ function findKeys(obj: any, parentKey: string = ""): string[] { for (const [key, value] of Object.entries(obj)) { const currentKey = parentKey ? `${parentKey}.${key}` : key + keys.push(currentKey) if (typeof value === "object" && value !== null) { keys = [...keys, ...findKeys(value, currentKey)] - } else { - keys.push(currentKey) } } From c8cdaf7427ddff247bfbab0409ebdc5433091e76 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 20:45:56 -0700 Subject: [PATCH 12/37] feat: change translation key escaping to use double dots Change key escaping format to use double dots, similar to SMTP byte stuffing: - Replace \. with .. for escaping dots in key paths - Update documentation with new format and clearer examples - Update test cases to use new escaping format - Fix delete mode tests to use -- separator - Maintain all existing functionality Example: Old: settings\.path -> settings.path New: settings..path -> settings.path The new format is more readable and follows the SMTP byte stuffing pattern for escaping dots. Signed-off-by: Eric Wheeler --- scripts/__tests__/manage-translations.test.ts | 154 +++++++++-- scripts/manage-translations.d.ts | 2 + scripts/manage-translations.js | 242 ++++++++++-------- 3 files changed, 272 insertions(+), 126 deletions(-) diff --git a/scripts/__tests__/manage-translations.test.ts b/scripts/__tests__/manage-translations.test.ts index f78343054b..54816b692e 100644 --- a/scripts/__tests__/manage-translations.test.ts +++ b/scripts/__tests__/manage-translations.test.ts @@ -1,14 +1,38 @@ import * as fs from "fs" import * as path from "path" import * as os from "os" -import { - getNestedValue, - setNestedValue, - deleteNestedValue, - addTranslations, - deleteTranslations, - main, -} from "../manage-translations" +// Mock the module +jest.mock("../manage-translations", () => { + const actualModule = jest.requireActual("../manage-translations") + return { + ...actualModule, + collectStdin: jest.fn(), + } +}) + +// Import after mocking +const manageTrans = require("../manage-translations") +const { getNestedValue, setNestedValue, deleteNestedValue, addTranslations, deleteTranslations, main } = manageTrans + +// Helper function to mock stdin with input data +function mockStdinWithData(inputData: string) { + const mockStdin: { + setEncoding: jest.Mock + on: jest.Mock + } = { + setEncoding: jest.fn(), + on: jest.fn().mockImplementation((event: string, callback: any) => { + if (event === "data") { + callback(inputData) + } + if (event === "end") { + callback() + } + return mockStdin + }), + } + Object.defineProperty(process, "stdin", { value: mockStdin }) +} describe("Translation Management", () => { let testDir: string @@ -37,8 +61,8 @@ describe("Translation Management", () => { expect(getNestedValue(obj, "settings.nested.key")).toBe("normal nested") // Paths with dots - expect(getNestedValue(obj, "settings.customStoragePath\\.description")).toBe("Storage path setting") - expect(getNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description")).toBe("Vendor setting") + expect(getNestedValue(obj, "settings.customStoragePath..description")).toBe("Storage path setting") + expect(getNestedValue(obj, "settings.vsCodeLmModelSelector..vendor..description")).toBe("Vendor setting") }) test("setNestedValue handles escaped dots", () => { @@ -48,8 +72,8 @@ describe("Translation Management", () => { setNestedValue(obj, "settings.nested.key", "normal nested") // Paths with dots - setNestedValue(obj, "settings.customStoragePath\\.description", "Storage path setting") - setNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description", "Vendor setting") + setNestedValue(obj, "settings.customStoragePath..description", "Storage path setting") + setNestedValue(obj, "settings.vsCodeLmModelSelector..vendor..description", "Vendor setting") expect(obj).toEqual({ settings: { @@ -77,8 +101,8 @@ describe("Translation Management", () => { expect(deleteNestedValue(obj, "settings.nested.key")).toBe(true) // Delete paths with dots - expect(deleteNestedValue(obj, "settings.customStoragePath\\.description")).toBe(true) - expect(deleteNestedValue(obj, "settings.vsCodeLmModelSelector\\.vendor\\.description")).toBe(true) + expect(deleteNestedValue(obj, "settings.customStoragePath..description")).toBe(true) + expect(deleteNestedValue(obj, "settings.vsCodeLmModelSelector..vendor..description")).toBe(true) expect(obj).toEqual({ settings: { @@ -172,6 +196,106 @@ describe("Translation Management", () => { }) describe("Main Function", () => { + beforeEach(() => { + // Reset process.argv before each test + process.argv = ["node", "script"] + }) + + describe("Stdin Operations", () => { + test("adds nested key paths via stdin", async () => { + const testFile = path.join(testDir, "test.json") + process.argv = ["node", "script", "--stdin", testFile] as any + mockStdinWithData('{"key.nested.path": "value1"}\n{"other.nested": "value2"}') + + await main() + + const data = JSON.parse(await fs.promises.readFile(testFile, "utf8")) + expect(data).toEqual({ + key: { + nested: { + path: "value1", + }, + }, + other: { + nested: "value2", + }, + }) + }) + + test("adds keys with dots using double-dot escaping via stdin", async () => { + const testFile = path.join(testDir, "test.json") + process.argv = ["node", "script", "--stdin", testFile] as any + mockStdinWithData('{"settings..path": "value1"}\n{"key..with..dots": "value2"}') + + await main() + + const data = JSON.parse(await fs.promises.readFile(testFile, "utf8")) + expect(data).toEqual({ + "settings.path": "value1", + "key.with.dots": "value2", + }) + }) + + test("deletes keys with dots using double-dot escaping via stdin", async () => { + const testFile = path.join(testDir, "test.json") + await fs.promises.writeFile( + testFile, + JSON.stringify({ + "settings.path": "value1", + "key.with.dots": "value2", + }), + ) + + process.argv = ["node", "script", "-d", "--stdin", testFile] as any + mockStdinWithData('["settings..path"]\n["key..with..dots"]') + + await main() + + const data = JSON.parse(await fs.promises.readFile(testFile, "utf8")) + expect(data).toEqual({}) + }) + + test("handles mixed nested paths and escaped dots via stdin", async () => { + const testFile = path.join(testDir, "test.json") + process.argv = ["node", "script", "--stdin", testFile] as any + mockStdinWithData('{"nested.key..with..dots": "value1"}\n' + '{"settings..path.sub.key": "value2"}') + + await main() + + const data = JSON.parse(await fs.promises.readFile(testFile, "utf8")) + expect(data).toEqual({ + nested: { + "key.with.dots": "value1", + }, + "settings.path": { + sub: { + key: "value2", + }, + }, + }) + }) + + test("deletes translations from multiple files via stdin", async () => { + mockStdinWithData('["key1"]\n["key2"]') + const testFile1 = path.join(testDir, "test1.json") + const testFile2 = path.join(testDir, "test2.json") + + // Create test files with initial content + await fs.promises.writeFile(testFile1, JSON.stringify({ key1: "value1", key2: "value2" })) + await fs.promises.writeFile(testFile2, JSON.stringify({ key1: "value1", key2: "value2" })) + + process.argv = ["node", "script", "-d", "--stdin", testFile1, testFile2] as any + + await main() + + const data1 = JSON.parse(await fs.promises.readFile(testFile1, "utf8")) + const data2 = JSON.parse(await fs.promises.readFile(testFile2, "utf8")) + + expect(data1).toEqual({}) + expect(data2).toEqual({}) + }) + }) + test("main throws error for invalid JSON file", async () => { const invalidJson = path.join(testDir, "invalid.json") await fs.promises.writeFile(invalidJson, "invalid json content") @@ -204,7 +328,7 @@ describe("Translation Management", () => { const testFile = path.join(testDir, "test.json") await fs.promises.writeFile(testFile, JSON.stringify({ key1: "value1", key2: "value2" })) - process.argv = ["node", "script", "-d", testFile, "key1"] + process.argv = ["node", "script", "-d", testFile, "--", "key1"] await main() const fileContent = await fs.promises.readFile(testFile, "utf8") diff --git a/scripts/manage-translations.d.ts b/scripts/manage-translations.d.ts index c68c035196..600b588867 100644 --- a/scripts/manage-translations.d.ts +++ b/scripts/manage-translations.d.ts @@ -1,6 +1,8 @@ export function getNestedValue(obj: any, keyPath: string): any export function setNestedValue(obj: any, keyPath: string, value: any): void export function deleteNestedValue(obj: any, keyPath: string): boolean +export function collectStdin(): Promise +export function parseInputLines(inputText: string): [string, string][] | [string][] export function processStdin(): Promise<[string, string][]> export function addTranslations( data: any, diff --git a/scripts/manage-translations.js b/scripts/manage-translations.js index 6affdeb5bd..54c3ad6c8c 100755 --- a/scripts/manage-translations.js +++ b/scripts/manage-translations.js @@ -14,29 +14,19 @@ module.exports = { } function splitPath(keyPath) { - // Split on unescaped dots, preserving escaped dots - const parts = [] - let current = "" - let escaped = false - - for (let i = 0; i < keyPath.length; i++) { - if (keyPath[i] === "\\" && !escaped) { - escaped = true - } else if (keyPath[i] === "." && !escaped) { - parts.push(current) - current = "" - escaped = false - } else { - current += keyPath[i] - escaped = false - } - } - parts.push(current) - return parts + // First replace double dots with a placeholder + const placeholder = "\u0000" + const escaped = keyPath.replace(/\.\./g, placeholder) + + // Split on single dots + const parts = escaped.split(".") + + // Restore dots in each part + return parts.map((part) => part.replace(new RegExp(placeholder, "g"), ".")) } function unescapeKey(key) { - return key.replace(/\\\./g, ".") + return key.replace(/\.\./g, ".") } function getNestedValue(obj, keyPath) { @@ -91,75 +81,61 @@ function deleteNestedValue(obj, keyPath) { return false } -async function processStdin() { +async function collectStdin() { return new Promise((resolve, reject) => { - const pairs = [] let buffer = "" - process.stdin.setEncoding("utf8") process.stdin.on("data", (chunk) => { buffer += chunk - const lines = buffer.split("\n") - buffer = lines.pop() || "" // Keep incomplete line in buffer - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - try { - const data = JSON.parse(trimmed) - if (Array.isArray(data)) { - // In delete mode, allow multiple elements in array - data.forEach((key) => pairs.push([key])) - } else if (typeof data === "object" && data !== null) { - const entries = Object.entries(data) - if (entries.length !== 1) { - reject(new Error("Each line must contain a single key-value pair")) - return - } - pairs.push(entries[0]) - } else { - reject(new Error("Each line must be a JSON object or array")) - return - } - } catch (err) { - reject(new Error(`Invalid JSON on line: ${trimmed}`)) - return - } - } }) process.stdin.on("end", () => { - if (buffer.trim()) { - try { - const data = JSON.parse(buffer.trim()) - if (Array.isArray(data)) { - // In delete mode, allow multiple elements in array - data.forEach((key) => pairs.push([key])) - } else if (typeof data === "object" && data !== null) { - const entries = Object.entries(data) - if (entries.length !== 1) { - reject(new Error("Each line must contain a single key-value pair")) - return - } - pairs.push(entries[0]) - } else { - reject(new Error("Each line must be a JSON object or array")) - return - } - } catch (err) { - reject(new Error(`Invalid JSON on line: ${buffer.trim()}`)) - return - } - } - resolve(pairs) + resolve(buffer) }) process.stdin.on("error", reject) }) } +function parseInputLines(inputText) { + const pairs = [] + const lines = inputText.split("\n") + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + try { + const data = JSON.parse(trimmed) + if (Array.isArray(data)) { + // In delete mode, allow multiple elements in array + data.forEach((key) => pairs.push([key])) + } else if (typeof data === "object" && data !== null) { + const entries = Object.entries(data) + if (entries.length !== 1) { + throw new Error("Each line must contain a single key-value pair") + } + pairs.push(entries[0]) + } else { + throw new Error("Each line must be a JSON object or array") + } + } catch (err) { + if (err.message.startsWith("Each line must")) { + throw err + } + throw new Error(`Invalid JSON on line: ${trimmed}`) + } + } + + return pairs +} + +async function processStdin() { + const inputText = await collectStdin() + return parseInputLines(inputText) +} + async function addTranslations(data, pairs, filePath, verbose = false) { let modified = false @@ -219,20 +195,19 @@ async function main() { console.log(" Add/update translations:") console.log(" node scripts/manage-translations.js [-v] TRANSLATION_FILE KEY_PATH VALUE [KEY_PATH VALUE...]") console.log(" Delete translations:") - console.log(" node scripts/manage-translations.js [-v] -d TRANSLATION_FILE KEY_PATH [KEY_PATH...]") + console.log( + " node scripts/manage-translations.js [-v] -d TRANSLATION_FILE1 [TRANSLATION_FILE2 ...] [ -- KEY1 ...]", + ) console.log("") console.log("Key Path Format:") - console.log(" - Use dots (.) to specify nested paths: 'command.newTask.title'") - console.log(" - To include a literal dot in a key name, escape it with backslash: '\\'") - console.log(" 'settings\\.customStoragePath\\.description'") - console.log(" - To include a literal backslash, escape it with another backslash: '\\\\'") - console.log(" 'settings\\\\path\\\\description'") + console.log(" - Use single dot (.) for nested paths: 'command.newTask.title'") + console.log(" - Use double dots (..) to include a literal dot in key names (like SMTP byte stuffing):") + console.log(" 'settings..path' -> { 'settings.path': 'value' }") + console.log("") console.log(" Examples:") - console.log(" 'command.newTask.title' -> { command: { newTask: { title: 'value' } } }") - console.log( - " 'settings\\.customStoragePath\\.description' -> { 'settings.customStoragePath.description': 'value' }", - ) - console.log(" 'path\\\\to\\\\file' -> { 'path\\to\\file': 'value' }") + console.log(" 'command.newTask.title' -> { command: { newTask: { title: 'value' } } }") + console.log(" 'settings..path' -> { 'settings.path': 'value' }") + console.log(" 'nested.key..with..dots' -> { nested: { 'key.with.dots': 'value' } }") console.log("") console.log("Line-by-Line JSON Mode (--stdin):") console.log(" Each line must be a complete, single JSON object/array") @@ -242,15 +217,15 @@ async function main() { console.log(" node scripts/manage-translations.js [-v] --stdin TRANSLATION_FILE") console.log(" Format: One object per line with exactly one key-value pair:") console.log(' {"command.newTask.title": "New Task"}') - console.log(' {"settings\\.customStoragePath\\.description": "Custom storage path"}') - console.log(' {"path\\\\to\\\\file": "File path with backslashes"}') + console.log(' {"settings..path": "Custom Path"}') + console.log(' {"nested.key..with..dots": "Value with dots in key"}') console.log("") console.log(" Delete translations:") console.log(" node scripts/manage-translations.js [-v] -d --stdin TRANSLATION_FILE") console.log(" Format: One array per line with exactly one key:") console.log(' ["command.newTask.title"]') - console.log(' ["settings\\.customStoragePath\\.description"]') - console.log(' ["path\\\\to\\\\file"]') + console.log(' ["settings..path"]') + console.log(' ["nested.key..with..dots"]') console.log("") console.log("Options:") console.log(" -v Enable verbose output (shows operations)") @@ -260,44 +235,96 @@ async function main() { console.log("Examples:") console.log(" # Add via command line:") console.log(' node scripts/manage-translations.js package.nls.json command.newTask.title "New Task"') - console.log( - ' node scripts/manage-translations.js package.nls.json settings\\.vsCodeLmModelSelector\\.vendor\\.description "The vendor of the language model"', - ) + console.log(' node scripts/manage-translations.js package.nls.json settings..path "Custom Path"') + console.log(' node scripts/manage-translations.js package.nls.json nested.key..with..dots "Value with dots"') console.log("") console.log(" # Add multiple translations (one JSON object per line):") console.log(" translations.txt:") console.log(' {"command.newTask.title": "New Task"}') - console.log( - ' {"settings\\.vsCodeLmModelSelector\\.vendor\\.description": "The vendor of the language model"}', - ) + console.log(' {"settings..path": "Custom Path"}') console.log(" node scripts/manage-translations.js --stdin package.nls.json < translations.txt") console.log("") console.log(" # Delete multiple keys (one JSON array per line):") console.log(" delete_keys.txt:") console.log(' ["command.newTask.title"]') - console.log(' ["settings\\.vsCodeLmModelSelector\\.vendor\\.description"]') + console.log(' ["settings..path"]') + console.log(' ["nested.key..with..dots"]') console.log(" node scripts/manage-translations.js -d --stdin package.nls.json < delete_keys.txt") console.log("") console.log(" # Using here document for batching:") console.log(" node scripts/manage-translations.js --stdin package.nls.json << EOF") console.log(' {"command.newTask.title": "New Task"}') - console.log( - ' {"settings\\.vsCodeLmModelSelector\\.vendor\\.description": "The vendor of the language model"}', - ) + console.log(' {"settings..path": "Custom Path"}') console.log(" EOF") console.log("") console.log(" # Delete using here document:") console.log(" node scripts/manage-translations.js -d --stdin package.nls.json << EOF") console.log(' ["command.newTask.title"]') - console.log(' ["settings\\.vsCodeLmModelSelector\\.vendor\\.description"]') + console.log(' ["settings..path"]') + console.log(' ["nested.key..with..dots"]') console.log(" EOF") process.exit(1) } - const filePath = args[0] let modified = false try { + if (stdinMode && deleteMode) { + const files = args + // Check if all files exist first + for (const filePath of files) { + try { + await fs.promises.access(filePath) + } catch (err) { + if (err.code === "ENOENT") { + throw new Error(`File not found: ${filePath}`) + } + throw err + } + } + + const input = await processStdin() + const keys = input.map(([key]) => key) + + // Process each file + for (const filePath of files) { + const data = JSON.parse(await fs.promises.readFile(filePath, "utf8")) + if (await deleteTranslations(data, keys, filePath, verbose)) { + await fs.promises.writeFile(filePath, JSON.stringify(data, null, "\t") + "\n") + modified = true + } + } + return + } else if (deleteMode) { + const separatorIndex = args.indexOf("--") + const files = separatorIndex === -1 ? args : args.slice(0, separatorIndex) + const keys = separatorIndex === -1 ? [] : args.slice(separatorIndex + 1) + + // Check if all files exist first + for (const filePath of files) { + try { + await fs.promises.access(filePath) + } catch (err) { + if (err.code === "ENOENT") { + throw new Error(`File not found: ${filePath}`) + } + throw err + } + } + + // Process each file + for (const filePath of files) { + const data = JSON.parse(await fs.promises.readFile(filePath, "utf8")) + if (await deleteTranslations(data, keys, filePath, verbose)) { + await fs.promises.writeFile(filePath, JSON.stringify(data, null, "\t") + "\n") + modified = true + } + } + return + } + + // Original non-delete mode code + const filePath = args[0] let data = {} try { data = JSON.parse(await fs.promises.readFile(filePath, "utf8")) @@ -307,7 +334,6 @@ async function main() { console.log(`File not found: ${filePath}`) console.log("Creating new file") } - // Create parent directories if they don't exist const directory = path.dirname(filePath) await fs.promises.mkdir(directory, { recursive: true }) } else { @@ -315,17 +341,9 @@ async function main() { } } - if (stdinMode && deleteMode) { - const input = await processStdin() - const keys = input.map(([key]) => key) - modified = await deleteTranslations(data, keys, filePath, verbose) - } else if (stdinMode) { + if (stdinMode) { const pairs = await processStdin() modified = await addTranslations(data, pairs, filePath, verbose) - } else if (deleteMode) { - // Process keys to delete from command line - const keys = args.slice(1) - modified = await deleteTranslations(data, keys, filePath, verbose) } else if (args.length >= 3 && args.length % 2 === 1) { // Process key-value pairs from command line const pairs = [] @@ -368,6 +386,8 @@ module.exports = { getNestedValue, setNestedValue, deleteNestedValue, + parseInputLines, + collectStdin, processStdin, addTranslations, deleteTranslations, From 9e775d2947e2ac462e09eca8345fa07a0b334b51 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 21:36:35 -0700 Subject: [PATCH 13/37] feat: improve translation management guidance Update error output in lint-translations.test.ts to match manage-translations.js: - Add key path format documentation (single/double dots) - Include realistic examples matching actual usage patterns - Document -v flag for verbose output - Show multiple ways to use the script (here-doc and direct) - Improve help text organization and clarity Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 53 +++++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index d5c6291902..f58a28c170 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -509,37 +509,48 @@ function formatSummary(results: Results): void { bufferLog("\n⚠️ Some translation issues were found.") if (totalMissing > 0) { - bufferLog("- For .md files: ") + bufferLog("\nFor missing translations:") + bufferLog("1. For .md files:") + bufferLog(" Create the missing translation files in the appropriate locale directory") + bufferLog(" from the English sources in the root of the repository.") + bufferLog(" Use for each file to maintain clear translation context.") + bufferLog("\n2. For .json files:") + bufferLog(" Add missing translations using manage-translations.js.") + bufferLog("\n Key Path Format:") + bufferLog(" - Use single dots (.) for nested paths: command.newTask.title") + bufferLog(" - Use double dots (..) for literal dots: settings..path.name") + bufferLog("\n Example adding translations:") + bufferLog(" # Using here document for volume changes to one file:") + bufferLog(" node scripts/manage-translations.js [-v] --stdin relative/path/to/settings.json << EOF") + bufferLog(' {"command.newTask.title": "Create New Task"}') + bufferLog(' {"settings..path.name": "Custom Path Setting"}') + bufferLog(" EOF") + bufferLog("\n # Or single key-value pairs:") bufferLog( - " Create the missing translation files in the appropriate locale directory from the English sources in the root of the repository", + ' node scripts/manage-translations.js [-v] relative/path/to/settings.json "command.newTask.title" "Create New Task" [key2 value2 ...]', ) - bufferLog( - " Use for each single file translation to keep translation context absolutely clear", - ) - bufferLog( - "- For .json files: Add the missing translations that exist in English but are missing in other locales", - ) - bufferLog(" Example adding translations (one JSONL/NDJSON record per line):") - bufferLog(" node scripts/manage-translations.js --stdin relative/path/to/settings.json << EOF") - bufferLog(' {"some.new.key1.label": "First Value"}') - bufferLog(' {"some.new.key2.label": "Second Value"}') - bufferLog(" EOF") } if (totalExtra > 0) { + bufferLog("\nFor extra translations:") + bufferLog("Remove translations not present in English (source of truth):") + bufferLog("\n# Using here document for volume changes:") + bufferLog( + "node scripts/manage-translations.js [-v] -d --stdin relative/path/to/settings.json [file2.json ...] << EOF", + ) + bufferLog('["command.oldTask.title"]') + bufferLog('["settings..old.path"]') + bufferLog("EOF") + bufferLog("\n# Or multiple files with specific keys:") bufferLog( - "- Remove translations that exist in other locales but not in English (English is the source of truth)", + 'node scripts/manage-translations.js [-v] -d file1.json file2.json -- "command.oldTask.title" "settings..old.path"', ) - bufferLog(" Example removing translations:") - bufferLog(" node scripts/manage-translations.js -d --stdin relative/path/to/settings.json << EOF") - bufferLog(' ["the.extra.key1.label"]') - bufferLog(' ["the.extra.key2.label"]') - bufferLog(" EOF") } + bufferLog("\nNotes:") bufferLog("- Always translate from the original English source documents") - bufferLog("- Fix the errors based on the instructions above reading this test script") - bufferLog("- AI NOTICE: You MUST fix the language issues, do not modify the test script") + bufferLog("- Use -v flag for verbose output showing each operation") + bufferLog("- Run manage-translations.js without arguments for full usage details") } } From 37d19f14aca38123758001c5a6cf7206fafddf60 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 21:38:24 -0700 Subject: [PATCH 14/37] lang: remove extra translations Remove translations not present in English source: - settings.providers.groqApiKey and getGroqApiKey from common.json - footer.version from settings.json - editSettings from mcp.json This ensures translation files only contain keys that exist in English source files. Signed-off-by: Eric Wheeler --- src/i18n/locales/ca/common.json | 6 ------ src/i18n/locales/de/common.json | 6 ------ src/i18n/locales/es/common.json | 6 ------ src/i18n/locales/fr/common.json | 6 ------ src/i18n/locales/hi/common.json | 6 ------ src/i18n/locales/it/common.json | 6 ------ src/i18n/locales/ja/common.json | 6 ------ src/i18n/locales/ko/common.json | 6 ------ src/i18n/locales/pl/common.json | 6 ------ src/i18n/locales/pt-BR/common.json | 6 ------ src/i18n/locales/ru/common.json | 6 ------ src/i18n/locales/tr/common.json | 6 ------ src/i18n/locales/vi/common.json | 6 ------ src/i18n/locales/zh-CN/common.json | 6 ------ src/i18n/locales/zh-TW/common.json | 6 ------ webview-ui/src/i18n/locales/ca/settings.json | 1 - webview-ui/src/i18n/locales/de/settings.json | 1 - webview-ui/src/i18n/locales/es/settings.json | 1 - webview-ui/src/i18n/locales/fr/settings.json | 1 - webview-ui/src/i18n/locales/hi/settings.json | 1 - webview-ui/src/i18n/locales/it/settings.json | 1 - webview-ui/src/i18n/locales/ja/settings.json | 1 - webview-ui/src/i18n/locales/ko/settings.json | 1 - webview-ui/src/i18n/locales/pl/settings.json | 1 - webview-ui/src/i18n/locales/pt-BR/settings.json | 1 - webview-ui/src/i18n/locales/tr/settings.json | 1 - webview-ui/src/i18n/locales/vi/mcp.json | 1 - webview-ui/src/i18n/locales/vi/settings.json | 1 - webview-ui/src/i18n/locales/zh-CN/mcp.json | 1 - webview-ui/src/i18n/locales/zh-CN/settings.json | 1 - webview-ui/src/i18n/locales/zh-TW/mcp.json | 1 - 31 files changed, 106 deletions(-) diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 2bd61dfd93..633b90ec3d 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -89,11 +89,5 @@ "path_placeholder": "D:\\RooCodeStorage", "enter_absolute_path": "Introdueix una ruta completa (p. ex. D:\\RooCodeStorage o /home/user/storage)", "enter_valid_path": "Introdueix una ruta vàlida" - }, - "settings": { - "providers": { - "groqApiKey": "Clau API de Groq", - "getGroqApiKey": "Obté la clau API de Groq" - } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 5eb6b05e8f..ceda64bad7 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Was soll Roo tun?", "task_placeholder": "Gib deine Aufgabe hier ein" - }, - "settings": { - "providers": { - "groqApiKey": "Groq API-Schlüssel", - "getGroqApiKey": "Groq API-Schlüssel erhalten" - } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index beea1ba3a9..2bfb43055a 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "¿Qué debe hacer Roo?", "task_placeholder": "Escribe tu tarea aquí" - }, - "settings": { - "providers": { - "groqApiKey": "Clave API de Groq", - "getGroqApiKey": "Obtener clave API de Groq" - } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index c34d0105a4..7399432c6a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Que doit faire Roo ?", "task_placeholder": "Écris ta tâche ici" - }, - "settings": { - "providers": { - "groqApiKey": "Clé API Groq", - "getGroqApiKey": "Obtenir la clé API Groq" - } } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 07438c873b..bf5421eff8 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Roo को क्या करना है?", "task_placeholder": "अपना कार्य यहाँ लिखें" - }, - "settings": { - "providers": { - "groqApiKey": "ग्रोक एपीआई कुंजी", - "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें" - } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 476285169f..69e2c2123f 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Cosa deve fare Roo?", "task_placeholder": "Scrivi il tuo compito qui" - }, - "settings": { - "providers": { - "groqApiKey": "Chiave API Groq", - "getGroqApiKey": "Ottieni chiave API Groq" - } } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index f44469d0c9..6f40c8e03d 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Rooにどんなことをさせますか?", "task_placeholder": "タスクをここに入力してください" - }, - "settings": { - "providers": { - "groqApiKey": "Groq APIキー", - "getGroqApiKey": "Groq APIキーを取得" - } } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 944d9ba19b..9026315da2 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Roo에게 무엇을 시킬까요?", "task_placeholder": "여기에 작업을 입력하세요" - }, - "settings": { - "providers": { - "groqApiKey": "Groq API 키", - "getGroqApiKey": "Groq API 키 받기" - } } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 46ed243b49..49f51cefff 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Co ma zrobić Roo?", "task_placeholder": "Wpisz swoje zadanie tutaj" - }, - "settings": { - "providers": { - "groqApiKey": "Klucz API Groq", - "getGroqApiKey": "Uzyskaj klucz API Groq" - } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index a6588b2fda..80112a91ab 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -89,11 +89,5 @@ "path_placeholder": "D:\\RooCodeStorage", "enter_absolute_path": "Por favor, digite um caminho absoluto (ex: D:\\RooCodeStorage ou /home/user/storage)", "enter_valid_path": "Por favor, digite um caminho válido" - }, - "settings": { - "providers": { - "groqApiKey": "Chave de API Groq", - "getGroqApiKey": "Obter chave de API Groq" - } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index baef76ea78..80829e138c 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Что должен сделать Roo?", "task_placeholder": "Введите вашу задачу здесь" - }, - "settings": { - "providers": { - "groqApiKey": "Ключ API Groq", - "getGroqApiKey": "Получить ключ API Groq" - } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 2c4ec8b354..61b8e12fb5 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Roo ne yapsın?", "task_placeholder": "Görevini buraya yaz" - }, - "settings": { - "providers": { - "groqApiKey": "Groq API Anahtarı", - "getGroqApiKey": "Groq API Anahtarı Al" - } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 499309df75..8945e9e098 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "Bạn muốn Roo làm gì?", "task_placeholder": "Nhập nhiệm vụ của bạn ở đây" - }, - "settings": { - "providers": { - "groqApiKey": "Khóa API Groq", - "getGroqApiKey": "Lấy khóa API Groq" - } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index ac3754ccd6..2fc49c9b37 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "让Roo做什么?", "task_placeholder": "在这里输入任务" - }, - "settings": { - "providers": { - "groqApiKey": "Groq API 密钥", - "getGroqApiKey": "获取 Groq API 密钥" - } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 93c59acbbb..a51cfa0e9a 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -89,11 +89,5 @@ "input": { "task_prompt": "讓 Roo 做什麼?", "task_placeholder": "在這裡輸入工作" - }, - "settings": { - "providers": { - "groqApiKey": "Groq API 金鑰", - "getGroqApiKey": "取得 Groq API 金鑰" - } } } diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index c9e1cb5606..4935dbaeef 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Si teniu qualsevol pregunta o comentari, no dubteu a obrir un issue a github.com/RooVetGit/Roo-Code o unir-vos a reddit.com/r/RooCode o discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Permetre informes anònims d'errors i ús", "description": "Ajudeu a millorar Roo Code enviant dades d'ús anònimes i informes d'errors. Mai s'envia codi, prompts o informació personal. Vegeu la nostra política de privacitat per a més detalls." diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index aac308c4a1..20f9344db7 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Wenn du Fragen oder Feedback hast, kannst du gerne ein Issue auf github.com/RooVetGit/Roo-Code öffnen oder reddit.com/r/RooCode oder discord.gg/roocode beitreten", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Anonyme Fehler- und Nutzungsberichte zulassen", "description": "Helfen Sie, Roo Code zu verbessern, indem Sie anonyme Nutzungsdaten und Fehlerberichte senden. Es werden niemals Code, Prompts oder persönliche Informationen gesendet. Weitere Details finden Sie in unserer Datenschutzrichtlinie." diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f862f13405..cbaa7b2686 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Si tiene alguna pregunta o comentario, no dude en abrir un issue en github.com/RooVetGit/Roo-Code o unirse a reddit.com/r/RooCode o discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Permitir informes anónimos de errores y uso", "description": "Ayude a mejorar Roo Code enviando datos de uso anónimos e informes de errores. Nunca se envía código, prompts o información personal. Consulte nuestra política de privacidad para más detalles." diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 9f694c5aa3..5b28473fab 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Si vous avez des questions ou des commentaires, n'hésitez pas à ouvrir un problème sur github.com/RooVetGit/Roo-Code ou à rejoindre reddit.com/r/RooCode ou discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Autoriser les rapports anonymes d'erreurs et d'utilisation", "description": "Aidez à améliorer Roo Code en envoyant des données d'utilisation anonymes et des rapports d'erreurs. Aucun code, prompt ou information personnelle n'est jamais envoyé. Consultez notre politique de confidentialité pour plus de détails." diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0b21ed906b..38d726fd9c 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "यदि आपके कोई प्रश्न या प्रतिक्रिया है, तो github.com/RooVetGit/Roo-Code पर एक मुद्दा खोलने या reddit.com/r/RooCode या discord.gg/roocode में शामिल होने में संकोच न करें", - "version": "Roo Code v{{version}}", "telemetry": { "label": "गुमनाम त्रुटि और उपयोग रिपोर्टिंग की अनुमति दें", "description": "गुमनाम उपयोग डेटा और त्रुटि रिपोर्ट भेजकर Roo Code को बेहतर बनाने में मदद करें। कोड, प्रॉम्प्ट, या व्यक्तिगत जानकारी कभी भी नहीं भेजी जाती है। अधिक विवरण के लिए हमारी गोपनीयता नीति देखें।" diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index a3e210c480..5f7a2d786d 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Se hai domande o feedback, sentiti libero di aprire un issue su github.com/RooVetGit/Roo-Code o unirti a reddit.com/r/RooCode o discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Consenti segnalazioni anonime di errori e utilizzo", "description": "Aiuta a migliorare Roo Code inviando dati di utilizzo anonimi e segnalazioni di errori. Non vengono mai inviati codice, prompt o informazioni personali. Consulta la nostra politica sulla privacy per maggiori dettagli." diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 1f1ec12c54..a44448a222 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "質問やフィードバックがある場合は、github.com/RooVetGit/Roo-Codeで問題を開くか、reddit.com/r/RooCodediscord.gg/roocodeに参加してください", - "version": "Roo Code v{{version}}", "telemetry": { "label": "匿名のエラーと使用状況レポートを許可", "description": "匿名の使用データとエラーレポートを送信してRoo Codeの改善にご協力ください。コード、プロンプト、個人情報が送信されることはありません。詳細については、プライバシーポリシーをご覧ください。" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 95195ff0a2..b896c16645 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "질문이나 피드백이 있으시면 github.com/RooVetGit/Roo-Code에서 이슈를 열거나 reddit.com/r/RooCode 또는 discord.gg/roocode에 가입하세요", - "version": "Roo Code v{{version}}", "telemetry": { "label": "익명 오류 및 사용 보고 허용", "description": "익명 사용 데이터 및 오류 보고서를 보내 Roo Code 개선에 도움을 주세요. 코드, 프롬프트 또는 개인 정보는 절대 전송되지 않습니다. 자세한 내용은 개인정보 보호정책을 참조하세요." diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 343ce01397..7b8e771c86 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Jeśli masz jakiekolwiek pytania lub opinie, śmiało otwórz zgłoszenie na github.com/RooVetGit/Roo-Code lub dołącz do reddit.com/r/RooCode lub discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Zezwól na anonimowe raportowanie błędów i użycia", "description": "Pomóż ulepszyć Roo Code, wysyłając anonimowe dane o użytkowaniu i raporty o błędach. Nigdy nie są wysyłane kod, podpowiedzi ani informacje osobiste. Zobacz naszą politykę prywatności, aby uzyskać więcej szczegółów." diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 0c66a21847..3efd39964f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Se tiver alguma dúvida ou feedback, sinta-se à vontade para abrir um problema em github.com/RooVetGit/Roo-Code ou juntar-se a reddit.com/r/RooCode ou discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Permitir relatórios anônimos de erros e uso", "description": "Ajude a melhorar o Roo Code enviando dados de uso anônimos e relatórios de erros. Nunca são enviados código, prompts ou informações pessoais. Consulte nossa política de privacidade para mais detalhes." diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 1d23b7e04e..2859ef6bcc 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Herhangi bir sorunuz veya geri bildiriminiz varsa, github.com/RooVetGit/Roo-Code adresinde bir konu açmaktan veya reddit.com/r/RooCode ya da discord.gg/roocode'a katılmaktan çekinmeyin", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Anonim hata ve kullanım raporlamaya izin ver", "description": "Anonim kullanım verileri ve hata raporları göndererek Roo Code'u geliştirmeye yardımcı olun. Hiçbir kod, istem veya kişisel bilgi asla gönderilmez. Daha fazla ayrıntı için gizlilik politikamıza bakın." diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index c7c10be655..c83e623b58 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -12,7 +12,6 @@ }, "editGlobalMCP": "Chỉnh sửa MCP toàn cục", "editProjectMCP": "Chỉnh sửa MCP dự án", - "editSettings": "Chỉnh sửa cài đặt MCP", "tool": { "alwaysAllow": "Luôn cho phép", "parameters": "Tham số", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index f964bf4b7a..bd022a52be 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "Nếu bạn có bất kỳ câu hỏi hoặc phản hồi nào, vui lòng mở một vấn đề tại github.com/RooVetGit/Roo-Code hoặc tham gia reddit.com/r/RooCode hoặc discord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "Cho phép báo cáo lỗi và sử dụng ẩn danh", "description": "Giúp cải thiện Roo Code bằng cách gửi dữ liệu sử dụng ẩn danh và báo cáo lỗi. Không bao giờ gửi mã, lời nhắc hoặc thông tin cá nhân. Xem chính sách bảo mật của chúng tôi để biết thêm chi tiết." diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index 8e1d1dbe8a..514a2b57e0 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -12,7 +12,6 @@ }, "editGlobalMCP": "编辑全局配置", "editProjectMCP": "编辑项目配置", - "editSettings": "参数设置", "tool": { "alwaysAllow": "始终允许", "parameters": "参数", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index ba14e48762..6ad116a5d7 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -458,7 +458,6 @@ }, "footer": { "feedback": "如果您有任何问题或反馈,请随时在 github.com/RooVetGit/Roo-Code 上提出问题或加入 reddit.com/r/RooCodediscord.gg/roocode", - "version": "Roo Code v{{version}}", "telemetry": { "label": "允许匿名数据收集", "description": "匿名收集错误报告和使用数据(不含代码/提示/个人信息),详情见隐私政策" diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index 67cc0bde4d..48fe770d11 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -12,7 +12,6 @@ }, "editGlobalMCP": "編輯全域 MCP", "editProjectMCP": "編輯專案 MCP", - "editSettings": "編輯 MCP 設定", "tool": { "alwaysAllow": "總是允許", "parameters": "參數", From d4a89cf62c2c557699dcc9f5076cf8a089de7a9a Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 22:57:35 -0700 Subject: [PATCH 15/37] test: migrate find-missing-i18n-key.js to test suite Move missing i18n key detection from scripts/ to test suite. Export helper functions from lint-translations for reuse. The migrated test: - Maintains existing scanning functionality - Groups and reports missing keys by file - Provides command line example for adding translations Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 229 ++++++++++++++++++ locales/__tests__/lint-translations.test.ts | 2 +- 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 locales/__tests__/find-missing-i18n-keys.test.ts diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts new file mode 100644 index 0000000000..3bce0051f6 --- /dev/null +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -0,0 +1,229 @@ +const fs = require("fs") +const path = require("path") +import { languages as schemaLanguages } from "../../src/schemas/index" +import { fileExists, loadFileContent, parseJsonContent, getValueAtPath } from "./lint-translations.test" + +const languages = schemaLanguages + +let logBuffer: string[] = [] + +function bufferLog(message: string) { + logBuffer.push(message) +} + +function printLogs(): string { + const output = logBuffer.join("\n") + logBuffer = [] + return output +} + +// findMissingI18nKeys: Directories to traverse and their corresponding locales +const SCAN_SOURCE_DIRS = { + components: { + path: "webview-ui/src/components", + localesDir: "webview-ui/src/i18n/locales", + }, + src: { + path: "src", + localesDir: "src/i18n/locales", + }, +} + +// i18n key patterns for findMissingI18nKeys +const i18nScanPatterns = [ + /{t\("([^"]+)"\)}/g, + /i18nKey="([^"]+)"/g, + /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)"\)/g, +] + +// Check if the key exists in all official language files, return a list of missing language files +function checkKeyInLocales(key: string, localesDir: string): Array<[string, boolean]> { + const [file, ...pathParts] = key.split(":") + const jsonPath = pathParts.join(".") + const missingLocales = new Map() // true = file missing, false = key missing + + // Check all official languages except English (source) + languages + .filter((lang) => lang !== "en") + .forEach((locale) => { + const filePath = path.join(localesDir, locale, `${file}.json`) + const localePath = `${locale}/${file}.json` + + // If file doesn't exist or can't be loaded, mark entire file as missing + if (!fileExists(filePath)) { + missingLocales.set(localePath, true) + return + } + + const content = loadFileContent(filePath) + if (!content) { + missingLocales.set(localePath, true) + return + } + + const json = parseJsonContent(content, filePath) + if (!json) { + missingLocales.set(localePath, true) + return + } + + // Only check for missing key if file exists and is valid + if (getValueAtPath(json, jsonPath) === undefined) { + missingLocales.set(localePath, false) + } + }) + + return Array.from(missingLocales.entries()) +} + +// Recursively traverse the directory +export function findMissingI18nKeys(): { output: string } { + logBuffer = [] // Clear buffer at start + let results: Array<{ key: string; file: string; missingLocales: Array<{ path: string; isFileMissing: boolean }> }> = + [] + + function walk(dir: string, baseDir: string, localesDir: string) { + const files = fs.readdirSync(dir) + + for (const file of files) { + const filePath = path.join(dir, file) + const stat = fs.statSync(filePath) + + // Exclude test files and __mocks__ directory + if (filePath.includes(".test.") || filePath.includes("__mocks__")) continue + + if (stat.isDirectory()) { + walk(filePath, baseDir, localesDir) // Recursively traverse subdirectories + } else if (stat.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(path.extname(filePath))) { + const relPath = path.relative(process.cwd(), filePath) + const content = fs.readFileSync(filePath, "utf8") + + // Match all i18n keys + const matches = new Set() + for (const pattern of i18nScanPatterns) { + let match + while ((match = pattern.exec(content)) !== null) { + matches.add(match[1]) + } + } + + // Check each unique key against all official languages + matches.forEach((key) => { + const missingLocales = checkKeyInLocales(key, localesDir) + if (missingLocales.length > 0) { + results.push({ + key, + missingLocales: missingLocales.map(([locale, isFileMissing]) => ({ + path: path.join(path.relative(process.cwd(), localesDir), locale), + isFileMissing, + })), + file: relPath, + }) + } + }) + } + } + } + + // Walk through all directories and check against official languages + Object.entries(SCAN_SOURCE_DIRS).forEach(([_name, config]) => { + // Create locales directory if it doesn't exist + if (!fs.existsSync(config.localesDir)) { + bufferLog(`Warning: Creating missing locales directory: ${config.localesDir}`) + fs.mkdirSync(config.localesDir, { recursive: true }) + } + walk(config.path, config.path, config.localesDir) + }) + + // Process results + bufferLog("=== i18n Key Check ===") + + if (!results || results.length === 0) { + bufferLog("\n✅ All i18n keys are present!") + } else { + bufferLog("\n❌ Missing i18n keys:") + + // Group by file status + const missingFiles = new Set() + const missingKeys = new Map>() + + results.forEach(({ key, missingLocales }) => { + missingLocales.forEach(({ path: locale, isFileMissing }) => { + if (isFileMissing) { + missingFiles.add(locale) + } else { + if (!missingKeys.has(locale)) { + missingKeys.set(locale, new Set()) + } + missingKeys.get(locale)?.add(key) + } + }) + }) + + // Show missing files first + if (missingFiles.size > 0) { + bufferLog("\nMissing translation files:") + Array.from(missingFiles) + .sort() + .forEach((file) => { + bufferLog(` - ${file}`) + }) + } + + // Then show files with missing keys + if (missingKeys.size > 0) { + bufferLog("\nFiles with missing keys:") + + // Group by file path to collect all keys per file + const fileKeys = new Map>>() + results.forEach(({ key, file, missingLocales }) => { + missingLocales.forEach(({ path: locale, isFileMissing }) => { + if (!isFileMissing) { + const [_localeDir, _localeFile] = locale.split("/") + const filePath = locale + if (!fileKeys.has(filePath)) { + fileKeys.set(filePath, new Map()) + } + if (!fileKeys.get(filePath)?.has(file)) { + fileKeys.get(filePath)?.set(file, new Set()) + } + fileKeys.get(filePath)?.get(file)?.add(key) + } + }) + }) + + // Show missing keys grouped by file + Array.from(fileKeys.entries()) + .sort() + .forEach(([file, sourceFiles]) => { + bufferLog(` - ${file}:`) + Array.from(sourceFiles.entries()) + .sort() + .forEach(([_sourceFile, keys]) => { + Array.from(keys) + .sort() + .forEach((key) => { + bufferLog(` ${key}`) + }) + }) + }) + } + + // Add simple command line example + if (missingKeys.size > 0) { + bufferLog("\nTo add missing translations:") + bufferLog( + " node scripts/manage-translations.js 'key' 'translation' [ 'key2' 'translation2' ... ]", + ) + } + } + + return { output: printLogs() } +} + +describe("Find Missing i18n Keys", () => { + test("findMissingI18nKeys scans for missing translations", () => { + const result = findMissingI18nKeys() + expect(result.output).toContain("✅ All i18n keys are present!") + }) +}) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index f58a28c170..defba89cda 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -665,7 +665,7 @@ function lintTranslations(args?: LintOptions): { output: string } { } // Export functions for use in other modules -module.exports = { +export { enumerateSourceFiles, resolveTargetPath, loadFileContent, From 41d3fb7e9e7bea1faaa371d71709532116443531 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 22:58:31 -0700 Subject: [PATCH 16/37] test: remove old i18n scripts Remove scripts that were migrated to test suite: - scripts/find-missing-i18n-key.js -> locales/__tests__/find-missing-i18n-keys.test.ts - scripts/find-missing-translations.js -> locales/__tests__/lint-translations.test.ts Signed-off-by: Eric Wheeler --- scripts/find-missing-i18n-key.js | 204 -------------------- scripts/find-missing-translations.js | 279 --------------------------- 2 files changed, 483 deletions(-) delete mode 100644 scripts/find-missing-i18n-key.js delete mode 100755 scripts/find-missing-translations.js diff --git a/scripts/find-missing-i18n-key.js b/scripts/find-missing-i18n-key.js deleted file mode 100644 index 87d160d490..0000000000 --- a/scripts/find-missing-i18n-key.js +++ /dev/null @@ -1,204 +0,0 @@ -const fs = require("fs") -const path = require("path") - -// Parse command-line arguments -const args = process.argv.slice(2).reduce((acc, arg) => { - if (arg === "--help") { - acc.help = true - } else if (arg.startsWith("--locale=")) { - acc.locale = arg.split("=")[1] - } else if (arg.startsWith("--file=")) { - acc.file = arg.split("=")[1] - } - return acc -}, {}) - -// Display help information -if (args.help) { - console.log(` -Find missing i18n translations - -A useful script to identify whether the i18n keys used in component files exist in all language files. - -Usage: - node scripts/find-missing-i18n-key.js [options] - -Options: - --locale= Only check a specific language (e.g., --locale=de) - --file= Only check a specific file (e.g., --file=chat.json) - --help Display help information - -Output: - - Generate a report of missing translations - `) - process.exit(0) -} - -// Directories to traverse and their corresponding locales -const DIRS = { - components: { - path: path.join(__dirname, "../webview-ui/src/components"), - localesDir: path.join(__dirname, "../webview-ui/src/i18n/locales"), - }, - src: { - path: path.join(__dirname, "../src"), - localesDir: path.join(__dirname, "../src/i18n/locales"), - }, -} - -// Regular expressions to match i18n keys -const i18nPatterns = [ - /{t\("([^"]+)"\)}/g, // Match {t("key")} format - /i18nKey="([^"]+)"/g, // Match i18nKey="key" format - /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)"\)/g, // Match t("key") format, where key contains a colon or dot -] - -// Get all language directories for a specific locales directory -function getLocaleDirs(localesDir) { - try { - const allLocales = fs.readdirSync(localesDir).filter((file) => { - const stats = fs.statSync(path.join(localesDir, file)) - return stats.isDirectory() // Do not exclude any language directories - }) - - // Filter to a specific language if specified - return args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales - } catch (error) { - if (error.code === "ENOENT") { - console.warn(`Warning: Locales directory not found: ${localesDir}`) - return [] - } - throw error - } -} - -// Get the value from JSON by path -function getValueByPath(obj, path) { - const parts = path.split(".") - let current = obj - - for (const part of parts) { - if (current === undefined || current === null) { - return undefined - } - current = current[part] - } - - return current -} - -// Check if the key exists in all language files, return a list of missing language files -function checkKeyInLocales(key, localeDirs, localesDir) { - const [file, ...pathParts] = key.split(":") - const jsonPath = pathParts.join(".") - - const missingLocales = [] - - localeDirs.forEach((locale) => { - const filePath = path.join(localesDir, locale, `${file}.json`) - if (!fs.existsSync(filePath)) { - missingLocales.push(`${locale}/${file}.json`) - return - } - - const json = JSON.parse(fs.readFileSync(filePath, "utf8")) - if (getValueByPath(json, jsonPath) === undefined) { - missingLocales.push(`${locale}/${file}.json`) - } - }) - - return missingLocales -} - -// Recursively traverse the directory -function findMissingI18nKeys() { - const results = [] - - function walk(dir, baseDir, localeDirs, localesDir) { - const files = fs.readdirSync(dir) - - for (const file of files) { - const filePath = path.join(dir, file) - const stat = fs.statSync(filePath) - - // Exclude test files and __mocks__ directory - if (filePath.includes(".test.") || filePath.includes("__mocks__")) continue - - if (stat.isDirectory()) { - walk(filePath, baseDir, localeDirs, localesDir) // Recursively traverse subdirectories - } else if (stat.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(path.extname(filePath))) { - const content = fs.readFileSync(filePath, "utf8") - - // Match all i18n keys - for (const pattern of i18nPatterns) { - let match - while ((match = pattern.exec(content)) !== null) { - const key = match[1] - const missingLocales = checkKeyInLocales(key, localeDirs, localesDir) - if (missingLocales.length > 0) { - results.push({ - key, - missingLocales, - file: path.relative(baseDir, filePath), - }) - } - } - } - } - } - } - - // Walk through all directories - Object.entries(DIRS).forEach(([name, config]) => { - const localeDirs = getLocaleDirs(config.localesDir) - if (localeDirs.length > 0) { - console.log(`\nChecking ${name} directory with ${localeDirs.length} languages: ${localeDirs.join(", ")}`) - walk(config.path, config.path, localeDirs, config.localesDir) - } - }) - - return results -} - -// Execute and output the results -function main() { - try { - if (args.locale) { - // Check if the specified locale exists in any of the locales directories - const localeExists = Object.values(DIRS).some((config) => { - const localeDirs = getLocaleDirs(config.localesDir) - return localeDirs.includes(args.locale) - }) - - if (!localeExists) { - console.error(`Error: Language '${args.locale}' not found in any locales directory`) - process.exit(1) - } - } - - const missingKeys = findMissingI18nKeys() - - if (missingKeys.length === 0) { - console.log("\n✅ All i18n keys are present!") - return - } - - console.log("\nMissing i18n keys:\n") - missingKeys.forEach(({ key, missingLocales, file }) => { - console.log(`File: ${file}`) - console.log(`Key: ${key}`) - console.log("Missing in:") - missingLocales.forEach((file) => console.log(` - ${file}`)) - console.log("-------------------") - }) - - // Exit code 1 indicates missing keys - process.exit(1) - } catch (error) { - console.error("Error:", error.message) - console.error(error.stack) - process.exit(1) - } -} - -main() diff --git a/scripts/find-missing-translations.js b/scripts/find-missing-translations.js deleted file mode 100755 index 9277d935ba..0000000000 --- a/scripts/find-missing-translations.js +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Script to find missing translations in locale files - * - * Usage: - * node scripts/find-missing-translations.js [options] - * - * Options: - * --locale= Only check a specific locale (e.g. --locale=fr) - * --file= Only check a specific file (e.g. --file=chat.json) - * --area= Only check a specific area (core, webview, or both) - * --help Show this help message - */ - -const fs = require("fs") -const path = require("path") - -// Process command line arguments -const args = process.argv.slice(2).reduce( - (acc, arg) => { - if (arg === "--help") { - acc.help = true - } else if (arg.startsWith("--locale=")) { - acc.locale = arg.split("=")[1] - } else if (arg.startsWith("--file=")) { - acc.file = arg.split("=")[1] - } else if (arg.startsWith("--area=")) { - acc.area = arg.split("=")[1] - // Validate area value - if (!["core", "webview", "both"].includes(acc.area)) { - console.error(`Error: Invalid area '${acc.area}'. Must be 'core', 'webview', or 'both'.`) - process.exit(1) - } - } - return acc - }, - { area: "both" }, -) // Default to checking both areas - -// Show help if requested -if (args.help) { - console.log(` -Find Missing Translations - -A utility script to identify missing translations across locale files. -Compares non-English locale files to the English ones to find any missing keys. - -Usage: - node scripts/find-missing-translations.js [options] - -Options: - --locale= Only check a specific locale (e.g. --locale=fr) - --file= Only check a specific file (e.g. --file=chat.json) - --area= Only check a specific area (core, webview, or both) - 'core' = Backend (src/i18n/locales) - 'webview' = Frontend UI (webview-ui/src/i18n/locales) - 'both' = Check both areas (default) - --help Show this help message - -Output: - - Generates a report of missing translations for each area - `) - process.exit(0) -} - -// Paths to the locales directories -const LOCALES_DIRS = { - core: path.join(__dirname, "../src/i18n/locales"), - webview: path.join(__dirname, "../webview-ui/src/i18n/locales"), -} - -// Determine which areas to check based on args -const areasToCheck = args.area === "both" ? ["core", "webview"] : [args.area] - -// Recursively find all keys in an object -function findKeys(obj, parentKey = "") { - let keys = [] - - for (const [key, value] of Object.entries(obj)) { - const currentKey = parentKey ? `${parentKey}.${key}` : key - - if (typeof value === "object" && value !== null) { - // If value is an object, recurse - keys = [...keys, ...findKeys(value, currentKey)] - } else { - // If value is a primitive, add the key - keys.push(currentKey) - } - } - - return keys -} - -// Get value at a dotted path in an object -function getValueAtPath(obj, path) { - const parts = path.split(".") - let current = obj - - for (const part of parts) { - if (current === undefined || current === null) { - return undefined - } - current = current[part] - } - - return current -} - -// Function to check translations for a specific area -function checkAreaTranslations(area) { - const LOCALES_DIR = LOCALES_DIRS[area] - - // Get all locale directories (or filter to the specified locale) - const allLocales = fs.readdirSync(LOCALES_DIR).filter((item) => { - const stats = fs.statSync(path.join(LOCALES_DIR, item)) - return stats.isDirectory() && item !== "en" // Exclude English as it's our source - }) - - // Filter to the specified locale if provided - const locales = args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales - - if (args.locale && locales.length === 0) { - console.error(`Error: Locale '${args.locale}' not found in ${LOCALES_DIR}`) - process.exit(1) - } - - console.log( - `\n${area === "core" ? "BACKEND" : "FRONTEND"} - Checking ${locales.length} non-English locale(s): ${locales.join(", ")}`, - ) - - // Get all English JSON files - const englishDir = path.join(LOCALES_DIR, "en") - let englishFiles = fs.readdirSync(englishDir).filter((file) => file.endsWith(".json") && !file.startsWith(".")) - - // Filter to the specified file if provided - if (args.file) { - if (!englishFiles.includes(args.file)) { - console.error(`Error: File '${args.file}' not found in ${englishDir}`) - process.exit(1) - } - englishFiles = englishFiles.filter((file) => file === args.file) - } - - // Load file contents - let englishFileContents - - try { - englishFileContents = englishFiles.map((file) => ({ - name: file, - content: JSON.parse(fs.readFileSync(path.join(englishDir, file), "utf8")), - })) - } catch (e) { - console.error(`Error: File '${englishDir}' is not a valid JSON file`) - process.exit(1) - } - - console.log( - `Checking ${englishFileContents.length} translation file(s): ${englishFileContents.map((f) => f.name).join(", ")}`, - ) - - // Results object to store missing translations - const missingTranslations = {} - - // For each locale, check for missing translations - for (const locale of locales) { - missingTranslations[locale] = {} - - for (const { name, content: englishContent } of englishFileContents) { - const localeFilePath = path.join(LOCALES_DIR, locale, name) - - // Check if the file exists in the locale - if (!fs.existsSync(localeFilePath)) { - missingTranslations[locale][name] = { file: "File is missing entirely" } - continue - } - - // Load the locale file - let localeContent - - try { - localeContent = JSON.parse(fs.readFileSync(localeFilePath, "utf8")) - } catch (e) { - console.error(`Error: File '${localeFilePath}' is not a valid JSON file`) - process.exit(1) - } - - // Find all keys in the English file - const englishKeys = findKeys(englishContent) - - // Check for missing keys in the locale file - const missingKeys = [] - - for (const key of englishKeys) { - const englishValue = getValueAtPath(englishContent, key) - const localeValue = getValueAtPath(localeContent, key) - - if (localeValue === undefined) { - missingKeys.push({ - key, - englishValue, - }) - } - } - - if (missingKeys.length > 0) { - missingTranslations[locale][name] = missingKeys - } - } - } - - return { missingTranslations, hasMissingTranslations: outputResults(missingTranslations, area) } -} - -// Function to output results for an area -function outputResults(missingTranslations, area) { - let hasMissingTranslations = false - - console.log(`\n${area === "core" ? "BACKEND" : "FRONTEND"} Missing Translations Report:\n`) - - for (const [locale, files] of Object.entries(missingTranslations)) { - if (Object.keys(files).length === 0) { - console.log(`✅ ${locale}: No missing translations`) - continue - } - - hasMissingTranslations = true - console.log(`📝 ${locale}:`) - - for (const [fileName, missingItems] of Object.entries(files)) { - if (missingItems.file) { - console.log(` - ${fileName}: ${missingItems.file}`) - continue - } - - console.log(` - ${fileName}: ${missingItems.length} missing translations`) - - for (const { key, englishValue } of missingItems) { - console.log(` ${key}: "${englishValue}"`) - } - } - - console.log("") - } - - return hasMissingTranslations -} - -// Main function to find missing translations -function findMissingTranslations() { - try { - console.log("Starting translation check...") - - let anyAreaMissingTranslations = false - - // Check each requested area - for (const area of areasToCheck) { - const { hasMissingTranslations } = checkAreaTranslations(area) - anyAreaMissingTranslations = anyAreaMissingTranslations || hasMissingTranslations - } - - // Summary - if (!anyAreaMissingTranslations) { - console.log("\n✅ All translations are complete across all checked areas!") - } else { - console.log("\n✏️ To add missing translations:") - console.log("1. Add the missing keys to the corresponding locale files") - console.log("2. Translate the English values to the appropriate language") - console.log("3. Run this script again to verify all translations are complete") - // Exit with error code to fail CI checks - process.exit(1) - } - } catch (error) { - console.error("Error:", error.message) - console.error(error.stack) - process.exit(1) - } -} - -// Run the main function -findMissingTranslations() From 921f8ed8a14020be48f76e8ce91010f6e0e35b88 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:13:00 -0700 Subject: [PATCH 17/37] refactor: move shared test utilities to dedicated module Move common utilities used by i18n test files into utils.ts: - File operations (fileExists, loadFileContent) - JSON parsing and path handling - Logging utilities Replace direct logBuffer access with clearLogs() function Remove duplicate code between test files Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 28 +++---- locales/__tests__/lint-translations.test.ts | 78 ++++--------------- locales/__tests__/utils.ts | 58 ++++++++++++++ 3 files changed, 84 insertions(+), 80 deletions(-) create mode 100644 locales/__tests__/utils.ts diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts index 3bce0051f6..b0aba72854 100644 --- a/locales/__tests__/find-missing-i18n-keys.test.ts +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -1,21 +1,15 @@ const fs = require("fs") const path = require("path") -import { languages as schemaLanguages } from "../../src/schemas/index" -import { fileExists, loadFileContent, parseJsonContent, getValueAtPath } from "./lint-translations.test" - -const languages = schemaLanguages - -let logBuffer: string[] = [] - -function bufferLog(message: string) { - logBuffer.push(message) -} - -function printLogs(): string { - const output = logBuffer.join("\n") - logBuffer = [] - return output -} +import { + languages, + bufferLog, + printLogs, + clearLogs, + fileExists, + loadFileContent, + parseJsonContent, + getValueAtPath, +} from "./utils" // findMissingI18nKeys: Directories to traverse and their corresponding locales const SCAN_SOURCE_DIRS = { @@ -78,7 +72,7 @@ function checkKeyInLocales(key: string, localesDir: string): Array<[string, bool // Recursively traverse the directory export function findMissingI18nKeys(): { output: string } { - logBuffer = [] // Clear buffer at start + clearLogs() // Clear buffer at start let results: Array<{ key: string; file: string; missingLocales: Array<{ path: string; isFileMissing: boolean }> }> = [] diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index defba89cda..6dab64ebd4 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -1,17 +1,19 @@ const fs = require("fs") const path = require("path") -import { languages as schemaLanguages } from "../../src/schemas/index" - -let logBuffer: string[] = [] -const bufferLog = (msg: string) => logBuffer.push(msg) -const printLogs = () => { - const output = logBuffer.join("\n") - logBuffer = [] - return output -} +import { + languages, + type Language, + bufferLog, + printLogs, + clearLogs, + fileExists, + loadFileContent, + parseJsonContent, + getValueAtPath, +} from "./utils" -const languages = schemaLanguages -type Language = (typeof languages)[number] +// Track unique errors to avoid duplication +const seenErrors = new Set() interface PathMapping { name: string @@ -119,38 +121,6 @@ function resolveTargetPath(sourceFile: string, targetTemplate: string, locale: s return path.join(targetPath, fileName) } -function loadFileContent(filePath: string): string | null { - try { - const fullPath = path.join("./", filePath) - return fs.readFileSync(fullPath, "utf8") - } catch (error) { - return null - } -} - -// Track unique errors to avoid duplication -const seenErrors = new Set() - -function parseJsonContent(content: string | null, filePath: string): any | null { - if (!content) return null - - try { - return JSON.parse(content) - } catch (error) { - // Only log first occurrence of each unique error - const errorKey = `${filePath}:${(error as Error).message}` - if (!seenErrors.has(errorKey)) { - seenErrors.add(errorKey) - bufferLog(`Error parsing ${path.basename(filePath)}: ${(error as Error).message}`) - } - return null - } -} - -function fileExists(filePath: string): boolean { - return fs.existsSync(path.join("./", filePath)) -} - function findKeys(obj: any, parentKey: string = ""): string[] { let keys: string[] = [] @@ -166,24 +136,6 @@ function findKeys(obj: any, parentKey: string = ""): string[] { return keys } -function getValueAtPath(obj: any, path: string): any { - if (obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, path)) { - return obj[path] - } - - const parts = path.split(".") - let current = obj - - for (const part of parts) { - if (current === undefined || current === null) { - return undefined - } - current = current[part] - } - - return current -} - function checkMissingTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { if (!sourceContent || !targetContent) return [] @@ -307,7 +259,7 @@ function processFileLocale( function formatResults(results: Results, checkTypes: string[], options: LintOptions, mappings: PathMapping[]): boolean { let hasIssues = false - logBuffer = [] // Clear buffer at start + clearLogs() // Clear buffer at start seenErrors.clear() // Clear error tracking bufferLog("=== Translation Results ===") @@ -613,7 +565,7 @@ function parseArgs(): LintOptions { } function lintTranslations(args?: LintOptions): { output: string } { - logBuffer = [] // Clear the buffer at the start + clearLogs() // Clear the buffer at the start const options = args || parseArgs() || { area: ["all"], check: ["all"] } const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] diff --git a/locales/__tests__/utils.ts b/locales/__tests__/utils.ts new file mode 100644 index 0000000000..575fe42e4e --- /dev/null +++ b/locales/__tests__/utils.ts @@ -0,0 +1,58 @@ +const fs = require("fs") +const path = require("path") +import { languages as schemaLanguages } from "../../src/schemas/index" + +export const languages = schemaLanguages +export type Language = (typeof languages)[number] + +let logBuffer: string[] = [] + +export function bufferLog(message: string) { + logBuffer.push(message) +} + +export function printLogs(): string { + const output = logBuffer.join("\n") + logBuffer = [] + return output +} + +export function clearLogs(): void { + logBuffer = [] +} + +export function fileExists(filePath: string): boolean { + return fs.existsSync(filePath) +} + +export function loadFileContent(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf8") + } catch (error) { + return null + } +} + +export function parseJsonContent(content: string | null, filePath: string): any | null { + if (!content) { + return null + } + try { + return JSON.parse(content) + } catch (error) { + bufferLog(`Error parsing JSON in ${filePath}: ${error}`) + return null + } +} + +export function getValueAtPath(obj: any, path: string): any { + const parts = path.split(".") + let current = obj + for (const part of parts) { + if (current === null || current === undefined) { + return undefined + } + current = current[part] + } + return current +} From d897a2beac05fa167ac8975c261ce3dbd1cfc946 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:14:44 -0700 Subject: [PATCH 18/37] feat: scan en locale for missing i18n keys Include English locale when checking for missing translations to ensure completeness across all language files. Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts index b0aba72854..2f08b4b779 100644 --- a/locales/__tests__/find-missing-i18n-keys.test.ts +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -36,36 +36,34 @@ function checkKeyInLocales(key: string, localesDir: string): Array<[string, bool const jsonPath = pathParts.join(".") const missingLocales = new Map() // true = file missing, false = key missing - // Check all official languages except English (source) - languages - .filter((lang) => lang !== "en") - .forEach((locale) => { - const filePath = path.join(localesDir, locale, `${file}.json`) - const localePath = `${locale}/${file}.json` - - // If file doesn't exist or can't be loaded, mark entire file as missing - if (!fileExists(filePath)) { - missingLocales.set(localePath, true) - return - } + // Check all official languages including English + languages.forEach((locale) => { + const filePath = path.join(localesDir, locale, `${file}.json`) + const localePath = `${locale}/${file}.json` + + // If file doesn't exist or can't be loaded, mark entire file as missing + if (!fileExists(filePath)) { + missingLocales.set(localePath, true) + return + } - const content = loadFileContent(filePath) - if (!content) { - missingLocales.set(localePath, true) - return - } + const content = loadFileContent(filePath) + if (!content) { + missingLocales.set(localePath, true) + return + } - const json = parseJsonContent(content, filePath) - if (!json) { - missingLocales.set(localePath, true) - return - } + const json = parseJsonContent(content, filePath) + if (!json) { + missingLocales.set(localePath, true) + return + } - // Only check for missing key if file exists and is valid - if (getValueAtPath(json, jsonPath) === undefined) { - missingLocales.set(localePath, false) - } - }) + // Only check for missing key if file exists and is valid + if (getValueAtPath(json, jsonPath) === undefined) { + missingLocales.set(localePath, false) + } + }) return Array.from(missingLocales.entries()) } From d80c896b8ad64039c823d10e62aa69ed5e40730e Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:21:02 -0700 Subject: [PATCH 19/37] fix: restore critical functionality in i18n utils Restore original implementations that were incorrectly refactored: - Add ./ prefix in fileExists for proper path resolution - Restore error deduplication tracking in parseJsonContent - Restore direct property access optimization in getValueAtPath Signed-off-by: Eric Wheeler --- locales/__tests__/utils.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/locales/__tests__/utils.ts b/locales/__tests__/utils.ts index 575fe42e4e..2cd945bf99 100644 --- a/locales/__tests__/utils.ts +++ b/locales/__tests__/utils.ts @@ -22,7 +22,7 @@ export function clearLogs(): void { } export function fileExists(filePath: string): boolean { - return fs.existsSync(filePath) + return fs.existsSync(path.join("./", filePath)) } export function loadFileContent(filePath: string): string | null { @@ -33,26 +33,39 @@ export function loadFileContent(filePath: string): string | null { } } +// Track unique errors to avoid duplication +const seenErrors = new Set() + export function parseJsonContent(content: string | null, filePath: string): any | null { - if (!content) { - return null - } + if (!content) return null + try { return JSON.parse(content) } catch (error) { - bufferLog(`Error parsing JSON in ${filePath}: ${error}`) + // Only log first occurrence of each unique error + const errorKey = `${filePath}:${(error as Error).message}` + if (!seenErrors.has(errorKey)) { + seenErrors.add(errorKey) + bufferLog(`Error parsing ${path.basename(filePath)}: ${(error as Error).message}`) + } return null } } export function getValueAtPath(obj: any, path: string): any { + if (obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, path)) { + return obj[path] + } + const parts = path.split(".") let current = obj + for (const part of parts) { - if (current === null || current === undefined) { + if (current === undefined || current === null) { return undefined } current = current[part] } + return current } From d21e9bc4c46fb82ea55fcfc2af5e32f498542d19 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:23:16 -0700 Subject: [PATCH 20/37] feat: migrate translation validation to Jest tests Replace find-missing-translations.js script with Jest tests for improved: - Test organization and reporting - Error handling and validation - Integration with existing test infrastructure Added detailed documentation for translation management script usage with examples for adding/updating/deleting translations Signed-off-by: Eric Wheeler --- .github/workflows/code-qa.yml | 2 +- .roo/rules-translate/001-general-rules.md | 82 ++++++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 667d0b6bf8..da0f83f38c 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -44,7 +44,7 @@ jobs: - name: Install dependencies run: npm run install:all - name: Verify all translations are complete - run: node scripts/find-missing-translations.js + run: npx jest --verbose locales/__tests__/lint-translations.test.ts locales/__tests__/find-missing-i18n-keys.test.ts knip: runs-on: ubuntu-latest diff --git a/.roo/rules-translate/001-general-rules.md b/.roo/rules-translate/001-general-rules.md index bdb18bea64..0cc5c48f91 100644 --- a/.roo/rules-translate/001-general-rules.md +++ b/.roo/rules-translate/001-general-rules.md @@ -88,11 +88,87 @@ - Watch for placeholders and preserve them in translations - Be mindful of text length in UI elements when translating to languages that might require more characters - Use context-aware translations when the same string has different meanings -- Always validate your translation work by running the missing translations script: +- Always validate your translation work by running the translation tests: ``` - node scripts/find-missing-translations.js + npx jest --verbose locales/__tests__/lint-translations.test.ts locales/__tests__/find-missing-i18n-keys.test.ts ``` -- Address any missing translations identified by the script to ensure complete coverage across all locales +- Address any missing translations identified by the tests by using `node scripts/manage-translations.js` to ensure complete coverage across all locales: + +```sh +./scripts/manage-translations.js +Usage: +Command Line Mode: + Add/update translations: + node scripts/manage-translations.js [-v] TRANSLATION_FILE KEY_PATH VALUE [KEY_PATH VALUE...] + Delete translations: + node scripts/manage-translations.js [-v] -d TRANSLATION_FILE1 [TRANSLATION_FILE2 ...] [ -- KEY1 ...] + +Key Path Format: + - Use single dot (.) for nested paths: 'command.newTask.title' + - Use double dots (..) to include a literal dot in key names (like SMTP byte stuffing): + 'settings..path' -> { 'settings.path': 'value' } + + Examples: + 'command.newTask.title' -> { command: { newTask: { title: 'value' } } } + 'settings..path' -> { 'settings.path': 'value' } + 'nested.key..with..dots' -> { nested: { 'key.with.dots': 'value' } } + +Line-by-Line JSON Mode (--stdin): + Each line must be a complete, single JSON object/array + Multi-line or combined JSON is not supported + + Add/update translations: + node scripts/manage-translations.js [-v] --stdin TRANSLATION_FILE + Format: One object per line with exactly one key-value pair: + {"command.newTask.title": "New Task"} + {"settings..path": "Custom Path"} + {"nested.key..with..dots": "Value with dots in key"} + + Delete translations: + node scripts/manage-translations.js [-v] -d --stdin TRANSLATION_FILE + Format: One array per line with exactly one key: + ["command.newTask.title"] + ["settings..path"] + ["nested.key..with..dots"] + +Options: + -v Enable verbose output (shows operations) + -d Delete mode - remove keys instead of setting them + --stdin Read line-by-line JSON from stdin + +Examples: + # Add via command line, it is recommended to execute multiple translations simultaneously. + # The script expects a single file at a time with multiple key-value pairs, not multiple files. + node scripts/manage-translations.js package.nls.json command.newTask.title "New Task" [ key2 translation2 ... ] && \ + node scripts/manage-translations.js package.nls.json settings..path "Custom Path" && \ + node scripts/manage-translations.js package.nls.json nested.key..with..dots "Value with dots" + + # Add multiple translations (one JSON object per line): + translations.txt: + {"command.newTask.title": "New Task"} + {"settings..path": "Custom Path"} + node scripts/manage-translations.js --stdin package.nls.json < translations.txt + + # Delete multiple keys (one JSON array per line): + delete_keys.txt: + ["command.newTask.title"] + ["settings..path"] + ["nested.key..with..dots"] + node scripts/manage-translations.js -d --stdin package.nls.json < delete_keys.txt + + # Using here document for batching: + node scripts/manage-translations.js --stdin package.nls.json << EOF + {"command.newTask.title": "New Task"} + {"settings..path": "Custom Path"} + EOF + + # Delete using here document: + node scripts/manage-translations.js -d --stdin package.nls.json << EOF + ["command.newTask.title"] + ["settings..path"] + ["nested.key..with..dots"] + EOF +``` # 9. TRANSLATOR'S CHECKLIST From 9a70cdca5f60cdf4a25390cfc4200350d2440864 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:32:31 -0700 Subject: [PATCH 21/37] lang: add missing modes.noMatchFound translations Add missing 'No matching modes found' translation key across all locales to fix failing translation tests. Fixes: #3422 Signed-off-by: Eric Wheeler --- webview-ui/src/i18n/locales/ca/prompts.json | 3 +- webview-ui/src/i18n/locales/de/prompts.json | 3 +- webview-ui/src/i18n/locales/en/prompts.json | 3 +- webview-ui/src/i18n/locales/es/prompts.json | 3 +- webview-ui/src/i18n/locales/fr/prompts.json | 3 +- webview-ui/src/i18n/locales/hi/prompts.json | 3 +- webview-ui/src/i18n/locales/it/prompts.json | 3 +- webview-ui/src/i18n/locales/ja/prompts.json | 3 +- webview-ui/src/i18n/locales/ko/prompts.json | 3 +- webview-ui/src/i18n/locales/nl/prompts.json | 295 +++++++++--------- webview-ui/src/i18n/locales/pl/prompts.json | 3 +- .../src/i18n/locales/pt-BR/prompts.json | 3 +- webview-ui/src/i18n/locales/ru/prompts.json | 3 +- webview-ui/src/i18n/locales/tr/prompts.json | 3 +- webview-ui/src/i18n/locales/vi/prompts.json | 3 +- .../src/i18n/locales/zh-CN/prompts.json | 3 +- .../src/i18n/locales/zh-TW/prompts.json | 3 +- 17 files changed, 180 insertions(+), 163 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json index 01f34f3398..5392816020 100644 --- a/webview-ui/src/i18n/locales/ca/prompts.json +++ b/webview-ui/src/i18n/locales/ca/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Editar modes globals", "editProjectModes": "Editar modes de projecte (.roomodes)", "createModeHelpText": "Feu clic a + per crear un nou mode personalitzat, o simplement demaneu a Roo al xat que en creï un per a vostè!", - "selectMode": "Cerqueu modes" + "selectMode": "Cerqueu modes", + "noMatchFound": "No s'han trobat modes coincidents" }, "apiConfiguration": { "title": "Configuració d'API", diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 98280a5e83..6043ada683 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Globale Modi bearbeiten", "editProjectModes": "Projektmodi bearbeiten (.roomodes)", "createModeHelpText": "Klicke auf +, um einen neuen benutzerdefinierten Modus zu erstellen, oder bitte Roo einfach im Chat, einen für dich zu erstellen!", - "selectMode": "Modi suchen" + "selectMode": "Modi suchen", + "noMatchFound": "Keine passenden Modi gefunden" }, "apiConfiguration": { "title": "API-Konfiguration", diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index c1d2a671fb..c7fbbea56f 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Edit Global Modes", "editProjectModes": "Edit Project Modes (.roomodes)", "createModeHelpText": "Hit the + to create a new custom mode, or just ask Roo in chat to create one for you!", - "selectMode": "Search modes" + "selectMode": "Search modes", + "noMatchFound": "No matching modes found" }, "apiConfiguration": { "title": "API Configuration", diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json index baf7246f25..9616c29c8e 100644 --- a/webview-ui/src/i18n/locales/es/prompts.json +++ b/webview-ui/src/i18n/locales/es/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Editar modos globales", "editProjectModes": "Editar modos del proyecto (.roomodes)", "createModeHelpText": "¡Haz clic en + para crear un nuevo modo personalizado, o simplemente pídele a Roo en el chat que te cree uno!", - "selectMode": "Buscar modos" + "selectMode": "Buscar modos", + "noMatchFound": "No se encontraron modos coincidentes" }, "apiConfiguration": { "title": "Configuración de API", diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json index 2a8e410cd5..825df19d2e 100644 --- a/webview-ui/src/i18n/locales/fr/prompts.json +++ b/webview-ui/src/i18n/locales/fr/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Modifier les modes globaux", "editProjectModes": "Modifier les modes du projet (.roomodes)", "createModeHelpText": "Cliquez sur + pour créer un nouveau mode personnalisé, ou demandez simplement à Roo dans le chat de vous en créer un !", - "selectMode": "Rechercher les modes" + "selectMode": "Rechercher les modes", + "noMatchFound": "Aucun mode correspondant trouvé" }, "apiConfiguration": { "title": "Configuration API", diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json index de31c41356..e8f8360909 100644 --- a/webview-ui/src/i18n/locales/hi/prompts.json +++ b/webview-ui/src/i18n/locales/hi/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "ग्लोबल मोड्स संपादित करें", "editProjectModes": "प्रोजेक्ट मोड्स संपादित करें (.roomodes)", "createModeHelpText": "नया कस्टम मोड बनाने के लिए + पर क्लिक करें, या बस चैट में Roo से आपके लिए एक बनाने को कहें!", - "selectMode": "मोड खोजें" + "selectMode": "मोड खोजें", + "noMatchFound": "कोई मिलान करने वाला मोड नहीं मिला" }, "apiConfiguration": { "title": "API कॉन्फ़िगरेशन", diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json index 1146f7012f..31159555c1 100644 --- a/webview-ui/src/i18n/locales/it/prompts.json +++ b/webview-ui/src/i18n/locales/it/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Modifica modalità globali", "editProjectModes": "Modifica modalità di progetto (.roomodes)", "createModeHelpText": "Clicca sul + per creare una nuova modalità personalizzata, o chiedi semplicemente a Roo nella chat di crearne una per te!", - "selectMode": "Cerca modalità" + "selectMode": "Cerca modalità", + "noMatchFound": "Nessuna modalità corrispondente trovata" }, "apiConfiguration": { "title": "Configurazione API", diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index 82f2bae20b..836e9980f4 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "グローバルモードを編集", "editProjectModes": "プロジェクトモードを編集 (.roomodes)", "createModeHelpText": "+ をクリックして新しいカスタムモードを作成するか、チャットで Roo に作成を依頼してください!", - "selectMode": "モードを検索" + "selectMode": "モードを検索", + "noMatchFound": "一致するモードが見つかりません" }, "apiConfiguration": { "title": "API設定", diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index 04be16e560..d6288c1fbc 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "전역 모드 편집", "editProjectModes": "프로젝트 모드 편집 (.roomodes)", "createModeHelpText": "새 커스텀 모드를 만들려면 + 버튼을 클릭하거나, 채팅에서 Roo에게 만들어달라고 요청하세요!", - "selectMode": "모드 검색" + "selectMode": "모드 검색", + "noMatchFound": "일치하는 모드를 찾을 수 없습니다" }, "apiConfiguration": { "title": "API 구성", diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json index c7d7c5ea0b..93bb413b15 100644 --- a/webview-ui/src/i18n/locales/nl/prompts.json +++ b/webview-ui/src/i18n/locales/nl/prompts.json @@ -1,149 +1,150 @@ { - "title": "Prompts", - "done": "Gereed", - "modes": { - "title": "Modi", - "createNewMode": "Nieuwe modus aanmaken", - "editModesConfig": "Modusconfiguratie bewerken", - "editGlobalModes": "Globale modi bewerken", - "editProjectModes": "Projectmodi bewerken (.roomodes)", - "createModeHelpText": "Klik op + om een nieuwe aangepaste modus te maken, of vraag Roo in de chat om er een voor je te maken!", - "selectMode": "Modus zoeken" - }, - "apiConfiguration": { - "title": "API-configuratie", - "select": "Selecteer welke API-configuratie voor deze modus gebruikt moet worden" - }, - "tools": { - "title": "Beschikbare tools", - "builtInModesText": "Tools voor ingebouwde modi kunnen niet worden aangepast", - "editTools": "Tools bewerken", - "doneEditing": "Bewerken voltooid", - "allowedFiles": "Toegestane bestanden:", - "toolNames": { - "read": "Bestanden lezen", - "edit": "Bestanden bewerken", - "browser": "Browser gebruiken", - "command": "Commando's uitvoeren", - "mcp": "MCP gebruiken" - }, - "noTools": "Geen" - }, - "roleDefinition": { - "title": "Roldefinitie", - "resetToDefault": "Terugzetten naar standaard", - "description": "Definieer Roo's expertise en persoonlijkheid voor deze modus. Deze beschrijving bepaalt hoe Roo zich presenteert en taken benadert." - }, - "customInstructions": { - "title": "Modusspecifieke instructies (optioneel)", - "resetToDefault": "Terugzetten naar standaard", - "description": "Voeg gedragsrichtlijnen toe die specifiek zijn voor de modus {{modeName}}.", - "loadFromFile": "Modusspecifieke instructies voor {{mode}} kunnen ook worden geladen uit de map .roo/rules-{{slug}}/ in je werkruimte (.roorules-{{slug}} en .clinerules-{{slug}} zijn verouderd en werken binnenkort niet meer)." - }, - "globalCustomInstructions": { - "title": "Aangepaste instructies voor alle modi", - "description": "Deze instructies gelden voor alle modi. Ze bieden een basisset aan gedragingen die kunnen worden uitgebreid met modusspecifieke instructies hieronder.\nWil je dat Roo in een andere taal denkt en spreekt dan de weergavetaal van je editor ({{language}}), dan kun je dat hier aangeven.", - "loadFromFile": "Instructies kunnen ook worden geladen uit de map .roo/rules/ in je werkruimte (.roorules en .clinerules zijn verouderd en werken binnenkort niet meer)." - }, - "systemPrompt": { - "preview": "Systeemprompt bekijken", - "copy": "Systeemprompt kopiëren naar klembord", - "title": "Systeemprompt ({{modeName}} modus)" - }, - "supportPrompts": { - "title": "Ondersteuningsprompts", - "resetPrompt": "Reset {{promptType}} prompt naar standaard", - "prompt": "Prompt", - "enhance": { - "apiConfiguration": "API-configuratie", - "apiConfigDescription": "Je kunt een API-configuratie selecteren die altijd wordt gebruikt voor het verbeteren van prompts, of gewoon de huidige selectie gebruiken", - "useCurrentConfig": "Huidige API-configuratie gebruiken", - "testPromptPlaceholder": "Voer een prompt in om de verbetering te testen", - "previewButton": "Voorbeeld promptverbetering" - }, - "types": { - "ENHANCE": { - "label": "Prompt verbeteren", - "description": "Gebruik promptverbetering om op maat gemaakte suggesties of verbeteringen voor je invoer te krijgen. Zo begrijpt Roo je intentie en krijg je de best mogelijke antwoorden. Beschikbaar via het ✨-icoon in de chat." - }, - "EXPLAIN": { - "label": "Code uitleggen", - "description": "Krijg gedetailleerde uitleg over codefragmenten, functies of hele bestanden. Handig om complexe code te begrijpen of nieuwe patronen te leren. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." - }, - "FIX": { - "label": "Problemen oplossen", - "description": "Krijg hulp bij het identificeren en oplossen van bugs, fouten of codekwaliteitsproblemen. Biedt stapsgewijze begeleiding bij het oplossen van problemen. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." - }, - "IMPROVE": { - "label": "Code verbeteren", - "description": "Ontvang suggesties voor codeoptimalisatie, betere praktijken en architecturale verbeteringen met behoud van functionaliteit. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." - }, - "ADD_TO_CONTEXT": { - "label": "Aan context toevoegen", - "description": "Voeg context toe aan je huidige taak of gesprek. Handig voor extra informatie of verduidelijkingen. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." - }, - "TERMINAL_ADD_TO_CONTEXT": { - "label": "Terminalinhoud aan context toevoegen", - "description": "Voeg terminaluitvoer toe aan je huidige taak of gesprek. Handig voor commando-uitvoer of logboeken. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." - }, - "TERMINAL_FIX": { - "label": "Terminalcommando repareren", - "description": "Krijg hulp bij het repareren van terminalcommando's die zijn mislukt of verbetering nodig hebben. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." - }, - "TERMINAL_EXPLAIN": { - "label": "Terminalcommando uitleggen", - "description": "Krijg gedetailleerde uitleg over terminalcommando's en hun uitvoer. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." - }, - "NEW_TASK": { - "label": "Nieuwe taak starten", - "description": "Start een nieuwe taak met gebruikersinvoer. Beschikbaar via de Command Palette." - } - } - }, - "advancedSystemPrompt": { - "title": "Geavanceerd: Systeemprompt overschrijven", - "description": "Je kunt de systeemprompt voor deze modus volledig vervangen (behalve de roldefinitie en aangepaste instructies) door een bestand aan te maken op .roo/system-prompt-{{slug}} in je werkruimte. Dit is een zeer geavanceerde functie die ingebouwde beveiligingen en consistentiecontroles omzeilt (vooral rond toolgebruik), dus wees voorzichtig!" - }, - "createModeDialog": { - "title": "Nieuwe modus aanmaken", - "close": "Sluiten", - "name": { - "label": "Naam", - "placeholder": "Voer de naam van de modus in" - }, - "slug": { - "label": "Slug", - "description": "De slug wordt gebruikt in URL's en bestandsnamen. Moet kleine letters, cijfers en koppeltekens bevatten." - }, - "saveLocation": { - "label": "Opslaglocatie", - "description": "Kies waar je deze modus wilt opslaan. Projectspecifieke modi hebben voorrang op globale modi.", - "global": { - "label": "Globaal", - "description": "Beschikbaar in alle werkruimtes" - }, - "project": { - "label": "Projectspecifiek (.roomodes)", - "description": "Alleen beschikbaar in deze werkruimte, heeft voorrang op globaal" - } - }, - "roleDefinition": { - "label": "Roldefinitie", - "description": "Definieer Roo's expertise en persoonlijkheid voor deze modus." - }, - "tools": { - "label": "Beschikbare tools", - "description": "Selecteer welke tools deze modus kan gebruiken." - }, - "customInstructions": { - "label": "Aangepaste instructies (optioneel)", - "description": "Voeg gedragsrichtlijnen toe die specifiek zijn voor deze modus." - }, - "buttons": { - "cancel": "Annuleren", - "create": "Modus aanmaken" - }, - "deleteMode": "Modus verwijderen" - }, - "allFiles": "alle bestanden" + "title": "Prompts", + "done": "Gereed", + "modes": { + "title": "Modi", + "createNewMode": "Nieuwe modus aanmaken", + "editModesConfig": "Modusconfiguratie bewerken", + "editGlobalModes": "Globale modi bewerken", + "editProjectModes": "Projectmodi bewerken (.roomodes)", + "createModeHelpText": "Klik op + om een nieuwe aangepaste modus te maken, of vraag Roo in de chat om er een voor je te maken!", + "selectMode": "Modus zoeken", + "noMatchFound": "Geen overeenkomende modi gevonden" + }, + "apiConfiguration": { + "title": "API-configuratie", + "select": "Selecteer welke API-configuratie voor deze modus gebruikt moet worden" + }, + "tools": { + "title": "Beschikbare tools", + "builtInModesText": "Tools voor ingebouwde modi kunnen niet worden aangepast", + "editTools": "Tools bewerken", + "doneEditing": "Bewerken voltooid", + "allowedFiles": "Toegestane bestanden:", + "toolNames": { + "read": "Bestanden lezen", + "edit": "Bestanden bewerken", + "browser": "Browser gebruiken", + "command": "Commando's uitvoeren", + "mcp": "MCP gebruiken" + }, + "noTools": "Geen" + }, + "roleDefinition": { + "title": "Roldefinitie", + "resetToDefault": "Terugzetten naar standaard", + "description": "Definieer Roo's expertise en persoonlijkheid voor deze modus. Deze beschrijving bepaalt hoe Roo zich presenteert en taken benadert." + }, + "customInstructions": { + "title": "Modusspecifieke instructies (optioneel)", + "resetToDefault": "Terugzetten naar standaard", + "description": "Voeg gedragsrichtlijnen toe die specifiek zijn voor de modus {{modeName}}.", + "loadFromFile": "Modusspecifieke instructies voor {{mode}} kunnen ook worden geladen uit de map .roo/rules-{{slug}}/ in je werkruimte (.roorules-{{slug}} en .clinerules-{{slug}} zijn verouderd en werken binnenkort niet meer)." + }, + "globalCustomInstructions": { + "title": "Aangepaste instructies voor alle modi", + "description": "Deze instructies gelden voor alle modi. Ze bieden een basisset aan gedragingen die kunnen worden uitgebreid met modusspecifieke instructies hieronder.\nWil je dat Roo in een andere taal denkt en spreekt dan de weergavetaal van je editor ({{language}}), dan kun je dat hier aangeven.", + "loadFromFile": "Instructies kunnen ook worden geladen uit de map .roo/rules/ in je werkruimte (.roorules en .clinerules zijn verouderd en werken binnenkort niet meer)." + }, + "systemPrompt": { + "preview": "Systeemprompt bekijken", + "copy": "Systeemprompt kopiëren naar klembord", + "title": "Systeemprompt ({{modeName}} modus)" + }, + "supportPrompts": { + "title": "Ondersteuningsprompts", + "resetPrompt": "Reset {{promptType}} prompt naar standaard", + "prompt": "Prompt", + "enhance": { + "apiConfiguration": "API-configuratie", + "apiConfigDescription": "Je kunt een API-configuratie selecteren die altijd wordt gebruikt voor het verbeteren van prompts, of gewoon de huidige selectie gebruiken", + "useCurrentConfig": "Huidige API-configuratie gebruiken", + "testPromptPlaceholder": "Voer een prompt in om de verbetering te testen", + "previewButton": "Voorbeeld promptverbetering" + }, + "types": { + "ENHANCE": { + "label": "Prompt verbeteren", + "description": "Gebruik promptverbetering om op maat gemaakte suggesties of verbeteringen voor je invoer te krijgen. Zo begrijpt Roo je intentie en krijg je de best mogelijke antwoorden. Beschikbaar via het ✨-icoon in de chat." + }, + "EXPLAIN": { + "label": "Code uitleggen", + "description": "Krijg gedetailleerde uitleg over codefragmenten, functies of hele bestanden. Handig om complexe code te begrijpen of nieuwe patronen te leren. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." + }, + "FIX": { + "label": "Problemen oplossen", + "description": "Krijg hulp bij het identificeren en oplossen van bugs, fouten of codekwaliteitsproblemen. Biedt stapsgewijze begeleiding bij het oplossen van problemen. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." + }, + "IMPROVE": { + "label": "Code verbeteren", + "description": "Ontvang suggesties voor codeoptimalisatie, betere praktijken en architecturale verbeteringen met behoud van functionaliteit. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." + }, + "ADD_TO_CONTEXT": { + "label": "Aan context toevoegen", + "description": "Voeg context toe aan je huidige taak of gesprek. Handig voor extra informatie of verduidelijkingen. Beschikbaar via codeacties (lampje in de editor) en het contextmenu (rechtsklik op geselecteerde code)." + }, + "TERMINAL_ADD_TO_CONTEXT": { + "label": "Terminalinhoud aan context toevoegen", + "description": "Voeg terminaluitvoer toe aan je huidige taak of gesprek. Handig voor commando-uitvoer of logboeken. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." + }, + "TERMINAL_FIX": { + "label": "Terminalcommando repareren", + "description": "Krijg hulp bij het repareren van terminalcommando's die zijn mislukt of verbetering nodig hebben. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." + }, + "TERMINAL_EXPLAIN": { + "label": "Terminalcommando uitleggen", + "description": "Krijg gedetailleerde uitleg over terminalcommando's en hun uitvoer. Beschikbaar in het terminalcontextmenu (rechtsklik op geselecteerde terminalinhoud)." + }, + "NEW_TASK": { + "label": "Nieuwe taak starten", + "description": "Start een nieuwe taak met gebruikersinvoer. Beschikbaar via de Command Palette." + } + } + }, + "advancedSystemPrompt": { + "title": "Geavanceerd: Systeemprompt overschrijven", + "description": "Je kunt de systeemprompt voor deze modus volledig vervangen (behalve de roldefinitie en aangepaste instructies) door een bestand aan te maken op .roo/system-prompt-{{slug}} in je werkruimte. Dit is een zeer geavanceerde functie die ingebouwde beveiligingen en consistentiecontroles omzeilt (vooral rond toolgebruik), dus wees voorzichtig!" + }, + "createModeDialog": { + "title": "Nieuwe modus aanmaken", + "close": "Sluiten", + "name": { + "label": "Naam", + "placeholder": "Voer de naam van de modus in" + }, + "slug": { + "label": "Slug", + "description": "De slug wordt gebruikt in URL's en bestandsnamen. Moet kleine letters, cijfers en koppeltekens bevatten." + }, + "saveLocation": { + "label": "Opslaglocatie", + "description": "Kies waar je deze modus wilt opslaan. Projectspecifieke modi hebben voorrang op globale modi.", + "global": { + "label": "Globaal", + "description": "Beschikbaar in alle werkruimtes" + }, + "project": { + "label": "Projectspecifiek (.roomodes)", + "description": "Alleen beschikbaar in deze werkruimte, heeft voorrang op globaal" + } + }, + "roleDefinition": { + "label": "Roldefinitie", + "description": "Definieer Roo's expertise en persoonlijkheid voor deze modus." + }, + "tools": { + "label": "Beschikbare tools", + "description": "Selecteer welke tools deze modus kan gebruiken." + }, + "customInstructions": { + "label": "Aangepaste instructies (optioneel)", + "description": "Voeg gedragsrichtlijnen toe die specifiek zijn voor deze modus." + }, + "buttons": { + "cancel": "Annuleren", + "create": "Modus aanmaken" + }, + "deleteMode": "Modus verwijderen" + }, + "allFiles": "alle bestanden" } diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json index 13bd766d31..8ce1edba35 100644 --- a/webview-ui/src/i18n/locales/pl/prompts.json +++ b/webview-ui/src/i18n/locales/pl/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Edytuj tryby globalne", "editProjectModes": "Edytuj tryby projektu (.roomodes)", "createModeHelpText": "Kliknij +, aby utworzyć nowy niestandardowy tryb, lub po prostu poproś Roo w czacie, aby utworzył go dla Ciebie!", - "selectMode": "Szukaj trybów" + "selectMode": "Szukaj trybów", + "noMatchFound": "Nie znaleziono pasujących trybów" }, "apiConfiguration": { "title": "Konfiguracja API", diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json index 38abe83e99..c38ad22241 100644 --- a/webview-ui/src/i18n/locales/pt-BR/prompts.json +++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Editar modos globais", "editProjectModes": "Editar modos do projeto (.roomodes)", "createModeHelpText": "Clique em + para criar um novo modo personalizado, ou simplesmente peça ao Roo no chat para criar um para você!", - "selectMode": "Buscar modos" + "selectMode": "Buscar modos", + "noMatchFound": "Nenhum modo correspondente encontrado" }, "apiConfiguration": { "title": "Configuração de API", diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json index 6e1c10adb8..f50e70c8d1 100644 --- a/webview-ui/src/i18n/locales/ru/prompts.json +++ b/webview-ui/src/i18n/locales/ru/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Редактировать глобальные режимы", "editProjectModes": "Редактировать режимы проекта (.roomodes)", "createModeHelpText": "Нажмите +, чтобы создать новый пользовательский режим, или просто попросите Roo в чате создать его для вас!", - "selectMode": "Поиск режимов" + "selectMode": "Поиск режимов", + "noMatchFound": "Соответствующие режимы не найдены" }, "apiConfiguration": { "title": "Конфигурация API", diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json index 27fb89c091..45df205997 100644 --- a/webview-ui/src/i18n/locales/tr/prompts.json +++ b/webview-ui/src/i18n/locales/tr/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Global modları düzenle", "editProjectModes": "Proje modlarını düzenle (.roomodes)", "createModeHelpText": "Yeni bir özel mod oluşturmak için + düğmesine tıklayın veya sohbette Roo'dan sizin için bir tane oluşturmasını isteyin!", - "selectMode": "Modları Ara" + "selectMode": "Modları Ara", + "noMatchFound": "Eşleşen mod bulunamadı" }, "apiConfiguration": { "title": "API Yapılandırması", diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json index c0d88f9007..2c9eccb9d5 100644 --- a/webview-ui/src/i18n/locales/vi/prompts.json +++ b/webview-ui/src/i18n/locales/vi/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "Chỉnh sửa chế độ toàn cục", "editProjectModes": "Chỉnh sửa chế độ dự án (.roomodes)", "createModeHelpText": "Nhấn + để tạo chế độ tùy chỉnh mới, hoặc chỉ cần yêu cầu Roo trong chat tạo một chế độ cho bạn!", - "selectMode": "Tìm kiếm chế độ" + "selectMode": "Tìm kiếm chế độ", + "noMatchFound": "Không tìm thấy chế độ phù hợp" }, "apiConfiguration": { "title": "Cấu hình API", diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index b2afe66f40..b3d0af4f8d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "修改全局模式", "editProjectModes": "编辑项目模式 (.roomodes)", "createModeHelpText": "点击 + 创建模式,或在对话时让Roo创建一个新模式。", - "selectMode": "搜索模式" + "selectMode": "搜索模式", + "noMatchFound": "未找到匹配的模式" }, "apiConfiguration": { "title": "API配置", diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json index c0365bf278..c5106ef621 100644 --- a/webview-ui/src/i18n/locales/zh-TW/prompts.json +++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json @@ -8,7 +8,8 @@ "editGlobalModes": "編輯全域模式", "editProjectModes": "編輯專案模式 (.roomodes)", "createModeHelpText": "點選 + 建立新的自訂模式,或者在聊天中直接請 Roo 為您建立!", - "selectMode": "搜尋模式" + "selectMode": "搜尋模式", + "noMatchFound": "未找到符合的模式" }, "apiConfiguration": { "title": "API 設定", From a117bcd923aaf26a87616a9ae7a9c9438757edee Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 9 May 2025 23:48:10 -0700 Subject: [PATCH 22/37] lang: improve Hindi grammar in privacy policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve readability by changing 'हमारी' (our) to 'हमें' (we/us) to express 'we don't have access' more naturally in Hindi while maintaining the same meaning Signed-off-by: Eric Wheeler --- locales/hi/PRIVACY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/hi/PRIVACY.md b/locales/hi/PRIVACY.md index a55cf6d662..2d1809eb9e 100644 --- a/locales/hi/PRIVACY.md +++ b/locales/hi/PRIVACY.md @@ -6,8 +6,8 @@ Roo Code आपकी गोपनीयता का सम्मान कर ### **आपका डेटा कहाँ जाता है (और कहाँ नहीं)** -- **कोड और फ़ाइलें**: Roo Code AI-सहायक सुविधाओं के लिए आवश्यक होने पर आपकी लोकल मशीन पर फ़ाइलों तक पहुंचता है। जब आप Roo Code को कमांड भेजते हैं, तो प्रासंगिक फ़ाइलें आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को प्रतिक्रियाएं उत्पन्न करने के लिए भेजी जा सकती हैं। हमारी इस डेटा तक पहुंच नहीं है, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे स्टोर कर सकते हैं। -- **कमांड**: Roo Code के माध्यम से निष्पादित की गई कोई भी कमांड आपके स्थानीय वातावरण में होती है। हालांकि, जब आप AI-आधारित सुविधाओं का उपयोग करते हैं, तो प्रासंगिक कोड और आपकी कमांड का संदर्भ प्रतिक्रियाएं उत्पन्न करने के लिए आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को भेजा जा सकता है। हमारी इस डेटा तक पहुंच नहीं है और न ही हम इसे स्टोर करते हैं, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे प्रोसेस कर सकते हैं। +- **कोड और फ़ाइलें**: Roo Code AI-सहायक सुविधाओं के लिए आवश्यक होने पर आपकी लोकल मशीन पर फ़ाइलों तक पहुंचता है। जब आप Roo Code को कमांड भेजते हैं, तो प्रासंगिक फ़ाइलें आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को प्रतिक्रियाएं उत्पन्न करने के लिए भेजी जा सकती हैं। हमें इस डेटा तक पहुंच नहीं है, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे स्टोर कर सकते हैं। +- **कमांड**: Roo Code के माध्यम से निष्पादित की गई कोई भी कमांड आपके स्थानीय वातावरण में होती है। हालांकि, जब आप AI-आधारित सुविधाओं का उपयोग करते हैं, तो प्रासंगिक कोड और आपकी कमांड का संदर्भ प्रतिक्रियाएं उत्पन्न करने के लिए आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को भेजा जा सकता है। हमें इस डेटा तक पहुंच नहीं है और न ही हम इसे स्टोर करते हैं, लेकिन AI प्रदाता अपनी गोपनीयता नीतियों के अनुसार इसे प्रोसेस कर सकते हैं। - **प्रॉम्प्ट्स और AI अनुरोध**: जब आप AI-आधारित सुविधाओं का उपयोग करते हैं, तो आपके प्रॉम्प्ट्स और प्रोजेक्ट का प्रासंगिक संदर्भ प्रतिक्रियाएं उत्पन्न करने के लिए आपके चुने हुए AI मॉडल प्रदाता (जैसे, OpenAI, Anthropic, OpenRouter) को भेजा जाता है। हम इस डेटा को स्टोर या प्रोसेस नहीं करते हैं। इन AI प्रदाताओं की अपनी गोपनीयता नीतियां हैं और वे अपनी सेवा शर्तों के अनुसार डेटा स्टोर कर सकते हैं। - **API कुंजियां और क्रेडेंशियल्स**: यदि आप कोई API कुंजी दर्ज करते हैं (जैसे, AI मॉडल को कनेक्ट करने के लिए), तो यह आपके डिवाइस पर स्थानीय रूप से स्टोर की जाती है और कभी भी हमें या किसी तीसरे पक्ष को नहीं भेजी जाती है, सिवाय आपके द्वारा चुने गए प्रदाता के। - **टेलीमेट्री (उपयोग डेटा)**: हम केवल तभी फीचर उपयोग और त्रुटि डेटा एकत्र करते हैं जब आप स्पष्ट रूप से सहमति देते हैं। यह टेलीमेट्री PostHog द्वारा संचालित है और हमें Roo Code को बेहतर बनाने के लिए फीचर उपयोग को समझने में मदद करती है। इसमें आपकी VS Code मशीन ID और फीचर उपयोग पैटर्न और अपवाद रिपोर्ट शामिल हैं। हम व्यक्तिगत रूप से पहचान योग्य जानकारी, आपका कोड, या AI प्रॉम्प्ट्स **नहीं** एकत्र करते हैं। From 8cc7d4ac686af483754c1c5cad07618d8836e00a Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Sat, 10 May 2025 20:18:29 -0700 Subject: [PATCH 23/37] feat: improve lint-translations with usage function and locale override - Add comprehensive usage function for --help flag - Implement global language list override for --locale flag - Ensure tests fail for incomplete translations - Centralize command-line argument parsing - Fix TypeScript errors with proper type casting Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 102 +++++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 6dab64ebd4..84125ef759 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -1,7 +1,7 @@ const fs = require("fs") const path = require("path") import { - languages, + languages as originalLanguages, type Language, bufferLog, printLogs, @@ -12,6 +12,9 @@ import { getValueAtPath, } from "./utils" +// Create a mutable copy of the languages array that can be overridden +let languages = [...originalLanguages] + // Track unique errors to avoid duplication const seenErrors = new Set() @@ -29,6 +32,7 @@ interface LintOptions { check?: ("missing" | "extra" | "all")[] help?: boolean verbose?: boolean + allowUnknownLocales?: boolean } interface TranslationIssue { @@ -183,12 +187,7 @@ function getFilteredLocales(localeArgs?: string[]): Language[] { return baseLocales } - const invalidLocales = localeArgs.filter((locale) => !languages.includes(locale as Language)) - if (invalidLocales.length > 0) { - throw new Error(`Error: The following locales are not officially supported: ${invalidLocales.join(", ")}`) - } - - return baseLocales.filter((locale) => localeArgs.includes(locale)) + return localeArgs as unknown as Language[] } function filterMappingsByArea(mappings: PathMapping[], areaArgs?: string[]): PathMapping[] { @@ -506,7 +505,37 @@ function formatSummary(results: Results): void { } } -function parseArgs(): LintOptions { +function printUsage(): void { + bufferLog("Usage: node lint-translations.js [options]") + bufferLog("\nDescription:") + bufferLog(" Lint translation files to find missing or extra translations across different locales.") + bufferLog("\nOptions:") + bufferLog(" --help Show this help message") + bufferLog(" --verbose Enable verbose output with detailed information") + bufferLog(" --locale= Filter by specific locales (comma-separated)") + bufferLog(" Example: --locale=fr,de,ja") + bufferLog(" Use 'all' for all supported locales") + bufferLog(" --file= Filter by specific files (comma-separated)") + bufferLog(" Example: --file=settings.json,commands.json") + bufferLog(" Use 'all' for all files") + bufferLog(" --area= Filter by specific areas (comma-separated)") + bufferLog(` Valid areas: ${PATH_MAPPINGS.map(m => m.area).join(", ")}, all`) + bufferLog(" Example: --area=docs,core") + bufferLog(" --check= Specify which checks to run (comma-separated)") + bufferLog(" Valid checks: missing, extra, all") + bufferLog(" Example: --check=missing,extra") + bufferLog("\nExamples:") + bufferLog(" # Check all translations in all areas") + bufferLog(" node lint-translations.js") + bufferLog("\n # Check only missing translations for French locale") + bufferLog(" node lint-translations.js --locale=fr --check=missing") + bufferLog("\n # Check only documentation translations for German and Japanese") + bufferLog(" node lint-translations.js --area=docs --locale=de,ja") + bufferLog("\n # Verbose output for specific files in core area") + bufferLog(" node lint-translations.js --area=core --file=settings.json,commands.json --verbose") +} + +function parseArgs(args: string[] = process.argv.slice(2)): LintOptions { const options: LintOptions = { area: ["all"], check: ["all"] as ("missing" | "extra" | "all")[], @@ -514,7 +543,10 @@ function parseArgs(): LintOptions { help: false, } - for (const arg of process.argv.slice(2)) { + // Reset languages to original value at the start + languages = [...originalLanguages] + + for (const arg of args) { if (arg === "--verbose") { options.verbose = true continue @@ -533,6 +565,9 @@ function parseArgs(): LintOptions { switch (key) { case "locale": options.locale = values + // Override the global languages array with the provided locales + // Add 'en' as it's always needed as the source language + languages = ['en', ...values] as unknown as Language[] break case "file": options.file = values @@ -567,6 +602,13 @@ function parseArgs(): LintOptions { function lintTranslations(args?: LintOptions): { output: string } { clearLogs() // Clear the buffer at the start const options = args || parseArgs() || { area: ["all"], check: ["all"] } + + // If help flag is set, print usage and return + if (options.help) { + printUsage() + return { output: printLogs() } + } + const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] const filteredMappings = filterMappingsByArea(PATH_MAPPINGS, options.area) @@ -636,13 +678,24 @@ export { describe("Translation Linting", () => { test("Run translation linting", () => { - // Run with default options to check all areas and all checks - const result = lintTranslations({ - area: ["all"], - check: ["all"], - verbose: process.argv.includes("--verbose"), - }) - expect(result.output).toContain("All translations are complete") + // Use the centralized parseArgs function to process Jest arguments + // Jest passes arguments after -- to the test + const options = parseArgs(process.argv); + + // If help flag is set, run with help option + if (options.help) { + const result = lintTranslations(options); + console.log(result.output); // Print help directly to console for visibility + expect(result.output).toContain("Usage: node lint-translations.js [options]"); + return; + } + + // Run with processed options + const result = lintTranslations(options); + + // MUST FAIL in ANY event where the output does not contain "All translations are complete" + // This will cause the test to fail for locales with missing or extra translations + expect(result.output).toContain("All translations are complete"); }) test("Filters mappings by area correctly", () => { @@ -651,6 +704,23 @@ describe("Translation Linting", () => { expect(filteredMappings[0].area).toBe("docs") }) + test("Displays help information when help flag is set", () => { + const result = lintTranslations({ + help: true, + area: ["all"], + check: ["all"] + }) + + // Verify help content + expect(result.output).toContain("Usage: node lint-translations.js [options]") + expect(result.output).toContain("Description:") + expect(result.output).toContain("Options:") + expect(result.output).toContain("Examples:") + + // Verify it doesn't run the linting process + expect(result.output).not.toContain("Translation Results") + }) + test("Checks for missing translations", () => { const source = { key1: "value1", key2: "value2" } const target = { key1: "value1" } From 7955ca18290b7e2dadd90f81c5bfcf5139ce3ee8 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Sat, 10 May 2025 20:34:43 -0700 Subject: [PATCH 24/37] fix: prevent false positives in translation linting results Ensures each translation area independently tracks its missing files, preventing files from being incorrectly reported as missing in multiple areas. This resolves an issue where PRIVACY.md was incorrectly shown as missing in the WEBVIEW area when it should only appear in the DOCS area. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 84125ef759..1e71f4115f 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -264,12 +264,12 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti // Group errors by type for summary const errorsByType = new Map() - const missingByFile = new Map>() for (const [area, areaResults] of Object.entries(results)) { let areaHasIssues = false const extraByLocale = new Map>() let missingCount = 0 + const missingByFile = new Map>() for (const [locale, localeResults] of Object.entries(areaResults)) { let localeMissingCount = 0 From 45a08475793886247fb131c91491e79e74d2b247 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Sat, 10 May 2025 22:07:48 -0700 Subject: [PATCH 25/37] fix: improve translation linting output format - Clearly distinguish between missing files and files with missing translations - Show English values for missing keys to help translators - Show all missing keys for missing files as well - Simplify display of complex values Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 127 ++++++++++++++++---- 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 1e71f4115f..0dcbd6839a 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -367,25 +367,102 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti }) // Group by locale first - const byLocale = new Map() + const missingFilesByLocale = new Map() + const missingKeysByLocale = new Map>>() + missingByFile.forEach((keys, fileAndLang) => { const [file, lang] = fileAndLang.split(":") - if (!byLocale.has(lang)) { - byLocale.set(lang, []) - } const mapping = mappings.find((m) => m.area === area) - if (mapping) { - const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) - byLocale.get(lang)?.push(targetPath) + if (!mapping) return + + const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) + + // Check if this is a missing file or missing keys + let isMissingFile = false + + // Check for the special "File missing" case + if (keys.size === 1) { + const key = Array.from(keys)[0] + // Either the key equals the source file or it's a file path + isMissingFile = key === file || key.includes("/") + } + + if (isMissingFile) { + // This is a missing file + if (!missingFilesByLocale.has(lang)) { + missingFilesByLocale.set(lang, []) + } + missingFilesByLocale.get(lang)?.push(targetPath) + } else { + // These are missing keys + if (!missingKeysByLocale.has(lang)) { + missingKeysByLocale.set(lang, new Map()) + } + if (!missingKeysByLocale.get(lang)?.has(targetPath)) { + missingKeysByLocale.get(lang)?.set(targetPath, new Set()) + } + keys.forEach((key) => { + // Skip keys that look like file paths + if (!key.includes("/")) { + missingKeysByLocale.get(lang)?.get(targetPath)?.add(key) + } + }) } }) - byLocale.forEach((files, lang) => { + // Report missing files + missingFilesByLocale.forEach((files, lang) => { bufferLog(` ${lang}: missing ${files.length} files`) files.sort().forEach((file) => { bufferLog(` ${file}`) + + // Show missing keys for missing files too + const sourceFile = file.replace(`/${lang}/`, "/en/") + const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + + if (sourceContent) { + bufferLog(` Missing keys:`) + const sourceKeys = findKeys(sourceContent) + sourceKeys.sort().forEach((key) => { + const englishValue = getValueAtPath(sourceContent, key) + if (typeof englishValue === "string") { + bufferLog(` - ${key} - '${englishValue}' [en]`) + } + }) + } }) }) + + // Report files with missing keys + missingKeysByLocale.forEach((fileMap, lang) => { + const filesWithMissingKeys = Array.from(fileMap.keys()) + if (filesWithMissingKeys.length > 0) { + bufferLog(` ${lang}: ${filesWithMissingKeys.length} files with missing translations`) + filesWithMissingKeys.sort().forEach((file) => { + bufferLog(` ${file}`) + const keys = fileMap.get(file) + if (keys && keys.size > 0) { + bufferLog(` Missing keys:`) + // Get the source file to extract English values + const sourceFile = file.replace(`/${lang}/`, "/en/") + const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + + Array.from(keys) + .sort() + .forEach((key) => { + const englishValue = sourceContent + ? getValueAtPath(sourceContent, key) + : undefined + + // Skip displaying complex objects + if (typeof englishValue === "string") { + bufferLog(` - ${key} - '${englishValue}' [en]`) + } + }) + } + }) + } + }) } // Show extra translations if any @@ -519,7 +596,7 @@ function printUsage(): void { bufferLog(" Example: --file=settings.json,commands.json") bufferLog(" Use 'all' for all files") bufferLog(" --area= Filter by specific areas (comma-separated)") - bufferLog(` Valid areas: ${PATH_MAPPINGS.map(m => m.area).join(", ")}, all`) + bufferLog(` Valid areas: ${PATH_MAPPINGS.map((m) => m.area).join(", ")}, all`) bufferLog(" Example: --area=docs,core") bufferLog(" --check= Specify which checks to run (comma-separated)") bufferLog(" Valid checks: missing, extra, all") @@ -567,7 +644,7 @@ function parseArgs(args: string[] = process.argv.slice(2)): LintOptions { options.locale = values // Override the global languages array with the provided locales // Add 'en' as it's always needed as the source language - languages = ['en', ...values] as unknown as Language[] + languages = ["en", ...values] as unknown as Language[] break case "file": options.file = values @@ -602,13 +679,13 @@ function parseArgs(args: string[] = process.argv.slice(2)): LintOptions { function lintTranslations(args?: LintOptions): { output: string } { clearLogs() // Clear the buffer at the start const options = args || parseArgs() || { area: ["all"], check: ["all"] } - + // If help flag is set, print usage and return if (options.help) { printUsage() return { output: printLogs() } } - + const checksToRun = options.check?.includes("all") ? ["missing", "extra"] : options.check || ["all"] const filteredMappings = filterMappingsByArea(PATH_MAPPINGS, options.area) @@ -680,22 +757,22 @@ describe("Translation Linting", () => { test("Run translation linting", () => { // Use the centralized parseArgs function to process Jest arguments // Jest passes arguments after -- to the test - const options = parseArgs(process.argv); - + const options = parseArgs(process.argv) + // If help flag is set, run with help option if (options.help) { - const result = lintTranslations(options); - console.log(result.output); // Print help directly to console for visibility - expect(result.output).toContain("Usage: node lint-translations.js [options]"); - return; + const result = lintTranslations(options) + console.log(result.output) // Print help directly to console for visibility + expect(result.output).toContain("Usage: node lint-translations.js [options]") + return } - + // Run with processed options - const result = lintTranslations(options); - + const result = lintTranslations(options) + // MUST FAIL in ANY event where the output does not contain "All translations are complete" // This will cause the test to fail for locales with missing or extra translations - expect(result.output).toContain("All translations are complete"); + expect(result.output).toContain("All translations are complete") }) test("Filters mappings by area correctly", () => { @@ -708,15 +785,15 @@ describe("Translation Linting", () => { const result = lintTranslations({ help: true, area: ["all"], - check: ["all"] + check: ["all"], }) - + // Verify help content expect(result.output).toContain("Usage: node lint-translations.js [options]") expect(result.output).toContain("Description:") expect(result.output).toContain("Options:") expect(result.output).toContain("Examples:") - + // Verify it doesn't run the linting process expect(result.output).not.toContain("Translation Results") }) From ce202f46ef3c909963e034b03afdedecdb5cac6a Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Sun, 11 May 2025 02:51:56 -0700 Subject: [PATCH 26/37] fix: show ALL KEYS/ALL CONTENT for missing translation files When a translation file is missing, now properly shows: - For JSON files: 'Missing keys: ALL KEYS (N total)' - For non-JSON files: 'Missing file: ALL CONTENT (entire file)' This makes it clearer that all content is missing when a file doesn't exist. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 120 +++++++++++++++----- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 0dcbd6839a..24232407e3 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -417,18 +417,40 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti bufferLog(` ${file}`) // Show missing keys for missing files too - const sourceFile = file.replace(`/${lang}/`, "/en/") - const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) - - if (sourceContent) { - bufferLog(` Missing keys:`) - const sourceKeys = findKeys(sourceContent) - sourceKeys.sort().forEach((key) => { - const englishValue = getValueAtPath(sourceContent, key) - if (typeof englishValue === "string") { - bufferLog(` - ${key} - '${englishValue}' [en]`) + let sourceFile = file + + // Handle different file patterns + if (file.includes(`/${lang}/`)) { + sourceFile = file.replace(`/${lang}/`, "/en/") + } else if (file.endsWith(`.${lang}.json`)) { + sourceFile = file.replace(`.${lang}.json`, ".json") + } + + // For JSON files, we can show the actual keys + if (sourceFile.endsWith(".json")) { + const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + if (sourceContent) { + // For missing files, show all keys from source as missing + const sourceKeys = findKeys(sourceContent) + if (sourceKeys.length > 0) { + bufferLog(` Missing keys: ALL KEYS (${sourceKeys.length} total)`) + if (options?.verbose) { + sourceKeys.sort().forEach((key) => { + const englishValue = getValueAtPath(sourceContent, key) + if (typeof englishValue === "string") { + bufferLog(` - ${key} - '${englishValue}' [en]`) + } + }) + } + } else { + bufferLog(` Missing keys: No keys found in source file`) } - }) + } else { + bufferLog(` Missing keys: Unable to load corresponding source file`) + } + } else { + // For non-JSON files (like Markdown), just indicate all content is missing + bufferLog(` Missing file: ALL CONTENT (entire file)`) } }) }) @@ -442,23 +464,67 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti bufferLog(` ${file}`) const keys = fileMap.get(file) if (keys && keys.size > 0) { - bufferLog(` Missing keys:`) - // Get the source file to extract English values - const sourceFile = file.replace(`/${lang}/`, "/en/") - const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) - - Array.from(keys) - .sort() - .forEach((key) => { - const englishValue = sourceContent - ? getValueAtPath(sourceContent, key) - : undefined - - // Skip displaying complex objects - if (typeof englishValue === "string") { - bufferLog(` - ${key} - '${englishValue}' [en]`) + // Check if this file is actually missing + const isMissingFile = Array.from(keys).some((key) => { + // Check if any key has englishValue "File missing" + const issue = Array.from(keys).find((k) => k === key) + return issue && keys.has(issue) && Array.from(keys)[0] === key && !fileExists(file) + }) + + if (isMissingFile) { + // This is actually a missing file + // Get the source file based on mapping patterns + let sourceFile = file + + // Handle different file patterns + if (file.includes(`/${lang}/`)) { + sourceFile = file.replace(`/${lang}/`, "/en/") + } else if (file.endsWith(`.${lang}.json`)) { + sourceFile = file.replace(`.${lang}.json`, ".json") + } + + // For JSON files, show all keys + if (file.endsWith(".json")) { + // For package.nls files, use the source from PATH_MAPPINGS + if (file.includes("package.nls")) { + sourceFile = "package.nls.json" } - }) + + const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + if (sourceContent) { + const sourceKeys = findKeys(sourceContent) + if (sourceKeys.length > 0) { + bufferLog(` Missing keys: ALL KEYS (${sourceKeys.length} total)`) + } else { + bufferLog(` Missing keys: No keys found in source file`) + } + } else { + bufferLog(` Missing keys: Unable to load source file`) + } + } else { + // For non-JSON files (like Markdown), just indicate all content is missing + bufferLog(` Missing file: ALL CONTENT (entire file)`) + } + } else { + // Normal case - file exists but has missing keys + bufferLog(` Missing keys:`) + // Get the source file to extract English values + const sourceFile = file.replace(`/${lang}/`, "/en/") + const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + + Array.from(keys) + .sort() + .forEach((key) => { + const englishValue = sourceContent + ? getValueAtPath(sourceContent, key) + : undefined + + // Skip displaying complex objects + if (typeof englishValue === "string") { + bufferLog(` - ${key} - '${englishValue}' [en]`) + } + }) + } } }) } From eb20b5ada284ae9e230aacdbb93151c4499a77e3 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Sun, 11 May 2025 02:44:57 -0700 Subject: [PATCH 27/37] fix: improve i18n key detection in source code Added a new pattern to match i18n keys used with parameters, which fixes detection of keys like common:errors.invalid_mcp_settings_validation. Refactored the code to split it into 4 functions: - accumulateSourceKeys(): Collects all keys used in source code - accumulateTranslationKeys(): Collects all keys found in translation files - getKeysInSourceNotInTranslation(): Returns keys in source not in translations - getKeysInTranslationNotInSource(): Returns keys in translations not in source Added tests to validate that there are no keys in source not in translations and no keys in translations not in source. Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 445 ++++++++++++------ 1 file changed, 307 insertions(+), 138 deletions(-) diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts index 2f08b4b779..0d1fb42519 100644 --- a/locales/__tests__/find-missing-i18n-keys.test.ts +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -1,20 +1,11 @@ const fs = require("fs") const path = require("path") -import { - languages, - bufferLog, - printLogs, - clearLogs, - fileExists, - loadFileContent, - parseJsonContent, - getValueAtPath, -} from "./utils" +import { bufferLog, printLogs, clearLogs, loadFileContent, parseJsonContent } from "./utils" // findMissingI18nKeys: Directories to traverse and their corresponding locales const SCAN_SOURCE_DIRS = { components: { - path: "webview-ui/src/components", + path: "webview-ui/src", localesDir: "webview-ui/src/i18n/locales", }, src: { @@ -28,51 +19,15 @@ const i18nScanPatterns = [ /{t\("([^"]+)"\)}/g, /i18nKey="([^"]+)"/g, /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)"\)/g, + // Add pattern to match t() calls with parameters + /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)",/g, ] // Check if the key exists in all official language files, return a list of missing language files -function checkKeyInLocales(key: string, localesDir: string): Array<[string, boolean]> { - const [file, ...pathParts] = key.split(":") - const jsonPath = pathParts.join(".") - const missingLocales = new Map() // true = file missing, false = key missing - - // Check all official languages including English - languages.forEach((locale) => { - const filePath = path.join(localesDir, locale, `${file}.json`) - const localePath = `${locale}/${file}.json` - - // If file doesn't exist or can't be loaded, mark entire file as missing - if (!fileExists(filePath)) { - missingLocales.set(localePath, true) - return - } - - const content = loadFileContent(filePath) - if (!content) { - missingLocales.set(localePath, true) - return - } - - const json = parseJsonContent(content, filePath) - if (!json) { - missingLocales.set(localePath, true) - return - } - - // Only check for missing key if file exists and is valid - if (getValueAtPath(json, jsonPath) === undefined) { - missingLocales.set(localePath, false) - } - }) - return Array.from(missingLocales.entries()) -} - -// Recursively traverse the directory -export function findMissingI18nKeys(): { output: string } { - clearLogs() // Clear buffer at start - let results: Array<{ key: string; file: string; missingLocales: Array<{ path: string; isFileMissing: boolean }> }> = - [] +// Function 1: Accumulate source keys +function accumulateSourceKeys(): Set { + const sourceCodeKeys = new Set() function walk(dir: string, baseDir: string, localesDir: string) { const files = fs.readdirSync(dir) @@ -87,7 +42,7 @@ export function findMissingI18nKeys(): { output: string } { if (stat.isDirectory()) { walk(filePath, baseDir, localesDir) // Recursively traverse subdirectories } else if (stat.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(path.extname(filePath))) { - const relPath = path.relative(process.cwd(), filePath) + // Read file content const content = fs.readFileSync(filePath, "utf8") // Match all i18n keys @@ -96,126 +51,340 @@ export function findMissingI18nKeys(): { output: string } { let match while ((match = pattern.exec(content)) !== null) { matches.add(match[1]) + sourceCodeKeys.add(match[1]) // Add to global set for unused key detection } } - - // Check each unique key against all official languages - matches.forEach((key) => { - const missingLocales = checkKeyInLocales(key, localesDir) - if (missingLocales.length > 0) { - results.push({ - key, - missingLocales: missingLocales.map(([locale, isFileMissing]) => ({ - path: path.join(path.relative(process.cwd(), localesDir), locale), - isFileMissing, - })), - file: relPath, - }) - } - }) } } } - // Walk through all directories and check against official languages + // Walk through all directories to collect source code keys Object.entries(SCAN_SOURCE_DIRS).forEach(([_name, config]) => { // Create locales directory if it doesn't exist if (!fs.existsSync(config.localesDir)) { - bufferLog(`Warning: Creating missing locales directory: ${config.localesDir}`) fs.mkdirSync(config.localesDir, { recursive: true }) } walk(config.path, config.path, config.localesDir) }) - // Process results - bufferLog("=== i18n Key Check ===") + return sourceCodeKeys +} - if (!results || results.length === 0) { - bufferLog("\n✅ All i18n keys are present!") - } else { - bufferLog("\n❌ Missing i18n keys:") +// Function 2: Accumulate translation keys +function accumulateTranslationKeys(): Set { + const translationFileKeys = new Set() - // Group by file status - const missingFiles = new Set() - const missingKeys = new Map>() + // Helper function to extract all keys from a JSON object with their full paths + function extractKeysFromJson(obj: any, prefix: string): string[] { + const keys: string[] = [] - results.forEach(({ key, missingLocales }) => { - missingLocales.forEach(({ path: locale, isFileMissing }) => { - if (isFileMissing) { - missingFiles.add(locale) - } else { - if (!missingKeys.has(locale)) { - missingKeys.set(locale, new Set()) + function traverse(o: any, p: string) { + if (o && typeof o === "object") { + Object.keys(o).forEach((key) => { + const newPath = p ? `${p}.${key}` : key + if (o[key] && typeof o[key] === "object") { + traverse(o[key], newPath) + } else { + keys.push(`${prefix}:${newPath}`) + } + }) + } + } + + traverse(obj, "") + return keys + } + + // Check all locale directories for translation keys + Object.entries(SCAN_SOURCE_DIRS).forEach(([_name, config]) => { + const enLocalesDir = path.join(config.localesDir, "en") + if (fs.existsSync(enLocalesDir)) { + const enFiles = fs.readdirSync(enLocalesDir) + + for (const file of enFiles) { + if (path.extname(file) === ".json") { + const filePath = path.join(enLocalesDir, file) + const content = loadFileContent(filePath) + const json = parseJsonContent(content, filePath) + + if (json) { + // Extract all keys from the JSON file + const fileKeys = extractKeysFromJson(json, file.replace(".json", "")) + + // Add all keys to the translation file keys set + fileKeys.forEach((key) => { + translationFileKeys.add(key) + }) } - missingKeys.get(locale)?.add(key) } - }) - }) + } + } + }) - // Show missing files first - if (missingFiles.size > 0) { - bufferLog("\nMissing translation files:") - Array.from(missingFiles) - .sort() - .forEach((file) => { - bufferLog(` - ${file}`) - }) + return translationFileKeys +} + +// Function 3: Return all keys in source that are not in translations +function getKeysInSourceNotInTranslation(sourceKeys: Set, translationKeys: Set): string[] { + return Array.from(sourceKeys) + .filter((key) => !translationKeys.has(key)) + .sort() +} + +// Function 4: Return all keys in translations that are not in source +function getKeysInTranslationNotInSource(sourceKeys: Set, translationKeys: Set): string[] { + return Array.from(translationKeys) + .filter((key) => !sourceKeys.has(key)) + .sort() +} + +// Recursively traverse the directory +export function findMissingI18nKeys(): { output: string } { + clearLogs() // Clear buffer at start + + // Get source code keys and translation keys + const sourceCodeKeys = accumulateSourceKeys() + const translationFileKeys = accumulateTranslationKeys() + + // Find keys in source not in translations and vice versa + const missingTranslationKeys = getKeysInSourceNotInTranslation(sourceCodeKeys, translationFileKeys) + const unusedTranslationKeys = getKeysInTranslationNotInSource(sourceCodeKeys, translationFileKeys) + + // Track unused keys in English locale files + const unusedKeys: Array<{ key: string; file: string }> = [] + + // Populate unusedKeys for the original output format + Object.entries(SCAN_SOURCE_DIRS).forEach(([_name, config]) => { + const enLocalesDir = path.join(config.localesDir, "en") + if (fs.existsSync(enLocalesDir)) { + const enFiles = fs.readdirSync(enLocalesDir) + + for (const file of enFiles) { + if (path.extname(file) === ".json") { + const filePath = path.join(enLocalesDir, file) + const content = loadFileContent(filePath) + const json = parseJsonContent(content, filePath) + + if (json) { + // Extract all keys from the JSON file + const fileKeys = extractKeysFromJson(json, file.replace(".json", "")) + + // Check if each key is used in source code + fileKeys.forEach((key) => { + if (!sourceCodeKeys.has(key)) { + unusedKeys.push({ + key, + file: path.relative(process.cwd(), filePath), + }) + } + }) + } + } + } } + }) - // Then show files with missing keys - if (missingKeys.size > 0) { - bufferLog("\nFiles with missing keys:") - - // Group by file path to collect all keys per file - const fileKeys = new Map>>() - results.forEach(({ key, file, missingLocales }) => { - missingLocales.forEach(({ path: locale, isFileMissing }) => { - if (!isFileMissing) { - const [_localeDir, _localeFile] = locale.split("/") - const filePath = locale - if (!fileKeys.has(filePath)) { - fileKeys.set(filePath, new Map()) - } - if (!fileKeys.get(filePath)?.has(file)) { - fileKeys.get(filePath)?.set(file, new Set()) - } - fileKeys.get(filePath)?.get(file)?.add(key) + // Accumulate all debug information into a single string + let summaryOutput = "\n=== i18n Keys Summary ===\n" + + // Add summary counts + summaryOutput += `\nTotal source code keys: ${sourceCodeKeys.size}\n` + summaryOutput += `Total translation file keys: ${translationFileKeys.size}\n` + + // Keys in source code but not in translation files + summaryOutput += `\n1. Keys in source code but not in translation files (${missingTranslationKeys.length}):\n` + if (missingTranslationKeys.length === 0) { + summaryOutput += " None - all source code keys have translations\n" + } else { + missingTranslationKeys.forEach((key) => { + summaryOutput += ` - ${key}\n` + }) + } + + // Keys in translation files but not in source code + summaryOutput += `\n2. Keys in translation files but not in source code (${unusedTranslationKeys.length}):\n` + if (unusedTranslationKeys.length === 0) { + summaryOutput += " None - all translation keys are used in source code\n" + } else { + // Group keys by locale directory and file + const localeFileMap = new Map>() + + // Process each key to extract file path and actual key + unusedTranslationKeys.forEach((fullKey) => { + // Extract file name and key path + const parts = fullKey.split(":") + if (parts.length >= 2) { + const filePrefix = parts[0] + const keyPath = parts.slice(1).join(".") + + // Find the locale directory for this file prefix + let foundLocaleDir = "" + Object.entries(SCAN_SOURCE_DIRS).forEach(([_name, config]) => { + const enLocalesDir = path.join(config.localesDir, "en") + const jsonFilePath = path.join(enLocalesDir, `${filePrefix}.json`) + if (fs.existsSync(jsonFilePath)) { + foundLocaleDir = path.relative(process.cwd(), jsonFilePath) } }) - }) - // Show missing keys grouped by file - Array.from(fileKeys.entries()) - .sort() - .forEach(([file, sourceFiles]) => { - bufferLog(` - ${file}:`) - Array.from(sourceFiles.entries()) - .sort() - .forEach(([_sourceFile, keys]) => { - Array.from(keys) - .sort() - .forEach((key) => { - bufferLog(` ${key}`) - }) + // If we found the locale directory, add the key to the map + if (foundLocaleDir) { + if (!localeFileMap.has(foundLocaleDir)) { + localeFileMap.set(foundLocaleDir, new Map()) + } + + if (!localeFileMap.get(foundLocaleDir)?.has(filePrefix)) { + localeFileMap.get(foundLocaleDir)?.set(filePrefix, []) + } + + localeFileMap.get(foundLocaleDir)?.get(filePrefix)?.push(keyPath) + } else { + // Fallback to the old behavior if we can't find the locale directory + if (!localeFileMap.has(filePrefix)) { + localeFileMap.set(filePrefix, new Map()) + } + + if (!localeFileMap.get(filePrefix)?.has("unknown")) { + localeFileMap.get(filePrefix)?.set("unknown", []) + } + + localeFileMap.get(filePrefix)?.get("unknown")?.push(keyPath) + } + } + }) + + // Display keys grouped by file + Array.from(localeFileMap.entries()) + .sort() + .forEach(([filePath, prefixMap]) => { + summaryOutput += ` - ${filePath}:\n` + + Array.from(prefixMap.entries()) + .sort() + .forEach(([_prefix, keys]) => { + keys.sort().forEach((key) => { + summaryOutput += ` ${key}\n` }) + }) + }) + } + + // Add to buffer as a single log entry + bufferLog(summaryOutput) + + // Helper function to extract all keys from a JSON object with their full paths + function extractKeysFromJson(obj: any, prefix: string): string[] { + const keys: string[] = [] + + function traverse(o: any, p: string) { + if (o && typeof o === "object") { + Object.keys(o).forEach((key) => { + const newPath = p ? `${p}.${key}` : key + if (o[key] && typeof o[key] === "object") { + traverse(o[key], newPath) + } else { + keys.push(`${prefix}:${newPath}`) + } }) + } } - // Add simple command line example - if (missingKeys.size > 0) { - bufferLog("\nTo add missing translations:") - bufferLog( - " node scripts/manage-translations.js 'key' 'translation' [ 'key2' 'translation2' ... ]", - ) - } + traverse(obj, "") + return keys } return { output: printLogs() } } describe("Find Missing i18n Keys", () => { - test("findMissingI18nKeys scans for missing translations", () => { + // Cache the source and translation keys + let sourceKeys: Set + let translationKeys: Set + let keysInSourceNotInTranslation: string[] + let keysInTranslationNotInSource: string[] + + beforeAll(() => { + // Accumulate keys once for all tests + sourceKeys = accumulateSourceKeys() + translationKeys = accumulateTranslationKeys() + + // Find differences + keysInSourceNotInTranslation = getKeysInSourceNotInTranslation(sourceKeys, translationKeys) + keysInTranslationNotInSource = getKeysInTranslationNotInSource(sourceKeys, translationKeys) + + // Clear logs at start + clearLogs() + + // Accumulate debug information into a buffer + bufferLog("\n=== DEBUG: i18n Keys Summary ===") + bufferLog(`\nTotal source code keys: ${sourceKeys.size}`) + bufferLog(`Total translation file keys: ${translationKeys.size}`) + + bufferLog(`\n1. Keys in source code but not in translation files (${keysInSourceNotInTranslation.length}):`) + if (keysInSourceNotInTranslation.length === 0) { + bufferLog(" None - all source code keys have translations") + } else { + keysInSourceNotInTranslation.forEach((key) => { + bufferLog(` - ${key}`) + }) + } + + bufferLog(`\n2. Keys in translation files but not in source code (${keysInTranslationNotInSource.length}):`) + if (keysInTranslationNotInSource.length === 0) { + bufferLog(" None - all translation keys are used in source code") + } else { + // Group keys by file prefix for better readability + const filePathMap = new Map() + + keysInTranslationNotInSource.forEach((fullKey) => { + const parts = fullKey.split(":") + if (parts.length >= 2) { + const filePrefix = parts[0] + + if (!filePathMap.has(filePrefix)) { + filePathMap.set(filePrefix, []) + } + + filePathMap.get(filePrefix)?.push(parts.slice(1).join(".")) + } + }) + + Array.from(filePathMap.entries()) + .sort() + .forEach(([filePrefix, keys]) => { + bufferLog(` - ${filePrefix}:`) + keys.sort().forEach((key) => { + bufferLog(` ${key}`) + }) + }) + } + + // Store the buffer output for tests + printLogs() + }) + + // Test 1: Fail if there are keys in source not in translations + test("Test 1: Fail if there are keys in source not in translations", () => { + // Run the original function to get the output const result = findMissingI18nKeys() - expect(result.output).toContain("✅ All i18n keys are present!") + + // Check for the expected message in the output + expect(result.output).toContain("None - all source code keys have translations") + + // This test should fail if there are any keys in source not in translations + expect(keysInSourceNotInTranslation.length).toBe(0) + }) + + // Test 2: Fail if there are keys in translations not in source + test("Test 2: Fail if there are keys in translations not in source", () => { + // Run the original function to get the output + const result = findMissingI18nKeys() + + // Check for the expected message in the output + expect(result.output).toContain("None - all translation keys are used in source code") + + // This test should fail if there are any keys in translations not in source + // We expect this test to fail in the current codebase + expect(keysInTranslationNotInSource.length).toBe(0) }) }) From 6ab5faad1e67d715b4f26ab6ceac1d22a9839a91 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 12 May 2025 21:23:43 -0700 Subject: [PATCH 28/37] test: add file:line to i18n key error output Enhance i18n key test error reporting with file:line information to help locate problematic keys in the codebase. - Track line numbers for i18n keys - Handle multi-line t() calls - Show file:line in test output Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 136 +++++++++++++----- 1 file changed, 101 insertions(+), 35 deletions(-) diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts index 0d1fb42519..f57c5235bf 100644 --- a/locales/__tests__/find-missing-i18n-keys.test.ts +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -16,13 +16,15 @@ const SCAN_SOURCE_DIRS = { // i18n key patterns for findMissingI18nKeys const i18nScanPatterns = [ - /{t\("([^"]+)"\)}/g, + /\bt\([\s\n]*"([^"]+)"/g, + /\bt\([\s\n]*'([^']+)'/g, + /\bt\([\s\n]*`([^`]+)`/g, /i18nKey="([^"]+)"/g, - /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)"\)/g, - // Add pattern to match t() calls with parameters - /t\("([a-zA-Z][a-zA-Z0-9_]*[:.][a-zA-Z0-9_.]+)",/g, ] +// Track line numbers for source code keys +const lineMap = new Map() + // Check if the key exists in all official language files, return a list of missing language files // Function 1: Accumulate source keys @@ -50,8 +52,14 @@ function accumulateSourceKeys(): Set { for (const pattern of i18nScanPatterns) { let match while ((match = pattern.exec(content)) !== null) { - matches.add(match[1]) - sourceCodeKeys.add(match[1]) // Add to global set for unused key detection + const key = match[1] + const lineNumber = content.slice(0, match.index).split('\n').length + matches.add(key) + sourceCodeKeys.add(key) + lineMap.set(key, { + file: path.relative(process.cwd(), filePath), + line: lineNumber + }) } } } @@ -138,17 +146,61 @@ function getKeysInTranslationNotInSource(sourceKeys: Set, translationKey .sort() } -// Recursively traverse the directory -export function findMissingI18nKeys(): { output: string } { +// Function to find dynamic i18n keys (containing ${...}) +function findDynamicKeys(sourceKeys: Set): string[] { + return Array.from(sourceKeys) + .filter(key => key.includes("${")) + .sort() +} + +// Function to find non-namespaced t() calls +export function findNonNamespacedI18nKeys(sourceKeys: Set): string[] { + return Array.from(sourceKeys) + .filter(key => !key.includes(":")) + .sort() +} + +export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: string[] } { clearLogs() // Clear buffer at start + // Helper function to extract all keys from a JSON object with their full paths + function extractKeysFromJson(obj: any, prefix: string): string[] { + const keys: string[] = [] + + function traverse(o: any, p: string) { + if (o && typeof o === "object") { + Object.keys(o).forEach((key) => { + const newPath = p ? `${p}.${key}` : key + if (o[key] && typeof o[key] === "object") { + traverse(o[key], newPath) + } else { + keys.push(`${prefix}:${newPath}`) + } + }) + } + } + + traverse(obj, "") + return keys + } + // Get source code keys and translation keys const sourceCodeKeys = accumulateSourceKeys() const translationFileKeys = accumulateTranslationKeys() + + // Find special keys + const dynamicKeys = findDynamicKeys(sourceCodeKeys) + const nonNamespacedKeys = findNonNamespacedI18nKeys(sourceCodeKeys) + + // Create sets for set operations + const dynamicSet = new Set(dynamicKeys) + const nonNamespacedSet = new Set(nonNamespacedKeys) + const remainingSourceKeys = new Set(Array.from(sourceCodeKeys) + .filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key))) // Find keys in source not in translations and vice versa - const missingTranslationKeys = getKeysInSourceNotInTranslation(sourceCodeKeys, translationFileKeys) - const unusedTranslationKeys = getKeysInTranslationNotInSource(sourceCodeKeys, translationFileKeys) + const missingTranslationKeys = getKeysInSourceNotInTranslation(remainingSourceKeys, translationFileKeys) + const unusedTranslationKeys = getKeysInTranslationNotInSource(remainingSourceKeys, translationFileKeys) // Track unused keys in English locale files const unusedKeys: Array<{ key: string; file: string }> = [] @@ -190,9 +242,31 @@ export function findMissingI18nKeys(): { output: string } { // Add summary counts summaryOutput += `\nTotal source code keys: ${sourceCodeKeys.size}\n` summaryOutput += `Total translation file keys: ${translationFileKeys.size}\n` + + // Dynamic keys + summaryOutput += `\n1. Dynamic i18n keys (${dynamicKeys.length}):\n` + if (dynamicKeys.length === 0) { + summaryOutput += " None - all i18n keys are static\n" + } else { + dynamicKeys.forEach(key => { + const loc = lineMap.get(key) + summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n` + }) + } + + // Non-namespaced keys + summaryOutput += `\n2. Non-namespaced t() calls (${nonNamespacedKeys.length}):\n` + if (nonNamespacedKeys.length === 0) { + summaryOutput += " None - all t() calls use namespaces\n" + } else { + nonNamespacedKeys.forEach(key => { + const loc = lineMap.get(key) + summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n` + }) + } // Keys in source code but not in translation files - summaryOutput += `\n1. Keys in source code but not in translation files (${missingTranslationKeys.length}):\n` + summaryOutput += `\n3. Keys in source code but not in translation files (${missingTranslationKeys.length}):\n` if (missingTranslationKeys.length === 0) { summaryOutput += " None - all source code keys have translations\n" } else { @@ -202,7 +276,7 @@ export function findMissingI18nKeys(): { output: string } { } // Keys in translation files but not in source code - summaryOutput += `\n2. Keys in translation files but not in source code (${unusedTranslationKeys.length}):\n` + summaryOutput += `\n4. Keys in translation files but not in source code (${unusedTranslationKeys.length}):\n` if (unusedTranslationKeys.length === 0) { summaryOutput += " None - all translation keys are used in source code\n" } else { @@ -272,28 +346,10 @@ export function findMissingI18nKeys(): { output: string } { // Add to buffer as a single log entry bufferLog(summaryOutput) - // Helper function to extract all keys from a JSON object with their full paths - function extractKeysFromJson(obj: any, prefix: string): string[] { - const keys: string[] = [] - - function traverse(o: any, p: string) { - if (o && typeof o === "object") { - Object.keys(o).forEach((key) => { - const newPath = p ? `${p}.${key}` : key - if (o[key] && typeof o[key] === "object") { - traverse(o[key], newPath) - } else { - keys.push(`${prefix}:${newPath}`) - } - }) - } - } - - traverse(obj, "") - return keys + return { + output: printLogs(), + nonNamespacedKeys } - - return { output: printLogs() } } describe("Find Missing i18n Keys", () => { @@ -308,9 +364,19 @@ describe("Find Missing i18n Keys", () => { sourceKeys = accumulateSourceKeys() translationKeys = accumulateTranslationKeys() + // Find special keys + const dynamicKeys = findDynamicKeys(sourceKeys) + const nonNamespacedKeys = findNonNamespacedI18nKeys(sourceKeys) + + // Create sets for set operations + const dynamicSet = new Set(dynamicKeys) + const nonNamespacedSet = new Set(nonNamespacedKeys) + const remainingSourceKeys = new Set(Array.from(sourceKeys) + .filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key))) + // Find differences - keysInSourceNotInTranslation = getKeysInSourceNotInTranslation(sourceKeys, translationKeys) - keysInTranslationNotInSource = getKeysInTranslationNotInSource(sourceKeys, translationKeys) + keysInSourceNotInTranslation = getKeysInSourceNotInTranslation(remainingSourceKeys, translationKeys) + keysInTranslationNotInSource = getKeysInTranslationNotInSource(remainingSourceKeys, translationKeys) // Clear logs at start clearLogs() From 4f21bb55e464da5c9edbddc3cad79d96b50bc9c3 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 12 May 2025 21:57:19 -0700 Subject: [PATCH 29/37] fix: reduce i18n key false positives by handling dynamic keys Improve i18n key detection by adding path segment matching to handle dynamic keys like: - chat:codeblock.tooltips.${windowShade ? "expand" : "collapse"} - prompts:supportPrompts.types.${type}.label This reduces false positives by recognizing that static keys in translation files (like chat:codeblock.tooltips.expand) are actually used via dynamic keys in the source code. The solution: - Splits keys into segments and marks dynamic parts as wildcards - Matches static keys against these patterns - Reduces reported unused keys from 108 to 78 Signed-off-by: Eric Wheeler --- .../__tests__/find-missing-i18n-keys.test.ts | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/locales/__tests__/find-missing-i18n-keys.test.ts b/locales/__tests__/find-missing-i18n-keys.test.ts index f57c5235bf..4c9c40f80d 100644 --- a/locales/__tests__/find-missing-i18n-keys.test.ts +++ b/locales/__tests__/find-missing-i18n-keys.test.ts @@ -23,7 +23,7 @@ const i18nScanPatterns = [ ] // Track line numbers for source code keys -const lineMap = new Map() +const lineMap = new Map() // Check if the key exists in all official language files, return a list of missing language files @@ -53,12 +53,12 @@ function accumulateSourceKeys(): Set { let match while ((match = pattern.exec(content)) !== null) { const key = match[1] - const lineNumber = content.slice(0, match.index).split('\n').length + const lineNumber = content.slice(0, match.index).split("\n").length matches.add(key) sourceCodeKeys.add(key) lineMap.set(key, { file: path.relative(process.cwd(), filePath), - line: lineNumber + line: lineNumber, }) } } @@ -139,24 +139,57 @@ function getKeysInSourceNotInTranslation(sourceKeys: Set, translationKey .sort() } +// Function to convert a key into segments and mark dynamic parts as undefined +function keyToSegments(key: string): (string | undefined)[] { + return key.split(".").map((segment) => (segment.includes("${") ? undefined : segment)) +} + +// Function to check if a static key matches a dynamic key pattern +function matchesKeyPattern(staticKey: string, dynamicKey: string): boolean { + const staticSegments = staticKey.split(".") + const dynamicSegments = keyToSegments(dynamicKey) + + if (staticSegments.length !== dynamicSegments.length) { + return false + } + + return dynamicSegments.every((dynSeg, i) => dynSeg === undefined || dynSeg === staticSegments[i]) +} + // Function 4: Return all keys in translations that are not in source -function getKeysInTranslationNotInSource(sourceKeys: Set, translationKeys: Set): string[] { +function getKeysInTranslationNotInSource( + sourceKeys: Set, + translationKeys: Set, + dynamicKeys: string[] = [], +): string[] { return Array.from(translationKeys) - .filter((key) => !sourceKeys.has(key)) + .filter((key) => { + // If key is directly used in source, it's not unused + if (sourceKeys.has(key)) { + return false + } + + // If key matches any dynamic key pattern, it's not unused + if (dynamicKeys.some((dynamicKey) => matchesKeyPattern(key, dynamicKey))) { + return false + } + + return true + }) .sort() } // Function to find dynamic i18n keys (containing ${...}) function findDynamicKeys(sourceKeys: Set): string[] { return Array.from(sourceKeys) - .filter(key => key.includes("${")) + .filter((key) => key.includes("${")) .sort() } // Function to find non-namespaced t() calls export function findNonNamespacedI18nKeys(sourceKeys: Set): string[] { return Array.from(sourceKeys) - .filter(key => !key.includes(":")) + .filter((key) => !key.includes(":")) .sort() } @@ -187,7 +220,7 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri // Get source code keys and translation keys const sourceCodeKeys = accumulateSourceKeys() const translationFileKeys = accumulateTranslationKeys() - + // Find special keys const dynamicKeys = findDynamicKeys(sourceCodeKeys) const nonNamespacedKeys = findNonNamespacedI18nKeys(sourceCodeKeys) @@ -195,12 +228,13 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri // Create sets for set operations const dynamicSet = new Set(dynamicKeys) const nonNamespacedSet = new Set(nonNamespacedKeys) - const remainingSourceKeys = new Set(Array.from(sourceCodeKeys) - .filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key))) + const remainingSourceKeys = new Set( + Array.from(sourceCodeKeys).filter((key) => !dynamicSet.has(key) && !nonNamespacedSet.has(key)), + ) // Find keys in source not in translations and vice versa const missingTranslationKeys = getKeysInSourceNotInTranslation(remainingSourceKeys, translationFileKeys) - const unusedTranslationKeys = getKeysInTranslationNotInSource(remainingSourceKeys, translationFileKeys) + const unusedTranslationKeys = getKeysInTranslationNotInSource(remainingSourceKeys, translationFileKeys, dynamicKeys) // Track unused keys in English locale files const unusedKeys: Array<{ key: string; file: string }> = [] @@ -242,13 +276,13 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri // Add summary counts summaryOutput += `\nTotal source code keys: ${sourceCodeKeys.size}\n` summaryOutput += `Total translation file keys: ${translationFileKeys.size}\n` - + // Dynamic keys summaryOutput += `\n1. Dynamic i18n keys (${dynamicKeys.length}):\n` if (dynamicKeys.length === 0) { summaryOutput += " None - all i18n keys are static\n" } else { - dynamicKeys.forEach(key => { + dynamicKeys.forEach((key) => { const loc = lineMap.get(key) summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n` }) @@ -259,7 +293,7 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri if (nonNamespacedKeys.length === 0) { summaryOutput += " None - all t() calls use namespaces\n" } else { - nonNamespacedKeys.forEach(key => { + nonNamespacedKeys.forEach((key) => { const loc = lineMap.get(key) summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n` }) @@ -275,10 +309,10 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri }) } - // Keys in translation files but not in source code - summaryOutput += `\n4. Keys in translation files but not in source code (${unusedTranslationKeys.length}):\n` + // Keys in translation files but not in source code (excluding dynamic matches) + summaryOutput += `\n4. Unused translation keys (${unusedTranslationKeys.length}):\n` if (unusedTranslationKeys.length === 0) { - summaryOutput += " None - all translation keys are used in source code\n" + summaryOutput += " None - all translation keys are either directly used or matched by dynamic patterns\n" } else { // Group keys by locale directory and file const localeFileMap = new Map>() @@ -348,7 +382,7 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri return { output: printLogs(), - nonNamespacedKeys + nonNamespacedKeys, } } @@ -371,12 +405,17 @@ describe("Find Missing i18n Keys", () => { // Create sets for set operations const dynamicSet = new Set(dynamicKeys) const nonNamespacedSet = new Set(nonNamespacedKeys) - const remainingSourceKeys = new Set(Array.from(sourceKeys) - .filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key))) + const remainingSourceKeys = new Set( + Array.from(sourceKeys).filter((key) => !dynamicSet.has(key) && !nonNamespacedSet.has(key)), + ) // Find differences keysInSourceNotInTranslation = getKeysInSourceNotInTranslation(remainingSourceKeys, translationKeys) - keysInTranslationNotInSource = getKeysInTranslationNotInSource(remainingSourceKeys, translationKeys) + keysInTranslationNotInSource = getKeysInTranslationNotInSource( + remainingSourceKeys, + translationKeys, + dynamicKeys, + ) // Clear logs at start clearLogs() From 155263ee5fdff857f84bd063c5953dafa5432309 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Wed, 14 May 2025 14:46:22 -0700 Subject: [PATCH 30/37] fix: improve display of missing translations Enhance the translation linting tool to better handle and display missing translations: - Add escapeDotsForDisplay utility function to properly handle dots in key names - Move utility function to utils.ts for better code organization - Use source values captured during scan time when displaying missing keys - Update property name from englishValue to sourceValue for consistency - Improve display of missing keys by using stored translation issues Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 39 +++++++++++---------- locales/__tests__/utils.ts | 5 +++ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 24232407e3..d36d9425b4 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -10,6 +10,7 @@ import { loadFileContent, parseJsonContent, getValueAtPath, + escapeDotsForDisplay, } from "./utils" // Create a mutable copy of the languages array that can be overridden @@ -37,7 +38,7 @@ interface LintOptions { interface TranslationIssue { key: string - englishValue?: any + sourceValue?: any localeValue?: any } @@ -153,7 +154,7 @@ function checkMissingTranslations(sourceContent: any, targetContent: any): Trans if (targetValue === undefined) { missingKeys.push({ key, - englishValue: sourceValue, + sourceValue: sourceValue, }) } } @@ -230,7 +231,7 @@ function processFileLocale( results[mapping.area][locale][targetFile].missing = [ { key: sourceFile, - englishValue: "File missing", + sourceValue: undefined, }, ] return @@ -436,10 +437,10 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti bufferLog(` Missing keys: ALL KEYS (${sourceKeys.length} total)`) if (options?.verbose) { sourceKeys.sort().forEach((key) => { - const englishValue = getValueAtPath(sourceContent, key) - if (typeof englishValue === "string") { - bufferLog(` - ${key} - '${englishValue}' [en]`) - } + const sourceValue = getValueAtPath(sourceContent, key) + bufferLog( + ` - ${escapeDotsForDisplay(key)} - ${JSON.stringify(sourceValue)} [en]`, + ) }) } } else { @@ -507,22 +508,22 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti } } else { // Normal case - file exists but has missing keys - bufferLog(` Missing keys:`) - // Get the source file to extract English values - const sourceFile = file.replace(`/${lang}/`, "/en/") - const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + bufferLog(` Missing keys (${keys.size} total):`) + + // Get the missing translations with their English values + const missingTranslations = results[area][lang][file].missing + // Display each missing key with its English value Array.from(keys) .sort() .forEach((key) => { - const englishValue = sourceContent - ? getValueAtPath(sourceContent, key) - : undefined + // Find the corresponding TranslationIssue for this key + const issue = missingTranslations.find((issue) => issue.key === key) + const englishValue = issue ? issue.sourceValue : undefined - // Skip displaying complex objects - if (typeof englishValue === "string") { - bufferLog(` - ${key} - '${englishValue}' [en]`) - } + bufferLog( + ` - ${escapeDotsForDisplay(key)} - ${JSON.stringify(englishValue)} [en]`, + ) }) } } @@ -551,7 +552,7 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti const targetPath = resolveTargetPath(file, mapping.targetTemplate, locale) bufferLog(` ${locale}: ${targetPath}: ${extras.length} extra translations`) for (const { key, localeValue } of extras) { - bufferLog(` ${key}: "${localeValue}"`) + bufferLog(` ${escapeDotsForDisplay(key)}: "${localeValue}"`) } } } diff --git a/locales/__tests__/utils.ts b/locales/__tests__/utils.ts index 2cd945bf99..36011af6be 100644 --- a/locales/__tests__/utils.ts +++ b/locales/__tests__/utils.ts @@ -69,3 +69,8 @@ export function getValueAtPath(obj: any, path: string): any { return current } + +// Utility function to escape dots in keys for display purposes +export function escapeDotsForDisplay(key: string): string { + return key.replace(/\./g, "..") +} From 5326a778d0d70f7db125fc8bb2bfaa413cfebd4c Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Tue, 20 May 2025 13:44:43 -0700 Subject: [PATCH 31/37] refactor(i18n): Improve translation linting key handling and display Refactors the translation linting script to more accurately handle and display translation keys. - Uses direct object comparison instead of key flattening - Changes internal key representation to path arrays for clearer structure - Fixes display of keys with literal dots versus nested keys - Prevents [object Object] from appearing in extra translations list - Adds configuration-driven namespace prefix for displayed keys - Introduces useFilenameAsNamespace property in PATH_MAPPINGS - Removes findKeys utility and cleans up related code Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 367 +++++++++++--------- locales/__tests__/utils.ts | 25 +- 2 files changed, 213 insertions(+), 179 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index d36d9425b4..d6e067ac30 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -24,6 +24,7 @@ interface PathMapping { area: "docs" | "core" | "webview" | "package-nls" source: string | string[] targetTemplate: string + useFilenameAsNamespace?: boolean // New property } interface LintOptions { @@ -37,7 +38,7 @@ interface LintOptions { } interface TranslationIssue { - key: string + key: string[] // Changed from string to string[] sourceValue?: any localeValue?: any } @@ -62,24 +63,28 @@ const PATH_MAPPINGS: PathMapping[] = [ area: "docs", source: ["CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "README.md", "PRIVACY.md"], targetTemplate: "locales//", + // useFilenameAsNamespace defaults to false or undefined }, { name: "Core UI Components", area: "core", source: "src/i18n/locales/en", targetTemplate: "src/i18n/locales//", + useFilenameAsNamespace: true, }, { name: "Webview UI Components", area: "webview", source: "webview-ui/src/i18n/locales/en", targetTemplate: "webview-ui/src/i18n/locales//", + useFilenameAsNamespace: true, }, { name: "Package NLS", area: "package-nls", source: "package.nls.json", targetTemplate: "package.nls..json", + // useFilenameAsNamespace defaults to false or undefined }, ] @@ -126,59 +131,71 @@ function resolveTargetPath(sourceFile: string, targetTemplate: string, locale: s return path.join(targetPath, fileName) } -function findKeys(obj: any, parentKey: string = ""): string[] { - let keys: string[] = [] - - for (const [key, value] of Object.entries(obj)) { - const currentKey = parentKey ? `${parentKey}.${key}` : key - keys.push(currentKey) - - if (typeof value === "object" && value !== null) { - keys = [...keys, ...findKeys(value, currentKey)] +// Helper function to recursively compare objects and identify differences +function compareObjects( + sourceObj: any, + targetObj: any, + currentPath: string[] = [], +): { missing: TranslationIssue[]; extra: TranslationIssue[] } { + const missing: TranslationIssue[] = [] + const extra: TranslationIssue[] = [] + + // Check for missing keys (present in source, not in target) + for (const key in sourceObj) { + if (Object.prototype.hasOwnProperty.call(sourceObj, key)) { + const newPath = [...currentPath, key] + if (!Object.prototype.hasOwnProperty.call(targetObj, key)) { + missing.push({ key: newPath, sourceValue: sourceObj[key] }) + } else if ( + typeof sourceObj[key] === "object" && + sourceObj[key] !== null && + typeof targetObj[key] === "object" && + targetObj[key] !== null + ) { + const nestedResult = compareObjects(sourceObj[key], targetObj[key], newPath) + missing.push(...nestedResult.missing) + extra.push(...nestedResult.extra) + } } } - return keys + // Check for extra keys (present in target, not in source) + for (const key in targetObj) { + if (Object.prototype.hasOwnProperty.call(targetObj, key)) { + const newPath = [...currentPath, key] + if (!Object.prototype.hasOwnProperty.call(sourceObj, key)) { + // Filter out object values from being reported as extra + if (typeof targetObj[key] !== "object" || targetObj[key] === null) { + extra.push({ key: newPath, localeValue: targetObj[key] }) + } + } + } + } + return { missing, extra } } function checkMissingTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { - if (!sourceContent || !targetContent) return [] - - const sourceKeys = findKeys(sourceContent) - const missingKeys: TranslationIssue[] = [] - - for (const key of sourceKeys) { - const sourceValue = getValueAtPath(sourceContent, key) - const targetValue = getValueAtPath(targetContent, key) - - if (targetValue === undefined) { - missingKeys.push({ - key, - sourceValue: sourceValue, - }) - } + if ( + typeof sourceContent !== "object" || + sourceContent === null || + typeof targetContent !== "object" || + targetContent === null + ) { + return [] } - - return missingKeys + return compareObjects(sourceContent, targetContent).missing } function checkExtraTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { - if (!sourceContent || !targetContent) return [] - - const sourceKeys = new Set(findKeys(sourceContent)) - const targetKeys = findKeys(targetContent) - const extraKeys: TranslationIssue[] = [] - - for (const key of targetKeys) { - if (!sourceKeys.has(key)) { - extraKeys.push({ - key, - localeValue: getValueAtPath(targetContent, key), - }) - } + if ( + typeof sourceContent !== "object" || + sourceContent === null || + typeof targetContent !== "object" || + targetContent === null + ) { + return [] } - - return extraKeys + return compareObjects(sourceContent, targetContent).extra } function getFilteredLocales(localeArgs?: string[]): Language[] { @@ -227,11 +244,12 @@ function processFileLocale( extra: [], } + // For missing files, the "key" is the filepath. This should be a single-element array. if (!fileExists(targetFile)) { results[mapping.area][locale][targetFile].missing = [ { - key: sourceFile, - sourceValue: undefined, + key: [sourceFile], // File path as a single element array + sourceValue: undefined, // Or perhaps a specific marker like "File missing" }, ] return @@ -294,11 +312,18 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti if (checkTypes.includes("missing") && fileResults.missing.length > 0) { localeMissingCount += fileResults.missing.length missingCount += fileResults.missing.length - const key = `${file}:${locale}` - if (!missingByFile.has(key)) { - missingByFile.set(key, new Set()) + const missingFileKey = `${file}:${locale}` // Keep this as string for map key + if (!missingByFile.has(missingFileKey)) { + missingByFile.set(missingFileKey, new Set()) } - fileResults.missing.forEach(({ key }) => missingByFile.get(`${file}:${locale}`)?.add(key)) + // The 'key' in TranslationIssue is now string[]. For the Set, we need a string representation. + // escapeDotsForDisplay is not ideal here as it's for final display. + // Let's join with a unique char for internal set storage, or use the pathArray directly if the set supports it (it doesn't directly). + // For simplicity in the Set, we'll join the pathArray with a known unique separator for storage in missingByFile. + // This stringified key is only for the purpose of the Set uniqueness. + fileResults.missing.forEach(({ key: pathArray }) => { + missingByFile.get(missingFileKey)?.add(pathArray.join("\u0000")) // Store a stringified version + }) areaHasIssues = true } @@ -372,41 +397,38 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti const missingKeysByLocale = new Map>>() missingByFile.forEach((keys, fileAndLang) => { - const [file, lang] = fileAndLang.split(":") + const [file, lang] = fileAndLang.split(":") // file is source file, lang is locale const mapping = mappings.find((m) => m.area === area) if (!mapping) return - const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) + const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) // This is the actual target file path // Check if this is a missing file or missing keys - let isMissingFile = false - - // Check for the special "File missing" case - if (keys.size === 1) { - const key = Array.from(keys)[0] - // Either the key equals the source file or it's a file path - isMissingFile = key === file || key.includes("/") - } - - if (isMissingFile) { - // This is a missing file + // A missing file has a single key in its 'missing' array, which is the source file path. + const fileMissingResult = results[area][lang][targetPath] + const isCompletelyMissingFile = + fileMissingResult && + fileMissingResult.missing.length === 1 && + fileMissingResult.missing[0].key.length === 1 && // pathArray has 1 element + fileMissingResult.missing[0].key[0] === file // that element is the source file path + + if (isCompletelyMissingFile) { if (!missingFilesByLocale.has(lang)) { missingFilesByLocale.set(lang, []) } missingFilesByLocale.get(lang)?.push(targetPath) } else { - // These are missing keys + // These are missing keys within an existing file if (!missingKeysByLocale.has(lang)) { missingKeysByLocale.set(lang, new Map()) } if (!missingKeysByLocale.get(lang)?.has(targetPath)) { missingKeysByLocale.get(lang)?.set(targetPath, new Set()) } - keys.forEach((key) => { - // Skip keys that look like file paths - if (!key.includes("/")) { - missingKeysByLocale.get(lang)?.get(targetPath)?.add(key) - } + // 'keys' here is a Set of stringified pathArrays (joined by \u0000) + keys.forEach((stringifiedPathArray) => { + // We don't need to check for path-like keys here anymore, as compareObjects handles structure. + missingKeysByLocale.get(lang)?.get(targetPath)?.add(stringifiedPathArray) }) } }) @@ -414,43 +436,71 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti // Report missing files missingFilesByLocale.forEach((files, lang) => { bufferLog(` ${lang}: missing ${files.length} files`) - files.sort().forEach((file) => { - bufferLog(` ${file}`) - - // Show missing keys for missing files too - let sourceFile = file + files.sort().forEach((targetFilePath) => { + // targetFilePath is the path of the missing translated file + bufferLog(` ${targetFilePath}`) + + // Determine the source file corresponding to this missing target file + let sourceFilePath = targetFilePath + const mapping = mappings.find((m) => { + // This logic to find the original source file from target might need refinement + // For now, assume a simple replacement based on common patterns + if (targetFilePath.includes(`/${lang}/`)) { + return targetFilePath.startsWith(m.targetTemplate.replace("", lang).split("/")[0]) + } + return targetFilePath.startsWith(m.targetTemplate.replace("", lang).split(".")[0]) + }) - // Handle different file patterns - if (file.includes(`/${lang}/`)) { - sourceFile = file.replace(`/${lang}/`, "/en/") - } else if (file.endsWith(`.${lang}.json`)) { - sourceFile = file.replace(`.${lang}.json`, ".json") + if (mapping) { + if (Array.isArray(mapping.source)) { + // If source is an array, we need to find which source file it corresponds to. + // This case is tricky if multiple source files map to the same target dir. + // For now, we'll assume a simple case or that this logic is primarily for single source files. + // A more robust way would be to trace back from target to source via resolveTargetPath inverse. + // For now, let's assume the first source file if it's a directory based mapping. + const baseName = path.basename(targetFilePath) + const matchedSource = mapping.source.find((s) => path.basename(s) === baseName) + sourceFilePath = matchedSource || mapping.source[0] // Fallback, might not be accurate + } else { + sourceFilePath = mapping.source // e.g. "package.nls.json" or "src/i18n/locales/en" + // If source is a directory, we need the specific file name from targetPath + if (!sourceFilePath.endsWith(".json") && targetFilePath.endsWith(".json")) { + sourceFilePath = path.join(sourceFilePath, path.basename(targetFilePath)) + } else if (mapping.area === "package-nls") { + sourceFilePath = "package.nls.json" + } + } } - // For JSON files, we can show the actual keys - if (sourceFile.endsWith(".json")) { - const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) + if (sourceFilePath.endsWith(".json")) { + const sourceContent = parseJsonContent(loadFileContent(sourceFilePath), sourceFilePath) if (sourceContent) { - // For missing files, show all keys from source as missing - const sourceKeys = findKeys(sourceContent) - if (sourceKeys.length > 0) { - bufferLog(` Missing keys: ALL KEYS (${sourceKeys.length} total)`) + const issues = checkMissingTranslations(sourceContent, {}) // Compare with empty to get all keys + if (issues.length > 0) { + bufferLog(` Missing keys: ALL KEYS (${issues.length} total)`) if (options?.verbose) { - sourceKeys.sort().forEach((key) => { - const sourceValue = getValueAtPath(sourceContent, key) - bufferLog( - ` - ${escapeDotsForDisplay(key)} - ${JSON.stringify(sourceValue)} [en]`, + issues + .sort((a, b) => + escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key)), ) - }) + .forEach((issue) => { + const displayNamespace = mapping?.useFilenameAsNamespace + ? path.basename(sourceFilePath, ".json") + : mapping?.area || "unknown" + bufferLog( + ` - ${displayNamespace}:${escapeDotsForDisplay(issue.key)} - ${JSON.stringify(issue.sourceValue)} [en]`, + ) + }) } } else { - bufferLog(` Missing keys: No keys found in source file`) + bufferLog(` Missing keys: No keys found in source file ${sourceFilePath}`) } } else { - bufferLog(` Missing keys: Unable to load corresponding source file`) + bufferLog( + ` Missing keys: Unable to load corresponding source file ${sourceFilePath}`, + ) } } else { - // For non-JSON files (like Markdown), just indicate all content is missing bufferLog(` Missing file: ALL CONTENT (entire file)`) } }) @@ -458,74 +508,45 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti // Report files with missing keys missingKeysByLocale.forEach((fileMap, lang) => { - const filesWithMissingKeys = Array.from(fileMap.keys()) + const filesWithMissingKeys = Array.from(fileMap.keys()) // These are targetFilePaths if (filesWithMissingKeys.length > 0) { bufferLog(` ${lang}: ${filesWithMissingKeys.length} files with missing translations`) - filesWithMissingKeys.sort().forEach((file) => { - bufferLog(` ${file}`) - const keys = fileMap.get(file) - if (keys && keys.size > 0) { - // Check if this file is actually missing - const isMissingFile = Array.from(keys).some((key) => { - // Check if any key has englishValue "File missing" - const issue = Array.from(keys).find((k) => k === key) - return issue && keys.has(issue) && Array.from(keys)[0] === key && !fileExists(file) - }) - - if (isMissingFile) { - // This is actually a missing file - // Get the source file based on mapping patterns - let sourceFile = file - - // Handle different file patterns - if (file.includes(`/${lang}/`)) { - sourceFile = file.replace(`/${lang}/`, "/en/") - } else if (file.endsWith(`.${lang}.json`)) { - sourceFile = file.replace(`.${lang}.json`, ".json") - } - - // For JSON files, show all keys - if (file.endsWith(".json")) { - // For package.nls files, use the source from PATH_MAPPINGS - if (file.includes("package.nls")) { - sourceFile = "package.nls.json" - } - - const sourceContent = parseJsonContent(loadFileContent(sourceFile), sourceFile) - if (sourceContent) { - const sourceKeys = findKeys(sourceContent) - if (sourceKeys.length > 0) { - bufferLog(` Missing keys: ALL KEYS (${sourceKeys.length} total)`) - } else { - bufferLog(` Missing keys: No keys found in source file`) + filesWithMissingKeys.sort().forEach((targetFilePath) => { + bufferLog(` ${targetFilePath}`) + const stringifiedPathArrays = fileMap.get(targetFilePath) // Set of stringified pathArrays + if (stringifiedPathArrays && stringifiedPathArrays.size > 0) { + bufferLog(` Missing keys (${stringifiedPathArrays.size} total):`) + + const missingIssuesInFile = results[area][lang][targetFilePath].missing + const mapping = mappings.find((m) => m.area === area) // Get current mapping for namespace + + Array.from(stringifiedPathArrays) + .map((spa) => spa.split("\u0000")) // Convert back to pathArray for sorting/finding + .sort((a, b) => escapeDotsForDisplay(a).localeCompare(escapeDotsForDisplay(b))) + .forEach((pathArrayKey) => { + const issue = missingIssuesInFile.find( + (iss) => iss.key.join("\u0000") === pathArrayKey.join("\u0000"), + ) + const englishValue = issue ? issue.sourceValue : undefined + let displayNamespace = mapping?.area || "unknown" + if (mapping?.useFilenameAsNamespace) { + // Determine source file from targetFilePath to get the namespace + let sourceFileForNamespace = targetFilePath + if (targetFilePath.includes(`/${lang}/`)) { + sourceFileForNamespace = targetFilePath.replace(`/${lang}/`, "/en/") + } else if (targetFilePath.endsWith(`.${lang}.json`)) { + sourceFileForNamespace = targetFilePath.replace( + `.${lang}.json`, + ".json", + ) } - } else { - bufferLog(` Missing keys: Unable to load source file`) + displayNamespace = path.basename(sourceFileForNamespace, ".json") } - } else { - // For non-JSON files (like Markdown), just indicate all content is missing - bufferLog(` Missing file: ALL CONTENT (entire file)`) - } - } else { - // Normal case - file exists but has missing keys - bufferLog(` Missing keys (${keys.size} total):`) - - // Get the missing translations with their English values - const missingTranslations = results[area][lang][file].missing - - // Display each missing key with its English value - Array.from(keys) - .sort() - .forEach((key) => { - // Find the corresponding TranslationIssue for this key - const issue = missingTranslations.find((issue) => issue.key === key) - const englishValue = issue ? issue.sourceValue : undefined - - bufferLog( - ` - ${escapeDotsForDisplay(key)} - ${JSON.stringify(englishValue)} [en]`, - ) - }) - } + + bufferLog( + ` - ${displayNamespace}:${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, + ) + }) } }) } @@ -537,23 +558,36 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti bufferLog(` ⚠️ Extra translations:`) let isFirstLocale = true for (const [locale, fileMap] of extraByLocale) { + // fileMap keys are sourceFile names if (!isFirstLocale) { - bufferLog("") // Add blank line between locales + bufferLog("") } isFirstLocale = false let isFirstFile = true - for (const [file, extras] of fileMap) { + for (const [sourceFileName, extras] of fileMap) { + // extras is TranslationIssue[] if (!isFirstFile) { - bufferLog("") // Add blank line between files + bufferLog("") } isFirstFile = false const mapping = mappings.find((m) => m.area === area) if (!mapping) continue - const targetPath = resolveTargetPath(file, mapping.targetTemplate, locale) - bufferLog(` ${locale}: ${targetPath}: ${extras.length} extra translations`) - for (const { key, localeValue } of extras) { - bufferLog(` ${escapeDotsForDisplay(key)}: "${localeValue}"`) + + // Determine the target file path for display + const targetFilePathDisplay = resolveTargetPath(sourceFileName, mapping.targetTemplate, locale) + let displayNamespace = mapping.area + if (mapping.useFilenameAsNamespace) { + displayNamespace = path.basename(sourceFileName, ".json") } + + bufferLog(` ${locale}: ${targetFilePathDisplay}: ${extras.length} extra translations`) + extras + .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key))) + .forEach(({ key: pathArray, localeValue }) => { + bufferLog( + ` ${displayNamespace}:${escapeDotsForDisplay(pathArray)}: "${localeValue}"`, + ) + }) } } } @@ -810,7 +844,6 @@ export { parseJsonContent, fileExists, PATH_MAPPINGS, - findKeys, getValueAtPath, checkMissingTranslations, checkExtraTranslations, @@ -870,7 +903,7 @@ describe("Translation Linting", () => { const target = { key1: "value1" } const issues = checkMissingTranslations(source, target) expect(issues).toHaveLength(1) - expect(issues[0].key).toBe("key2") + expect(issues[0].key).toEqual(["key2"]) }) test("Checks for extra translations", () => { @@ -878,6 +911,6 @@ describe("Translation Linting", () => { const target = { key1: "value1", extraKey: "extra" } const issues = checkExtraTranslations(source, target) expect(issues).toHaveLength(1) - expect(issues[0].key).toBe("extraKey") + expect(issues[0].key).toEqual(["extraKey"]) }) }) diff --git a/locales/__tests__/utils.ts b/locales/__tests__/utils.ts index 36011af6be..6fb55d90dc 100644 --- a/locales/__tests__/utils.ts +++ b/locales/__tests__/utils.ts @@ -52,25 +52,26 @@ export function parseJsonContent(content: string | null, filePath: string): any } } -export function getValueAtPath(obj: any, path: string): any { - if (obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, path)) { - return obj[path] - } - - const parts = path.split(".") +export function getValueAtPath(obj: any, pathArray: string[]): any { let current = obj - - for (const part of parts) { - if (current === undefined || current === null) { + for (const part of pathArray) { + if ( + current === undefined || + current === null || + typeof current !== "object" || + !Object.prototype.hasOwnProperty.call(current, part) + ) { return undefined } current = current[part] } - return current } // Utility function to escape dots in keys for display purposes -export function escapeDotsForDisplay(key: string): string { - return key.replace(/\./g, "..") +export function escapeDotsForDisplay(pathArray: string[]): string { + if (!pathArray || pathArray.length === 0) { + return "" + } + return pathArray.map((segment) => segment.replace(/\./g, "..")).join(".") } From 679254e6f63f231bfb22c89abe36217342a76fff Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Tue, 20 May 2025 16:18:58 -0700 Subject: [PATCH 32/37] locales: Refactor translation linting for improved configuration and reporting Enhance the translation linting script with configuration-driven behavior: - Update PathMapping interface with displayNamespace and reportFileLevelOnly attributes - Make namespace prefixing configurable via PathMapping attributes - Implement recursive object comparison for accurate key detection - Add file size checking for documentation translations - Add detection of extra files in translation directories - Refine display of translation issues with appropriate namespacing These changes allow for more accurate reporting of translation issues while maintaining a clean and consistent output format across different types of translation files (JSON, Markdown). Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 761 +++++++++++++------- 1 file changed, 496 insertions(+), 265 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index d6e067ac30..a4bdd59918 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -1,4 +1,4 @@ -const fs = require("fs") +import * as fs from "fs" const path = require("path") import { languages as originalLanguages, @@ -21,10 +21,12 @@ const seenErrors = new Set() interface PathMapping { name: string - area: "docs" | "core" | "webview" | "package-nls" + area: "docs" | "core" | "webview" | "package-nls" // Remains for grouping/filtering source: string | string[] targetTemplate: string - useFilenameAsNamespace?: boolean // New property + useFilenameAsNamespace?: boolean // For areas like 'core', 'webview' + displayNamespace?: string // Explicit namespace for display, e.g., "nls" for package-nls + reportFileLevelOnly?: boolean // True for 'docs', false for JSON-based areas } interface LintOptions { @@ -47,6 +49,7 @@ interface FileResult { missing: TranslationIssue[] extra: TranslationIssue[] error?: string + sizeWarning?: string // Added for file size issues } interface Results { @@ -63,28 +66,35 @@ const PATH_MAPPINGS: PathMapping[] = [ area: "docs", source: ["CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "README.md", "PRIVACY.md"], targetTemplate: "locales//", - // useFilenameAsNamespace defaults to false or undefined + reportFileLevelOnly: true, // Key change: Only report missing/extra files + // useFilenameAsNamespace and displayNamespace are omitted (or false/undefined) }, { name: "Core UI Components", - area: "core", - source: "src/i18n/locales/en", + area: "core", // Internal grouping + source: "src/i18n/locales/en", // Directory targetTemplate: "src/i18n/locales//", - useFilenameAsNamespace: true, + useFilenameAsNamespace: true, // e.g., "common", "tools" + reportFileLevelOnly: false, // Key change: report keys + // displayNamespace is omitted }, { name: "Webview UI Components", - area: "webview", - source: "webview-ui/src/i18n/locales/en", + area: "webview", // Internal grouping + source: "webview-ui/src/i18n/locales/en", // Directory targetTemplate: "webview-ui/src/i18n/locales//", - useFilenameAsNamespace: true, + useFilenameAsNamespace: true, // e.g., "chat", "settings" + reportFileLevelOnly: false, // Key change: report keys + // displayNamespace is omitted }, { name: "Package NLS", - area: "package-nls", - source: "package.nls.json", + area: "package-nls", // Internal grouping + source: "package.nls.json", // File targetTemplate: "package.nls..json", - // useFilenameAsNamespace defaults to false or undefined + displayNamespace: "nls", // Key change: Explicit display namespace + reportFileLevelOnly: false, // Key change: report keys + // useFilenameAsNamespace is omitted (or false/undefined) }, ] @@ -140,7 +150,7 @@ function compareObjects( const missing: TranslationIssue[] = [] const extra: TranslationIssue[] = [] - // Check for missing keys (present in source, not in target) + // Missing Keys Loop (Iterate sourceObj keys) for (const key in sourceObj) { if (Object.prototype.hasOwnProperty.call(sourceObj, key)) { const newPath = [...currentPath, key] @@ -159,13 +169,14 @@ function compareObjects( } } - // Check for extra keys (present in target, not in source) + // Extra Keys Loop (Iterate targetObj keys) for (const key in targetObj) { if (Object.prototype.hasOwnProperty.call(targetObj, key)) { const newPath = [...currentPath, key] if (!Object.prototype.hasOwnProperty.call(sourceObj, key)) { - // Filter out object values from being reported as extra - if (typeof targetObj[key] !== "object" || targetObj[key] === null) { + // Crucially: If typeof targetObj[key] === "object" && targetObj[key] !== null + // (i.e., the value of the extra key is an object), then do not add it to the extra list. + if (!(typeof targetObj[key] === "object" && targetObj[key] !== null)) { extra.push({ key: newPath, localeValue: targetObj[key] }) } } @@ -183,7 +194,8 @@ function checkMissingTranslations(sourceContent: any, targetContent: any): Trans ) { return [] } - return compareObjects(sourceContent, targetContent).missing + const { missing } = compareObjects(sourceContent, targetContent) + return missing } function checkExtraTranslations(sourceContent: any, targetContent: any): TranslationIssue[] { @@ -195,7 +207,8 @@ function checkExtraTranslations(sourceContent: any, targetContent: any): Transla ) { return [] } - return compareObjects(sourceContent, targetContent).extra + const { extra } = compareObjects(sourceContent, targetContent) + return extra } function getFilteredLocales(localeArgs?: string[]): Language[] { @@ -244,33 +257,163 @@ function processFileLocale( extra: [], } - // For missing files, the "key" is the filepath. This should be a single-element array. - if (!fileExists(targetFile)) { - results[mapping.area][locale][targetFile].missing = [ - { - key: [sourceFile], // File path as a single element array - sourceValue: undefined, // Or perhaps a specific marker like "File missing" - }, - ] + const reportFileLevelOnly = mapping.reportFileLevelOnly === true + + if (reportFileLevelOnly) { + // Handle file-level checks (e.g., for "docs") + if (!fileExists(targetFile)) { + results[mapping.area][locale][targetFile].missing = [ + { + key: [sourceFile], // Source filename as the key + sourceValue: undefined, + }, + ] + return // No further processing for this file + } + + // Target file exists, perform size check + try { + const sourceStats = fs.statSync(sourceFile) + const targetStats = fs.statSync(targetFile) + const sourceSize = sourceStats.size + const targetSize = targetStats.size + + if (targetSize > sourceSize * 2) { + results[mapping.area][locale][targetFile].sizeWarning = + `Target file ${targetFile} is more than 2x larger than source ${sourceFile}. It may require retranslation to be within +/- 20% of the source file size.` + } + } catch (e: any) { + results[mapping.area][locale][targetFile].error = + `Error getting file stats for size comparison: ${e.message}` + } + // Do NOT attempt to load/parse content as JSON or call key-based checks return + } else { + // Handle key-based checks (e.g., for JSON files) + if (!fileExists(targetFile)) { + results[mapping.area][locale][targetFile].missing = [ + { + key: [sourceFile], // File path as a single element array + sourceValue: undefined, // Or perhaps a specific marker like "File missing" + }, + ] + return + } + + // Ensure sourceContent is parsed if it's a JSON file (already handled before calling this function for JSONs) + // The main check here is for targetContent and proceeding with key comparisons. + if (!sourceFile.endsWith(".json")) { + // This case should ideally not be hit if reportFileLevelOnly is false, + // as non-JSONs would typically have reportFileLevelOnly = true. + // However, keeping a safeguard. + // If source is not JSON, but we are in this branch, it implies a configuration mismatch + // or that sourceContent might be raw text content for a non-JSON file that somehow + // needs key-based comparison (which is unlikely with current setup). + // For now, if it's not JSON, we can't do key-based comparison. + return + } + + const targetContent = parseJsonContent(loadFileContent(targetFile), targetFile) + if (!targetContent) { + results[mapping.area][locale][targetFile].error = `Failed to load or parse target file: ${targetFile}` + return + } + + if (checksToRun.includes("missing") || checksToRun.includes("all")) { + results[mapping.area][locale][targetFile].missing = checkMissingTranslations(sourceContent, targetContent) + } + + if (checksToRun.includes("extra") || checksToRun.includes("all")) { + results[mapping.area][locale][targetFile].extra = checkExtraTranslations(sourceContent, targetContent) + } } +} - if (!sourceFile.endsWith(".json")) { - return +function checkExtraFiles( + mapping: PathMapping, + locale: Language, + results: Results, + allSourceFileBasenamesForMapping: Set, // A Set of basenames like {"common.json", "tools.json"} or {"README.md"} +): void { + const targetDir = mapping.targetTemplate.replace("", locale) + + if (!fs.existsSync(targetDir)) { + return // No target directory, so no extra files to check } - const targetContent = parseJsonContent(loadFileContent(targetFile), targetFile) - if (!targetContent) { - results[mapping.area][locale][targetFile].error = `Failed to load or parse target file: ${targetFile}` + let actualTargetFilesDirents: fs.Dirent[] + try { + actualTargetFilesDirents = fs.readdirSync(targetDir, { withFileTypes: true }) + } catch (e: any) { + // This case should be rare if existsSync passed, but good to handle + // We can't report this against a specific file in results, so perhaps log it + // Or, if we want to be very strict, create a dummy entry in results for the directory itself. + // For now, let's bufferLog it. + bufferLog(`Error reading target directory ${targetDir} for locale ${locale}: ${e.message}`) return } - if (checksToRun.includes("missing") || checksToRun.includes("all")) { - results[mapping.area][locale][targetFile].missing = checkMissingTranslations(sourceContent, targetContent) - } + for (const actualTargetFileDirent of actualTargetFilesDirents) { + if (actualTargetFileDirent.isFile()) { + const actualTargetFilename = actualTargetFileDirent.name + let derivedSourceBasename: string + + // Derive Corresponding Source Basename + if (mapping.targetTemplate.endsWith("..json")) { + // Handles cases like package.nls..json + derivedSourceBasename = actualTargetFilename.replace(`.${locale}.json`, ".json") + } else if (mapping.targetTemplate.endsWith("/")) { + // Handles cases like locales// or src/i18n/locales// + // The actualTargetFilename is the basename we compare against source basenames + derivedSourceBasename = actualTargetFilename + } else { + // This case should ideally not be hit if targetTemplate is well-defined + // for extra file checking. If it's a direct file path without , + // it implies a 1:1 mapping, and extra files aren't typically checked this way. + // However, to be safe, let's assume the filename itself if no clear pattern. + // This might need refinement based on actual PathMapping structures. + // For now, if it's not a directory and not a .json pattern, + // we might not have a clear way to derive source basename. + // Let's log a warning and skip, or make a best guess. + // Best guess: if targetTemplate is `foo..bar` and actual is `foo.ca.bar`, source is `foo.bar` + // This is complex. For now, let's stick to the defined cases. + // If targetTemplate is like `specific-file..ext` + const langPattern = `.${locale}.` + if (actualTargetFilename.includes(langPattern)) { + derivedSourceBasename = actualTargetFilename.replace(langPattern, ".") + } else { + // If no in filename, and template is not a dir, it's ambiguous. + // Example: targetTemplate = "fixed_name.json" (no ) + // In this scenario, extra file check might not make sense or needs different logic. + // For now, we assume such mappings won't be common for this check or + // that `allSourceFileBasenamesForMapping` would be very specific. + // Let's assume if it's not a directory and not a ..json pattern, + // the actualTargetFilename is what we'd look for in source (less common). + derivedSourceBasename = actualTargetFilename + } + } - if (checksToRun.includes("extra") || checksToRun.includes("all")) { - results[mapping.area][locale][targetFile].extra = checkExtraTranslations(sourceContent, targetContent) + if (!allSourceFileBasenamesForMapping.has(derivedSourceBasename)) { + const fullPathToActualTargetFile = path.join(targetDir, actualTargetFilename) + + // Ensure the result structure exists + results[mapping.area] = results[mapping.area] || {} + results[mapping.area][locale] = results[mapping.area][locale] || {} + results[mapping.area][locale][fullPathToActualTargetFile] = results[mapping.area][locale][ + fullPathToActualTargetFile + ] || { + missing: [], + extra: [], + error: undefined, + sizeWarning: undefined, + } + + results[mapping.area][locale][fullPathToActualTargetFile].extra.push({ + key: ["EXTRA_FILE_MARKER"], // Standardized marker + localeValue: fullPathToActualTargetFile, // The path of the extra file itself + }) + } + } } } @@ -281,271 +424,301 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti seenErrors.clear() // Clear error tracking bufferLog("=== Translation Results ===") - // Group errors by type for summary + // Group errors by type for summary (excluding size warnings initially) const errorsByType = new Map() for (const [area, areaResults] of Object.entries(results)) { let areaHasIssues = false - const extraByLocale = new Map>() + const extraByLocale = new Map>() let missingCount = 0 - const missingByFile = new Map>() + // missingByFile: key is targetFilePath:locale, value is { keys: Set, sourceFile: string } + // No, missingByFile is `sourceFile:locale` -> Set + // We need to adjust how missingKeysByLocale and missingFilesByLocale are built or used. + const missingByFile = new Map>() // Stores sourceFile:locale -> Set of stringified keys for (const [locale, localeResults] of Object.entries(areaResults)) { let localeMissingCount = 0 let localeExtraCount = 0 let localeErrorCount = 0 - for (const [file, fileResults] of Object.entries(localeResults)) { - // Group errors by type - if (fileResults.error) { + for (const [targetFilePath, fileResult] of Object.entries(localeResults)) { + // file is targetFilePath + if (fileResult.error) { localeErrorCount++ - const errorType = fileResults.error.split(":")[0] - if (!errorsByType.has(errorType)) { - errorsByType.set(errorType, []) - } - errorsByType.get(errorType)?.push(`${locale} - ${file}`) + const errorType = fileResult.error.split(":")[0] // Basic error type + if (!errorsByType.has(errorType)) errorsByType.set(errorType, []) + errorsByType.get(errorType)?.push(`${locale} - ${targetFilePath} (Error: ${fileResult.error})`) areaHasIssues = true + } + + if (fileResult.sizeWarning) { + areaHasIssues = true // Ensure area is reported if there's a size warning + } + + if (fileResult.error && !fileResult.sizeWarning) { continue } - // Group missing translations by file and language - if (checkTypes.includes("missing") && fileResults.missing.length > 0) { - localeMissingCount += fileResults.missing.length - missingCount += fileResults.missing.length - const missingFileKey = `${file}:${locale}` // Keep this as string for map key + if (checkTypes.includes("missing") && fileResult.missing.length > 0) { + localeMissingCount += fileResult.missing.length + missingCount += fileResult.missing.length + areaHasIssues = true + + // Populate missingByFile (sourceFile:locale -> keys) + // This requires knowing the sourceFile for this targetFilePath. + // This information should ideally be in fileResult if we modify it. + // For now, we'll build missingFilesByLocale and missingKeysByLocale more directly later. + const missingFileKey = `${targetFilePath}:${locale}` // Using targetFilePath for now, will refine if (!missingByFile.has(missingFileKey)) { missingByFile.set(missingFileKey, new Set()) } - // The 'key' in TranslationIssue is now string[]. For the Set, we need a string representation. - // escapeDotsForDisplay is not ideal here as it's for final display. - // Let's join with a unique char for internal set storage, or use the pathArray directly if the set supports it (it doesn't directly). - // For simplicity in the Set, we'll join the pathArray with a known unique separator for storage in missingByFile. - // This stringified key is only for the purpose of the Set uniqueness. - fileResults.missing.forEach(({ key: pathArray }) => { - missingByFile.get(missingFileKey)?.add(pathArray.join("\u0000")) // Store a stringified version + fileResult.missing.forEach(({ key: pathArray }) => { + missingByFile.get(missingFileKey)?.add(pathArray.join("\u0000")) }) - areaHasIssues = true } - if (checkTypes.includes("extra") && fileResults.extra.length > 0) { - localeExtraCount += fileResults.extra.length - - // Group extra translations by locale + if (checkTypes.includes("extra") && fileResult.extra.length > 0) { + localeExtraCount += fileResult.extra.length + areaHasIssues = true + // Populate extraByLocale (locale -> targetFilePath -> {issues, sourceFile}) + // Requires sourceFile for targetFilePath. + // Assuming fileResult.sourceFilePath exists (hypothetical change) + // const sourceFileForExtra = (fileResult as any).sourceFilePath || "unknown_source_for_extra"; + // For now, we'll handle sourceFile derivation during display. if (!extraByLocale.has(locale)) { extraByLocale.set(locale, new Map()) } - extraByLocale.get(locale)?.set(file, fileResults.extra) - - areaHasIssues = true + // Storing raw extras for now, sourceFile to be derived in display loop + extraByLocale + .get(locale) + ?.set(targetFilePath, { issues: fileResult.extra, sourceFile: "DERIVE_LATER" }) } } - - hasIssues ||= localeErrorCount > 0 || localeMissingCount > 0 || localeExtraCount > 0 + hasIssues ||= localeErrorCount > 0 || localeMissingCount > 0 || localeExtraCount > 0 || areaHasIssues } if (areaHasIssues) { bufferLog(`\n${area.toUpperCase()} Translations:`) + const mappingForArea = mappings.find((m) => m.area === area) + if (!mappingForArea) continue - // Show error summaries - // Show error summaries by area + // Show error summaries (excluding size warnings) errorsByType.forEach((files, errorType) => { - const mapping = mappings.find((m) => m.area === area) - if (!mapping) return - - // For array sources, check if the file matches any of the source paths - const isSourceFile = (fileName: string) => { - if (Array.isArray(mapping.source)) { - return mapping.source.some((src) => fileName === (src.startsWith("/") ? src.slice(1) : src)) - } - return fileName.startsWith(mapping.source) - } - - const areaFiles = files.filter((file) => { - const [, fileName] = file.split(" - ") - return isSourceFile(fileName) + if (errorType === "SizeWarning") return // Skip size warnings here + + const areaFiles = files.filter((fileEntry) => { + // fileEntry is "locale - targetFilePath (Error: message)" + // Check if targetFilePath belongs to the current area's mapping + const targetFileFromEntry = fileEntry.split(" - ")[1]?.split(" (Error:")[0] + if (!targetFileFromEntry) return false + + // A simple check: does the targetFileFromEntry look like it came from this mapping? + // This is hard without knowing the source file. + // For now, assume if the error was logged under this area, it belongs. + return true // Simplified, as errors are already grouped by area in `results` }) if (areaFiles.length > 0) { bufferLog(` ❌ ${errorType}:`) bufferLog(` Affected files: ${areaFiles.length}`) if (options?.verbose) { - areaFiles.forEach((file) => { - const [locale, fileName] = file.split(" - ") - const targetPath = mapping.targetTemplate.replace("", locale) - const fullPath = path.join(targetPath, fileName) - bufferLog(` ${locale} - ${fullPath}`) - }) + areaFiles.forEach((file) => bufferLog(` ${file}`)) } } }) + errorsByType.clear() // Clear after processing for an area to avoid carry-over + + // Display Size Warnings for the area + const sizeWarningMessages: string[] = [] + for (const [_locale, localeResults] of Object.entries(areaResults)) { + for (const [targetFilePath, fileRes] of Object.entries(localeResults)) { + if (fileRes.sizeWarning) { + sizeWarningMessages.push(` ⚠️ Size Warning for ${targetFilePath}: ${fileRes.sizeWarning}`) + } + } + } + if (sizeWarningMessages.length > 0) { + sizeWarningMessages.sort().forEach((msg) => bufferLog(msg)) + } // Show missing translations summary - if (missingCount > 0) { + if (missingCount > 0 && (checkTypes.includes("missing") || checkTypes.includes("all"))) { bufferLog(` 📝 Missing translations (${missingCount} total):`) - const byFile = new Map>>() - missingByFile.forEach((keys, fileAndLang) => { - const [file, lang] = fileAndLang.split(":") - if (!byFile.has(file)) { - byFile.set(file, new Map()) - } - byFile.get(file)?.set(lang, keys) - }) + const missingFilesByLocaleDisplay = new Map() // lang -> [targetFilePath] + const missingKeysByLocaleDisplay = new Map< + string, + Map; sourceFile: string }> + >() // lang -> targetFilePath -> {keys, sourceFile} + + // Re-iterate results to correctly categorize and get sourceFile + for (const [locale, localeResults] of Object.entries(areaResults)) { + for (const [targetFilePath, fileRes] of Object.entries(localeResults)) { + if (fileRes.missing && fileRes.missing.length > 0) { + // Try to find the original source file for this targetFilePath + // This is the challenging part without direct storage. + // Attempt to find sourceFile based on how results are populated by processFileLocale + let sourceFileAssociatedWithTarget = "unknown_source.file" // Default + // Heuristic: iterate all source files for this mapping, resolve their target, and see if it matches. + const currentMapping = mappings.find((m) => m.area === area) + if (currentMapping) { + const allSourcesForMapping = enumerateSourceFiles(currentMapping.source) + for (const sf of allSourcesForMapping) { + if ( + resolveTargetPath(sf, currentMapping.targetTemplate, locale) === targetFilePath + ) { + sourceFileAssociatedWithTarget = sf + break + } + } + // If still unknown, and missing[0].key[0] looks like a source file (common for full file missing) + if ( + sourceFileAssociatedWithTarget === "unknown_source.file" && + fileRes.missing[0]?.key?.length === 1 + ) { + // This key is often the source file path when the entire file is missing + sourceFileAssociatedWithTarget = fileRes.missing[0].key[0] + } + } - // Group by locale first - const missingFilesByLocale = new Map() - const missingKeysByLocale = new Map>>() - - missingByFile.forEach((keys, fileAndLang) => { - const [file, lang] = fileAndLang.split(":") // file is source file, lang is locale - const mapping = mappings.find((m) => m.area === area) - if (!mapping) return - - const targetPath = resolveTargetPath(file, mapping.targetTemplate, lang) // This is the actual target file path - - // Check if this is a missing file or missing keys - // A missing file has a single key in its 'missing' array, which is the source file path. - const fileMissingResult = results[area][lang][targetPath] - const isCompletelyMissingFile = - fileMissingResult && - fileMissingResult.missing.length === 1 && - fileMissingResult.missing[0].key.length === 1 && // pathArray has 1 element - fileMissingResult.missing[0].key[0] === file // that element is the source file path - - if (isCompletelyMissingFile) { - if (!missingFilesByLocale.has(lang)) { - missingFilesByLocale.set(lang, []) - } - missingFilesByLocale.get(lang)?.push(targetPath) - } else { - // These are missing keys within an existing file - if (!missingKeysByLocale.has(lang)) { - missingKeysByLocale.set(lang, new Map()) - } - if (!missingKeysByLocale.get(lang)?.has(targetPath)) { - missingKeysByLocale.get(lang)?.set(targetPath, new Set()) + const isCompletelyMissingFile = + fileRes.missing.length === 1 && + fileRes.missing[0].key.length === 1 && + fileRes.missing[0].key[0] === sourceFileAssociatedWithTarget // Check against derived/found source + + if (isCompletelyMissingFile) { + if (!missingFilesByLocaleDisplay.has(locale)) + missingFilesByLocaleDisplay.set(locale, []) + missingFilesByLocaleDisplay.get(locale)?.push(targetFilePath) + } else { + if (!missingKeysByLocaleDisplay.has(locale)) + missingKeysByLocaleDisplay.set(locale, new Map()) + if (!missingKeysByLocaleDisplay.get(locale)?.has(targetFilePath)) { + missingKeysByLocaleDisplay + .get(locale) + ?.set(targetFilePath, { + keys: new Set(), + sourceFile: sourceFileAssociatedWithTarget, + }) + } + fileRes.missing.forEach((issue) => { + missingKeysByLocaleDisplay + .get(locale) + ?.get(targetFilePath) + ?.keys.add(issue.key.join("\u0000")) + }) + } } - // 'keys' here is a Set of stringified pathArrays (joined by \u0000) - keys.forEach((stringifiedPathArray) => { - // We don't need to check for path-like keys here anymore, as compareObjects handles structure. - missingKeysByLocale.get(lang)?.get(targetPath)?.add(stringifiedPathArray) - }) } - }) + } // Report missing files - missingFilesByLocale.forEach((files, lang) => { + missingFilesByLocaleDisplay.forEach((files, lang) => { bufferLog(` ${lang}: missing ${files.length} files`) files.sort().forEach((targetFilePath) => { - // targetFilePath is the path of the missing translated file bufferLog(` ${targetFilePath}`) - - // Determine the source file corresponding to this missing target file - let sourceFilePath = targetFilePath - const mapping = mappings.find((m) => { - // This logic to find the original source file from target might need refinement - // For now, assume a simple replacement based on common patterns - if (targetFilePath.includes(`/${lang}/`)) { - return targetFilePath.startsWith(m.targetTemplate.replace("", lang).split("/")[0]) - } - return targetFilePath.startsWith(m.targetTemplate.replace("", lang).split(".")[0]) - }) - - if (mapping) { - if (Array.isArray(mapping.source)) { - // If source is an array, we need to find which source file it corresponds to. - // This case is tricky if multiple source files map to the same target dir. - // For now, we'll assume a simple case or that this logic is primarily for single source files. - // A more robust way would be to trace back from target to source via resolveTargetPath inverse. - // For now, let's assume the first source file if it's a directory based mapping. - const baseName = path.basename(targetFilePath) - const matchedSource = mapping.source.find((s) => path.basename(s) === baseName) - sourceFilePath = matchedSource || mapping.source[0] // Fallback, might not be accurate - } else { - sourceFilePath = mapping.source // e.g. "package.nls.json" or "src/i18n/locales/en" - // If source is a directory, we need the specific file name from targetPath - if (!sourceFilePath.endsWith(".json") && targetFilePath.endsWith(".json")) { - sourceFilePath = path.join(sourceFilePath, path.basename(targetFilePath)) - } else if (mapping.area === "package-nls") { - sourceFilePath = "package.nls.json" + const mapping = mappingForArea // Use mappingForArea + + // Find the source file that this targetFilePath corresponds to + let sourceFileForMissing = "unknown_source.file" + const fileResultForMissing = results[area][lang][targetFilePath] + if ( + fileResultForMissing && + fileResultForMissing.missing.length === 1 && + fileResultForMissing.missing[0].key.length === 1 + ) { + sourceFileForMissing = fileResultForMissing.missing[0].key[0] + } else { + // Fallback if not easily found (should be available for completely missing files) + const allSourcesForMapping = enumerateSourceFiles(mapping.source) + for (const sf of allSourcesForMapping) { + if (resolveTargetPath(sf, mapping.targetTemplate, lang) === targetFilePath) { + sourceFileForMissing = sf + break } } } - if (sourceFilePath.endsWith(".json")) { - const sourceContent = parseJsonContent(loadFileContent(sourceFilePath), sourceFilePath) + if (mapping.reportFileLevelOnly === true) { + bufferLog(` Missing file: ALL CONTENT (entire file)`) + } else { + // JSON files + const sourceContent = parseJsonContent( + loadFileContent(sourceFileForMissing), + sourceFileForMissing, + ) if (sourceContent) { - const issues = checkMissingTranslations(sourceContent, {}) // Compare with empty to get all keys - if (issues.length > 0) { - bufferLog(` Missing keys: ALL KEYS (${issues.length} total)`) - if (options?.verbose) { - issues - .sort((a, b) => - escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key)), - ) - .forEach((issue) => { - const displayNamespace = mapping?.useFilenameAsNamespace - ? path.basename(sourceFilePath, ".json") - : mapping?.area || "unknown" - bufferLog( - ` - ${displayNamespace}:${escapeDotsForDisplay(issue.key)} - ${JSON.stringify(issue.sourceValue)} [en]`, - ) - }) + const issues = checkMissingTranslations(sourceContent, {}) // Get all keys + bufferLog(` Missing keys: ALL KEYS (${issues.length} total)`) + if (options?.verbose && issues.length > 0) { + let displayKeyPrefix = "" + if (mapping.displayNamespace) { + displayKeyPrefix = mapping.displayNamespace + ":" + } else if (mapping.useFilenameAsNamespace) { + displayKeyPrefix = path.basename(sourceFileForMissing, ".json") + ":" } - } else { - bufferLog(` Missing keys: No keys found in source file ${sourceFilePath}`) + issues + .sort((a, b) => + escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key)), + ) + .forEach((issue) => { + bufferLog( + ` - ${displayKeyPrefix}${escapeDotsForDisplay(issue.key)} - ${JSON.stringify(issue.sourceValue)} [en]`, + ) + }) } } else { bufferLog( - ` Missing keys: Unable to load corresponding source file ${sourceFilePath}`, + ` Missing keys: Unable to load corresponding source file ${sourceFileForMissing}`, ) } - } else { - bufferLog(` Missing file: ALL CONTENT (entire file)`) } }) }) // Report files with missing keys - missingKeysByLocale.forEach((fileMap, lang) => { - const filesWithMissingKeys = Array.from(fileMap.keys()) // These are targetFilePaths + missingKeysByLocaleDisplay.forEach((fileMap, lang) => { + const filesWithMissingKeys = Array.from(fileMap.keys()) if (filesWithMissingKeys.length > 0) { bufferLog(` ${lang}: ${filesWithMissingKeys.length} files with missing translations`) filesWithMissingKeys.sort().forEach((targetFilePath) => { bufferLog(` ${targetFilePath}`) - const stringifiedPathArrays = fileMap.get(targetFilePath) // Set of stringified pathArrays - if (stringifiedPathArrays && stringifiedPathArrays.size > 0) { - bufferLog(` Missing keys (${stringifiedPathArrays.size} total):`) + const fileData = fileMap.get(targetFilePath) + if (fileData && fileData.keys.size > 0) { + bufferLog(` Missing keys (${fileData.keys.size} total):`) + const mapping = mappingForArea + const originalSourceFileName = fileData.sourceFile // Use stored sourceFile const missingIssuesInFile = results[area][lang][targetFilePath].missing - const mapping = mappings.find((m) => m.area === area) // Get current mapping for namespace - - Array.from(stringifiedPathArrays) - .map((spa) => spa.split("\u0000")) // Convert back to pathArray for sorting/finding + Array.from(fileData.keys) + .map((spa) => spa.split("\u0000")) // pathArrayKey .sort((a, b) => escapeDotsForDisplay(a).localeCompare(escapeDotsForDisplay(b))) .forEach((pathArrayKey) => { const issue = missingIssuesInFile.find( (iss) => iss.key.join("\u0000") === pathArrayKey.join("\u0000"), ) const englishValue = issue ? issue.sourceValue : undefined - let displayNamespace = mapping?.area || "unknown" - if (mapping?.useFilenameAsNamespace) { - // Determine source file from targetFilePath to get the namespace - let sourceFileForNamespace = targetFilePath - if (targetFilePath.includes(`/${lang}/`)) { - sourceFileForNamespace = targetFilePath.replace(`/${lang}/`, "/en/") - } else if (targetFilePath.endsWith(`.${lang}.json`)) { - sourceFileForNamespace = targetFilePath.replace( - `.${lang}.json`, - ".json", - ) - } - displayNamespace = path.basename(sourceFileForNamespace, ".json") + + let displayKeyPrefix = "" + if (mapping.displayNamespace) { + displayKeyPrefix = mapping.displayNamespace + ":" + } else if ( + mapping.useFilenameAsNamespace && + originalSourceFileName !== "unknown_source.file" + ) { + displayKeyPrefix = path.basename(originalSourceFileName, ".json") + ":" } - bufferLog( - ` - ${displayNamespace}:${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, - ) + if (mapping.reportFileLevelOnly === true) { + // Should not be reached if logic is correct, keys are not reported for fileLevelOnly + bufferLog( + ` - ${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, + ) // Fallback + } else { + bufferLog( + ` - ${displayKeyPrefix}${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, + ) + } }) } }) @@ -554,51 +727,85 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti } // Show extra translations if any - if (extraByLocale.size > 0) { + if (extraByLocale.size > 0 && (checkTypes.includes("extra") || checkTypes.includes("all"))) { bufferLog(` ⚠️ Extra translations:`) - let isFirstLocale = true - for (const [locale, fileMap] of extraByLocale) { - // fileMap keys are sourceFile names - if (!isFirstLocale) { - bufferLog("") - } - isFirstLocale = false - let isFirstFile = true - for (const [sourceFileName, extras] of fileMap) { - // extras is TranslationIssue[] - if (!isFirstFile) { - bufferLog("") - } - isFirstFile = false - const mapping = mappings.find((m) => m.area === area) - if (!mapping) continue - - // Determine the target file path for display - const targetFilePathDisplay = resolveTargetPath(sourceFileName, mapping.targetTemplate, locale) - let displayNamespace = mapping.area - if (mapping.useFilenameAsNamespace) { - displayNamespace = path.basename(sourceFileName, ".json") + extraByLocale.forEach((fileMap, locale) => { + // locale -> targetFilePath -> {issues, sourceFile (DERIVE_LATER)} + fileMap.forEach(({ issues: extras, sourceFile: derivedSourceFile }, targetFilePath) => { + const mapping = mappingForArea + if (!mapping) return + + // Attempt to derive sourceFile if it's "DERIVE_LATER" + let actualSourceFile = derivedSourceFile + if (actualSourceFile === "DERIVE_LATER") { + const allSourcesForMapping = enumerateSourceFiles(mapping.source) + for (const sf of allSourcesForMapping) { + if (resolveTargetPath(sf, mapping.targetTemplate, locale) === targetFilePath) { + actualSourceFile = sf + break + } + } + if (actualSourceFile === "DERIVE_LATER") actualSourceFile = "unknown_source.file" } - bufferLog(` ${locale}: ${targetFilePathDisplay}: ${extras.length} extra translations`) - extras - .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key))) - .forEach(({ key: pathArray, localeValue }) => { - bufferLog( - ` ${displayNamespace}:${escapeDotsForDisplay(pathArray)}: "${localeValue}"`, - ) - }) - } - } + const extraFileMarkers = extras.filter( + (ex) => ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER", + ) + const extraJsonKeys = extras.filter( + (ex) => !(ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER"), + ) + + extraFileMarkers.forEach((issue) => { + const extraFilePathDisplay = issue.localeValue as string // Full path to extra file + bufferLog(` ${locale}: ${extraFilePathDisplay} (Extra File)`) + }) + + if (extraJsonKeys.length > 0) { + let displayKeyPrefix = "" + if (mapping.displayNamespace) { + displayKeyPrefix = mapping.displayNamespace + ":" + } else if (mapping.useFilenameAsNamespace && actualSourceFile !== "unknown_source.file") { + displayKeyPrefix = path.basename(actualSourceFile, ".json") + ":" + } + + bufferLog(` ${locale}: ${targetFilePath}: ${extraJsonKeys.length} extra translations`) + extraJsonKeys + .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key))) + .forEach((issue) => { + bufferLog( + ` ${displayKeyPrefix}${escapeDotsForDisplay(issue.key)}: "${issue.localeValue}"`, + ) + }) + } + }) + }) } - if (!areaHasIssues) { - bufferLog(` ✅ No issues found`) + // if (!areaHasIssues) { // This check might be misleading now with how hasIssues is set + // bufferLog(` ✅ No issues found`); + // } + } + } + // Final check for overall issues to determine return value + let overallHasIssues = false + for (const areaRes of Object.values(results)) { + for (const localeRes of Object.values(areaRes)) { + for (const fileRes of Object.values(localeRes)) { + if ( + fileRes.error || + fileRes.sizeWarning || + (fileRes.missing && fileRes.missing.length > 0) || + (fileRes.extra && fileRes.extra.length > 0) + ) { + overallHasIssues = true + break + } } + if (overallHasIssues) break } + if (overallHasIssues) break } - - return hasIssues + return overallHasIssues } function formatSummary(results: Results): void { @@ -793,37 +1000,61 @@ function lintTranslations(args?: LintOptions): { output: string } { const results: Results = {} for (const mapping of filteredMappings) { - let sourceFiles = enumerateSourceFiles(mapping.source) - sourceFiles = filterSourceFiles(sourceFiles, options.file) + let enumeratedSourceFilesFullPaths = enumerateSourceFiles(mapping.source) + enumeratedSourceFilesFullPaths = filterSourceFiles(enumeratedSourceFilesFullPaths, options.file) - if (sourceFiles.length === 0) { - bufferLog(`No matching files found for area ${mapping.name}`) + if (enumeratedSourceFilesFullPaths.length === 0) { + bufferLog(`No matching files found for area ${mapping.name} with current filters.`) continue } + const allSourceFileBasenamesForMapping = new Set(enumeratedSourceFilesFullPaths.map((sf) => path.basename(sf))) + const locales = getFilteredLocales(options.locale) if (locales.length === 0) { - bufferLog(`No matching locales found for area ${mapping.name}`) + bufferLog(`No matching locales found for area ${mapping.name} with current filters.`) continue } - for (const sourceFile of sourceFiles) { + // Process each source file against each locale + for (const sourceFile of enumeratedSourceFilesFullPaths) { let sourceContent: any = null + // Load and parse source content once per source file if (sourceFile.endsWith(".json")) { const content = loadFileContent(sourceFile) - if (!content) continue + if (!content) { + // Log error or handle missing source file appropriately + // This might already be handled by enumerateSourceFiles or loadFileContent + bufferLog(`Warning: Could not load source file: ${sourceFile}`) + continue + } sourceContent = parseJsonContent(content, sourceFile) - if (!sourceContent) continue + if (!sourceContent) { + bufferLog(`Warning: Could not parse source JSON file: ${sourceFile}`) + continue + } } else { - sourceContent = loadFileContent(sourceFile) - if (!sourceContent) continue + // For non-JSON files (like .md), sourceContent is the raw text + sourceContent = loadFileContent(sourceFile) // Assuming loadFileContent returns string or null + if (sourceContent === null || sourceContent === undefined) { + // Check for null or undefined explicitly + bufferLog(`Warning: Could not load source file content: ${sourceFile}`) + continue + } } for (const locale of locales) { processFileLocale(sourceFile, sourceContent, mapping, locale, checksToRun, results) } } + + // After processing all source files for a mapping, check for extra files in target directories + if (checksToRun.includes("extra") || checksToRun.includes("all")) { + for (const locale of locales) { + checkExtraFiles(mapping, locale, results, allSourceFileBasenamesForMapping) + } + } } const hasIssues = formatResults(results, checksToRun, options, filteredMappings) From e0b27a12260c0221c4ea6bec59ed66f22dd7f185 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Tue, 20 May 2025 17:01:07 -0700 Subject: [PATCH 33/37] refactor: Isolate file/size checks in lint-translations Refactor `lint-translations.test.ts` to improve testability: - Extracted `checkIfFileMissing` to centralize file existence checks. - Extracted `checkFileSizeDifference` to isolate file size comparison logic. - Renamed `checkExtraFiles` to `identifyExtraFiles` and modified it to return a list of issues instead of directly mutating the results object. - Updated `processFileLocale` and the main `lintTranslations` loop to use these new/refactored helper functions. These changes make the logic for these specific checks more isolated and prepare the codebase for adding more focused unit tests. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 130 ++++++++++---------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index a4bdd59918..85d44b3cb1 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -240,6 +240,28 @@ function filterSourceFiles(sourceFiles: string[], fileArgs?: string[]): string[] }) } +function checkIfFileMissing(targetFilePath: string): boolean { + return !fileExists(targetFilePath) +} + +function checkFileSizeDifference(sourceFilePath: string, targetFilePath: string): { warning?: string; error?: string } { + try { + const sourceStats = fs.statSync(sourceFilePath) + const targetStats = fs.statSync(targetFilePath) + const sourceSize = sourceStats.size + const targetSize = targetStats.size + + if (targetSize > sourceSize * 2) { + return { + warning: `Target file ${targetFilePath} is more than 2x larger than source ${sourceFilePath}. It may require retranslation to be within +/- 20% of the source file size.`, + } + } + return {} // No warning, no error from this function's core logic + } catch (e: any) { + return { error: `Error getting file stats for size comparison: ${e.message}` } + } +} + function processFileLocale( sourceFile: string, sourceContent: any, @@ -261,7 +283,7 @@ function processFileLocale( if (reportFileLevelOnly) { // Handle file-level checks (e.g., for "docs") - if (!fileExists(targetFile)) { + if (checkIfFileMissing(targetFile)) { results[mapping.area][locale][targetFile].missing = [ { key: [sourceFile], // Source filename as the key @@ -272,25 +294,18 @@ function processFileLocale( } // Target file exists, perform size check - try { - const sourceStats = fs.statSync(sourceFile) - const targetStats = fs.statSync(targetFile) - const sourceSize = sourceStats.size - const targetSize = targetStats.size - - if (targetSize > sourceSize * 2) { - results[mapping.area][locale][targetFile].sizeWarning = - `Target file ${targetFile} is more than 2x larger than source ${sourceFile}. It may require retranslation to be within +/- 20% of the source file size.` - } - } catch (e: any) { - results[mapping.area][locale][targetFile].error = - `Error getting file stats for size comparison: ${e.message}` + const sizeCheckResult = checkFileSizeDifference(sourceFile, targetFile) + if (sizeCheckResult.warning) { + results[mapping.area][locale][targetFile].sizeWarning = sizeCheckResult.warning + } + if (sizeCheckResult.error) { + results[mapping.area][locale][targetFile].error = sizeCheckResult.error } // Do NOT attempt to load/parse content as JSON or call key-based checks return } else { // Handle key-based checks (e.g., for JSON files) - if (!fileExists(targetFile)) { + if (checkIfFileMissing(targetFile)) { results[mapping.area][locale][targetFile].missing = [ { key: [sourceFile], // File path as a single element array @@ -329,16 +344,22 @@ function processFileLocale( } } -function checkExtraFiles( +interface ExtraFileIssue { + extraFilePath: string + key: string[] // Typically ["EXTRA_FILE_MARKER"] + localeValue: string // The path of the extra file itself +} + +function identifyExtraFiles( mapping: PathMapping, locale: Language, - results: Results, allSourceFileBasenamesForMapping: Set, // A Set of basenames like {"common.json", "tools.json"} or {"README.md"} -): void { +): ExtraFileIssue[] { const targetDir = mapping.targetTemplate.replace("", locale) + const foundExtraFiles: ExtraFileIssue[] = [] if (!fs.existsSync(targetDir)) { - return // No target directory, so no extra files to check + return foundExtraFiles // No target directory, so no extra files to check } let actualTargetFilesDirents: fs.Dirent[] @@ -346,11 +367,8 @@ function checkExtraFiles( actualTargetFilesDirents = fs.readdirSync(targetDir, { withFileTypes: true }) } catch (e: any) { // This case should be rare if existsSync passed, but good to handle - // We can't report this against a specific file in results, so perhaps log it - // Or, if we want to be very strict, create a dummy entry in results for the directory itself. - // For now, let's bufferLog it. bufferLog(`Error reading target directory ${targetDir} for locale ${locale}: ${e.message}`) - return + return foundExtraFiles // Return empty or perhaps an issue indicating directory read error } for (const actualTargetFileDirent of actualTargetFilesDirents) { @@ -360,61 +378,29 @@ function checkExtraFiles( // Derive Corresponding Source Basename if (mapping.targetTemplate.endsWith("..json")) { - // Handles cases like package.nls..json derivedSourceBasename = actualTargetFilename.replace(`.${locale}.json`, ".json") } else if (mapping.targetTemplate.endsWith("/")) { - // Handles cases like locales// or src/i18n/locales// - // The actualTargetFilename is the basename we compare against source basenames derivedSourceBasename = actualTargetFilename } else { - // This case should ideally not be hit if targetTemplate is well-defined - // for extra file checking. If it's a direct file path without , - // it implies a 1:1 mapping, and extra files aren't typically checked this way. - // However, to be safe, let's assume the filename itself if no clear pattern. - // This might need refinement based on actual PathMapping structures. - // For now, if it's not a directory and not a .json pattern, - // we might not have a clear way to derive source basename. - // Let's log a warning and skip, or make a best guess. - // Best guess: if targetTemplate is `foo..bar` and actual is `foo.ca.bar`, source is `foo.bar` - // This is complex. For now, let's stick to the defined cases. - // If targetTemplate is like `specific-file..ext` const langPattern = `.${locale}.` if (actualTargetFilename.includes(langPattern)) { derivedSourceBasename = actualTargetFilename.replace(langPattern, ".") } else { - // If no in filename, and template is not a dir, it's ambiguous. - // Example: targetTemplate = "fixed_name.json" (no ) - // In this scenario, extra file check might not make sense or needs different logic. - // For now, we assume such mappings won't be common for this check or - // that `allSourceFileBasenamesForMapping` would be very specific. - // Let's assume if it's not a directory and not a ..json pattern, - // the actualTargetFilename is what we'd look for in source (less common). derivedSourceBasename = actualTargetFilename } } if (!allSourceFileBasenamesForMapping.has(derivedSourceBasename)) { const fullPathToActualTargetFile = path.join(targetDir, actualTargetFilename) - - // Ensure the result structure exists - results[mapping.area] = results[mapping.area] || {} - results[mapping.area][locale] = results[mapping.area][locale] || {} - results[mapping.area][locale][fullPathToActualTargetFile] = results[mapping.area][locale][ - fullPathToActualTargetFile - ] || { - missing: [], - extra: [], - error: undefined, - sizeWarning: undefined, - } - - results[mapping.area][locale][fullPathToActualTargetFile].extra.push({ + foundExtraFiles.push({ + extraFilePath: fullPathToActualTargetFile, key: ["EXTRA_FILE_MARKER"], // Standardized marker localeValue: fullPathToActualTargetFile, // The path of the extra file itself }) } } } + return foundExtraFiles } function formatResults(results: Results, checkTypes: string[], options: LintOptions, mappings: PathMapping[]): boolean { @@ -594,12 +580,10 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti if (!missingKeysByLocaleDisplay.has(locale)) missingKeysByLocaleDisplay.set(locale, new Map()) if (!missingKeysByLocaleDisplay.get(locale)?.has(targetFilePath)) { - missingKeysByLocaleDisplay - .get(locale) - ?.set(targetFilePath, { - keys: new Set(), - sourceFile: sourceFileAssociatedWithTarget, - }) + missingKeysByLocaleDisplay.get(locale)?.set(targetFilePath, { + keys: new Set(), + sourceFile: sourceFileAssociatedWithTarget, + }) } fileRes.missing.forEach((issue) => { missingKeysByLocaleDisplay @@ -1052,7 +1036,23 @@ function lintTranslations(args?: LintOptions): { output: string } { // After processing all source files for a mapping, check for extra files in target directories if (checksToRun.includes("extra") || checksToRun.includes("all")) { for (const locale of locales) { - checkExtraFiles(mapping, locale, results, allSourceFileBasenamesForMapping) + const extraFileIssues = identifyExtraFiles(mapping, locale, allSourceFileBasenamesForMapping) + for (const issue of extraFileIssues) { + results[mapping.area] = results[mapping.area] || {} + results[mapping.area][locale] = results[mapping.area][locale] || {} + results[mapping.area][locale][issue.extraFilePath] = results[mapping.area][locale][ + issue.extraFilePath + ] || { + missing: [], + extra: [], + error: undefined, + sizeWarning: undefined, + } + results[mapping.area][locale][issue.extraFilePath].extra.push({ + key: issue.key, + localeValue: issue.localeValue, + }) + } } } } From 9d9088402e1dda2e3e1530145282ab812e909c26 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Tue, 20 May 2025 18:26:29 -0700 Subject: [PATCH 34/37] fix: ignore dotfiles in translation linting Modify translation linting to ignore dotfiles in both source and target directories: - Update enumerateSourceFiles to skip dotfiles when scanning source directories - Update identifyExtraFiles to skip dotfiles when scanning target directories This prevents .gitkeep files from being reported as missing or extra translations. Also updates the file size warning threshold from 2x to 3x to reduce false positives for languages that naturally require more characters than English. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 85d44b3cb1..f3dde272d6 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -117,7 +117,7 @@ function enumerateSourceFiles(source: string | string[]): string[] { } else { const entries = fs.readdirSync(sourcePath, { withFileTypes: true }) for (const entry of entries) { - if (entry.isFile()) { + if (entry.isFile() && !entry.name.startsWith(".")) { files.push(path.join(source, entry.name)) } } @@ -251,9 +251,9 @@ function checkFileSizeDifference(sourceFilePath: string, targetFilePath: string) const sourceSize = sourceStats.size const targetSize = targetStats.size - if (targetSize > sourceSize * 2) { + if (targetSize > sourceSize * 3) { return { - warning: `Target file ${targetFilePath} is more than 2x larger than source ${sourceFilePath}. It may require retranslation to be within +/- 20% of the source file size.`, + warning: `Target file ${targetFilePath} is more than 3x larger than source ${sourceFilePath}. It may require retranslation to be within +/- 20% of the source file size.`, } } return {} // No warning, no error from this function's core logic @@ -372,7 +372,7 @@ function identifyExtraFiles( } for (const actualTargetFileDirent of actualTargetFilesDirents) { - if (actualTargetFileDirent.isFile()) { + if (actualTargetFileDirent.isFile() && !actualTargetFileDirent.name.startsWith(".")) { const actualTargetFilename = actualTargetFileDirent.name let derivedSourceBasename: string From 9be8c0655e9730ff05385682402c49785a798973 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Tue, 20 May 2025 19:51:30 -0700 Subject: [PATCH 35/37] fix(i18n): Refine missing key reporting for lint-translations Filter out keys with object values when reporting 'ALL KEYS' for a missing JSON file. This prevents parent keys of nested objects from being incorrectly displayed as missing individual translations. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index f3dde272d6..65caa7514a 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -632,16 +632,20 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti sourceFileForMissing, ) if (sourceContent) { - const issues = checkMissingTranslations(sourceContent, {}) // Get all keys - bufferLog(` Missing keys: ALL KEYS (${issues.length} total)`) - if (options?.verbose && issues.length > 0) { + let issues = checkMissingTranslations(sourceContent, {}) // Get all keys + // Filter out issues where the source value is an object + const primitiveIssues = issues.filter( + (issue) => typeof issue.sourceValue !== "object" || issue.sourceValue === null, + ) + bufferLog(` Missing keys: ALL KEYS (${primitiveIssues.length} total)`) + if (options?.verbose && primitiveIssues.length > 0) { let displayKeyPrefix = "" if (mapping.displayNamespace) { displayKeyPrefix = mapping.displayNamespace + ":" } else if (mapping.useFilenameAsNamespace) { displayKeyPrefix = path.basename(sourceFileForMissing, ".json") + ":" } - issues + primitiveIssues .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key)), ) From 1f2c77ec01e8b35394a8e3362e5f05bd28737463 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Thu, 5 Jun 2025 00:18:35 -0700 Subject: [PATCH 36/37] fix: hide English translations for missing keys in lint output Modified the translation linting script to no longer display English source text when reporting missing translation keys. This makes the output cleaner and focuses only on the missing key paths rather than showing both the path and the untranslated English text. The changes remove the display of "[en]" source values while preserving the key path information needed to identify missing translations. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 65caa7514a..20c7f7660e 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -677,15 +677,15 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti const mapping = mappingForArea const originalSourceFileName = fileData.sourceFile // Use stored sourceFile - const missingIssuesInFile = results[area][lang][targetFilePath].missing + // const missingIssuesInFile = results[area][lang][targetFilePath].missing // Removed as it's no longer used Array.from(fileData.keys) .map((spa) => spa.split("\u0000")) // pathArrayKey .sort((a, b) => escapeDotsForDisplay(a).localeCompare(escapeDotsForDisplay(b))) .forEach((pathArrayKey) => { - const issue = missingIssuesInFile.find( - (iss) => iss.key.join("\u0000") === pathArrayKey.join("\u0000"), - ) - const englishValue = issue ? issue.sourceValue : undefined + // const issue = missingIssuesInFile.find( // Removed as it's no longer used + // (iss) => iss.key.join("\u0000") === pathArrayKey.join("\u0000"), + // ) + // const englishValue = issue ? issue.sourceValue : undefined // Removed as it's no longer used let displayKeyPrefix = "" if (mapping.displayNamespace) { @@ -699,12 +699,10 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti if (mapping.reportFileLevelOnly === true) { // Should not be reached if logic is correct, keys are not reported for fileLevelOnly - bufferLog( - ` - ${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, - ) // Fallback + bufferLog(` - ${escapeDotsForDisplay(pathArrayKey)}`) // Fallback } else { bufferLog( - ` - ${displayKeyPrefix}${escapeDotsForDisplay(pathArrayKey)} - ${JSON.stringify(englishValue)} [en]`, + ` - ${displayKeyPrefix}${escapeDotsForDisplay(pathArrayKey)}`, ) } }) From f35577d4dd89d5970e559b62eb4a2f7654156efd Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Thu, 5 Jun 2025 00:46:48 -0700 Subject: [PATCH 37/37] refactor: create dedicated functions for rendering translation issues Create separate functions for rendering missing and extra translations: - renderMissingFilesSection: For completely missing files - renderMissingKeysSection: For files with missing keys - renderExtraTranslationsSection: For extra translations These functions ensure consistent formatting across the codebase and fix the issue of showing translation values for extra keys. Signed-off-by: Eric Wheeler --- locales/__tests__/lint-translations.test.ts | 319 ++++++++++---------- 1 file changed, 162 insertions(+), 157 deletions(-) diff --git a/locales/__tests__/lint-translations.test.ts b/locales/__tests__/lint-translations.test.ts index 20c7f7660e..e916206ee4 100644 --- a/locales/__tests__/lint-translations.test.ts +++ b/locales/__tests__/lint-translations.test.ts @@ -13,6 +13,165 @@ import { escapeDotsForDisplay, } from "./utils" +/** + * Renders the section for completely missing files for a specific locale + * @param locale The language locale + * @param files Array of target file paths that are completely missing + * @param area The translation area + * @param results Results object containing file results + * @param mapping The path mapping containing namespace information + * @returns Void - outputs directly to bufferLog + */ +function renderMissingFilesSection( + locale: string, + files: string[], + area: string, + results: Results, + mapping: PathMapping, +): void { + bufferLog(` ${locale}: missing ${files.length} files`) + files.sort().forEach((targetFilePath) => { + bufferLog(` ${targetFilePath}`) + + // Find the source file that this targetFilePath corresponds to + let sourceFileForMissing = "unknown_source.file" + const fileResultForMissing = results[area][locale][targetFilePath] + if ( + fileResultForMissing && + fileResultForMissing.missing.length === 1 && + fileResultForMissing.missing[0].key.length === 1 + ) { + sourceFileForMissing = fileResultForMissing.missing[0].key[0] + } else { + // Fallback if not easily found (should be available for completely missing files) + const allSourcesForMapping = enumerateSourceFiles(mapping.source) + for (const sf of allSourcesForMapping) { + if (resolveTargetPath(sf, mapping.targetTemplate, locale) === targetFilePath) { + sourceFileForMissing = sf + break + } + } + } + + if (mapping.reportFileLevelOnly === true) { + bufferLog(` Missing file: ALL CONTENT (entire file)`) + } else { + // JSON files + const sourceContent = parseJsonContent(loadFileContent(sourceFileForMissing), sourceFileForMissing) + if (sourceContent) { + let issues = checkMissingTranslations(sourceContent, {}) // Get all keys + // Filter out issues where the source value is an object + const primitiveIssues = issues.filter( + (issue) => typeof issue.sourceValue !== "object" || issue.sourceValue === null, + ) + bufferLog(` Missing keys: ALL KEYS (${primitiveIssues.length} total)`) + } else { + bufferLog(` Missing keys: Unable to load corresponding source file ${sourceFileForMissing}`) + } + } + }) +} + +/** + * Renders the section for files with missing keys for a specific locale + * @param locale The language locale + * @param fileMap Map of target files to their missing keys and source files + * @param mapping The path mapping containing namespace information + * @param options Lint options for verbose output + * @returns Void - outputs directly to bufferLog + */ +function renderMissingKeysSection( + locale: string, + fileMap: Map; sourceFile: string }>, + mapping: PathMapping, +): void { + const filesWithMissingKeys = Array.from(fileMap.keys()) + if (filesWithMissingKeys.length > 0) { + bufferLog(` ${locale}: ${filesWithMissingKeys.length} files with missing translations`) + filesWithMissingKeys.sort().forEach((targetFilePath) => { + bufferLog(` ${targetFilePath}`) + const fileData = fileMap.get(targetFilePath) + if (fileData && fileData.keys.size > 0) { + bufferLog(` Missing keys (${fileData.keys.size} total):`) + const originalSourceFileName = fileData.sourceFile + + Array.from(fileData.keys) + .map((spa) => spa.split("\u0000")) // pathArrayKey + .sort((a, b) => escapeDotsForDisplay(a).localeCompare(escapeDotsForDisplay(b))) + .forEach((pathArrayKey) => { + let displayKeyPrefix = "" + if (mapping.displayNamespace) { + displayKeyPrefix = mapping.displayNamespace + ":" + } else if (mapping.useFilenameAsNamespace && originalSourceFileName !== "unknown_source.file") { + displayKeyPrefix = path.basename(originalSourceFileName, ".json") + ":" + } + + if (mapping.reportFileLevelOnly === true) { + // Should not be reached if logic is correct, keys are not reported for fileLevelOnly + bufferLog(` - ${escapeDotsForDisplay(pathArrayKey)}`) // Fallback + } else { + bufferLog(` - ${displayKeyPrefix}${escapeDotsForDisplay(pathArrayKey)}`) + } + }) + } + }) + } +} + +/** + * Renders the section for extra translations for a specific locale + * @param locale The language locale + * @param fileMap Map of target files to their extra translation issues + * @param mapping The path mapping containing namespace information + * @returns Void - outputs directly to bufferLog + */ +function renderExtraTranslationsSection( + locale: string, + fileMap: Map, + mapping: PathMapping, +): void { + fileMap.forEach(({ issues: extras, sourceFile: derivedSourceFile }, targetFilePath) => { + if (!mapping) return + + // Attempt to derive sourceFile if it's "DERIVE_LATER" + let actualSourceFile = derivedSourceFile + if (actualSourceFile === "DERIVE_LATER") { + const allSourcesForMapping = enumerateSourceFiles(mapping.source) + for (const sf of allSourcesForMapping) { + if (resolveTargetPath(sf, mapping.targetTemplate, locale) === targetFilePath) { + actualSourceFile = sf + break + } + } + if (actualSourceFile === "DERIVE_LATER") actualSourceFile = "unknown_source.file" + } + + const extraFileMarkers = extras.filter((ex) => ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER") + const extraJsonKeys = extras.filter((ex) => !(ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER")) + + extraFileMarkers.forEach((issue) => { + const extraFilePathDisplay = issue.localeValue as string // Full path to extra file + bufferLog(` ${locale}: ${extraFilePathDisplay} (Extra File)`) + }) + + if (extraJsonKeys.length > 0) { + let displayKeyPrefix = "" + if (mapping.displayNamespace) { + displayKeyPrefix = mapping.displayNamespace + ":" + } else if (mapping.useFilenameAsNamespace && actualSourceFile !== "unknown_source.file") { + displayKeyPrefix = path.basename(actualSourceFile, ".json") + ":" + } + + bufferLog(` ${locale}: ${targetFilePath}: ${extraJsonKeys.length} extra translations`) + extraJsonKeys + .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key))) + .forEach((issue) => { + bufferLog(` ${displayKeyPrefix}${escapeDotsForDisplay(issue.key)}`) + }) + } + }) +} + // Create a mutable copy of the languages array that can be overridden let languages = [...originalLanguages] @@ -542,8 +701,6 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti for (const [targetFilePath, fileRes] of Object.entries(localeResults)) { if (fileRes.missing && fileRes.missing.length > 0) { // Try to find the original source file for this targetFilePath - // This is the challenging part without direct storage. - // Attempt to find sourceFile based on how results are populated by processFileLocale let sourceFileAssociatedWithTarget = "unknown_source.file" // Default // Heuristic: iterate all source files for this mapping, resolve their target, and see if it matches. const currentMapping = mappings.find((m) => m.area === area) @@ -598,117 +755,12 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti // Report missing files missingFilesByLocaleDisplay.forEach((files, lang) => { - bufferLog(` ${lang}: missing ${files.length} files`) - files.sort().forEach((targetFilePath) => { - bufferLog(` ${targetFilePath}`) - const mapping = mappingForArea // Use mappingForArea - - // Find the source file that this targetFilePath corresponds to - let sourceFileForMissing = "unknown_source.file" - const fileResultForMissing = results[area][lang][targetFilePath] - if ( - fileResultForMissing && - fileResultForMissing.missing.length === 1 && - fileResultForMissing.missing[0].key.length === 1 - ) { - sourceFileForMissing = fileResultForMissing.missing[0].key[0] - } else { - // Fallback if not easily found (should be available for completely missing files) - const allSourcesForMapping = enumerateSourceFiles(mapping.source) - for (const sf of allSourcesForMapping) { - if (resolveTargetPath(sf, mapping.targetTemplate, lang) === targetFilePath) { - sourceFileForMissing = sf - break - } - } - } - - if (mapping.reportFileLevelOnly === true) { - bufferLog(` Missing file: ALL CONTENT (entire file)`) - } else { - // JSON files - const sourceContent = parseJsonContent( - loadFileContent(sourceFileForMissing), - sourceFileForMissing, - ) - if (sourceContent) { - let issues = checkMissingTranslations(sourceContent, {}) // Get all keys - // Filter out issues where the source value is an object - const primitiveIssues = issues.filter( - (issue) => typeof issue.sourceValue !== "object" || issue.sourceValue === null, - ) - bufferLog(` Missing keys: ALL KEYS (${primitiveIssues.length} total)`) - if (options?.verbose && primitiveIssues.length > 0) { - let displayKeyPrefix = "" - if (mapping.displayNamespace) { - displayKeyPrefix = mapping.displayNamespace + ":" - } else if (mapping.useFilenameAsNamespace) { - displayKeyPrefix = path.basename(sourceFileForMissing, ".json") + ":" - } - primitiveIssues - .sort((a, b) => - escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key)), - ) - .forEach((issue) => { - bufferLog( - ` - ${displayKeyPrefix}${escapeDotsForDisplay(issue.key)} - ${JSON.stringify(issue.sourceValue)} [en]`, - ) - }) - } - } else { - bufferLog( - ` Missing keys: Unable to load corresponding source file ${sourceFileForMissing}`, - ) - } - } - }) + renderMissingFilesSection(lang, files, area, results, mappingForArea) }) // Report files with missing keys missingKeysByLocaleDisplay.forEach((fileMap, lang) => { - const filesWithMissingKeys = Array.from(fileMap.keys()) - if (filesWithMissingKeys.length > 0) { - bufferLog(` ${lang}: ${filesWithMissingKeys.length} files with missing translations`) - filesWithMissingKeys.sort().forEach((targetFilePath) => { - bufferLog(` ${targetFilePath}`) - const fileData = fileMap.get(targetFilePath) - if (fileData && fileData.keys.size > 0) { - bufferLog(` Missing keys (${fileData.keys.size} total):`) - const mapping = mappingForArea - const originalSourceFileName = fileData.sourceFile // Use stored sourceFile - - // const missingIssuesInFile = results[area][lang][targetFilePath].missing // Removed as it's no longer used - Array.from(fileData.keys) - .map((spa) => spa.split("\u0000")) // pathArrayKey - .sort((a, b) => escapeDotsForDisplay(a).localeCompare(escapeDotsForDisplay(b))) - .forEach((pathArrayKey) => { - // const issue = missingIssuesInFile.find( // Removed as it's no longer used - // (iss) => iss.key.join("\u0000") === pathArrayKey.join("\u0000"), - // ) - // const englishValue = issue ? issue.sourceValue : undefined // Removed as it's no longer used - - let displayKeyPrefix = "" - if (mapping.displayNamespace) { - displayKeyPrefix = mapping.displayNamespace + ":" - } else if ( - mapping.useFilenameAsNamespace && - originalSourceFileName !== "unknown_source.file" - ) { - displayKeyPrefix = path.basename(originalSourceFileName, ".json") + ":" - } - - if (mapping.reportFileLevelOnly === true) { - // Should not be reached if logic is correct, keys are not reported for fileLevelOnly - bufferLog(` - ${escapeDotsForDisplay(pathArrayKey)}`) // Fallback - } else { - bufferLog( - ` - ${displayKeyPrefix}${escapeDotsForDisplay(pathArrayKey)}`, - ) - } - }) - } - }) - } + renderMissingKeysSection(lang, fileMap, mappingForArea) }) } @@ -716,54 +768,7 @@ function formatResults(results: Results, checkTypes: string[], options: LintOpti if (extraByLocale.size > 0 && (checkTypes.includes("extra") || checkTypes.includes("all"))) { bufferLog(` ⚠️ Extra translations:`) extraByLocale.forEach((fileMap, locale) => { - // locale -> targetFilePath -> {issues, sourceFile (DERIVE_LATER)} - fileMap.forEach(({ issues: extras, sourceFile: derivedSourceFile }, targetFilePath) => { - const mapping = mappingForArea - if (!mapping) return - - // Attempt to derive sourceFile if it's "DERIVE_LATER" - let actualSourceFile = derivedSourceFile - if (actualSourceFile === "DERIVE_LATER") { - const allSourcesForMapping = enumerateSourceFiles(mapping.source) - for (const sf of allSourcesForMapping) { - if (resolveTargetPath(sf, mapping.targetTemplate, locale) === targetFilePath) { - actualSourceFile = sf - break - } - } - if (actualSourceFile === "DERIVE_LATER") actualSourceFile = "unknown_source.file" - } - - const extraFileMarkers = extras.filter( - (ex) => ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER", - ) - const extraJsonKeys = extras.filter( - (ex) => !(ex.key.length === 1 && ex.key[0] === "EXTRA_FILE_MARKER"), - ) - - extraFileMarkers.forEach((issue) => { - const extraFilePathDisplay = issue.localeValue as string // Full path to extra file - bufferLog(` ${locale}: ${extraFilePathDisplay} (Extra File)`) - }) - - if (extraJsonKeys.length > 0) { - let displayKeyPrefix = "" - if (mapping.displayNamespace) { - displayKeyPrefix = mapping.displayNamespace + ":" - } else if (mapping.useFilenameAsNamespace && actualSourceFile !== "unknown_source.file") { - displayKeyPrefix = path.basename(actualSourceFile, ".json") + ":" - } - - bufferLog(` ${locale}: ${targetFilePath}: ${extraJsonKeys.length} extra translations`) - extraJsonKeys - .sort((a, b) => escapeDotsForDisplay(a.key).localeCompare(escapeDotsForDisplay(b.key))) - .forEach((issue) => { - bufferLog( - ` ${displayKeyPrefix}${escapeDotsForDisplay(issue.key)}: "${issue.localeValue}"`, - ) - }) - } - }) + renderExtraTranslationsSection(locale, fileMap, mappingForArea) }) }