mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
174 lines
5.1 KiB
TypeScript
174 lines
5.1 KiB
TypeScript
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
|
import { initReactI18next } from "react-i18next";
|
|
import LanguageDetector from "i18next-browser-languagedetector";
|
|
|
|
import enTranslation from "../locales/en.json";
|
|
import { TRANSLATED_LANGUAGES } from "./languages";
|
|
|
|
type LocaleModule = { default: ResourceKey };
|
|
|
|
/** i18n code -> translated file name, shared by core and plugin locales. */
|
|
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
|
|
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
|
|
);
|
|
|
|
const translatedFiles = import.meta.glob<LocaleModule>(
|
|
"../locales/translated/*.json",
|
|
);
|
|
|
|
const localeLoaders: Record<string, () => Promise<LocaleModule>> =
|
|
Object.fromEntries(
|
|
Object.entries(LOCALE_FILES).map(([code, file]) => [
|
|
code,
|
|
() => {
|
|
const load = translatedFiles[`../locales/translated/${file}.json`];
|
|
return load ? load() : Promise.reject(new Error(`no ${file}`));
|
|
},
|
|
]),
|
|
);
|
|
|
|
/**
|
|
* Loads a plugin namespace. Set by the plugin loader; until then a plugin
|
|
* namespace resolves to nothing and keys fall back to core.
|
|
*/
|
|
export type PluginLocaleResolver = (
|
|
namespace: string,
|
|
language: string,
|
|
file: string,
|
|
) => Promise<ResourceKey | null>;
|
|
|
|
let pluginLocaleResolver: PluginLocaleResolver | null = null;
|
|
|
|
export function setPluginLocaleResolver(
|
|
resolver: PluginLocaleResolver | null,
|
|
): void {
|
|
pluginLocaleResolver = resolver;
|
|
}
|
|
|
|
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
|
|
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
|
|
|
|
/** Core strings. Each plugin gets a namespace named after its id. */
|
|
export const CORE_NAMESPACE = "translation";
|
|
|
|
export function normalizeLanguageCode(language?: string | null): string {
|
|
if (!language) return "en";
|
|
|
|
const normalized = language.replaceAll("_", "-");
|
|
if (supportedLngs.includes(normalized)) return normalized;
|
|
|
|
const exactMatch = supportedLngs.find(
|
|
(supported) => supported.toLowerCase() === normalized.toLowerCase(),
|
|
);
|
|
if (exactMatch) return exactMatch;
|
|
|
|
const baseLanguage = normalized.split("-")[0].toLowerCase();
|
|
if (supportedLngs.includes(baseLanguage)) return baseLanguage;
|
|
|
|
// Regional variants we don't ship (es-MX, pt, sv) use the closest one we do.
|
|
if (baseLanguage === "zh") {
|
|
return /-(tw|hk|mo|hant)\b/i.test(normalized) ? "zh-TW" : "zh-CN";
|
|
}
|
|
if (baseLanguage === "nb" || baseLanguage === "nn") return "no";
|
|
if (baseLanguage === "pt") return "pt-BR";
|
|
return (
|
|
supportedLngs.find((supported) =>
|
|
supported.toLowerCase().startsWith(`${baseLanguage}-`),
|
|
) ?? "en"
|
|
);
|
|
}
|
|
|
|
const localeBackend: BackendModule = {
|
|
type: "backend",
|
|
init: () => {},
|
|
read: (language, namespace, callback) => {
|
|
const normalizedLanguage = normalizeLanguageCode(language);
|
|
|
|
if (namespace !== CORE_NAMESPACE) {
|
|
const file =
|
|
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
|
|
if (!pluginLocaleResolver || !file) {
|
|
callback(null, {});
|
|
return;
|
|
}
|
|
pluginLocaleResolver(namespace, normalizedLanguage, file)
|
|
.then((resources) => callback(null, resources ?? {}))
|
|
.catch(() => callback(null, {}));
|
|
return;
|
|
}
|
|
|
|
if (normalizedLanguage === "en") {
|
|
callback(null, enTranslation);
|
|
return;
|
|
}
|
|
|
|
const loadLocale = localeLoaders[normalizedLanguage];
|
|
if (!loadLocale) {
|
|
callback(null, enTranslation);
|
|
return;
|
|
}
|
|
|
|
loadLocale()
|
|
.then((module) => callback(null, module.default))
|
|
.catch(() => callback(null, enTranslation));
|
|
},
|
|
};
|
|
|
|
i18n
|
|
.use(localeBackend)
|
|
.use(LanguageDetector)
|
|
.use(initReactI18next)
|
|
.init({
|
|
supportedLngs,
|
|
fallbackLng: "en",
|
|
// A plugin's namespace falls back to core, so plugins keep using the
|
|
// shared strings (common.*, hosts.*) without copying them.
|
|
fallbackNS: CORE_NAMESPACE,
|
|
debug: false,
|
|
|
|
detection: {
|
|
order: ["localStorage", "cookie"],
|
|
caches: ["localStorage", "cookie"],
|
|
lookupLocalStorage: "i18nextLng",
|
|
lookupCookie: "i18nextLng",
|
|
},
|
|
|
|
resources: {
|
|
en: {
|
|
translation: enTranslation,
|
|
},
|
|
},
|
|
partialBundledLanguages: true,
|
|
|
|
interpolation: {
|
|
escapeValue: false,
|
|
},
|
|
|
|
react: {
|
|
useSuspense: false,
|
|
// Repaint when a plugin enabled later loads its strings.
|
|
bindI18n: "languageChanged loaded",
|
|
},
|
|
});
|
|
|
|
export async function changeAppLanguage(language: string): Promise<string> {
|
|
const normalizedLanguage = normalizeLanguageCode(language);
|
|
await i18n.changeLanguage(normalizedLanguage);
|
|
localStorage.setItem("i18nextLng", normalizedLanguage);
|
|
return normalizedLanguage;
|
|
}
|
|
|
|
export function rememberLoginLanguage(language: string): string {
|
|
const normalizedLanguage = normalizeLanguageCode(language);
|
|
sessionStorage.setItem(PENDING_LOGIN_LANGUAGE_KEY, normalizedLanguage);
|
|
return normalizedLanguage;
|
|
}
|
|
|
|
export function consumeLoginLanguage(): string | null {
|
|
const language = sessionStorage.getItem(PENDING_LOGIN_LANGUAGE_KEY);
|
|
sessionStorage.removeItem(PENDING_LOGIN_LANGUAGE_KEY);
|
|
return language ? normalizeLanguageCode(language) : null;
|
|
}
|
|
|
|
export default i18n;
|