/**
*
* ## Translation management
*
* This module is used to manage the translations of static and dynamic texts.
*
* What's the difference between **static** and **dynamic** texts?
*
* - **Static**: The texts which are used in the general interface (buttons, menus, etc.) and loaded from a static file
* - **Dynamic**: The texts which are defined by the user (application names, view names, model names, field labels, etc.) and loaded from the database.
*
* Use case:
* - You build a SaaS application and you want to provide a multi-language interface, through static texts.
* - User can build their own application, with their own views, models, fields, etc. and you want to provide them a multi-language interface for those dynamic texts.
*
* @namespace
*
*/
kiss.language = {
// Current static language (for the interface)
current: "en",
// Current dynamic language (for user-defined texts)
currentDynamic: "en",
// Store all the localized texts
texts: {},
// Store missing texts while browsing the application views
missingTexts: [],
// Available interface languages
// If the navigator's default language is not included in the available languages, it defaults to "en"
available: [{
code: "en",
name: "English"
},
{
code: "fr",
name: "Français"
},
{
code: "es",
name: "Español"
}
],
/**
* Set the available interface languages
*
* @param {object[]} languages - Passed as an array of objects with a "code" and a "name" property
* @returns this
*
* @example
* kiss.language.setAvailable([
* {
* code: "en",
* name: "English"
* },
* {
* code: "fr",
* name: "Français"
* },
* {
* code: "es",
* name: "Español"
* }
* ])
*/
setAvailable(languages) {
kiss.language.available = languages
return this
},
/**
* Init the current languages for the interface (static texts) and for the user-defined texts (dynamic texts).
*
* - first check the url parameter "language" (for example: &language=fr)
* - if no parameter, tries to get the browser language
* - defaults to "en" (English)
*
* @param {boolean} [loadAllLanguages] - If true, load all the languages available in the library. Default is false.
*/
async init(loadAllLanguages = false) {
if (kiss.language.isInitialized) return
// Set static language
const urlLanguage = kiss.router.getRoute().language
if (urlLanguage) {
if (!kiss.language.available.find(lang => lang.code == urlLanguage)) urlLanguage = "en"
kiss.language.current = urlLanguage
} else {
const navigatorLanguage = (navigator.languages ? navigator.languages[0] : navigator.language).substring(0, 2)
const storedLanguage = localStorage.getItem("config-language")
kiss.language.current = storedLanguage || navigatorLanguage || "en"
// Restrict to languages which are available
if (!kiss.language.available.find(lang => lang.code == kiss.language.current)) kiss.language.current = "en"
}
// Set dynamic language
// If not set, use the static language as default
const storedDynamicLanguage = localStorage.getItem("config-language-dynamic")
if (storedDynamicLanguage) {
kiss.language.currentDynamic = storedDynamicLanguage
}
else {
kiss.language.currentDynamic = kiss.language.current
}
// If the library texts are not loaded yet, load them
if (!kiss.language.texts._) {
await kiss.language.loadLibraryTexts(loadAllLanguages)
}
kiss.language.isInitialized = true
},
/**
* Load the library texts
*
* @async
* @param {boolean} [loadAllLanguages] - If true, load all the languages available in the library. Default is false.
* @returns {Promise} - A promise that resolves when the texts are loaded
*/
async loadLibraryTexts(loadAllLanguages = false) {
const libraryPath = kiss.loader.getLibraryPath()
if (loadAllLanguages) {
await kiss.loader.loadScript(libraryPath + "/texts/all")
}
else {
await kiss.loader.loadScript(libraryPath + "/texts/" + kiss.language.current)
}
},
/**
* Get the current static and dynamic languages
*
* @returns {object} - The current static and dynamic languages. Example: {static: "en", dynamic: "it"}
*/
get() {
return {
static: kiss.language.current,
dynamic: kiss.language.currentDynamic
}
},
/**
* Return a localized text from a key, or a fallback text
* If a translation is missing, the text key is stored into the "kiss.language.missingTexts" array, in order to help fixing the missing translations.
* When a merge object is passed as a parameter, the values are merged into the final string.
*
* @param {string} key - The text key, which should be in lowercase by convention (txtTitleCase, txtUpperCase, and txtLowerCase handle the case)
* @param {object} [customSourceTexts] - Custom source texts. Must be provided in the same format as default source texts. See example.
* @param {object} merge - Contextual data that must be merged into the text
* @returns {string} - The localized text, or the key passed to the function if not found
*
* @example
* txt("hello") // bonjour
* txtTitleCase("hello") // Bonjour
* txt("hello %firstName %lastName", null, {firstName: "Bob", lastName: "Wilson"}) // bonjour Bob Wilson
* txt("Devil number: %num%num%num", null, {num: 6}) // Devil number: 666
*
* let customSource = {
* "apple": {
* fr: "pomme"
* },
* "banana": {
* fr: "banana"
* },
* "fruits": {
* en: "choose between those fruits...",
* fr: "choisissez parmi ces fruits..."
* }
* }
*
* kiss.language.set("en")
* txtTitleCase("apple", customSource) // Apple
* txtUpperCase("banana", customSource) // BANANA
* txtTitleCase("fruits", customSource) // Choose between those fruits...
*
* kiss.language.set("fr")
* txtUpperCase("fruits", customSource) // CHOISISSEZ PARMI CES FRUITS...
*/
translate(key, customSourceTexts, merge) {
// Get static text
let translationKey = (customSourceTexts) ? customSourceTexts[key] : kiss.language.texts[key]
let translation = (translationKey) ? translationKey[kiss.language.current] : null
// If there is no English text, then the text is the key itself
let isMissing = false
if (!translation) {
translation = key
isMissing = true
}
// Merge dynamic text
if (merge) {
Object.keys(merge).forEach(key => {
let tag = new RegExp("%" + key, "g")
translation = translation.replace(tag, merge[key])
})
}
if (isMissing && kiss.language.missingTexts.indexOf(key) == -1) {
kiss.language.missingTexts.push(key)
log(`kiss.language - Missing [${kiss.language.current}] translation for [${key}]`, 4)
}
// On-the-fly translation, not ready yet because of the possibility of merging text into translated strings
// return `<span class="translation" id="${key.hashCode()}">${translation}</span>`
return translation
},
/**
* Return a localized text from a key, or a fallback text.
* By convention, if a property has a translation, it should be named "propertyTranslations" (for example: nameTranslations).
*
* @param {object} obj
* @param {string} key
* @returns {*} - The localized text, or the original property if not found
*/
translateProperty(obj, key) {
let value = obj[key]
if (obj[key + "Translations"]) {
value = obj[key + "Translations"][kiss.language.currentDynamic] || obj[key]
}
return value
},
/**
* Switch language for the interface (static texts)
*
* @param {string} newLanguage
*/
set(newLanguage) {
kiss.language.current = newLanguage
localStorage.setItem("config-language", newLanguage)
document.location.reload()
},
/**
* Switch language for user-defined texts (dynamic texts)
*
* @param {string} newLanguage
*/
setDynamic(newLanguage) {
kiss.language.currentDynamic = newLanguage
localStorage.setItem("config-language-dynamic", newLanguage)
document.location.reload()
},
/**
* Show all missing texts in the console
*
* @returns {string} - All the missing texts
*/
showMissingTexts() {
let i = 0
kiss.language.missingTexts.forEach(text => {
console.log(text)
i++
})
console.log(`kiss.language - Result: ${i} missing texts for language ${kiss.language.current}`)
},
/**
* Show a window to translate texts without translation
*/
showTranslationWindow() {
const containerId = kiss.tools.uid()
const items = kiss.language.missingTexts.map(text => {
return {
type: "text",
label: text,
labelPosition: "top",
width: "100%",
events: {
onchange: () => $(containerId).update()
}
}
})
createPanel({
title: "Quick translation for " + kiss.language.current.toUpperCase(),
width: () => kiss.screen.current.width - 100,
height: () => kiss.screen.current.height - 100,
align: "center",
verticalAlign: "center",
modal: true,
draggable: true,
closable: true,
layout: "horizontal",
overflowY: "auto",
defaultConfig: {
margin: 10,
borderRadius: 10,
boxShadow: "var(--shadow-4)"
},
items: [{
id: containerId,
layout: "vertical",
flex: 1,
overflowY: "auto",
padding: 10,
items: items,
methods: {
update: function () {
const translationFields = $(containerId).items
const translations = {}
translationFields.forEach(field => {
const translation = field.getValue()
if (translation) {
const translationKey = field.getLabel()
translations[translationKey] = {
[kiss.language.current]: translation
}
}
})
$("export").setValue(JSON.stringify(translations, null, 4))
}
}
},
{
id: "export",
type: "textarea",
label: "Export",
labelPosition: "top",
flex: 1,
fieldHeight: "100%"
}
]
}).render()
},
/**
* Open a window to switch the languages:
* - static language, for the interface
* - dynamic language (if the parameter "dynamic" is set to true)
*
* @param {object} config - Configuration object
* @param {boolean} [config.static] - Allow to select the interface languages, among the available languages. Default is true.
* @param {boolean} [config.dynamic] - If true, show a dropdown list to select the content language. Default is false.
*/
select(config = {}) {
if ($("language-window")) return
const isMobile = kiss.screen.isMobile
if (isMobile) {
responsiveOptions = {
top: () => 0,
left: () => 0,
width: "100%",
height: "100%",
borderRadius: "0 0 0 0",
draggable: false,
}
}
else {
responsiveOptions = {
verticalAlign: "center",
draggable: true
}
}
const titleStyle = {
color: "var(--blue)",
fontSize: "1.8rem",
fontWeight: "bold",
flex: 1,
width: "100%",
margin: "3rem 0 1.5rem 0",
boxShadow: "none",
textAlign: (isMobile) ? "center" : "left"
}
const defaultConfig = {
type: "button",
flex: 1,
height: "5rem",
minWidth: (isMobile) ? "100%" : "",
margin: (isMobile) ? "0 0 1rem 0" : "0 1rem 0 0",
iconColor: "var(--blue)",
fontSize: "1.6rem",
textAlign: "left",
boxShadow: "var(--shadow-1)",
boxShadowHover: "var(--shadow-4)"
}
const languageButtons = kiss.language.available.map(language => {
return {
text: language.name,
icon: "fas fa-flag",
action: () => kiss.language.set(language.code)
}
})
return createPanel({
id: "language-window",
icon: "fas fa-globe",
title: txtTitleCase("#switch language"),
modal: true,
backdropFilter: true,
closable: true,
display: "block",
position: "absolute",
align: "center",
overflowY: "auto",
padding: "0 2rem 2rem 2rem",
...responsiveOptions,
items: [
// INTERFACE LANGUAGE
{
hidden: (config.static == false),
type: "html",
...titleStyle,
html: txtTitleCase("interface language"),
},
{
hidden: (config.static == false),
defaultConfig,
display: "flex",
flexWrap: "wrap",
items: languageButtons
},
// CONTENT LANGUAGE
{
hidden: (config.dynamic != true),
type: "html",
...titleStyle,
html: txtTitleCase("prefered language for content"),
},
{
hidden: (config.dynamic != true),
type: "html",
html: txtTitleCase("#content language help"),
},
{
hidden: (config.dynamic != true),
defaultConfig,
items: [
{
type: "select",
label: txtTitleCase("pick a language"),
labelPosition: "top",
flex: 1,
height: "7rem",
margin: "2rem 0",
maxHeight: (isMobile) ? "" : "20rem",
options: kiss.language.codes.map(code => {
return {
label: code.name,
value: code.code
}
}),
value: kiss.language.currentDynamic,
events: {
change: function() {
const selectedContentLanguage = this.getValue()
if (selectedContentLanguage) {
kiss.language.setDynamic(selectedContentLanguage)
}
}
}
}
]
}
]
}).render()
},
/**
* Get the language name from the code
*
* @param {string} code
* @returns {string} - The language name
*
* @example
* kiss.language.getLanguageName("en") // English
* kiss.language.getLanguageName("fr") // Français
*/
getLanguageName(code) {
const language = kiss.language.codes.find(lang => lang.code == code)
if (language) {
return language.name
} else {
return code
}
},
/**
* All ISO 639-1 language codes
*/
codes: [
{name: "Afaan Oromoo", code: "om"},
{name: "Afaraf", code: "aa"},
{name: "Afrikaans", code: "af"},
{name: "Akan", code: "ak"},
{name: "Aragonés", code: "an"},
{name: "Asụsụ Igbo", code: "ig"},
{name: "Avañe'ẽ", code: "gn"},
{name: "Aymar aru", code: "ay"},
{name: "Azərbaycanca", code: "az"},
{name: "Bahasa Indonesia", code: "id"},
{name: "Bahasa Melayu", code: "ms"},
{name: "Bamanakan", code: "bm"},
{name: "Basa Sunda", code: "su"},
{name: "Bislama", code: "bi"},
{name: "Brezhoneg", code: "br"},
{name: "Bosanski", code: "bs"},
{name: "Català", code: "ca"},
{name: "Chamoru", code: "ch"},
{name: "Corsu", code: "co"},
{name: "Cymraeg", code: "cy"},
{name: "Čeština", code: "cs"},
{name: "Diné bizaad", code: "nv"},
{name: "Deutsch", code: "de"},
{name: "Eesti", code: "et"},
{name: "Eʋegbe", code: "ee"},
{name: "English", code: "en"},
{name: "Español", code: "es"},
{name: "Esperanto", code: "eo"},
{name: "Euskara", code: "eu"},
{name: "Faka‑Tonga", code: "to"},
{name: "Føroyskt", code: "fo"},
{name: "Français", code: "fr"},
{name: "Frysk", code: "fy"},
{name: "Fulfulde", code: "ff"},
{name: "Gaelg", code: "gv"},
{name: "Gaeilge", code: "ga"},
{name: "Gàidhlig", code: "gd"},
{name: "Galego", code: "gl"},
{name: "Gĩkũyũ", code: "ki"},
{name: "Hausa", code: "ha"},
{name: "Hiri Motu", code: "ho"},
{name: "Hrvatski", code: "hr"},
{name: "IsiNdebele", code: "nd"},
{name: "IsiXhosa", code: "xh"},
{name: "IsiZulu", code: "zu"},
{name: "Italiano", code: "it"},
{name: "Íslenska", code: "is"},
{name: "Kajin M̧ajeļ", code: "mh"},
{name: "Kanuri", code: "kr"},
{name: "Kashmiri", code: "ks"},
{name: "Kernewek", code: "kw"},
{name: "Kikongo", code: "kg"},
{name: "Kirundi", code: "rn"},
{name: "Kiswahili", code: "sw"},
{name: "Kinyarwanda", code: "rw"},
{name: "Kurdî", code: "ku"},
{name: "Lao", code: "lo"},
{name: "Latine", code: "la"},
{name: "Latviešu", code: "lv"},
{name: "Lëtzebuergesch", code: "lb"},
{name: "Lietuvių", code: "lt"},
{name: "Lingála", code: "ln"},
{name: "Luganda", code: "lg"},
{name: "Magyar", code: "hu"},
{name: "Malagasy", code: "mg"},
{name: "Malti", code: "mt"},
{name: "Māori", code: "mi"},
{name: "Melayu", code: "ms"},
{name: "Nederlands", code: "nl"},
{name: "Norsk", code: "no"},
{name: "Norsk bokmål", code: "nb"},
{name: "Norsk nynorsk", code: "nn"},
{name: "Occitan", code: "oc"},
{name: "Odia", code: "or"},
{name: "Oshikwanyama", code: "kj"},
{name: "Pāli", code: "pi"},
{name: "Polski", code: "pl"},
{name: "Português", code: "pt"},
{name: "Reo Tahiti", code: "ty"},
{name: "Română", code: "ro"},
{name: "Rumantsch", code: "rm"},
{name: "Русский", code: "ru"},
{name: "Sängö", code: "sg"},
{name: "Sesotho", code: "st"},
{name: "Setswana", code: "tn"},
{name: "Shqip", code: "sq"},
{name: "Slovenčina", code: "sk"},
{name: "Slovenščina", code: "sl"},
{name: "Soomaali", code: "so"},
{name: "Suomi", code: "fi"},
{name: "Svenska", code: "sv"},
{name: "Tiếng Việt", code: "vi"},
{name: "Türkçe", code: "tr"},
{name: "Yкраїнська", code: "uk"},
{name: "اردو", code: "ur"},
{name: "العربية", code: "ar"},
{name: "فارسی", code: "fa"},
{name: "עברית", code: "he"},
{name: "हिन्दी", code: "hi"},
{name: "বাংলা", code: "bn"},
{name: "தமிழ்", code: "ta"},
{name: "తెలుగు", code: "te"},
{name: "ไทย", code: "th"},
{name: "ᐃᓄᒃᑎᑐᑦ", code: "iu"},
{name: "日本語", code: "ja"},
{name: "中文", code: "zh"},
{name: "한국어", code: "ko"},
{name: "ꆇꉙ", code: "ii"},
{name: "བོད་སྐད་", code: "bo"},
{name: "རྫོང་ཁ", code: "dz"},
{name: "မြန်မာဘာသာ", code: "my"},
{name: "ქართული", code: "ka"},
{name: "ትግርኛ", code: "ti"},
{name: "አማርኛ", code: "am"},
{name: "ᐊᓂᔑᓈᐯᒧᐎᓐ", code: "oj"},
{name: "Ṣọ̀rọ̀ Yorùbá", code: "yo"},
{name: "Wolof", code: "wo"},
{name: "IsiSwati", code: "ss"},
{name: "Volapük", code: "vo"},
{name: "Tshivenḓa", code: "ve"},
{name: "Xitsonga", code: "ts"}
]
};
// Shortcuts to uppercase, lowercase, titlecase
const txt = kiss.language.translate
const txtUpperCase = (key, customSourceTexts, merge) => txt(key, customSourceTexts, merge).toUpperCase()
const txtLowerCase = (key, customSourceTexts, merge) => txt(key, customSourceTexts, merge).toLowerCase()
const txtTitleCase = (key, customSourceTexts, merge) => txt(key, customSourceTexts, merge).toTitleCase()
;
Source