diff --git a/src/App.vue b/src/App.vue
index 09ae113e..baea8f5e 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -16,6 +16,7 @@ div#wrapper(v-if="loaded")
import { useSettingsStore } from '~/stores/settings';
import { useServerStore } from '~/stores/server';
import { detectPreferredTheme } from '~/util/theme';
+import { setLocale } from '~/i18n';
// if vite is used, you can import css file as module
//import darkCssUrl from '../static/dark.css?url';
//import darkCssContent from '../static/dark.css?inline';
@@ -33,12 +34,22 @@ export default {
fullContainer() {
return this.$route.meta.fullContainer;
},
+ language() {
+ return useSettingsStore().language;
+ },
+ },
+
+ watch: {
+ language(language: string) {
+ setLocale(language);
+ },
},
async beforeCreate() {
// Get Theme From LocalStorage
const settingsStore = useSettingsStore();
await settingsStore.ensureLoaded();
+ setLocale(settingsStore.language);
const theme = settingsStore.theme;
const detectedTheme = theme === 'auto' ? detectPreferredTheme() : theme;
diff --git a/src/components/BucketMerge.vue b/src/components/BucketMerge.vue
index 12bd5035..acadbb2e 100644
--- a/src/components/BucketMerge.vue
+++ b/src/components/BucketMerge.vue
@@ -1,43 +1,45 @@
div
- h3 Merge buckets
+ h3 {{ $t('bucketTools.mergeTitle') }}
p.small
- | Sometimes, you might want to merge the events of two buckets together into one.
- | This is commonly useful to address the case where your hostname might have changed,
- | creating two buckets for the same watcher and host, which you want to combine together again.
+ | {{ $t('bucketTools.mergeDescription') }}
// TODO: select which buckets to merge
b-row
b-col
- h4 Bucket from
+ h4 {{ $t('bucketTools.bucketFrom') }}
b-form-select(v-model="bucket_from" :options="buckets" :disabled="buckets.length === 0")
p.small
- | Select the bucket from which you want to merge the events.
- | This bucket will be deleted after the merge.
+ | {{ $t('bucketTools.mergeFromHelp') }}
+ |
+ | {{ $t('bucketTools.mergeFromDeleted') }}
p.small(v-if="events_from !== null")
- | Events: {{ events_from.length }}
+ | {{ $t('bucketTools.events', { count: events_from.length }) }}
b-col
- h4 Bucket to
+ h4 {{ $t('bucketTools.bucketTo') }}
b-form-select(v-model="bucket_to" :options="buckets" :disabled="buckets.length === 0")
p.small
- | Select the bucket to which you want to merge the events.
- | This bucket will remain after the merge.
+ | {{ $t('bucketTools.mergeToHelp') }}
+ |
+ | {{ $t('bucketTools.mergeToRemain') }}
p.small(v-if="events_to !== null")
- | Events: {{ events_to.length }}
+ | {{ $t('bucketTools.events', { count: events_to.length }) }}
// TODO: check for overlapping events
div(v-if="overlappingEvents !== null && overlappingEvents.length > 0")
- h3 Overlapping events
+ h3 {{ $t('bucketTools.overlappingEvents') }}
p
- | The following {{ overlappingEvents.length }} events are overlapping:
+ | {{ $t('bucketTools.overlappingCount', { count: overlappingEvents.length }) }}
ul
li(v-for="event in overlappingEvents")
| {{ event[0].start }} - {{ event[0].end }} ({{ event[0].event.id }})
- | overlaps with
+ |
+ | {{ $t('bucketTools.overlapsWith') }}
+ |
| {{ event[1].start }} - {{ event[1].end }} ({{ event[1].event.id }})
// TODO: confirm dialog
- b-button(variant="success" :disabled="!validate" @click="merge()") Merge
+ b-button(variant="success" :disabled="!validate" @click="merge()") {{ $t('bucketTools.merge') }}
// TODO: delete old bucket? (ask user to backup their db if they want to be able to restore after delete)
diff --git a/src/components/BucketValidate.vue b/src/components/BucketValidate.vue
index b817a0bc..b4269ea2 100644
--- a/src/components/BucketValidate.vue
+++ b/src/components/BucketValidate.vue
@@ -1,18 +1,18 @@
div
- h3 Validate buckets
+ h3 {{ $t('bucketTools.validateTitle') }}
p.small
- | This is a small tool to check the validity of your buckets and their events.
+ | {{ $t('bucketTools.validateDescription') }}
// Form
b-row
b-col
- h4 Bucket
+ h4 {{ $t('bucketTools.bucket') }}
b-form-select(v-model="bucket" :options="buckets" :disabled="buckets.length === 0")
p.small
- | Select the bucket to validate.
+ | {{ $t('bucketTools.validateBucketHelp') }}
p.small(v-if="events !== null")
- | Events: {{ events.length }}
+ | {{ $t('bucketTools.events', { count: events.length }) }}
// Checks
// check for duplicate events
@@ -21,12 +21,12 @@ div
summary
icon.mx-2(name="check", style="color: #0C0", v-if="duplicateEvents.length === 0")
icon.mx-2(name="exclamation-triangle", style="color: #CC0", v-else)
- | Duplicates: {{ duplicateEvents.length }}
+ | {{ $t('bucketTools.duplicates', { count: duplicateEvents.length }) }}
div.p-2
p(v-if="duplicateEvents.length === 0")
- | No duplicate events found.
+ | {{ $t('bucketTools.noDuplicates') }}
p(v-else)
- | The following {{ duplicateEvents.length }} duplicates were found.
+ | {{ $t('bucketTools.duplicatesFound', { count: duplicateEvents.length }) }}
ul.mt-2
li(v-for="overlap in duplicateEvents")
| {{ overlap[0].start.toISOString() }} - (id: {{ overlap[0].event.id }} & {{ overlap[1].event.id }}): {{ JSON.stringify(overlap[0].data) }}
@@ -37,15 +37,15 @@ div
summary
icon.mx-2(name="check", style="color: #0C0", v-if="overlappingEvents.length === 0")
icon.mx-2(name="exclamation-triangle", style="color: #CC0", v-else)
- | Overlaps: {{ overlappingEvents.length }}x with a total duration of {{ overlapDuration / 1000 | friendlyduration }}
+ | {{ $t('bucketTools.overlapsBefore', { count: overlappingEvents.length }) }} {{ overlapDuration / 1000 | friendlyduration }}
div.p-2
p(v-if="overlappingEvents.length === 0")
- | No overlapping events found.
+ | {{ $t('bucketTools.noOverlaps') }}
p(v-else)
- | The following {{ overlappingEvents.length }} overlaps were found.
+ | {{ $t('bucketTools.overlapsFound', { count: overlappingEvents.length }) }}
br
span(v-if="overlapDurationSameData > 0")
- | Of these, {{ overlapDurationSameData / 1000 | friendlyduration }} are overlaps where the data is the same. These events could potentially be merged.
+ | {{ $t('bucketTools.sameDataOverlapsBefore') }} {{ overlapDurationSameData / 1000 | friendlyduration }} {{ $t('bucketTools.sameDataOverlapsAfter') }}
p.mt-2(v-for="event in overlappingEvents")
ul
li {{ event[0].start.toISOString() }}/{{ event[0].end.toISOString() }} - (id: {{ event[0].event.id }}): {{ JSON.stringify(event[0].event.data) }}
@@ -57,12 +57,12 @@ div
summary
icon.mx-2(name="check", style="color: #0C0", v-if="zeroDurationEvents.length === 0")
icon.mx-2(name="info-circle", style="color: #09F", v-else)
- | Zero-duration events: {{ zeroDurationEvents.length }}
+ | {{ $t('bucketTools.zeroDuration', { count: zeroDurationEvents.length }) }}
div.p-2
p.ml-3(v-if="zeroDurationEvents.length === 0")
- | No zero-duration events found.
+ | {{ $t('bucketTools.noZeroDuration') }}
p.ml-3(v-else)
- | The following {{ zeroDurationEvents.length }} zero-duration events were found:
+ | {{ $t('bucketTools.zeroDurationFound', { count: zeroDurationEvents.length }) }}
ul.mt-2
li(v-for="event in zeroDurationEvents")
| {{ event.timestamp.toISOString() }}/{{ new Date(new Date(event.timestamp).valueOf() + 1000 * event.duration).toISOString() }} - (id: {{ event.id }}): {{ JSON.stringify(event.data) }}
diff --git a/src/components/CategoryEditModal.vue b/src/components/CategoryEditModal.vue
index 3ac610ff..36d44645 100644
--- a/src/components/CategoryEditModal.vue
+++ b/src/components/CategoryEditModal.vue
@@ -1,59 +1,58 @@
// The category edit modal
-b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="hidden" @ok="handleOk")
+b-modal(id="edit" ref="edit" :title="$t('categoryEdit.editTitle')" @show="resetModal" @hidden="hidden" @ok="handleOk")
div.my-1
- b-input-group.my-1(prepend="Name")
+ b-input-group.my-1(:prepend="$t('categoryEdit.name')")
b-form-input(v-model="editing.name")
- b-input-group(prepend="Parent")
+ b-input-group(:prepend="$t('categoryEdit.parent')")
b-select(v-model="editing.parent", :options="allCategories")
//| ID: {{editing.id}}
hr
div.my-1
- b Rule
- b-input-group.my-1(prepend="Type")
+ b {{ $t('categoryEdit.rule') }}
+ b-input-group.my-1(:prepend="$t('categoryEdit.type')")
b-select(v-model="editing.rule.type", :options="allRuleTypes")
div(v-if="editing.rule.type === 'regex'")
- b-input-group.my-1(prepend="Pattern")
+ b-input-group.my-1(:prepend="$t('categoryEdit.pattern')")
b-form-input(v-model="editing.rule.regex")
div.d-flex
div.flex-grow-1
b-form-checkbox(v-model="editing.rule.ignore_case" switch)
- | Case insensitive
+ | {{ $t('categoryEdit.caseInsensitive') }}
div.flex-grow-1
small.text-right
- div.text-danger(v-if="!validPattern") Invalid pattern
- div.text-warning(v-if="validPattern && broad_pattern") Pattern too broad
+ div.text-danger(v-if="!validPattern") {{ $t('categoryEdit.invalidPattern') }}
+ div.text-warning(v-if="validPattern && broad_pattern") {{ $t('categoryEdit.patternTooBroad') }}
hr
div.my-1
- b Color
+ b {{ $t('categoryEdit.color') }}
b-form-checkbox(v-model="editing.inherit_color" switch)
- | Inherit parent color
+ | {{ $t('categoryEdit.inheritParentColor') }}
div.mt-1(v-show="!editing.inherit_color")
color-picker(v-model="editing.color")
hr
div.my-1
- b Productivity score
+ b {{ $t('categoryEdit.productivityScore') }}
b-form-checkbox(v-model="editing.inherit_score" switch)
- | Inherit parent score
- b-input-group.my-1(prepend="Score" v-if="!editing.inherit_score")
+ | {{ $t('categoryEdit.inheritParentScore') }}
+ b-input-group.my-1(:prepend="$t('categoryEdit.score')" v-if="!editing.inherit_score")
b-form-input(v-model="editing.score")
hr
div.my-1
b-btn(variant="danger", @click="removeClass(categoryId); $refs.edit.hide()")
icon(name="trash")
- | Remove category
+ | {{ $t('categoryEdit.removeCategory') }}
diff --git a/src/views/settings/ReleaseNotificationSettings.vue b/src/views/settings/ReleaseNotificationSettings.vue
index ba172b0b..617153c3 100644
--- a/src/views/settings/ReleaseNotificationSettings.vue
+++ b/src/views/settings/ReleaseNotificationSettings.vue
@@ -2,11 +2,11 @@
div
div.d-flex.justify-content-between.align-items-center
div
- h5.mb-0 Check for new releases
+ h5.mb-0 {{ $t('settingsSections.releaseNotification') }}
div
b-form-checkbox(v-model="isEnabled" switch)
small.text-muted
- | When enabled, the web UI checks once per day for a new ActivityWatch release and shows a hint if one is available.
+ | {{ $t('settings.releaseNotificationDescription') }}
diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue
index 90501f76..f9c83c40 100644
--- a/src/visualizations/VisTimeline.vue
+++ b/src/visualizations/VisTimeline.vue
@@ -3,7 +3,7 @@
div#visualization
div.small.text-muted.my-2(v-if="bucketsFromEither.length != 1")
- i Buckets with no events in the queried range will be hidden.
+ i {{ $t('visualizationStatus.hiddenBuckets') }}
div(v-if="editingEvent")
EventEditor(:event="editingEvent" :bucket_id="editingEventBucket")
@@ -60,6 +60,7 @@ import { getCategoryColorFromEvent, getTitleAttr } from '../util/color';
import { getSwimlane } from '../util/swimlane.js';
import { IEvent } from '../util/interfaces';
import { formatTimelineBucketLabelHtml, shortenBucketLabel } from '../util/timelineLabels';
+import { getLocale } from '~/i18n';
import { Timeline } from 'vis-timeline/esnext';
import 'vis-timeline/styles/vis-timeline-graph2d.css';
@@ -122,6 +123,9 @@ export default {
};
},
computed: {
+ currentLocale() {
+ return getLocale();
+ },
bucketsFromEither() {
if (this.buckets) {
return this.buckets;
@@ -186,6 +190,9 @@ export default {
this.update();
},
+ currentLocale() {
+ this.update();
+ },
},
mounted() {
this.$nextTick(() => {
@@ -271,8 +278,8 @@ export default {
// edit flow. Persist the dismissal via localStorage so the user
// doesn't see it every session.
if (!this.editRefreshHintDismissed()) {
- this.$bvToast.toast('Your edit is saved. Refresh the timeline to see it reflected.', {
- title: 'Heads up',
+ this.$bvToast.toast(this.$t('visualizationStatus.refreshRequiredAfterEditToast'), {
+ title: this.$t('visualizationStatus.headsUp'),
variant: 'info',
autoHideDelay: 6000,
solid: true,
@@ -282,7 +289,11 @@ export default {
isAlertWarningShown = true;
}
} else {
- alert('selected multiple items: ' + JSON.stringify(properties.items));
+ alert(
+ this.$t('visualizationStatus.selectedMultipleItems', {
+ items: JSON.stringify(properties.items),
+ })
+ );
}
},
abbreviateBucketName(bucketId: string): string {
@@ -388,7 +399,10 @@ export default {
if (groups.length > 0 && items.length > 0) {
if (this.queriedInterval && this.showQueriedInterval) {
const duration = this.queriedInterval[1].diff(this.queriedInterval[0], 'seconds');
- groups.push({ id: String(groups.length), content: 'queried interval' });
+ groups.push({
+ id: String(groups.length),
+ content: this.$t('visualizationStatus.queriedInterval'),
+ });
items.push({
id: String(items.length + 1),
group: groups.length - 1,
@@ -400,7 +414,7 @@ export default {
data: { title: 'test' },
}
),
- content: 'query',
+ content: this.$t('visualizationStatus.query'),
start: this.queriedInterval[0],
end: this.queriedInterval[1],
style: 'background-color: #aaa; height: 10px',
diff --git a/src/visualizations/periodusage.ts b/src/visualizations/periodusage.ts
index eaebb547..100ab209 100644
--- a/src/visualizations/periodusage.ts
+++ b/src/visualizations/periodusage.ts
@@ -3,6 +3,7 @@ import _ from 'lodash';
import moment from 'moment';
import { seconds_to_duration, get_hour_offset } from '../util/time.ts';
+import { t } from '~/i18n';
function create(svg_elem: SVGElement) {
// Clear element
@@ -34,7 +35,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) {
// No apps, sets status to "No data"
if (usage_arr.length <= 0) {
- set_status(svg_elem, 'No data');
+ set_status(svg_elem, t('visualizationStatus.noData'));
return;
}
svg_elem.innerHTML = '';
@@ -84,7 +85,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) {
.append('text')
.attr('x', x + 1.5 * width + '%')
.attr('y', '30')
- .text('Today');
+ .text(t('visualizationStatus.today'));
}
const rect = svg
diff --git a/src/visualizations/summary.ts b/src/visualizations/summary.ts
index f0f3dc5a..75ac26bc 100644
--- a/src/visualizations/summary.ts
+++ b/src/visualizations/summary.ts
@@ -8,6 +8,7 @@ import { useCategoryStore } from '~/stores/categories';
import { getCategoryColorFromString } from '~/util/color';
import { seconds_to_duration } from '~/util/time';
import { IEvent } from '~/util/interfaces';
+import { t } from '~/i18n';
const textColor = '#333';
@@ -49,7 +50,7 @@ interface Entry {
function update(container: HTMLElement, apps: Entry[]) {
// No apps, sets status to "No data"
if (apps.length <= 0) {
- set_status(container, 'No data');
+ set_status(container, t('visualizationStatus.noData'));
return container;
}
diff --git a/src/visualizations/timeline-simple.ts b/src/visualizations/timeline-simple.ts
index 1e6ce6b9..78693ecf 100644
--- a/src/visualizations/timeline-simple.ts
+++ b/src/visualizations/timeline-simple.ts
@@ -6,6 +6,7 @@ const _ = require('lodash');
const moment = require('moment');
import { getTitleAttr, getColorFromString } from '../util/color';
+import { t } from '~/i18n';
const time = require('../util/time');
@@ -36,7 +37,7 @@ function update(svg_el, events, event_type: string) {
timeline.selectAll('*').remove();
if (events.length <= 0) {
- set_status(svg_el, 'No data');
+ set_status(svg_el, t('visualizationStatus.noData'));
return;
}
@@ -86,7 +87,8 @@ function update(svg_el, events, event_type: string) {
.text(
timestamp.format() +
'\n' +
- 'Duration: ' +
+ t('visualizationStatus.duration') +
+ ': ' +
time.seconds_to_duration(e.duration) +
'\n' +
JSON.stringify(e.data)
diff --git a/src/visualizations/timeline.ts b/src/visualizations/timeline.ts
index 81b4f9e8..33458ea4 100644
--- a/src/visualizations/timeline.ts
+++ b/src/visualizations/timeline.ts
@@ -8,6 +8,7 @@ import _ from 'lodash';
import moment from 'moment';
import { getColorFromString } from '../util/color';
+import { t as translate } from '~/i18n';
import { seconds_to_duration } from '../util/time';
import { IEvent } from '../util/interfaces';
@@ -78,7 +79,7 @@ function update(
d3.select(container.querySelector('.titleinfo-container')).html(null);
if (events.length <= 0) {
- set_status(container, 'No data');
+ set_status(container, translate('visualizationStatus.noData'));
return container;
}
diff --git a/test/unit/LanguageSettings.test.js b/test/unit/LanguageSettings.test.js
new file mode 100644
index 00000000..10939c74
--- /dev/null
+++ b/test/unit/LanguageSettings.test.js
@@ -0,0 +1,53 @@
+import { shallowMount } from '@vue/test-utils';
+import { createTestingPinia } from '@pinia/testing';
+import LanguageSettings from '~/views/settings/LanguageSettings.vue';
+import { getLocale, installI18n, setLocale } from '~/i18n';
+import { useSettingsStore } from '~/stores/settings';
+
+installI18n();
+
+describe('LanguageSettings', () => {
+ beforeEach(() => {
+ setLocale('en');
+ });
+
+ function mountLanguageSettings() {
+ return shallowMount(LanguageSettings, {
+ global: {
+ plugins: [
+ createTestingPinia({
+ initialState: {
+ settings: {
+ _loaded: true,
+ },
+ },
+ stubActions: true,
+ }),
+ ],
+ },
+ stubs: {
+ 'b-select': {
+ template: '',
+ },
+ },
+ });
+ }
+
+ test('renders language options', () => {
+ const wrapper = mountLanguageSettings();
+
+ expect(wrapper.text()).toContain('Language');
+ expect(wrapper.text()).toContain('English');
+ expect(wrapper.text()).toContain('简体中文');
+ });
+
+ test('updates the display language when a locale is selected', async () => {
+ const wrapper = mountLanguageSettings();
+ const settingsStore = useSettingsStore();
+
+ await wrapper.find('select').setValue('zh-CN');
+
+ expect(settingsStore.update).toHaveBeenCalledWith({ language: 'zh-CN' });
+ expect(getLocale()).toBe('zh-CN');
+ });
+});
diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts
new file mode 100644
index 00000000..cb07acee
--- /dev/null
+++ b/test/unit/i18n.test.node.ts
@@ -0,0 +1,183 @@
+import {
+ DEFAULT_LOCALE,
+ SUPPORTED_LOCALES,
+ getLocale,
+ installI18n,
+ interpolate,
+ setLocale,
+ t,
+ TranslationParams,
+ translate,
+} from '~/i18n';
+import en from '~/i18n/locales/en';
+import zhCN from '~/i18n/locales/zh-CN';
+import Vue from 'vue';
+
+type LocaleValue = string | LocaleTree;
+type LocaleTree = {
+ [key: string]: LocaleValue;
+};
+
+function findExtraLocaleKeys(canonical: LocaleTree, candidate: LocaleTree, prefix = ''): string[] {
+ return Object.keys(candidate).flatMap(key => {
+ const path = prefix ? `${prefix}.${key}` : key;
+
+ if (!Object.prototype.hasOwnProperty.call(canonical, key)) {
+ return [path];
+ }
+
+ const canonicalValue = canonical[key];
+ const candidateValue = candidate[key];
+
+ if (typeof canonicalValue === 'string') {
+ return typeof candidateValue === 'string' ? [] : [path];
+ }
+
+ return typeof candidateValue === 'string'
+ ? []
+ : findExtraLocaleKeys(canonicalValue, candidateValue, path);
+ });
+}
+
+describe('i18n', () => {
+ const originalVueT = Vue.prototype.$t;
+
+ beforeEach(() => {
+ setLocale(DEFAULT_LOCALE);
+ });
+
+ afterEach(() => {
+ setLocale(DEFAULT_LOCALE);
+
+ if (originalVueT === undefined) {
+ delete Vue.prototype.$t;
+ } else {
+ Vue.prototype.$t = originalVueT;
+ }
+ });
+
+ test('uses English as the default locale', () => {
+ expect(DEFAULT_LOCALE).toBe('en');
+ expect(SUPPORTED_LOCALES).toContainEqual({
+ code: 'en',
+ label: 'English',
+ });
+ });
+
+ test('exposes supported locales as an immutable iterable list', () => {
+ expect(Array.from(SUPPORTED_LOCALES)).toEqual(
+ expect.arrayContaining([
+ { code: 'en', label: 'English' },
+ { code: 'zh-CN', label: '简体中文' },
+ ])
+ );
+ expect(Object.isFrozen(SUPPORTED_LOCALES)).toBe(true);
+ expect(SUPPORTED_LOCALES.every(locale => Object.isFrozen(locale))).toBe(true);
+ expect(() => {
+ (SUPPORTED_LOCALES as unknown as Array<{ code: string; label: string }>).push({
+ code: 'fr-FR',
+ label: 'French',
+ });
+ }).toThrow();
+ });
+
+ test.each([['zh-CN', zhCN]])('%s does not define keys outside the English schema', (_, locale) => {
+ expect(findExtraLocaleKeys(en, locale)).toEqual([]);
+ });
+
+ test('translates English keys', () => {
+ expect(t('common.loading')).toBe('Loading...');
+ expect(translate('nav.activity', 'en')).toBe('Activity');
+ });
+
+ test('translates zh-CN keys', () => {
+ expect(translate('common.loading', 'zh-CN')).toBe('加载中...');
+ expect(translate('nav.activity', 'zh-CN')).toBe('活动');
+ });
+
+ test('falls back to English when zh-CN key is missing', () => {
+ expect(translate('test.onlyEnglish', 'zh-CN')).toBe('Only English');
+ });
+
+ test('returns the key when no locale contains the key', () => {
+ expect(translate('missing.key', 'zh-CN')).toBe('missing.key');
+ });
+
+ test('does not resolve missing keys through the object prototype chain', () => {
+ expect(translate('constructor.name', 'en')).toBe('constructor.name');
+ });
+
+ test('interpolates named params', () => {
+ expect(translate('activity.hostLabel', 'en', { host: 'laptop' })).toBe('Host: laptop');
+ expect(translate('activity.hostLabel', 'zh-CN', { host: 'laptop' })).toBe('主机:laptop');
+ });
+
+ test('t follows the active locale', () => {
+ setLocale('zh-CN');
+ expect(t('nav.activity')).toBe('活动');
+ setLocale('en');
+ expect(t('nav.activity')).toBe('Activity');
+ });
+
+ test('falls back to the default locale for invalid locale input', () => {
+ setLocale('zh-CN');
+
+ setLocale('fr-FR');
+ expect(getLocale()).toBe(DEFAULT_LOCALE);
+ expect(t('nav.activity')).toBe('Activity');
+
+ setLocale('zh-CN');
+ setLocale(null);
+ expect(getLocale()).toBe(DEFAULT_LOCALE);
+
+ setLocale('zh-CN');
+ setLocale(undefined);
+ expect(getLocale()).toBe(DEFAULT_LOCALE);
+ });
+
+ test('installs $t on Vue without requiring a Vue argument', () => {
+ delete Vue.prototype.$t;
+
+ installI18n();
+
+ expect(Vue.prototype.$t('nav.activity')).toBe('Activity');
+ });
+
+ test('updates Vue instance translations when the active locale changes', async () => {
+ installI18n();
+
+ const vm = new Vue({
+ computed: {
+ activityLabel(this: Vue & { $t: typeof t }) {
+ return this.$t('nav.activity');
+ },
+ },
+ }) as Vue & { activityLabel: string };
+
+ expect(vm.activityLabel).toBe('Activity');
+
+ setLocale('zh-CN');
+ await Vue.nextTick();
+
+ expect(vm.activityLabel).toBe('活动');
+
+ vm.$destroy();
+ });
+
+ test('keeps placeholders for missing params', () => {
+ expect(interpolate('Found {count} events in {seconds} seconds', { count: 5 })).toBe(
+ 'Found 5 events in {seconds} seconds'
+ );
+ });
+
+ test('keeps placeholders for inherited or nullish params', () => {
+ const inheritedParams = Object.create({ name: 'inherited' }) as TranslationParams;
+ const explicitParams = Object.create(null) as TranslationParams;
+ explicitParams['constructor'] = 'explicit';
+
+ expect(interpolate('Missing {constructor}', {})).toBe('Missing {constructor}');
+ expect(interpolate('Missing {name}', inheritedParams)).toBe('Missing {name}');
+ expect(interpolate('Missing {name}', { name: null })).toBe('Missing {name}');
+ expect(interpolate('Found {constructor}', explicitParams)).toBe('Found explicit');
+ });
+});
diff --git a/test/unit/store/categories.test.node.ts b/test/unit/store/categories.test.node.ts
index 323c4a52..05df80b0 100644
--- a/test/unit/store/categories.test.node.ts
+++ b/test/unit/store/categories.test.node.ts
@@ -1,6 +1,7 @@
import { isEqual } from 'lodash';
import { setActivePinia, createPinia } from 'pinia';
+import { setLocale } from '~/i18n';
import { useCategoryStore } from '~/stores/categories';
import { createMissingParents, defaultCategories, Category } from '~/util/classes';
@@ -9,6 +10,7 @@ describe('categories store', () => {
const categoryStore = useCategoryStore();
beforeEach(() => {
+ setLocale('en');
categoryStore.clearAll();
});
@@ -45,6 +47,18 @@ describe('categories store', () => {
expect(categoryStore.all_categories).toHaveLength(1);
});
+ test('translates category select metadata options without changing category names', () => {
+ categoryStore.load([{ name: ['Test'], rule: { type: 'none' } }]);
+
+ setLocale('zh-CN');
+
+ expect(categoryStore.category_select(true).slice(0, 3)).toEqual([
+ { text: '全部', value: null },
+ { text: '未分类', value: ['Uncategorized'] },
+ { text: 'Test', value: ['Test'] },
+ ]);
+ });
+
test('get category hierarchy', () => {
categoryStore.restoreDefaultClasses();
const hier = categoryStore.classes_hierarchy;
diff --git a/test/unit/store/settings.test.node.ts b/test/unit/store/settings.test.node.ts
index ff1f32fc..2ceb2ec3 100644
--- a/test/unit/store/settings.test.node.ts
+++ b/test/unit/store/settings.test.node.ts
@@ -1,17 +1,98 @@
import { setActivePinia, createPinia } from 'pinia';
-
import { useSettingsStore } from '~/stores/settings';
+import { DEFAULT_LOCALE, getLocale, setLocale } from '~/i18n';
-describe('settings store', () => {
- setActivePinia(createPinia());
- const settingsStore = useSettingsStore();
+const App = require('~/App.vue').default;
+
+const postMock = jest.fn();
+const getSettingsMock = jest.fn();
+let consoleLogSpy: jest.SpyInstance;
+
+jest.mock('~/util/awclient', () => ({
+ getClient: () => ({
+ get_settings: getSettingsMock,
+ req: {
+ defaults: {
+ timeout: 0,
+ },
+ post: postMock,
+ },
+ }),
+}));
+
+type MemoryStorage = Storage & {
+ store: Record;
+};
+function createLocalStorage(items: Record = {}): MemoryStorage {
+ const storage = {
+ store: { ...items },
+ get length() {
+ return Object.keys(this.store).length;
+ },
+ clear() {
+ this.store = {};
+ },
+ getItem(key: string) {
+ return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
+ },
+ key(index: number) {
+ return Object.keys(this.store)[index] ?? null;
+ },
+ removeItem(key: string) {
+ delete this.store[key];
+ },
+ setItem(key: string, value: string) {
+ this.store[key] = String(value);
+ },
+ };
+
+ return new Proxy(storage, {
+ get(target, property) {
+ if (typeof property === 'string' && property in target.store) {
+ return target.store[property];
+ }
+
+ return target[property];
+ },
+ ownKeys(target) {
+ return Reflect.ownKeys(target.store);
+ },
+ getOwnPropertyDescriptor(target, property) {
+ if (typeof property === 'string' && property in target.store) {
+ return {
+ configurable: true,
+ enumerable: true,
+ value: target.store[property],
+ };
+ }
+
+ return undefined;
+ },
+ }) as MemoryStorage;
+}
+
+describe('settings store', () => {
beforeEach(() => {
- settingsStore.$reset();
- jest.restoreAllMocks();
+ setActivePinia(createPinia());
+ getSettingsMock.mockResolvedValue({});
+ postMock.mockResolvedValue({});
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+ Object.defineProperty(global, 'localStorage', {
+ configurable: true,
+ value: createLocalStorage(),
+ });
+ setLocale(DEFAULT_LOCALE);
+ });
+
+ afterEach(() => {
+ consoleLogSpy.mockRestore();
+ jest.clearAllMocks();
+ setLocale(DEFAULT_LOCALE);
});
test('ensureLoaded coalesces concurrent loads', async () => {
+ const settingsStore = useSettingsStore();
let resolveLoad!: () => void;
const loadMock = jest.spyOn(settingsStore, 'load').mockImplementation(
() =>
@@ -35,6 +116,7 @@ describe('settings store', () => {
});
test('update waits for settings to load before patching state', async () => {
+ const settingsStore = useSettingsStore();
const savedQueries = [
{
id: 'daily-coding-time',
@@ -63,4 +145,101 @@ describe('settings store', () => {
expect(steps).toEqual(['ensureLoaded', 'save']);
expect(settingsStore.saved_queries).toEqual(savedQueries);
});
+
+ test('defaults to English', () => {
+ const settingsStore = useSettingsStore();
+ expect(settingsStore.language).toBe('en');
+ });
+
+ test('can update language in store state', () => {
+ const settingsStore = useSettingsStore();
+ settingsStore.$patch({ language: 'zh-CN' });
+ expect(settingsStore.language).toBe('zh-CN');
+ });
+
+ test('loads supported language from server settings', async () => {
+ getSettingsMock.mockResolvedValue({ language: 'zh-CN' });
+ const settingsStore = useSettingsStore();
+
+ await settingsStore.load();
+
+ expect(settingsStore.language).toBe('zh-CN');
+ });
+
+ test('falls back to default language when server settings contain an unsupported language', async () => {
+ getSettingsMock.mockResolvedValue({ language: 'fr-FR' });
+ const settingsStore = useSettingsStore();
+
+ await settingsStore.load();
+
+ expect(settingsStore.language).toBe(DEFAULT_LOCALE);
+ });
+
+ test.each(['true', 'false'])(
+ 'falls back to default language when server settings contain boolean-like language %s',
+ async language => {
+ getSettingsMock.mockResolvedValue({ language });
+ const settingsStore = useSettingsStore();
+
+ await settingsStore.load();
+
+ expect(settingsStore.language).toBe(DEFAULT_LOCALE);
+ }
+ );
+
+ test('falls back to default language when localStorage contains an unsupported language', async () => {
+ getSettingsMock.mockResolvedValue({});
+ Object.defineProperty(global, 'localStorage', {
+ configurable: true,
+ value: createLocalStorage({ language: 'fr-FR' }),
+ });
+ const settingsStore = useSettingsStore();
+
+ await settingsStore.load();
+
+ expect(settingsStore.language).toBe(DEFAULT_LOCALE);
+ });
+
+ test.each(['true', 'false'])(
+ 'falls back to default language when localStorage contains boolean-like language %s',
+ async language => {
+ getSettingsMock.mockResolvedValue({});
+ Object.defineProperty(global, 'localStorage', {
+ configurable: true,
+ value: createLocalStorage({ language }),
+ });
+ const settingsStore = useSettingsStore();
+
+ await settingsStore.load();
+
+ expect(settingsStore.language).toBe(DEFAULT_LOCALE);
+ }
+ );
+
+ test('saves language through the backend settings endpoint', async () => {
+ getSettingsMock.mockResolvedValue({});
+ const settingsStore = useSettingsStore();
+ settingsStore.$patch({
+ _loaded: true,
+ language: 'zh-CN',
+ });
+
+ await settingsStore.save();
+
+ expect(postMock).toHaveBeenCalledWith('/0/settings/language', 'zh-CN', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ });
+
+ test('App language watcher syncs the active locale', () => {
+ const watcher = (App as any).watch.language;
+
+ watcher('zh-CN');
+ expect(getLocale()).toBe('zh-CN');
+
+ watcher('en');
+ expect(getLocale()).toBe('en');
+ });
});
diff --git a/test/unit/store/views.test.node.ts b/test/unit/store/views.test.node.ts
index 9a1453d7..75c610b8 100644
--- a/test/unit/store/views.test.node.ts
+++ b/test/unit/store/views.test.node.ts
@@ -1,14 +1,22 @@
import { setActivePinia, createPinia } from 'pinia';
+import { setLocale } from '~/i18n';
+import { useSettingsStore } from '~/stores/settings';
import { useViewsStore } from '~/stores/views';
describe('views store', () => {
- setActivePinia(createPinia());
- const viewsStore = useViewsStore();
+ let viewsStore: ReturnType;
beforeEach(() => {
+ setLocale('en');
+ setActivePinia(createPinia());
+ viewsStore = useViewsStore();
viewsStore.clearViews();
});
+ afterEach(() => {
+ setLocale('en');
+ });
+
test('load default views', async () => {
expect(viewsStore.views).toHaveLength(0);
await viewsStore.load();
@@ -20,4 +28,25 @@ describe('views store', () => {
viewsStore.loadViews([{ id: 'something', name: 'Something', elements: [] }]);
expect(viewsStore.views).not.toHaveLength(0);
});
+
+ test('loads default view names in zh-CN', async () => {
+ setLocale('zh-CN');
+
+ await viewsStore.load();
+
+ expect(viewsStore.views.map(view => view.name)).toContain('概览');
+ });
+
+ test('saves localized default views with canonical English names', async () => {
+ setLocale('zh-CN');
+ await viewsStore.load();
+
+ const settingsStore = useSettingsStore();
+ await viewsStore.save();
+
+ expect(settingsStore.views.find(view => view.id === 'summary')).toMatchObject({
+ name: 'Summary',
+ nameKey: 'views.summary',
+ });
+ });
});
diff --git a/test/unit/tooltip.test.js b/test/unit/tooltip.test.js
new file mode 100644
index 00000000..36642a81
--- /dev/null
+++ b/test/unit/tooltip.test.js
@@ -0,0 +1,39 @@
+import { buildTooltip } from '~/util/tooltip';
+
+function buildWebTooltip(url) {
+ return buildTooltip(
+ { type: 'web.tab.current' },
+ {
+ timestamp: '2024-01-01T12:00:00Z',
+ duration: 60,
+ data: {
+ title: 'Example',
+ url,
+ },
+ }
+ );
+}
+
+function parseTooltip(html) {
+ const container = document.createElement('div');
+ container.innerHTML = html;
+ return container;
+}
+
+describe('buildTooltip', () => {
+ test('does not allow web URLs to inject attributes', () => {
+ const tooltip = parseTooltip(buildWebTooltip('https://example.com/" onclick="alert(1)'));
+ const anchor = tooltip.querySelector('a');
+
+ expect(anchor).not.toBeNull();
+ expect(anchor.getAttribute('onclick')).toBeNull();
+ });
+
+ test('does not render javascript URLs as clickable links', () => {
+ const tooltip = parseTooltip(buildWebTooltip('javascript:alert(1)'));
+ const anchor = tooltip.querySelector('a');
+
+ expect(anchor).toBeNull();
+ expect(tooltip.textContent).toContain('javascript:alert(1)');
+ });
+});