Source

client/core/modules/language.js

/**
 * 
 * ## 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()