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)
        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()

;