mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: dynamic languages (#27869)
Co-authored-by: xantin <github@xantin.be>
This commit is contained in:
+3
-10
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||||
import { defaultLang, langs } from '$lib/constants';
|
import { defaultLang } from '$lib/constants';
|
||||||
import { lang } from '$lib/stores/preferences.store';
|
import { lang } from '$lib/stores/preferences.store';
|
||||||
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
import { getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
|
||||||
import { Label, Text } from '@immich/ui';
|
import { Label, Text } from '@immich/ui';
|
||||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -13,14 +13,7 @@
|
|||||||
|
|
||||||
let { showSettingDescription = false }: Props = $props();
|
let { showSettingDescription = false }: Props = $props();
|
||||||
|
|
||||||
const langOptions = langs
|
const langOptions = langs.map((lang) => ({ label: lang.name, value: lang.code }));
|
||||||
.map((lang) => ({ label: lang.name, value: lang.code }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (b.label.startsWith('Development')) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return a.label.localeCompare(b.label);
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
|
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
|
||||||
|
|
||||||
|
|||||||
+1
-100
@@ -229,114 +229,15 @@ export const locales = [
|
|||||||
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
|
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Lang {
|
export interface Lang {
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
loader: () => Promise<{ default: object }>;
|
loader: () => Promise<{ default: object }>;
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
weblateCode?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLang: Lang = { name: 'English', code: 'en', loader: () => import('$i18n/en.json') };
|
export const defaultLang: Lang = { name: 'English', code: 'en', loader: () => import('$i18n/en.json') };
|
||||||
|
|
||||||
export const langs: Lang[] = [
|
|
||||||
{ name: 'Afrikaans', code: 'af', loader: () => import('$i18n/af.json') },
|
|
||||||
{ name: 'Arabic', code: 'ar', loader: () => import('$i18n/ar.json'), rtl: true },
|
|
||||||
{ name: 'Azerbaijani', code: 'az', loader: () => import('$i18n/az.json'), rtl: true },
|
|
||||||
{ name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') },
|
|
||||||
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
|
|
||||||
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
|
|
||||||
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
|
|
||||||
{ name: 'Breton', code: 'br', loader: () => import('$i18n/br.json') },
|
|
||||||
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
|
|
||||||
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
|
|
||||||
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
|
|
||||||
{ name: 'Danish', code: 'da', loader: () => import('$i18n/da.json') },
|
|
||||||
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
|
|
||||||
{ name: 'German (Switzerland)', code: 'de-CH', weblateCode: 'de_CH', loader: () => import('$i18n/de_CH.json') },
|
|
||||||
defaultLang,
|
|
||||||
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
|
|
||||||
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
|
|
||||||
{ name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') },
|
|
||||||
{ name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
|
|
||||||
{ name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') },
|
|
||||||
{ name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json'), rtl: true },
|
|
||||||
{ name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
|
|
||||||
{ name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
|
|
||||||
{ name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
|
|
||||||
{ name: 'Irish', code: 'ga', loader: () => import('$i18n/ga.json') },
|
|
||||||
{ name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') },
|
|
||||||
{ name: 'Alemannic', code: 'gsw', loader: () => import('$i18n/gsw.json') },
|
|
||||||
{ name: 'Gujarati', code: 'gu', loader: () => import('$i18n/gu.json') },
|
|
||||||
{ name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true },
|
|
||||||
{ name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
|
|
||||||
{ name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') },
|
|
||||||
{ name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') },
|
|
||||||
{ name: 'Armenian', code: 'hy', loader: () => import('$i18n/hy.json') },
|
|
||||||
{ name: 'Indonesian', code: 'id', loader: () => import('$i18n/id.json') },
|
|
||||||
{ name: 'Icelandic', code: 'is', loader: () => import('$i18n/is.json') },
|
|
||||||
{ name: 'Italian', code: 'it', loader: () => import('$i18n/it.json') },
|
|
||||||
{ name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') },
|
|
||||||
{ name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') },
|
|
||||||
{ name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') },
|
|
||||||
{ name: 'Khmer (Central)', code: 'km', loader: () => import('$i18n/km.json') },
|
|
||||||
{ name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true },
|
|
||||||
{ name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') },
|
|
||||||
{ name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') },
|
|
||||||
{ name: 'Luxembourgish', code: 'lb', loader: () => import('$i18n/lb.json') },
|
|
||||||
{ name: 'Lithuanian', code: 'lt', loader: () => import('$i18n/lt.json') },
|
|
||||||
{ name: 'Latvian', code: 'lv', loader: () => import('$i18n/lv.json') },
|
|
||||||
{ name: 'Malay (Pattani)', code: 'mfa', loader: () => import('$i18n/mfa.json') },
|
|
||||||
{ name: 'Macedonian', code: 'mk', loader: () => import('$i18n/mk.json') },
|
|
||||||
{ name: 'Malayalam', code: 'ml', loader: () => import('$i18n/ml.json') },
|
|
||||||
{ name: 'Mongolian', code: 'mn', loader: () => import('$i18n/mn.json') },
|
|
||||||
{ name: 'Marathi', code: 'mr', loader: () => import('$i18n/mr.json') },
|
|
||||||
{ name: 'Malay', code: 'ms', loader: () => import('$i18n/ms.json') },
|
|
||||||
{ name: 'Norwegian Bokmål', code: 'nb-NO', weblateCode: 'nb_NO', loader: () => import('$i18n/nb_NO.json') },
|
|
||||||
{ name: 'Dutch', code: 'nl', loader: () => import('$i18n/nl.json') },
|
|
||||||
{ name: 'Norwegian Nynorsk', code: 'nn', loader: () => import('$i18n/nn.json') },
|
|
||||||
{ name: 'Punjabi', code: 'pa', loader: () => import('$i18n/pa.json') },
|
|
||||||
{ name: 'Polish', code: 'pl', loader: () => import('$i18n/pl.json') },
|
|
||||||
{ name: 'Portuguese', code: 'pt', loader: () => import('$i18n/pt.json') },
|
|
||||||
{ name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
|
|
||||||
{ name: 'Romanian', code: 'ro', loader: () => import('$i18n/ro.json') },
|
|
||||||
{ name: 'Russian', code: 'ru', loader: () => import('$i18n/ru.json') },
|
|
||||||
{ name: 'Sinhala', code: 'si', loader: () => import('$i18n/si.json') },
|
|
||||||
{ name: 'Slovak', code: 'sk', loader: () => import('$i18n/sk.json') },
|
|
||||||
{ name: 'Slovenian', code: 'sl', loader: () => import('$i18n/sl.json') },
|
|
||||||
{ name: 'Albanian', code: 'sq', loader: () => import('$i18n/sq.json') },
|
|
||||||
{
|
|
||||||
name: 'Serbian (Cyrillic)',
|
|
||||||
code: 'sr-Cyrl',
|
|
||||||
weblateCode: 'sr_Cyrl',
|
|
||||||
loader: () => import('$i18n/sr_Cyrl.json'),
|
|
||||||
},
|
|
||||||
{ name: 'Serbian (Latin)', code: 'sr-Latn', weblateCode: 'sr_Latn', loader: () => import('$i18n/sr_Latn.json') },
|
|
||||||
{ name: 'Swedish', code: 'sv', loader: () => import('$i18n/sv.json') },
|
|
||||||
{ name: 'Tamil', code: 'ta', loader: () => import('$i18n/ta.json') },
|
|
||||||
{ name: 'Telugu', code: 'te', loader: () => import('$i18n/te.json') },
|
|
||||||
{ name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') },
|
|
||||||
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
|
|
||||||
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
|
|
||||||
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true },
|
|
||||||
{ name: 'Uzbek', code: 'uz', loader: () => import('$i18n/uz.json') },
|
|
||||||
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
|
|
||||||
{ name: 'Cantonese (Traditional Han script)', code: 'yue_Hant', loader: () => import('$i18n/yue_Hant.json') },
|
|
||||||
{
|
|
||||||
name: 'Chinese (Traditional)',
|
|
||||||
code: 'zh-TW',
|
|
||||||
weblateCode: 'zh_Hant',
|
|
||||||
loader: () => import('$i18n/zh_Hant.json'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Chinese (Simplified)',
|
|
||||||
code: 'zh-CN',
|
|
||||||
weblateCode: 'zh_Hans',
|
|
||||||
loader: () => import('$i18n/zh_Hans.json'),
|
|
||||||
},
|
|
||||||
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
|
||||||
];
|
|
||||||
|
|
||||||
export enum ImmichProduct {
|
export enum ImmichProduct {
|
||||||
Client = 'immich-client',
|
Client = 'immich-client',
|
||||||
Server = 'immich-server',
|
Server = 'immich-server',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { langs } from '$lib/constants';
|
import { getClosestAvailableLocale, langs } from '$lib/utils/i18n';
|
||||||
import { getClosestAvailableLocale } from '$lib/utils/i18n';
|
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
|
||||||
describe('i18n', () => {
|
describe('i18n', () => {
|
||||||
@@ -12,7 +11,7 @@ describe('i18n', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = filename.replaceAll('.json', '');
|
const code = filename.replaceAll('.json', '');
|
||||||
const item = langs.find((lang) => lang.weblateCode === code || lang.code === code);
|
const item = langs.find((lang) => lang.code === code);
|
||||||
expect(item, `${filename} has no loader`).toBeDefined();
|
expect(item, `${filename} has no loader`).toBeDefined();
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { langs } from '$lib/constants';
|
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { lang } from '$lib/stores/preferences.store';
|
import { lang } from '$lib/stores/preferences.store';
|
||||||
|
import { langs } from '$lib/utils/i18n';
|
||||||
|
|
||||||
class LanguageManager {
|
class LanguageManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { defaultLang, langs, locales } from '$lib/constants';
|
import { defaultLang, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { langs } from '$lib/utils/i18n';
|
||||||
import {
|
import {
|
||||||
AssetMediaSize,
|
AssetMediaSize,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
|
|||||||
+111
-12
@@ -1,8 +1,8 @@
|
|||||||
import { langs } from '$lib/constants';
|
import type { Lang } from '$lib/constants';
|
||||||
import { locale, t, waitLocale } from 'svelte-i18n';
|
import { locale, t, waitLocale } from 'svelte-i18n';
|
||||||
import { get, type Unsubscriber } from 'svelte/store';
|
import { get, type Unsubscriber } from 'svelte/store';
|
||||||
|
|
||||||
export async function getFormatter() {
|
export const getFormatter = async () => {
|
||||||
let unsubscribe: Unsubscriber | undefined;
|
let unsubscribe: Unsubscriber | undefined;
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
unsubscribe = locale.subscribe((value) => value && resolve(value));
|
unsubscribe = locale.subscribe((value) => value && resolve(value));
|
||||||
@@ -11,23 +11,122 @@ export async function getFormatter() {
|
|||||||
|
|
||||||
await waitLocale();
|
await waitLocale();
|
||||||
return get(t);
|
return get(t);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const modules = import.meta.glob('$i18n/*.json');
|
||||||
|
|
||||||
|
const fileCodes = Object.keys(modules)
|
||||||
|
.map((path) => path.match(/\/(\w+)\.json$/)?.[1])
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const convertBCP47 = (code: string) => code.replace('_', '-');
|
||||||
|
|
||||||
|
export const langCodes = fileCodes.map((code) => convertBCP47(code));
|
||||||
|
|
||||||
// https://github.com/kaisermann/svelte-i18n/blob/780932a3e1270d521d348aac8ba03be9df309f04/src/runtime/stores/locale.ts#L11
|
// https://github.com/kaisermann/svelte-i18n/blob/780932a3e1270d521d348aac8ba03be9df309f04/src/runtime/stores/locale.ts#L11
|
||||||
function getSubLocales(refLocale: string) {
|
const getSubLocales = (locale: string) => {
|
||||||
return refLocale
|
return locale
|
||||||
.split('-')
|
.split('-')
|
||||||
.map((_, i, arr) => arr.slice(0, i + 1).join('-'))
|
.map((_, i, arr) => arr.slice(0, i + 1).join('-'))
|
||||||
.reverse();
|
.reverse();
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getClosestAvailableLocale(locales: readonly string[], allLocales: readonly string[]) {
|
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
|
||||||
const allLocalesSet = new Set(allLocales);
|
const allLocalesSet = new Set(allLocales);
|
||||||
return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
|
return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const langCodes = langs.map((lang) => lang.code);
|
export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes);
|
||||||
|
|
||||||
export function getPreferredLocale() {
|
const rtlCodes = new Set([
|
||||||
return getClosestAvailableLocale(navigator.languages, langCodes);
|
'ae',
|
||||||
}
|
'aeb',
|
||||||
|
'aii',
|
||||||
|
'ajp',
|
||||||
|
'apc',
|
||||||
|
'apd',
|
||||||
|
'ar',
|
||||||
|
'ar_BH',
|
||||||
|
'ar_DZ',
|
||||||
|
'ar_EG',
|
||||||
|
'ar_KW',
|
||||||
|
'ar_LY',
|
||||||
|
'ar_MA',
|
||||||
|
'ar_SA',
|
||||||
|
'ar_YE',
|
||||||
|
'ara',
|
||||||
|
'arc',
|
||||||
|
'arq',
|
||||||
|
'ars',
|
||||||
|
'arz',
|
||||||
|
'ave',
|
||||||
|
'bal',
|
||||||
|
'bcc',
|
||||||
|
'bgn',
|
||||||
|
'bqi',
|
||||||
|
'ckb',
|
||||||
|
'ckb_IR',
|
||||||
|
'dv',
|
||||||
|
'egy',
|
||||||
|
'fa',
|
||||||
|
'fa_AF',
|
||||||
|
'fas',
|
||||||
|
'glk',
|
||||||
|
'ha',
|
||||||
|
'he',
|
||||||
|
'heb',
|
||||||
|
'khw',
|
||||||
|
'kmr',
|
||||||
|
'ks',
|
||||||
|
'ku',
|
||||||
|
'lrc',
|
||||||
|
'luz',
|
||||||
|
'ms_Arab',
|
||||||
|
'mzn',
|
||||||
|
'nqo',
|
||||||
|
'pa_PK',
|
||||||
|
'pal',
|
||||||
|
'per',
|
||||||
|
'phn',
|
||||||
|
'pnb',
|
||||||
|
'prs',
|
||||||
|
'ps',
|
||||||
|
'rhg',
|
||||||
|
'sam',
|
||||||
|
'sd',
|
||||||
|
'sdh',
|
||||||
|
'skr',
|
||||||
|
'syc',
|
||||||
|
'syr',
|
||||||
|
'ug',
|
||||||
|
'ur',
|
||||||
|
'ur_IN',
|
||||||
|
'urd',
|
||||||
|
'yi',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const capitalize = (string: string) =>
|
||||||
|
string
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const nonIntlNames: Record<string, string> = {
|
||||||
|
mfa: 'Malay (Pattani)',
|
||||||
|
bi: 'Bislama',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLanguageName = (code: string) =>
|
||||||
|
nonIntlNames[code] ?? capitalize(new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code);
|
||||||
|
|
||||||
|
export const langs: Lang[] = [
|
||||||
|
...fileCodes
|
||||||
|
.map((code) => ({
|
||||||
|
name: getLanguageName(convertBCP47(code)),
|
||||||
|
code,
|
||||||
|
loader: () => import(`$i18n/${code}.json`),
|
||||||
|
rtl: rtlCodes.has(code),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||||
|
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user