/**
*
* ## 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)
kiss.router.updateUrlHash({
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",
headerStyle: "flat",
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 = {
fontSize: "1.8rem",
fontWeight: "bold",
flex: 1,
width: "100%",
margin: "3rem 0 1.5rem 0",
padding: "0 0 1rem 0",
textAlign: (isMobile) ? "center" : "left",
borderStyle: "solid",
borderWidth: "0 0 1px 0",
borderColor: "var(--body-alt)"
}
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",
}
const languageButtons = kiss.language.available.map(language => {
return {
text: language.name,
icon: "fas fa-flag",
margin: "1rem 0.5rem",
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",
headerStyle: "flat",
...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.global.languages.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.global.languages.find(lang => lang.code == code)
if (language) {
return language.name
} else {
return code
}
}
};
// 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