From 7da2f716885b8e85541882897740a8de30609a2d Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Thu, 27 Aug 2026 11:14:45 +0100 Subject: [PATCH 1/3] feat: add Vuetify i18n locale support Co-Authored-By: Claude Opus 5 Signed-off-by: Pedro Lamas --- AGENTS.md | 8 +++++ docs/docs/development.md | 17 ++++++++++ docs/docs/features/localization.md | 3 +- src/plugins/vuetify.ts | 54 +++++++++++++++++++++++++++++- src/store/config/actions.ts | 7 ++-- src/typings/vuetify.d.ts | 8 +++++ 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 583ed8a78d..c36118d1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,6 +261,14 @@ src/ - `I18nLocales` — locale YAML files - `CameraComponents` — camera service Vue components - Views also dynamically imported in `src/router/index.ts` via `() => import('@/views/X.vue')` +- `src/plugins/vuetify.ts` holds a private `locales` map of `() => import('vuetify/lib/locale/*')` + thunks, keyed by **Fluidd** locale code, so the map doubles as the code translation + (`zh-CN` → `zh-Hans`, `zh-HK` → `zh-Hant`, `pt_BR` → `pt`); a missing key (`ta`) leaves + `$vuetify.lang` on `en`. `config/onLocaleChange` drives it via `loadVuetifyLocaleAsync`, which + `Vue.set`s the locale into `lang.locales` **before** assigning `lang.current` — the `current` + write is what re-renders. These `import()`s do not actually code-split: `vuetify/lib/presets/default` + statically imports the `lib/locale` barrel, so every referenced locale lands in the eager + `vuetify` chunk regardless of `codeSplitting` groups ## Testing Conventions diff --git a/docs/docs/development.md b/docs/docs/development.md index 8c86ee5e0b..30fdfe64e0 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -247,6 +247,23 @@ Fluidd uses [vue-i18n](https://kazupon.github.io/vue-i18n/) for its localization Locales can be found in the `src/locales` folder and are in YAML format. +### Vuetify component translations + +Vuetify keeps its own string table, separate from `src/locales`, for strings its +components render without Fluidd passing them — the `v-data-table` footer, +pagination and sort aria-labels, and the `v-file-input` counter. These +translations ship with Vuetify and are **not** managed through Weblate, so +editing `src/locales` will not change them. + +`src/plugins/vuetify.ts` maps each Fluidd locale code onto the matching Vuetify +one and loads it when the language changes. Most codes match one-to-one; the +exceptions are `zh-CN`, `zh-HK` and `pt_BR`, which map onto Vuetify's `zh-Hans`, +`zh-Hant` and `pt`. A code left out of that map falls back to English, which is +what happens for Tamil — Vuetify has no `ta` translation. + +Adding a language therefore needs an entry in that map alongside the one in +`SupportedLocales` (`src/globals.ts`), unless Vuetify has no translation for it. + ### How to contribute Translations are hosted on Weblate. If you want to help translating our project, please click the widget below: diff --git a/docs/docs/features/localization.md b/docs/docs/features/localization.md index e8f9de1ace..8b0159f83c 100644 --- a/docs/docs/features/localization.md +++ b/docs/docs/features/localization.md @@ -22,8 +22,7 @@ Translations are managed through [Weblate](https://hosted.weblate.org/engage/fluidd/). Do not edit non-English locale files directly — use Weblate instead. The [Weblate project page](https://hosted.weblate.org/engage/fluidd/) shows the -current completion percentage for each language, and new languages can be added -there without needing a code change. +current completion percentage for each language. See the [developer localization](/development#localization) docs for technical details on how translations work. diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index 89e5148eee..ac131c8780 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -3,12 +3,41 @@ import Vue from 'vue' import Vuetify from 'vuetify/lib' import { Ripple } from 'vuetify/lib/directives' import colors from 'vuetify/lib/util/colors' +import type { VuetifyLocale } from 'vuetify/types/services/lang' + +// Vuetify's own component translations, keyed by Fluidd locale code. +// Codes absent here (ie, `ta`) have no Vuetify translation and stay english. +const locales: Record Promise<{ default: VuetifyLocale }>> = { + af: () => import('vuetify/lib/locale/af'), + ar: () => import('vuetify/lib/locale/ar'), + cs: () => import('vuetify/lib/locale/cs'), + de: () => import('vuetify/lib/locale/de'), + en: () => import('vuetify/lib/locale/en'), + es: () => import('vuetify/lib/locale/es'), + fr: () => import('vuetify/lib/locale/fr'), + hu: () => import('vuetify/lib/locale/hu'), + it: () => import('vuetify/lib/locale/it'), + ja: () => import('vuetify/lib/locale/ja'), + ko: () => import('vuetify/lib/locale/ko'), + nl: () => import('vuetify/lib/locale/nl'), + pl: () => import('vuetify/lib/locale/pl'), + pt: () => import('vuetify/lib/locale/pt'), + pt_BR: () => import('vuetify/lib/locale/pt'), + ru: () => import('vuetify/lib/locale/ru'), + sl: () => import('vuetify/lib/locale/sl'), + sv: () => import('vuetify/lib/locale/sv'), + th: () => import('vuetify/lib/locale/th'), + tr: () => import('vuetify/lib/locale/tr'), + uk: () => import('vuetify/lib/locale/uk'), + 'zh-CN': () => import('vuetify/lib/locale/zh-Hans'), + 'zh-HK': () => import('vuetify/lib/locale/zh-Hant') +} Vue.use(Vuetify, { directives: { Ripple } }) -export default new Vuetify({ +const vuetify = new Vuetify({ breakpoint: { mobileBreakpoint: 'xs' }, @@ -45,3 +74,26 @@ export default new Vuetify({ } } }) + +/** + * Loads and applies the Vuetify component translations for a given locale. + */ +export const loadVuetifyLocaleAsync = async (locale: string) => { + const { lang } = vuetify.framework + + const load = locales[locale] + + if (!load) { + return + } + + if (!(locale in lang.locales)) { + const { default: messages } = await load() + + Vue.set(lang.locales, locale, messages) + } + + lang.current = locale +} + +export default vuetify diff --git a/src/store/config/actions.ts b/src/store/config/actions.ts index dc3cf40269..a5e7933366 100644 --- a/src/store/config/actions.ts +++ b/src/store/config/actions.ts @@ -1,4 +1,4 @@ -import vuetify from '@/plugins/vuetify' +import vuetify, { loadVuetifyLocaleAsync } from '@/plugins/vuetify' import type { ActionTree } from 'vuex' import type { ConfigState, SaveByPath, InitConfig, InstanceConfig, TemperaturePreset, UiSettings, ThemeConfig, ConfiguredTableHeader } from './types' import type { RootState } from '../types' @@ -54,9 +54,6 @@ export const actions = { * Sets, and saves a locale change. */ async onLocaleChange ({ dispatch, state }, payload: string) { - // Set the correct language. - // vuetify.framework.lang.current = state.uiSettings.general.locale - // Add the wait. dispatch('wait/addWait', Waits.onLoadLanguage, { root: true }) @@ -68,6 +65,8 @@ export const actions = { ? await loadLocaleMessagesAsync(payload) : await loadLocaleMessagesAsync(startingLocale) + await loadVuetifyLocaleAsync(locale) + // If the locale doesn't match what we have in settings, update it. if ( state.uiSettings.general.locale !== payload diff --git a/src/typings/vuetify.d.ts b/src/typings/vuetify.d.ts index 794bb4702e..a515e4f943 100644 --- a/src/typings/vuetify.d.ts +++ b/src/typings/vuetify.d.ts @@ -119,3 +119,11 @@ declare module 'vuetify/lib/components' { delimiters: string[] } } + +declare module 'vuetify/lib/locale/*' { + import type { VuetifyLocale } from 'vuetify/types/services/lang' + + const locale: VuetifyLocale + + export default locale +} From 61637679be8d03285300c7fc628f24da69e292c6 Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Thu, 27 Aug 2026 12:27:12 +0100 Subject: [PATCH 2/3] fix: detect region-specific browser locales getStartingLocale stripped the region before matching, so a browser set to zh-CN resolved to zh, which is not a supported code, and fell back to english. It also only read the first navigator preference, ignoring a supported second one. Navigator locales are now walked in order, with the full code matched before the language alone. Also simplifies loadLocaleMessagesAsync onto i18n.availableLocales rather than tracking loaded locales separately, takes the browser default when called with no locale, logs load failures through consola, and keeps the language wait in a finally so a failed load cannot strand it. Co-Authored-By: Claude Opus 5 Signed-off-by: Pedro Lamas --- src/components/settings/GeneralSettings.vue | 1 + src/plugins/__tests__/i18n.spec.ts | 64 +++++++++++++++++++++ src/plugins/i18n.ts | 62 +++++++++----------- src/store/config/actions.ts | 43 +++++++------- 4 files changed, 114 insertions(+), 56 deletions(-) create mode 100644 src/plugins/__tests__/i18n.spec.ts diff --git a/src/components/settings/GeneralSettings.vue b/src/components/settings/GeneralSettings.vue index 5ea4d22230..0bfb7dedb4 100644 --- a/src/components/settings/GeneralSettings.vue +++ b/src/components/settings/GeneralSettings.vue @@ -34,6 +34,7 @@ hide-details="auto" :items="supportedLocales" :value="locale" + :loading="hasWait($waits.onLoadLanguage)" item-text="name" item-value="code" @change="setLocale" diff --git a/src/plugins/__tests__/i18n.spec.ts b/src/plugins/__tests__/i18n.spec.ts new file mode 100644 index 0000000000..43cc5787f8 --- /dev/null +++ b/src/plugins/__tests__/i18n.spec.ts @@ -0,0 +1,64 @@ +import { getStartingLocale } from '../i18n' + +describe('getStartingLocale', () => { + const originalLanguages = Object.getOwnPropertyDescriptor(navigator.constructor.prototype, 'languages') + + const setNavigatorLanguages = (languages: string[]) => { + Object.defineProperty(navigator, 'languages', { + configurable: true, + value: languages + }) + } + + afterEach(() => { + if (originalLanguages) { + Object.defineProperty(navigator.constructor.prototype, 'languages', originalLanguages) + } + + Reflect.deleteProperty(navigator, 'languages') + }) + + it.each([ + [['zh-CN', 'en-US'], 'zh-CN'], + [['zh-HK'], 'zh-HK'], + [['de'], 'de'], + [['fr-CA'], 'fr'] + ])('matches the full locale code before the language (%j)', (languages, expected) => { + setNavigatorLanguages(languages) + + expect(getStartingLocale()).toBe(expected) + }) + + it('falls back to the language when the full code is unsupported', () => { + setNavigatorLanguages(['pt-BR']) + + expect(getStartingLocale()).toBe('pt') + }) + + it('normalizes the casing of the matched code', () => { + setNavigatorLanguages(['zh-hk']) + + expect(getStartingLocale()).toBe('zh-HK') + }) + + it('takes the underscore form of a locale code', () => { + setNavigatorLanguages(['pt_BR']) + + expect(getStartingLocale()).toBe('pt') + }) + + it('walks the preferences in order until one is supported', () => { + setNavigatorLanguages(['nb-NO', 'de-DE']) + + expect(getStartingLocale()).toBe('de') + }) + + it.each([ + [['xx-YY']], + [[]] + ])('falls back to english when nothing is supported (%j)', (languages) => { + setNavigatorLanguages(languages) + + expect(getStartingLocale()).toBe('en') + }) +}) diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 18dacb7a5f..5e4000817f 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -1,5 +1,6 @@ import Vue from 'vue' import VueI18n, { type Locale } from 'vue-i18n' +import { consola } from 'consola' import { SupportedLocales } from '@/globals' import messages from '@/locales/en.yaml' import { I18nLocales } from '@/dynamicImports' @@ -21,23 +22,25 @@ export const getAllLocales = (): Intl.LocalesArgument => { * Loads the starting locale for the user. */ export const getStartingLocale = () => { - const navigatorLocale = getNavigatorLocales()[0] - const countryCode = navigatorLocale.split(/-|_/)[0] - - if ( - countryCode && - SupportedLocales.some(locale => locale.code === countryCode) - ) { - return countryCode - } else { - return import.meta.env.VUE_APP_I18N_LOCALE || 'en' + for (const navigatorLocale of getNavigatorLocales()) { + const [code] = navigatorLocale.split('_') + const [language] = code.split('-') + + const supported = ( + SupportedLocales.find(locale => locale.code.toLowerCase() === code.toLowerCase()) ?? + SupportedLocales.find(locale => locale.code.toLowerCase() === language.toLowerCase()) + ) + + if (supported) { + return supported.code + } } -} -const startingLocale = getStartingLocale() + return import.meta.env.VUE_APP_I18N_LOCALE || 'en' +} const i18n = new VueI18n({ - locale: startingLocale, + locale: getStartingLocale(), fallbackLocale: import.meta.env.VUE_APP_I18N_FALLBACK_LOCALE || 'en', messages: {} }) @@ -45,33 +48,24 @@ const i18n = new VueI18n({ // Pre apply the en language for fallback. i18n.setLocaleMessage('en', messages) -const loadedLanguages: Locale[] = [] +export const loadLocaleMessagesAsync = async (locale?: Locale | null) => { + const resolvedLocale = locale ?? getStartingLocale() -export const loadLocaleMessagesAsync = async (locale: Locale) => { - // If already loaded, and currently selected. - if (loadedLanguages.length > 0 && i18n.locale === locale) { - return locale - } + if (!i18n.availableLocales.includes(resolvedLocale)) { + try { + i18n.setLocaleMessage(resolvedLocale, await I18nLocales[resolvedLocale]()) + } catch (error) { + consola.error(`[i18n] failed to load locale "${resolvedLocale}"`, error) - // If already loaded, but not the currently selected. - if (loadedLanguages.includes(locale)) { - i18n.locale = locale - return locale + return i18n.locale + } } - // Not loaded - try { - const messages = await I18nLocales[locale]() + i18n.locale = resolvedLocale - i18n.setLocaleMessage(locale, messages) - loadedLanguages.push(locale) - i18n.locale = locale - return locale - } catch { - return i18n.locale - } + return resolvedLocale } -loadLocaleMessagesAsync(startingLocale) +loadLocaleMessagesAsync() export default i18n diff --git a/src/store/config/actions.ts b/src/store/config/actions.ts index a5e7933366..3ff8f1769a 100644 --- a/src/store/config/actions.ts +++ b/src/store/config/actions.ts @@ -3,7 +3,7 @@ import type { ActionTree } from 'vuex' import type { ConfigState, SaveByPath, InitConfig, InstanceConfig, TemperaturePreset, UiSettings, ThemeConfig, ConfiguredTableHeader } from './types' import type { RootState } from '../types' import { SocketActions } from '@/api/socketActions' -import { loadLocaleMessagesAsync, getStartingLocale } from '@/plugins/i18n' +import { loadLocaleMessagesAsync } from '@/plugins/i18n' import { Waits } from '@/globals' import type { FileFilterType } from '../files/types' import { TinyColor } from '@ctrl/tinycolor' @@ -54,30 +54,29 @@ export const actions = { * Sets, and saves a locale change. */ async onLocaleChange ({ dispatch, state }, payload: string) { - // Add the wait. dispatch('wait/addWait', Waits.onLoadLanguage, { root: true }) - // Grab the browsers starting locale. - const startingLocale = getStartingLocale() - - // Set the locale. If its set as default, use the starting locale. - const locale = (payload !== 'default') - ? await loadLocaleMessagesAsync(payload) - : await loadLocaleMessagesAsync(startingLocale) - - await loadVuetifyLocaleAsync(locale) - - // If the locale doesn't match what we have in settings, update it. - if ( - state.uiSettings.general.locale !== payload - ) { - dispatch('saveByPath', { - path: 'uiSettings.general.locale', - value: (payload !== 'default') ? locale : payload, - server: true - }) + try { + const locale = await loadLocaleMessagesAsync( + payload === 'default' + ? null + : payload + ) + + await loadVuetifyLocaleAsync(locale) + + if (state.uiSettings.general.locale !== payload) { + dispatch('saveByPath', { + path: 'uiSettings.general.locale', + value: payload === 'default' + ? payload + : locale, + server: true + }) + } + } finally { + dispatch('wait/removeWait', Waits.onLoadLanguage, { root: true }) } - dispatch('wait/removeWait', Waits.onLoadLanguage, { root: true }) }, /** From d4070c3ebd064752118f75e73ed8521124cc08ac Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Thu, 27 Aug 2026 14:32:24 +0100 Subject: [PATCH 3/3] fix: correct locale fallback edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadVuetifyLocaleAsync` returned early for a locale with no Vuetify translation, leaving `lang.current` on the previously selected language. Switching from Portuguese to Tamil left the data table footer reading "Linhas por página:". Reset `lang.current` to `en` instead, matching the documented behaviour. `getStartingLocale` truncated at the underscore rather than normalizing it, so an underscore-form browser locale such as `zh_CN` collapsed to `zh` and matched nothing. Adds coverage for `loadVuetifyLocaleAsync` (locale mapping, the English reset, and load caching) plus the underscore form. Co-Authored-By: Claude Opus 5 Signed-off-by: Pedro Lamas --- src/plugins/__tests__/i18n.spec.ts | 9 ++++-- src/plugins/__tests__/vuetify.spec.ts | 41 +++++++++++++++++++++++++++ src/plugins/i18n.ts | 2 +- src/plugins/vuetify.ts | 2 ++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/plugins/__tests__/vuetify.spec.ts diff --git a/src/plugins/__tests__/i18n.spec.ts b/src/plugins/__tests__/i18n.spec.ts index 43cc5787f8..f03463e902 100644 --- a/src/plugins/__tests__/i18n.spec.ts +++ b/src/plugins/__tests__/i18n.spec.ts @@ -41,10 +41,13 @@ describe('getStartingLocale', () => { expect(getStartingLocale()).toBe('zh-HK') }) - it('takes the underscore form of a locale code', () => { - setNavigatorLanguages(['pt_BR']) + it.each([ + [['pt_BR'], 'pt'], + [['zh_CN'], 'zh-CN'] + ])('takes the underscore form of a locale code (%j)', (languages, expected) => { + setNavigatorLanguages(languages) - expect(getStartingLocale()).toBe('pt') + expect(getStartingLocale()).toBe(expected) }) it('walks the preferences in order until one is supported', () => { diff --git a/src/plugins/__tests__/vuetify.spec.ts b/src/plugins/__tests__/vuetify.spec.ts new file mode 100644 index 0000000000..c84f0ea67f --- /dev/null +++ b/src/plugins/__tests__/vuetify.spec.ts @@ -0,0 +1,41 @@ +import vuetify, { loadVuetifyLocaleAsync } from '../vuetify' + +describe('loadVuetifyLocaleAsync', () => { + const { lang } = vuetify.framework + + beforeEach(() => { + lang.current = 'en' + }) + + it.each([ + ['de', 'Schließen'], + ['pt', 'Fechar'], + ['pt_BR', 'Fechar'], + ['zh-CN', '关闭'], + ['zh-HK', '關閉'] + ])('loads and applies the vuetify locale (%s)', async (locale, close) => { + await loadVuetifyLocaleAsync(locale) + + expect(lang.current).toBe(locale) + expect(lang.t('$vuetify.close')).toBe(close) + }) + + it('falls back to english when the locale has no vuetify translation', async () => { + await loadVuetifyLocaleAsync('pt') + await loadVuetifyLocaleAsync('ta') + + expect(lang.current).toBe('en') + expect(lang.t('$vuetify.close')).toBe('Close') + }) + + it('does not reload an already loaded locale', async () => { + await loadVuetifyLocaleAsync('fr') + + const messages = lang.locales.fr + + await loadVuetifyLocaleAsync('en') + await loadVuetifyLocaleAsync('fr') + + expect(lang.locales.fr).toBe(messages) + }) +}) diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 5e4000817f..795c193de5 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -23,7 +23,7 @@ export const getAllLocales = (): Intl.LocalesArgument => { */ export const getStartingLocale = () => { for (const navigatorLocale of getNavigatorLocales()) { - const [code] = navigatorLocale.split('_') + const code = navigatorLocale.replace('_', '-') const [language] = code.split('-') const supported = ( diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index ac131c8780..7311c5fbcb 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -84,6 +84,8 @@ export const loadVuetifyLocaleAsync = async (locale: string) => { const load = locales[locale] if (!load) { + lang.current = 'en' + return }