From 833af6c4b10fb9ae655269f21dd209a1ff271512 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 3 Jul 2026 02:02:10 +0000 Subject: [PATCH 1/5] feat(i18n): add vue-i18n with en, uk, de, ru, zh-CN locales Consolidates i18n work from PR #855 (vue-i18n with uk/de/ru) and PR #865 (zh-CN translations) into a single clean PR on latest master. Includes: - vue-i18n@8 infrastructure with TypeScript locale files - Language picker in Settings with localStorage persistence - Browser locale detection on first visit - Locale files: en, uk, de, ru, zh-CN - Core views migrated to $t() calls - Locale validation script and unit tests Co-Authored-By: NureRykushBohdan Co-Authored-By: JhihJian --- package.json | 2 + scripts/check-locales.mjs | 200 +++++++++ src/components/Footer.vue | 23 +- src/components/Header.vue | 53 +-- src/components/UncategorizedNotification.vue | 45 +- src/i18n/index.ts | 82 ++++ src/i18n/locales/de.ts | 390 +++++++++++++++++ src/i18n/locales/en.ts | 413 ++++++++++++++++++ src/i18n/locales/ru.ts | 390 +++++++++++++++++ src/i18n/locales/uk.ts | 390 +++++++++++++++++ src/i18n/locales/zh-CN.ts | 361 +++++++++++++++ src/main.js | 4 + src/stores/settings.ts | 22 + src/views/Buckets.vue | 156 +++---- src/views/Home.vue | 84 ++-- src/views/Timeline.vue | 14 +- src/views/activity/Activity.vue | 86 ++-- src/views/settings/CategorizationSettings.vue | 83 +--- src/views/settings/LanguageSettings.vue | 42 ++ src/views/settings/Settings.vue | 46 +- test/unit/i18n.test.js | 65 +++ test/unit/store/settings.locale.test.js | 74 ++++ 22 files changed, 2699 insertions(+), 326 deletions(-) create mode 100644 scripts/check-locales.mjs create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/de.ts create mode 100644 src/i18n/locales/en.ts create mode 100644 src/i18n/locales/ru.ts create mode 100644 src/i18n/locales/uk.ts create mode 100644 src/i18n/locales/zh-CN.ts create mode 100644 src/views/settings/LanguageSettings.vue create mode 100644 test/unit/i18n.test.js create mode 100644 test/unit/store/settings.locale.test.js 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..e10adfff --- /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']; + +/** 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', + 'patreon', + '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', + 'forum', +]; + +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 @@