find-missing-translations.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. /**
  2. * Script to find missing translations in locale files
  3. *
  4. * Usage:
  5. * node scripts/find-missing-translations.js [options]
  6. *
  7. * Options:
  8. * --locale=<locale> Only check a specific locale (e.g. --locale=fr)
  9. * --file=<file> Only check a specific file (e.g. --file=chat.json)
  10. * --area=<area> Only check a specific area (core, webview, package-nls, or all)
  11. * --help Show this help message
  12. */
  13. const path = require("path")
  14. const { promises: fs } = require("fs")
  15. const readFile = fs.readFile
  16. const readdir = fs.readdir
  17. const stat = fs.stat
  18. // Process command line arguments
  19. const args = process.argv.slice(2).reduce(
  20. (acc, arg) => {
  21. if (arg === "--help") {
  22. acc.help = true
  23. } else if (arg.startsWith("--locale=")) {
  24. acc.locale = arg.split("=")[1]
  25. } else if (arg.startsWith("--file=")) {
  26. acc.file = arg.split("=")[1]
  27. } else if (arg.startsWith("--area=")) {
  28. acc.area = arg.split("=")[1]
  29. // Validate area value
  30. if (!["core", "webview", "package-nls", "all"].includes(acc.area)) {
  31. console.error(`Error: Invalid area '${acc.area}'. Must be 'core', 'webview', 'package-nls', or 'all'.`)
  32. process.exit(1)
  33. }
  34. }
  35. return acc
  36. },
  37. { area: "all" },
  38. ) // Default to checking all areas
  39. // Show help if requested
  40. if (args.help) {
  41. console.log(`
  42. Find Missing Translations
  43. A utility script to identify missing translations across locale files.
  44. Compares non-English locale files to the English ones to find any missing keys.
  45. Usage:
  46. node scripts/find-missing-translations.js [options]
  47. Options:
  48. --locale=<locale> Only check a specific locale (e.g. --locale=fr)
  49. --file=<file> Only check a specific file (e.g. --file=chat.json)
  50. --area=<area> Only check a specific area (core, webview, package-nls, or all)
  51. 'core' = Backend (src/i18n/locales)
  52. 'webview' = Frontend UI (webview-ui/src/i18n/locales)
  53. 'package-nls' = VSCode package.nls.json files
  54. 'all' = Check all areas (default)
  55. --help Show this help message
  56. Output:
  57. - Generates a report of missing translations for each area
  58. `)
  59. process.exit(0)
  60. }
  61. // Paths to the locales directories
  62. const LOCALES_DIRS = {
  63. core: path.join(__dirname, "../src/i18n/locales"),
  64. webview: path.join(__dirname, "../webview-ui/src/i18n/locales"),
  65. }
  66. // Determine which areas to check based on args
  67. const areasToCheck = args.area === "all" ? ["core", "webview", "package-nls"] : [args.area]
  68. // Recursively find all keys in an object
  69. function findKeys(obj, parentKey = "") {
  70. let keys = []
  71. for (const [key, value] of Object.entries(obj)) {
  72. const currentKey = parentKey ? `${parentKey}.${key}` : key
  73. if (typeof value === "object" && value !== null) {
  74. // If value is an object, recurse
  75. keys = [...keys, ...findKeys(value, currentKey)]
  76. } else {
  77. // If value is a primitive, add the key
  78. keys.push(currentKey)
  79. }
  80. }
  81. return keys
  82. }
  83. // Get value at a dotted path in an object
  84. function getValueAtPath(obj, path) {
  85. const parts = path.split(".")
  86. let current = obj
  87. for (const part of parts) {
  88. if (current === undefined || current === null) {
  89. return undefined
  90. }
  91. current = current[part]
  92. }
  93. return current
  94. }
  95. // Shared utility to safely parse JSON files with error handling
  96. async function parseJsonFile(filePath) {
  97. try {
  98. const content = await readFile(filePath, "utf8")
  99. return JSON.parse(content)
  100. } catch (error) {
  101. if (error.code === "ENOENT") {
  102. return null // File doesn't exist
  103. }
  104. throw new Error(`Error parsing JSON file '${filePath}': ${error.message}`)
  105. }
  106. }
  107. // Validate that a JSON object has a flat structure (no nested objects)
  108. function validateFlatStructure(obj, filePath) {
  109. for (const [key, value] of Object.entries(obj)) {
  110. if (typeof value === "object" && value !== null) {
  111. console.error(`Error: ${filePath} should be a flat JSON structure. Found nested object at key '${key}'`)
  112. process.exit(1)
  113. }
  114. }
  115. }
  116. // Function to check translations for a specific area
  117. async function checkAreaTranslations(area) {
  118. const LOCALES_DIR = LOCALES_DIRS[area]
  119. // Get all locale directories (or filter to the specified locale)
  120. const dirContents = await readdir(LOCALES_DIR)
  121. const allLocales = await Promise.all(
  122. dirContents.map(async (item) => {
  123. const stats = await stat(path.join(LOCALES_DIR, item))
  124. return stats.isDirectory() && item !== "en" ? item : null
  125. }),
  126. )
  127. const filteredLocales = allLocales.filter(Boolean)
  128. // Filter to the specified locale if provided
  129. const locales = args.locale ? filteredLocales.filter((locale) => locale === args.locale) : filteredLocales
  130. if (args.locale && locales.length === 0) {
  131. console.error(`Error: Locale '${args.locale}' not found in ${LOCALES_DIR}`)
  132. process.exit(1)
  133. }
  134. console.log(
  135. `\n${area === "core" ? "BACKEND" : "FRONTEND"} - Checking ${locales.length} non-English locale(s): ${locales.join(", ")}`,
  136. )
  137. // Get all English JSON files
  138. const englishDir = path.join(LOCALES_DIR, "en")
  139. const englishDirContents = await readdir(englishDir)
  140. let englishFiles = englishDirContents.filter((file) => file.endsWith(".json") && !file.startsWith("."))
  141. // Filter to the specified file if provided
  142. if (args.file) {
  143. if (!englishFiles.includes(args.file)) {
  144. console.error(`Error: File '${args.file}' not found in ${englishDir}`)
  145. process.exit(1)
  146. }
  147. englishFiles = englishFiles.filter((file) => file === args.file)
  148. }
  149. // Load file contents in parallel
  150. const englishFileContents = await Promise.all(
  151. englishFiles.map(async (file) => {
  152. const filePath = path.join(englishDir, file)
  153. const content = await parseJsonFile(filePath)
  154. if (!content) {
  155. console.error(`Error: Could not read file '${filePath}'`)
  156. process.exit(1)
  157. }
  158. return { name: file, content }
  159. }),
  160. )
  161. console.log(
  162. `Checking ${englishFileContents.length} translation file(s): ${englishFileContents.map((f) => f.name).join(", ")}`,
  163. )
  164. // Precompute English keys per file
  165. const englishFileKeys = new Map(englishFileContents.map((f) => [f.name, findKeys(f.content)]))
  166. // Results object to store missing translations
  167. const missingTranslations = {}
  168. // Process all locales in parallel
  169. await Promise.all(
  170. locales.map(async (locale) => {
  171. missingTranslations[locale] = {}
  172. // Process all files for this locale in parallel
  173. await Promise.all(
  174. englishFileContents.map(async ({ name, content: englishContent }) => {
  175. const localeFilePath = path.join(LOCALES_DIR, locale, name)
  176. // Check if the file exists in the locale
  177. const localeContent = await parseJsonFile(localeFilePath)
  178. if (!localeContent) {
  179. missingTranslations[locale][name] = { file: "File is missing entirely" }
  180. return
  181. }
  182. // Find all keys in the English file
  183. const englishKeys = englishFileKeys.get(name) || []
  184. // Check for missing keys in the locale file
  185. const missingKeys = []
  186. for (const key of englishKeys) {
  187. const englishValue = getValueAtPath(englishContent, key)
  188. const localeValue = getValueAtPath(localeContent, key)
  189. if (localeValue === undefined) {
  190. missingKeys.push({
  191. key,
  192. englishValue,
  193. })
  194. }
  195. }
  196. if (missingKeys.length > 0) {
  197. missingTranslations[locale][name] = missingKeys
  198. }
  199. }),
  200. )
  201. }),
  202. )
  203. return { missingTranslations, hasMissingTranslations: outputResults(missingTranslations, area) }
  204. }
  205. // Function to output results for an area
  206. function outputResults(missingTranslations, area) {
  207. let hasMissingTranslations = false
  208. console.log(`\n${area === "core" ? "BACKEND" : "FRONTEND"} Missing Translations Report:\n`)
  209. for (const [locale, files] of Object.entries(missingTranslations)) {
  210. if (Object.keys(files).length === 0) {
  211. console.log(`✅ ${locale}: No missing translations`)
  212. continue
  213. }
  214. hasMissingTranslations = true
  215. console.log(`📝 ${locale}:`)
  216. for (const [fileName, missingItems] of Object.entries(files)) {
  217. if (missingItems.file) {
  218. console.log(` - ${fileName}: ${missingItems.file}`)
  219. continue
  220. }
  221. console.log(` - ${fileName}: ${missingItems.length} missing translations`)
  222. for (const { key, englishValue } of missingItems) {
  223. console.log(` ${key}: "${englishValue}"`)
  224. }
  225. }
  226. console.log("")
  227. }
  228. return hasMissingTranslations
  229. }
  230. // Function to check package.nls.json translations
  231. async function checkPackageNlsTranslations() {
  232. const SRC_DIR = path.join(__dirname, "../src")
  233. // Read the base package.nls.json file
  234. const baseFilePath = path.join(SRC_DIR, "package.nls.json")
  235. const baseContent = await parseJsonFile(baseFilePath)
  236. if (!baseContent) {
  237. console.warn(`Warning: Base package.nls.json not found at ${baseFilePath} - skipping package.nls checks`)
  238. return { missingTranslations: {}, hasMissingTranslations: false }
  239. }
  240. // Validate that the base file has a flat structure
  241. validateFlatStructure(baseContent, baseFilePath)
  242. // Get all package.nls.*.json files
  243. const srcDirContents = await readdir(SRC_DIR)
  244. const nlsFiles = srcDirContents
  245. .filter((file) => file.startsWith("package.nls.") && file.endsWith(".json"))
  246. .filter((file) => file !== "package.nls.json") // Exclude the base file
  247. // Filter to the specified locale if provided
  248. const filesToCheck = args.locale
  249. ? nlsFiles.filter((file) => {
  250. const locale = file.replace("package.nls.", "").replace(".json", "")
  251. return locale === args.locale
  252. })
  253. : nlsFiles
  254. if (args.locale && filesToCheck.length === 0) {
  255. console.error(`Error: Locale '${args.locale}' not found in package.nls files`)
  256. process.exit(1)
  257. }
  258. console.log(
  259. `\nPACKAGE.NLS - Checking ${filesToCheck.length} locale file(s): ${filesToCheck.map((f) => f.replace("package.nls.", "").replace(".json", "")).join(", ")}`,
  260. )
  261. console.log(`Checking against base package.nls.json with ${Object.keys(baseContent).length} keys`)
  262. // Results object to store missing translations
  263. const missingTranslations = {}
  264. // Get all keys from the base file (package.nls files are flat, not nested)
  265. const baseKeys = Object.keys(baseContent)
  266. // Process all locale files in parallel
  267. await Promise.all(
  268. filesToCheck.map(async (file) => {
  269. const locale = file.replace("package.nls.", "").replace(".json", "")
  270. const localeFilePath = path.join(SRC_DIR, file)
  271. const localeContent = await parseJsonFile(localeFilePath)
  272. if (!localeContent) {
  273. console.error(`Error: Could not read file '${localeFilePath}'`)
  274. process.exit(1)
  275. }
  276. // Validate that the locale file has a flat structure
  277. validateFlatStructure(localeContent, localeFilePath)
  278. // Check for missing keys
  279. const missingKeys = []
  280. for (const key of baseKeys) {
  281. const baseValue = baseContent[key]
  282. const localeValue = localeContent[key]
  283. if (localeValue === undefined) {
  284. missingKeys.push({
  285. key,
  286. englishValue: baseValue,
  287. })
  288. }
  289. }
  290. if (missingKeys.length > 0) {
  291. missingTranslations[locale] = {
  292. "package.nls.json": missingKeys,
  293. }
  294. }
  295. }),
  296. )
  297. return { missingTranslations, hasMissingTranslations: outputPackageNlsResults(missingTranslations) }
  298. }
  299. // Function to output package.nls results
  300. function outputPackageNlsResults(missingTranslations) {
  301. let hasMissingTranslations = false
  302. console.log(`\nPACKAGE.NLS Missing Translations Report:\n`)
  303. for (const [locale, files] of Object.entries(missingTranslations)) {
  304. if (Object.keys(files).length === 0) {
  305. console.log(`✅ ${locale}: No missing translations`)
  306. continue
  307. }
  308. hasMissingTranslations = true
  309. console.log(`📝 ${locale}:`)
  310. for (const [fileName, missingItems] of Object.entries(files)) {
  311. console.log(` - ${fileName}: ${missingItems.length} missing translations`)
  312. for (const { key, englishValue } of missingItems) {
  313. console.log(` ${key}: "${englishValue}"`)
  314. }
  315. }
  316. console.log("")
  317. }
  318. return hasMissingTranslations
  319. }
  320. // Main function to find missing translations
  321. async function findMissingTranslations() {
  322. try {
  323. console.log("Starting translation check...")
  324. let anyAreaMissingTranslations = false
  325. // Check each requested area
  326. for (const area of areasToCheck) {
  327. if (area === "package-nls") {
  328. const { hasMissingTranslations } = await checkPackageNlsTranslations()
  329. anyAreaMissingTranslations = anyAreaMissingTranslations || hasMissingTranslations
  330. } else {
  331. const { hasMissingTranslations } = await checkAreaTranslations(area)
  332. anyAreaMissingTranslations = anyAreaMissingTranslations || hasMissingTranslations
  333. }
  334. }
  335. // Summary
  336. if (!anyAreaMissingTranslations) {
  337. console.log("\n✅ All translations are complete across all checked areas!")
  338. } else {
  339. console.log("\n✏️ To add missing translations:")
  340. console.log("1. Add the missing keys to the corresponding locale files")
  341. console.log("2. Translate the English values to the appropriate language")
  342. console.log("3. Run this script again to verify all translations are complete")
  343. // Exit with error code to fail CI checks
  344. process.exit(1)
  345. }
  346. } catch (error) {
  347. console.error("Error:", error.message)
  348. console.error(error.stack)
  349. process.exit(1)
  350. }
  351. }
  352. // Run the main function
  353. findMissingTranslations()