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">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
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 { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
||||
import { getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
|
||||
@@ -13,14 +13,7 @@
|
||||
|
||||
let { showSettingDescription = false }: Props = $props();
|
||||
|
||||
const langOptions = langs
|
||||
.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 langOptions = langs.map((lang) => ({ label: lang.name, value: lang.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)' },
|
||||
];
|
||||
|
||||
interface Lang {
|
||||
export interface Lang {
|
||||
name: string;
|
||||
code: string;
|
||||
loader: () => Promise<{ default: object }>;
|
||||
rtl?: boolean;
|
||||
weblateCode?: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
Client = 'immich-client',
|
||||
Server = 'immich-server',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { langs } from '$lib/constants';
|
||||
import { getClosestAvailableLocale } from '$lib/utils/i18n';
|
||||
import { getClosestAvailableLocale, langs } from '$lib/utils/i18n';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
describe('i18n', () => {
|
||||
@@ -12,7 +11,7 @@ describe('i18n', () => {
|
||||
}
|
||||
|
||||
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();
|
||||
if (!item) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { langs } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
|
||||
class LanguageManager {
|
||||
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 { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
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 { get, type Unsubscriber } from 'svelte/store';
|
||||
|
||||
export async function getFormatter() {
|
||||
export const getFormatter = async () => {
|
||||
let unsubscribe: Unsubscriber | undefined;
|
||||
await new Promise((resolve) => {
|
||||
unsubscribe = locale.subscribe((value) => value && resolve(value));
|
||||
@@ -11,23 +11,122 @@ export async function getFormatter() {
|
||||
|
||||
await waitLocale();
|
||||
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
|
||||
function getSubLocales(refLocale: string) {
|
||||
return refLocale
|
||||
const getSubLocales = (locale: string) => {
|
||||
return locale
|
||||
.split('-')
|
||||
.map((_, i, arr) => arr.slice(0, i + 1).join('-'))
|
||||
.reverse();
|
||||
}
|
||||
};
|
||||
|
||||
export function getClosestAvailableLocale(locales: readonly string[], allLocales: readonly string[]) {
|
||||
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
|
||||
const allLocalesSet = new Set(allLocales);
|
||||
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() {
|
||||
return getClosestAvailableLocale(navigator.languages, langCodes);
|
||||
}
|
||||
const rtlCodes = new Set([
|
||||
'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