diff --git a/package-lock.json b/package-lock.json index 13d1cd31..cf8a78db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "vue-color": "^2.8.1", "vue-d3-sunburst": "git+https://github.com/ErikBjare/Vue.D3.sunburst.git#patch-1", "vue-datetime": "^1.0.0-beta.13", + "vue-i18n": "^8.28.2", "vuedraggable": "^2.24.3", "weekstart": "^1.0.1", "xss": "^1.0.14" @@ -23563,6 +23564,13 @@ "dev": true, "license": "MIT" }, + "node_modules/vue-i18n": { + "version": "8.28.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.28.2.tgz", + "integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT" + }, "node_modules/vue-loader": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.4.2.tgz", diff --git a/package.json b/package.json index 6ac792ee..f6968612 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "jest", "test:unit": "jest --selectProjects jsdom", "test:e2e": "vue-cli-service test:e2e", + "check:locales": "node scripts/check-locales.mjs", "lint": "vue-cli-service lint", "lint_old": "eslint --ignore-path=.gitignore --ext .vue,.js,.ts ." }, @@ -69,6 +70,7 @@ "vue-color": "^2.8.1", "vue-d3-sunburst": "git+https://github.com/ErikBjare/Vue.D3.sunburst.git#patch-1", "vue-datetime": "^1.0.0-beta.13", + "vue-i18n": "^8.28.2", "vuedraggable": "^2.24.3", "weekstart": "^1.0.1", "xss": "^1.0.14" diff --git a/scripts/check-locales.mjs b/scripts/check-locales.mjs new file mode 100644 index 00000000..0ca72d11 --- /dev/null +++ b/scripts/check-locales.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * Validates i18n locale files: key parity vs en, placeholder consistency, untranslated strings. + * Usage: node scripts/check-locales.mjs + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = path.join(__dirname, '../src/i18n/locales'); +const LOCALES = ['en', 'uk', 'de', 'ru', 'zh-CN']; + +/** Substrings: identical en/value is OK when value contains any of these (case-insensitive). */ +const ALLOWLIST_SUBSTRINGS = [ + 'activitywatch', + 'discord', + 'github', + 'twitter', + 'facebook', + 'patreon', + 'reddit', + 'producthunt', + 'alternativeto', + 'opentelemetry', + 'json', + 'csv', + 'api', + 'host', + 'bucket', + 'watcher', + 'afk', + 'stopwatch', + 'timespiral', + 'devtools', + 'devmode', + 'hostname', + 'http', + 'forum', + 'liberapay', + 'opencollective', + 'mozilla', + 'chrome', + 'android', + 'ios', + 'linux', + 'windows', + 'macos', + 'npm', + 'vue', + 'pinia', + 'id', + 'url', + 'regex', + 'score', + 'timeline', + 'graph', + 'query', + 'trends', + 'report', + 'search', + 'settings', + 'home', + 'tools', + 'theme', + 'auto', + 'light', + 'dark', + 'monday', + 'saturday', + 'sunday', + 'version', + 'experiment', + 'wip', + 'demo', + 'cancel', + 'save', + 'open', + 'more', + 'delete', + 'import', + 'export', + 'confirm', + 'enabled', + 'disabled', + 'loading', + 'refresh', + 'options', + 'filters', + 'remove', + 'start', + 'end', + 'running', + 'history', + 'documentation', + 'website', +]; + +function loadLocale(code) { + const filePath = path.join(LOCALES_DIR, `${code}.ts`); + let text = fs.readFileSync(filePath, 'utf8'); + text = text.replace(/export\s+default\s+/, '').replace(/;\s*$/, ''); + // Locale files are static object literals only. + // eslint-disable-next-line no-new-func + return new Function(`return (${text})`)(); +} + +function flatten(obj, prefix = '') { + const out = {}; + for (const [key, value] of Object.entries(obj)) { + const pathKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(out, flatten(value, pathKey)); + } else if (typeof value === 'string') { + out[pathKey] = value; + } + } + return out; +} + +function placeholders(s) { + const m = s.match(/\{[a-zA-Z]+\}/g); + return m ? [...new Set(m)].sort().join(',') : ''; +} + +function isAllowlistedIdentical(enValue, targetValue) { + if (enValue !== targetValue) return false; + const lower = enValue.toLowerCase(); + if (enValue.length <= 3) return true; + return ALLOWLIST_SUBSTRINGS.some(sub => lower.includes(sub)); +} + +let failed = false; + +const enFlat = flatten(loadLocale('en')); + +for (const code of LOCALES) { + if (code === 'en') continue; + const flat = flatten(loadLocale(code)); + const enKeys = new Set(Object.keys(enFlat)); + const keys = new Set(Object.keys(flat)); + + const missing = [...enKeys].filter(k => !keys.has(k)); + const extra = [...keys].filter(k => !enKeys.has(k)); + + if (missing.length) { + failed = true; + console.error(`\n[${code}] Missing keys (${missing.length}):`); + missing.slice(0, 20).forEach(k => console.error(` - ${k}`)); + if (missing.length > 20) console.error(` ... and ${missing.length - 20} more`); + } + if (extra.length) { + failed = true; + console.error(`\n[${code}] Extra keys (${extra.length}):`); + extra.slice(0, 20).forEach(k => console.error(` - ${k}`)); + } + + const placeholderMismatches = []; + const untranslated = []; + + for (const key of enKeys) { + if (!keys.has(key)) continue; + const enVal = enFlat[key]; + const val = flat[key]; + if (placeholders(enVal) !== placeholders(val)) { + placeholderMismatches.push({ key, en: placeholders(enVal), got: placeholders(val) }); + } + if (key.startsWith('common.language')) continue; + if (enVal === val && !isAllowlistedIdentical(enVal, val)) { + untranslated.push(key); + } + } + + if (placeholderMismatches.length) { + failed = true; + console.error(`\n[${code}] Placeholder mismatches (${placeholderMismatches.length}):`); + placeholderMismatches + .slice(0, 15) + .forEach(({ key, en, got }) => console.error(` - ${key}: en [${en}] vs [${got}]`)); + } + + if (untranslated.length) { + console.warn(`\n[${code}] Possibly untranslated (identical to en, ${untranslated.length}):`); + untranslated + .slice(0, 15) + .forEach(k => console.warn(` - ${k}: "${enFlat[k].slice(0, 60)}..."`)); + if (untranslated.length > 15) console.warn(` ... and ${untranslated.length - 15} more`); + } +} + +const enCount = Object.keys(enFlat).length; +console.log(`\nChecked ${LOCALES.join(', ')} — ${enCount} keys in en.`); + +if (failed) { + console.error('\nLocale check FAILED.\n'); + process.exit(1); +} + +console.log('Locale check passed (keys + placeholders).\n'); +process.exit(0); diff --git a/src/components/Footer.vue b/src/components/Footer.vue index 8ad67c61..98e78ae0 100644 --- a/src/components/Footer.vue +++ b/src/components/Footer.vue @@ -1,47 +1,46 @@ diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index ad66f197..4c9e9923 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -1,6 +1,6 @@