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 @@
div.container(style="color: #555; font-size: 0.9em")
div.mb-2
- | Made with
+ | {{ $t('footer.madeWith') }}
a(href="https://activitywatch.net/donate/", target="_blank" rel="noopener noreferrer")
icon(name="heart" scale=0.75 style="fill: #E55")
- | by the #[a(href="http://activitywatch.net/contributors/") ActivityWatch developers]
+ | {{ $t('footer.byDevs') }}
div
span.mt-2(v-if="info", style="color: #888; font-size: 0.8em")
span.mr-2
- b Host:
+ b {{ $t('footer.host') }}
| {{info.hostname}}
span
- b Version:
+ b {{ $t('footer.version') }}
| {{info.version}}
div(style="font-size: 0.9em; opacity: 0.8; fill: #88F")
div.float-none.float-md-right.my-2
a(href="https://github.com/ActivityWatch/activitywatch/issues/new/choose", target="_blank" rel="noopener noreferrer").mr-3
icon(name="bug")
- | Report a bug
+ | {{ $t('footer.reportBug') }}
a(href="https://forum.activitywatch.net/c/support", target="_blank" rel="noopener noreferrer").mr-3
icon(name="question-circle")
- | Ask for help
+ | {{ $t('footer.askHelp') }}
a(href="https://forum.activitywatch.net/c/features", target="_blank" rel="noopener noreferrer")
icon(name="vote-yea")
- | Vote on features
+ | {{ $t('footer.voteFeatures') }}
div.float-none.float-md-left.my-2
a(href="https://twitter.com/ActivityWatchIt", target="_blank" rel="noopener noreferrer")
icon(name="brands/twitter")
- | Twitter
+ | {{ $t('footer.twitter') }}
a(href="https://github.com/ActivityWatch", target="_blank" rel="noopener noreferrer").ml-3
icon(name="brands/github")
- | GitHub
+ | {{ $t('footer.github') }}
a(href="https://www.reddit.com/r/activitywatch/", target="_blank" rel="noopener noreferrer").ml-3
icon(name="brands/reddit")
- | Reddit
+ | {{ $t('footer.reddit') }}
a(href="https://activitywatch.net/donate/", target="_blank" rel="noopener noreferrer").ml-3
icon(name="hand-holding-heart")
- | Donate
+ | {{ $t('footer.donate') }}
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 @@
div
- h3.mb-3 Settings
+ h3.mb-3 {{ $t('settings.title') }}
div.settings-layout
nav.settings-nav
@@ -26,6 +26,7 @@ div
import { useSettingsStore } from '~/stores/settings';
import { useCategoryStore } from '~/stores/categories';
+import LanguageSettings from '~/views/settings/LanguageSettings.vue';
import DaystartSettings from '~/views/settings/DaystartSettings.vue';
import TimelineDurationSettings from '~/views/settings/TimelineDurationSettings.vue';
import ReleaseNotificationSettings from '~/views/settings/ReleaseNotificationSettings.vue';
@@ -48,6 +49,7 @@ interface Group {
export default {
name: 'Settings',
components: {
+ LanguageSettings,
DaystartSettings,
TimelineDurationSettings,
ReleaseNotificationSettings,
@@ -63,7 +65,7 @@ export default {
beforeRouteLeave(to, from, next) {
const categoryStore = useCategoryStore();
if (categoryStore.classes_unsaved_changes) {
- if (confirm('Your categories have unsaved changes, are you sure you want to leave?')) {
+ if (confirm(this.$t('settings.unsavedCategoriesLeave'))) {
next();
} else {
next(false);
@@ -73,8 +75,6 @@ export default {
}
},
props: {
- // Hydrated from the /settings/:group route param so reloads / direct
- // links preserve which panel is open.
group: { type: String, default: '' },
},
computed: {
@@ -85,47 +85,42 @@ export default {
groups(): Group[] {
const general: Group = {
id: 'general',
- label: 'General',
- help: 'Defaults that shape how time periods, the timeline, and landing page behave.',
+ label: this.$t('settings.groups.general'),
+ help: this.$t('settings.groups.generalHelp'),
components: [
+ { name: 'LanguageSettings' },
{ name: 'DaystartSettings' },
{ name: 'TimelineDurationSettings' },
{ name: 'LandingPageSettings' },
{ name: 'UncategorizedHintSettings' },
- // Release-notification check folded in here so it doesn't
- // need its own one-setting "Updates" panel.
...(this.$isAndroid ? [] : [{ name: 'ReleaseNotificationSettings' }]),
],
};
const appearance: Group = {
id: 'appearance',
- label: 'Appearance',
- help: 'Theme and visualization colors.',
+ label: this.$t('settings.groups.appearance'),
+ help: this.$t('settings.groups.appearanceHelp'),
components: [{ name: 'Theme' }, { name: 'ColorSettings' }],
};
const categorization: Group = {
id: 'categorization',
- label: 'Categorization',
- help: 'Rules that classify events into categories, plus AFK/active-pattern overrides.',
- // CategorizationSettings (rules) is the primary content; the
- // ActivePatternSettings AFK override is an advanced edge-case
- // setting so it lives at the bottom of the group.
+ label: this.$t('settings.groups.categorization'),
+ help: this.$t('settings.groups.categorizationHelp'),
components: [{ name: 'CategorizationSettings' }, { name: 'ActivePatternSettings' }],
};
const privacy: Group = {
id: 'privacy',
- label: 'Privacy',
- help: 'Filters that drop or redact sensitive event data before it is stored.',
+ label: this.$t('settings.groups.privacy'),
+ help: this.$t('settings.groups.privacyHelp'),
components: [{ name: 'PrivacyFilterSettings' }],
};
const developer: Group = {
id: 'developer',
- label: 'Developer',
+ label: this.$t('settings.groups.developer'),
components: [{ name: 'DeveloperSettings' }],
};
- const groups: Group[] = [general, appearance, categorization, privacy, developer];
- return groups;
+ return [general, appearance, categorization, privacy, developer];
},
},
async created() {
@@ -172,13 +167,10 @@ export default {
}
.settings-content {
- min-width: 0; // prevent grid blowout from long content
+ min-width: 0;
}
.settings-section {
- // Modest breathing room at the end of each panel — keeps long
- // subviews from crashing into the card border without making
- // short ones feel overly padded.
padding-bottom: 1rem;
}
@@ -207,16 +199,10 @@ export default {
.settings-nav {
position: static;
- // Keep the nav fully inside the layout so the horizontal pill row
- // doesn't push the page to overflow at xs widths.
min-width: 0;
max-width: 100%;
}
- // Flex-wrap the pills onto multiple rows instead of overflow-x: auto.
- // The hidden horizontal scrollbar produced "incorrect" extra horizontal
- // scroll space on xs viewports and let pills like "Developer" hide
- // off-screen — both of which break discoverability.
::v-deep .settings-nav .nav {
flex-direction: row !important;
flex-wrap: wrap;
diff --git a/test/unit/i18n.test.js b/test/unit/i18n.test.js
new file mode 100644
index 00000000..f908667a
--- /dev/null
+++ b/test/unit/i18n.test.js
@@ -0,0 +1,76 @@
+describe('i18n', () => {
+ const loadI18n = () => {
+ jest.resetModules();
+ return require('~/i18n');
+ };
+
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('lang');
+ });
+
+ afterEach(() => {
+ jest.resetModules();
+ });
+
+ test('isAppLocale accepts supported locales only', () => {
+ const { isAppLocale } = loadI18n();
+ expect(isAppLocale('en')).toBe(true);
+ expect(isAppLocale('uk')).toBe(true);
+ expect(isAppLocale('de')).toBe(true);
+ expect(isAppLocale('ru')).toBe(true);
+ expect(isAppLocale('zh-CN')).toBe(true);
+ expect(isAppLocale('fr')).toBe(false);
+ expect(isAppLocale('')).toBe(false);
+ expect(isAppLocale(null)).toBe(false);
+ });
+
+ test('initial locale comes from localStorage when valid', () => {
+ localStorage.setItem('locale', 'uk');
+ const { i18n } = loadI18n();
+ expect(i18n.locale).toBe('uk');
+ });
+
+ test('initial locale detects browser language', () => {
+ Object.defineProperty(navigator, 'language', {
+ value: 'de-DE',
+ configurable: true,
+ });
+ const { i18n } = loadI18n();
+ expect(i18n.locale).toBe('de');
+ });
+
+ test('initial locale detects Chinese browser language', () => {
+ Object.defineProperty(navigator, 'language', {
+ value: 'zh-CN',
+ configurable: true,
+ });
+ const { i18n } = loadI18n();
+ expect(i18n.locale).toBe('zh-CN');
+ });
+
+ test('initial locale falls back to en', () => {
+ Object.defineProperty(navigator, 'language', {
+ value: 'fr-FR',
+ configurable: true,
+ });
+ const { i18n } = loadI18n();
+ expect(i18n.locale).toBe('en');
+ });
+
+ test('setAppLocale updates i18n, moment, document, and storage', () => {
+ const { setAppLocale, i18n } = loadI18n();
+ setAppLocale('zh-CN');
+ expect(i18n.locale).toBe('zh-CN');
+ expect(document.documentElement.lang).toBe('zh-CN');
+ expect(localStorage.getItem('locale')).toBe('zh-CN');
+ });
+
+ test('setAppLocale falls back to en for invalid locale', () => {
+ const { setAppLocale, i18n } = loadI18n();
+ setAppLocale('not-a-locale');
+ expect(i18n.locale).toBe('en');
+ expect(document.documentElement.lang).toBe('en');
+ expect(localStorage.getItem('locale')).toBe('en');
+ });
+});
diff --git a/test/unit/store/settings.locale.test.js b/test/unit/store/settings.locale.test.js
new file mode 100644
index 00000000..3e89ada6
--- /dev/null
+++ b/test/unit/store/settings.locale.test.js
@@ -0,0 +1,74 @@
+const mockGetSettings = jest.fn();
+
+jest.mock('~/util/awclient', () => ({
+ getClient: () => ({
+ get_settings: mockGetSettings,
+ req: {
+ defaults: { timeout: 0 },
+ post: jest.fn(),
+ },
+ }),
+}));
+
+import { setActivePinia, createPinia } from 'pinia';
+import { useSettingsStore } from '~/stores/settings';
+import { i18n } from '~/i18n';
+
+describe('settings store locale loading', () => {
+ let settingsStore;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ settingsStore = useSettingsStore();
+ settingsStore.$reset();
+ settingsStore.$patch({ _loaded: false });
+ mockGetSettings.mockReset();
+ mockGetSettings.mockResolvedValue({});
+ i18n.locale = 'en';
+ localStorage.clear();
+ });
+
+ test('load applies valid locale from server', async () => {
+ mockGetSettings.mockResolvedValue({ locale: 'de' });
+
+ await settingsStore.load();
+
+ expect(settingsStore.locale).toBe('de');
+ expect(i18n.locale).toBe('de');
+ expect(document.documentElement.lang).toBe('de');
+ expect(settingsStore.loaded).toBe(true);
+ });
+
+ test('load ignores invalid locale from server', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ mockGetSettings.mockResolvedValue({ locale: 'fr' });
+
+ await settingsStore.load();
+
+ expect(warn).toHaveBeenCalledWith('Ignoring invalid locale from server:', 'fr');
+ expect(settingsStore.locale).toBe('en');
+ warn.mockRestore();
+ });
+
+ test('load applies valid locale from localStorage', async () => {
+ localStorage.setItem('locale', 'zh-CN');
+ mockGetSettings.mockResolvedValue({});
+
+ await settingsStore.load();
+
+ expect(settingsStore.locale).toBe('zh-CN');
+ expect(i18n.locale).toBe('zh-CN');
+ });
+
+ test('load ignores invalid locale from localStorage', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ localStorage.setItem('locale', 'xx');
+ mockGetSettings.mockResolvedValue({});
+
+ await settingsStore.load();
+
+ expect(warn).toHaveBeenCalledWith('Ignoring invalid locale from storage:', 'xx');
+ expect(settingsStore.locale).toBe('en');
+ warn.mockRestore();
+ });
+});