webapp/src/ts/i18n.ts

470 lines
18 KiB
TypeScript

import { StringLike } from './StringLike';
import { Format } from './format';
import { Emitter, Unsubscribe, createNanoEvents } from 'nanoevents';
import Config from '../../config.json';
/// All string keys.
export enum I18nStringKey {
// Generic things
kGeneric_CollabVM = 'kGeneric_CollabVM',
kGeneric_Yes = 'kGeneric_Yes',
kGeneric_No = 'kGeneric_No',
kGeneric_Ok = 'kGeneric_Ok',
kGeneric_Cancel = 'kGeneric_Cancel',
kGeneric_Send = 'kGeneric_Send',
kGeneric_Understood = 'kGeneric_Understood',
kGeneric_Username = 'kGeneric_Username',
kGeneric_Password = 'kGeneric_Password',
kGeneric_Login = 'kGeneric_Login',
kGeneric_Register = 'kGeneric_Register',
kGeneric_EMail = 'kGeneric_EMail',
kGeneric_DateOfBirth = 'kGeneric_DateOfBirth',
kGeneric_VerificationCode = 'kGeneric_VerificationCode',
kGeneric_Verify = 'kGeneric_Verify',
kGeneric_Update = 'kGeneric_Update',
kGeneric_Logout = 'kGeneric_Logout',
kWelcomeModal_Header = 'kWelcomeModal_Header',
kWelcomeModal_Body = 'kWelcomeModal_Body',
kSiteButtons_Home = 'kSiteButtons_Home',
kSiteButtons_FAQ = 'kSiteButtons_FAQ',
kSiteButtons_Rules = 'kSiteButtons_Rules',
kSiteButtons_DarkMode = 'kSiteButtons_DarkMode',
kSiteButtons_LightMode = 'kSiteButtons_LightMode',
kSiteButtons_Languages = 'kSiteButtons_Languages',
kVM_UsersOnlineText = 'kVM_UsersOnlineText',
kVM_TurnTimeTimer = 'kVM_TurnTimeTimer',
kVM_WaitingTurnTimer = 'kVM_WaitingTurnTimer',
kVM_VoteCooldownTimer = 'kVM_VoteCooldownTimer',
kVM_VoteForResetTitle = 'kVM_VoteForResetTitle',
kVM_VoteForResetTimer = 'kVM_VoteForResetTimer',
kVMButtons_TakeTurn = 'kVMButtons_TakeTurn',
kVMButtons_EndTurn = 'kVMButtons_EndTurn',
kVMButtons_ChangeUsername = 'kVMButtons_ChangeUsername',
kVMButtons_Keyboard = 'kVMButtons_Keyboard',
KVMButtons_CtrlAltDel = 'KVMButtons_CtrlAltDel',
kVMButtons_VoteForReset = 'kVMButtons_VoteForReset',
kVMButtons_Screenshot = 'kVMButtons_Screenshot',
// Admin VM buttons
kQEMUMonitor = 'kQEMUMonitor',
kAdminVMButtons_PassVote = 'kAdminVMButtons_PassVote',
kAdminVMButtons_CancelVote = 'kAdminVMButtons_CancelVote',
kAdminVMButtons_Restore = 'kAdminVMButtons_Restore',
kAdminVMButtons_Reboot = 'kAdminVMButtons_Reboot',
kAdminVMButtons_ClearTurnQueue = 'kAdminVMButtons_ClearTurnQueue',
kAdminVMButtons_BypassTurn = 'kAdminVMButtons_BypassTurn',
kAdminVMButtons_IndefiniteTurn = 'kAdminVMButtons_IndefiniteTurn',
kAdminVMButtons_GhostTurnOn = 'kAdminVMButtons_GhostTurnOn',
kAdminVMButtons_GhostTurnOff = 'kAdminVMButtons_GhostTurnOff',
kAdminVMButtons_Ban = 'kAdminVMButtons_Ban',
kAdminVMButtons_Kick = 'kAdminVMButtons_Kick',
kAdminVMButtons_TempMute = 'kAdminVMButtons_TempMute',
kAdminVMButtons_IndefMute = 'kAdminVMButtons_IndefMute',
kAdminVMButtons_Unmute = 'kAdminVMButtons_Unmute',
kAdminVMButtons_GetIP = 'kAdminVMButtons_GetIP',
// prompts
kVMPrompts_AdminChangeUsernamePrompt = 'kVMPrompts_AdminChangeUsernamePrompt',
kVMPrompts_AdminRestoreVMPrompt = 'kVMPrompts_AdminRestoreVMPrompt',
kVMPrompts_EnterNewUsernamePrompt = 'kVMPrompts_EnterNewUsernamePrompt',
// error messages
kError_UnexpectedDisconnection = 'kError_UnexpectedDisconnection',
kError_UsernameTaken = 'kError_UsernameTaken',
kError_UsernameInvalid = 'kError_UsernameInvalid',
kError_UsernameBlacklisted = 'kError_UsernameBlacklisted',
kError_IncorrectPassword = 'kError_IncorrectPassword',
// Auth
kAccountModal_Verify = 'kAccountModal_Verify',
kAccountModal_AccountSettings = 'kAccountModal_AccountSettings',
kAccountModal_ResetPassword = 'kAccountModal_ResetPassword',
kAccountModal_NewPassword = 'kAccountModal_NewPassword',
kAccountModal_ConfirmNewPassword = 'kAccountModal_ConfirmNewPassword',
kAccountModal_CurrentPassword = 'kAccountModal_CurrentPassword',
kAccountModal_ConfirmPassword = 'kAccountModal_ConfirmPassword',
kAccountModal_HideFlag = 'kAccountModal_HideFlag',
kAccountModal_VerifyText = 'kAccountModal_VerifyText',
kAccountModal_VerifyPasswordResetText = 'kAccountModal_VerifyPasswordResetText',
kAccountModal_PasswordResetSuccess = 'kAccountModal_PasswordResetSuccess',
kMissingCaptcha = 'kMissingCaptcha',
kPasswordsMustMatch = 'kPasswordsMustMatch',
kNotLoggedIn = 'kNotLoggedIn',
}
export interface I18nEvents {
// Called when the language is changed
languageChanged: (lang: string) => void;
}
// This models the JSON structure.
export type Language = {
languageName: string;
translatedLanguageName: string;
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
author: string;
stringKeys: {
// This is fancy typescript speak for
// "any string index returns a string",
// which is our expectation.
// See https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures if this is confusing.
[key: string]: string;
};
};
export type LanguageMetadata = {
languageName: string;
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
};
// `languages.json`
export type LanguagesJson = {
// Array of language IDs to allow loading
languages: {[key: string]: LanguageMetadata};
// The default language (set if a invalid language not in the languages array is set, or no language is set)
defaultLanguage: string;
};
// ID for fallback language
const fallbackId = '!!fallback';
// This language is provided in the webapp itself just in case language stuff fails
import fallbackLanguage from './fallbackLanguage.js';
interface StringKeyMap {
[k: string]: I18nStringKey;
}
/// our fancy internationalization helper.
export class I18n {
// The language data itself
private langs : Map<string, LanguageMetadata> = new Map<string, Language>();
private lang: Language = fallbackLanguage;
private languageDropdown: HTMLSpanElement = document.getElementById('languageDropdown') as HTMLSpanElement;
private emitter: Emitter<I18nEvents> = createNanoEvents();
CurrentLanguage = () => this.langId;
// the ID of the language
private langId: string = fallbackId;
private regionNameRenderer = new Intl.DisplayNames(['en-US'], {type: 'region'});
async Init() {
// Load language list
var res = await fetch("lang/languages.json");
if (!res.ok) {
alert("Failed to load languages.json: " + res.statusText);
await this.SetLanguage(fallbackId);
this.ReplaceStaticStrings();
return;
}
var langData = await res.json() as LanguagesJson;
for (const langId in langData.languages) {
this.langs.set(langId, langData.languages[langId]);
}
this.langs.forEach((_lang, langId) => {
// Add to language dropdown
var a = document.createElement('a');
a.classList.add('dropdown-item');
a.href = '#';
a.innerText = `${_lang.flag} ${_lang.languageName}`;
a.addEventListener('click', async e => {
e.preventDefault();
await this.SetLanguage(langId);
this.ReplaceStaticStrings();
});
this.languageDropdown.appendChild(a);
});
let lang = null;
let lsLang = window.localStorage.getItem('i18n-lang');
var browserLang = navigator.language.toLowerCase();
// If the language is set in localstorage, use that
if (lsLang !== null && this.langs.has(lsLang)) lang = lsLang;
// If the browser language is in the list, use that
else if (this.langs.has(browserLang)) lang = browserLang;
else {
// If the exact browser language isn't in the list, try to find a language with the same prefix
for (let langId in langData.languages) {
if (langId.split('-')[0] === browserLang.split('-')[0]) {
lang = langId;
break;
}
}
}
// If all else fails, use the default language
if (lang === null) lang = langData.defaultLanguage;
await this.SetLanguage(lang);
this.ReplaceStaticStrings();
}
getCountryName(code: string) : string {
return this.regionNameRenderer.of(code) || code;
}
private async SetLanguage(id: string) {
let lastId = this.langId;
this.langId = id;
let lang;
if (id === fallbackId) lang = fallbackLanguage;
else {
let path = `./lang/${id}.json`;
let res = await fetch(path);
if (!res.ok) {
console.error(`Failed to load lang/${id}.json: ${res.statusText}`);
await this.SetLanguage(fallbackId);
return;
}
lang = await res.json() as Language;
}
this.lang = lang;
if (this.langId != lastId) {
// Replace static strings
this.ReplaceStaticStrings();
// Update region name renderer target language
this.regionNameRenderer = new Intl.DisplayNames([this.langId], {type: 'region'});
};
// Set the language ID localstorage entry
if (this.langId !== fallbackId) {
window.localStorage.setItem('i18n-lang', this.langId);
}
this.emitter.emit('languageChanged', this.langId);
console.log('i18n initalized for', id, 'sucessfully!');
}
// Replaces static strings that we don't recompute
private ReplaceStaticStrings() {
const kDomIdtoStringMap: StringKeyMap = {
siteNameText: I18nStringKey.kGeneric_CollabVM,
homeBtnText: I18nStringKey.kSiteButtons_Home,
faqBtnText: I18nStringKey.kSiteButtons_FAQ,
rulesBtnText: I18nStringKey.kSiteButtons_Rules,
accountLoginButton: I18nStringKey.kGeneric_Login,
accountRegisterButton: I18nStringKey.kGeneric_Register,
accountSettingsButton: I18nStringKey.kAccountModal_AccountSettings,
accountLogoutButton: I18nStringKey.kGeneric_Logout,
languageDropdownText: I18nStringKey.kSiteButtons_Languages,
welcomeModalHeader: I18nStringKey.kWelcomeModal_Header,
welcomeModalBody: I18nStringKey.kWelcomeModal_Body,
welcomeModalDismiss: I18nStringKey.kGeneric_Understood,
usersOnlineText: I18nStringKey.kVM_UsersOnlineText,
voteResetHeaderText: I18nStringKey.kVM_VoteForResetTitle,
voteYesBtnText: I18nStringKey.kGeneric_Yes,
voteNoBtnText: I18nStringKey.kGeneric_No,
changeUsernameBtnText: I18nStringKey.kVMButtons_ChangeUsername,
oskBtnText: I18nStringKey.kVMButtons_Keyboard,
ctrlAltDelBtnText: I18nStringKey.KVMButtons_CtrlAltDel,
voteForResetBtnText: I18nStringKey.kVMButtons_VoteForReset,
screenshotBtnText: I18nStringKey.kVMButtons_Screenshot,
// admin stuff
badPasswordAlertText: I18nStringKey.kError_IncorrectPassword,
loginModalPasswordText: I18nStringKey.kGeneric_Password,
loginButton: I18nStringKey.kGeneric_Login,
passVoteBtnText: I18nStringKey.kAdminVMButtons_PassVote,
cancelVoteBtnText: I18nStringKey.kAdminVMButtons_CancelVote,
endTurnBtnText: I18nStringKey.kVMButtons_EndTurn,
qemuMonitorBtnText: I18nStringKey.kQEMUMonitor,
qemuModalHeader: I18nStringKey.kQEMUMonitor,
qemuMonitorSendBtn: I18nStringKey.kGeneric_Send,
restoreBtnText: I18nStringKey.kAdminVMButtons_Restore,
rebootBtnText: I18nStringKey.kAdminVMButtons_Reboot,
clearQueueBtnText: I18nStringKey.kAdminVMButtons_ClearTurnQueue,
bypassTurnBtnText: I18nStringKey.kAdminVMButtons_BypassTurn,
indefTurnBtnText: I18nStringKey.kAdminVMButtons_IndefiniteTurn,
ghostTurnBtnText: I18nStringKey.kAdminVMButtons_GhostTurnOff,
// Account modal
accountLoginUsernameLabel: I18nStringKey.kGeneric_Username,
accountLoginPasswordLabel: I18nStringKey.kGeneric_Password,
accountModalLoginBtn: I18nStringKey.kGeneric_Login,
accountForgotPasswordButton: I18nStringKey.kAccountModal_ResetPassword,
accountRegisterEmailLabel: I18nStringKey.kGeneric_EMail,
accountRegisterUsernameLabel: I18nStringKey.kGeneric_Username,
accountRegisterPasswordLabel: I18nStringKey.kGeneric_Password,
accountRegisterConfirmPasswordLabel: I18nStringKey.kAccountModal_ConfirmPassword,
accountRegisterDateOfBirthLabel: I18nStringKey.kGeneric_DateOfBirth,
accountModalRegisterBtn: I18nStringKey.kGeneric_Register,
accountVerifyEmailCodeLabel: I18nStringKey.kGeneric_VerificationCode,
accountVerifyEmailPasswordLabel: I18nStringKey.kGeneric_Password,
accountModalVerifyEmailBtn: I18nStringKey.kGeneric_Verify,
accountSettingsEmailLabel: I18nStringKey.kGeneric_EMail,
accountSettingsUsernameLabel: I18nStringKey.kGeneric_Username,
accountSettingsNewPasswordLabel: I18nStringKey.kAccountModal_NewPassword,
accountSettingsConfirmNewPasswordLabel: I18nStringKey.kAccountModal_ConfirmNewPassword,
accountSettingsCurrentPasswordLabel: I18nStringKey.kAccountModal_CurrentPassword,
hideFlagCheckboxLabel: I18nStringKey.kAccountModal_HideFlag,
updateAccountSettingsBtn: I18nStringKey.kGeneric_Update,
accountResetPasswordEmailLabel: I18nStringKey.kGeneric_EMail,
accountResetPasswordUsernameLabel: I18nStringKey.kGeneric_Username,
accountResetPasswordBtn: I18nStringKey.kAccountModal_ResetPassword,
accountResetPasswordCodeLabel: I18nStringKey.kGeneric_VerificationCode,
accountResetPasswordNewPasswordLabel: I18nStringKey.kAccountModal_NewPassword,
accountResetPasswordConfirmNewPasswordLabel: I18nStringKey.kAccountModal_ConfirmNewPassword,
accountResetPasswordVerifyBtn: I18nStringKey.kAccountModal_ResetPassword,
};
const kDomAttributeToStringMap = {
adminPassword: {
placeholder: I18nStringKey.kGeneric_Password,
},
accountLoginUsername: {
placeholder: I18nStringKey.kGeneric_Username,
},
accountLoginPassword: {
placeholder: I18nStringKey.kGeneric_Password,
},
accountRegisterEmail: {
placeholder: I18nStringKey.kGeneric_EMail,
},
accountRegisterUsername: {
placeholder: I18nStringKey.kGeneric_Username,
},
accountRegisterPassword: {
placeholder: I18nStringKey.kGeneric_Password,
},
accountRegisterConfirmPassword: {
placeholder: I18nStringKey.kAccountModal_ConfirmPassword,
},
accountVerifyEmailCode: {
placeholder: I18nStringKey.kGeneric_VerificationCode,
},
accountVerifyEmailPassword: {
placeholder: I18nStringKey.kGeneric_Password,
},
accountSettingsEmail: {
placeholder: I18nStringKey.kGeneric_EMail,
},
accountSettingsUsername: {
placeholder: I18nStringKey.kGeneric_Username,
},
accountSettingsNewPassword: {
placeholder: I18nStringKey.kAccountModal_NewPassword,
},
accountSettingsConfirmNewPassword: {
placeholder: I18nStringKey.kAccountModal_ConfirmNewPassword,
},
accountSettingsCurrentPassword: {
placeholder: I18nStringKey.kAccountModal_CurrentPassword,
},
accountResetPasswordEmail: {
placeholder: I18nStringKey.kGeneric_EMail,
},
accountResetPasswordUsername: {
placeholder: I18nStringKey.kGeneric_Username,
},
accountResetPasswordCode: {
placeholder: I18nStringKey.kGeneric_VerificationCode,
},
accountResetPasswordNewPassword: {
placeholder: I18nStringKey.kAccountModal_NewPassword,
},
accountResetPasswordConfirmNewPassword: {
placeholder: I18nStringKey.kAccountModal_ConfirmNewPassword,
},
};
const kDomClassToStringMap: StringKeyMap = {
"mod-end-turn-btn": I18nStringKey.kVMButtons_EndTurn,
"mod-ban-btn": I18nStringKey.kAdminVMButtons_Ban,
"mod-kick-btn": I18nStringKey.kAdminVMButtons_Kick,
"mod-change-username-btn": I18nStringKey.kVMButtons_ChangeUsername,
"mod-temp-mute-btn": I18nStringKey.kAdminVMButtons_TempMute,
"mod-indef-mute-btn": I18nStringKey.kAdminVMButtons_IndefMute,
"mod-unmute-btn": I18nStringKey.kAdminVMButtons_Unmute,
"mod-get-ip-btn": I18nStringKey.kAdminVMButtons_GetIP,
}
for (let domId of Object.keys(kDomIdtoStringMap)) {
let element = document.getElementById(domId);
if (element == null) {
alert(`Error: Could not find element with ID ${domId} in the DOM! Please tell a site admin this happened.`);
return;
}
// Do the magic.
// N.B: For now, we assume all strings in this map are not formatted.
// If this assumption changes, then we should just use GetString() again
// and maybe include arguments, but for now this is okay
element.innerHTML = this.GetStringRaw(kDomIdtoStringMap[domId]);
}
for (let domId of Object.keys(kDomAttributeToStringMap)) {
let element = document.getElementById(domId);
if (element == null) {
alert(`Error: Could not find element with ID ${domId} in the DOM! Please tell a site admin this happened.`);
return;
}
// TODO: Figure out if we can get rid of this ts-ignore
// @ts-ignore
let attributes = kDomAttributeToStringMap[domId];
for (let attr of Object.keys(attributes)) {
element.setAttribute(attr, this.GetStringRaw(attributes[attr] as I18nStringKey));
}
}
for (let domClass of Object.keys(kDomClassToStringMap)) {
let elements = document.getElementsByClassName(domClass);
for (let element of elements) {
element.innerHTML = this.GetStringRaw(kDomClassToStringMap[domClass]);
}
}
}
// Returns a (raw, unformatted) string. Currently only used if we don't need formatting.
GetStringRaw(key: I18nStringKey): string {
if (key === I18nStringKey.kGeneric_CollabVM && Config.SiteNameOverride) return Config.SiteNameOverride;
if (key === I18nStringKey.kWelcomeModal_Header && Config.WelcomeModalTitleOverride) return Config.WelcomeModalTitleOverride;
if (key === I18nStringKey.kWelcomeModal_Body && Config.WelcomeModalBodyOverride) return Config.WelcomeModalBodyOverride;
let val = this.lang.stringKeys[key];
// Look up the fallback language by default if the language doesn't
// have that string key yet; if the fallback doesn't have it either,
// then just return the string key and a bit of a notice things have gone wrong
if (val == undefined) {
let fallback = fallbackLanguage.stringKeys[key];
if (fallback !== undefined) val = fallback;
else return `${key} (ERROR LOOKING UP TRANSLATION!!!)`;
}
return val;
}
// Returns a formatted localized string.
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
return Format(this.GetStringRaw(key), ...replacements);
}
on<e extends keyof I18nEvents>(event: e, cb: I18nEvents[e]): Unsubscribe {
return this.emitter.on(event, cb);
}
}
export let TheI18n = new I18n();