/* * Copyright (C) 2022 Kristóf Marussy * * This file is part of Sophie. * * Sophie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * SPDX-License-Identifier: AGPL-3.0-only */ import type { i18n, ResourceKey, TFunction } from 'i18next'; import { IAtom, createAtom } from 'mobx'; import { getLogger } from '../utils/log'; const log = getLogger('I18nStore'); export type UseTranslationResult = | { ready: true; i18n: i18n; t: TFunction } | { ready: false }; export default class I18nStore { private readonly languageChangedAtom: IAtom; private readonly namespaceLoadedAtoms: Map = new Map(); private readonly notifyLanguageChange = () => this.languageChangedAtom.reportObserved(); constructor(private readonly i18next: i18n) { this.languageChangedAtom = createAtom( 'i18next', () => i18next.on('languageChanged', this.notifyLanguageChange), () => i18next.off('languageChanged', this.notifyLanguageChange), ); } useTranslation(ns?: string): UseTranslationResult { const observed = this.languageChangedAtom.reportObserved(); const namespaceToLoad = ns ?? this.i18next.options.defaultNS ?? 'translation'; if ( this.i18next.isInitialized && this.i18next.hasLoadedNamespace(namespaceToLoad) ) { return { ready: true, i18n: this.i18next, // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. t: this.i18next.getFixedT(null, namespaceToLoad), }; } if (observed) { this.loadNamespace(namespaceToLoad); } return { ready: false }; } private loadNamespace(ns: string): void { const existingAtom = this.namespaceLoadedAtoms.get(ns); if (existingAtom !== undefined) { existingAtom.reportObserved(); return; } const atom = createAtom(`i18next-${ns}`); this.namespaceLoadedAtoms.set(ns, atom); atom.reportObserved(); const loaded = () => { this.namespaceLoadedAtoms.delete(ns); atom.reportChanged(); }; const loadAsync = async () => { try { await this.i18next.loadNamespaces([ns]); } catch (error) { setImmediate(loaded); throw error; } if (this.i18next.isInitialized) { setImmediate(loaded); return; } const initialized = () => { setImmediate(() => { this.i18next.off('initialized', initialized); loaded(); }); }; this.i18next.on('initialized', initialized); }; loadAsync().catch((error) => { log.error('Failed to load translations for namespace', ns, error); }); } async reloadTranslations(): Promise { await this.i18next.reloadResources(); setImmediate(() => { this.languageChangedAtom.reportChanged(); }); log.debug('Reloaded translations'); } async getTranslation( language: string, namespace: string, ): Promise { if (!this.i18next.hasResourceBundle(language, namespace)) { await this.i18next.loadLanguages([language]); await this.i18next.loadNamespaces([namespace]); } const bundle = this.i18next.getResourceBundle( language, namespace, ) as unknown; if (typeof bundle !== 'object' || bundle === null) { throw new Error( `Failed to load ${namespace} resource bundle for language ${language}`, ); } return bundle as ResourceKey; } addMissingTranslation( languages: string[], namespace: string, key: string, value: string, ): void { this.i18next.modules.backend?.create?.(languages, namespace, key, value); } }