From 561a9d5fdc0f8be67703e8d594148cda5d74f754 Mon Sep 17 00:00:00 2001 From: Robert Jaros Date: Sun, 19 Aug 2018 23:10:23 +0200 Subject: Internationalization support. --- src/main/kotlin/pl/treksoft/kvision/KVManager.kt | 1 + src/main/kotlin/pl/treksoft/kvision/core/Widget.kt | 30 ++++- .../pl/treksoft/kvision/form/select/SelectInput.kt | 2 +- .../treksoft/kvision/form/select/SelectOptGroup.kt | 2 +- .../treksoft/kvision/form/select/SelectOption.kt | 4 +- .../treksoft/kvision/form/spinner/SpinnerInput.kt | 2 +- .../kvision/form/text/AbstractTextInput.kt | 2 +- .../pl/treksoft/kvision/form/text/RichTextInput.kt | 2 +- .../pl/treksoft/kvision/form/time/DateTimeInput.kt | 2 +- src/main/kotlin/pl/treksoft/kvision/html/Image.kt | 2 +- src/main/kotlin/pl/treksoft/kvision/html/List.kt | 5 +- src/main/kotlin/pl/treksoft/kvision/html/Tag.kt | 8 +- src/main/kotlin/pl/treksoft/kvision/i18n/I18n.kt | 150 +++++++++++++++++++++ src/main/kotlin/pl/treksoft/kvision/panel/Root.kt | 2 +- 14 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/pl/treksoft/kvision/i18n/I18n.kt (limited to 'src') diff --git a/src/main/kotlin/pl/treksoft/kvision/KVManager.kt b/src/main/kotlin/pl/treksoft/kvision/KVManager.kt index 1d1a3dc7..b762bc01 100644 --- a/src/main/kotlin/pl/treksoft/kvision/KVManager.kt +++ b/src/main/kotlin/pl/treksoft/kvision/KVManager.kt @@ -142,6 +142,7 @@ internal object KVManager { require("handlebars/dist/handlebars.runtime.min.js") } catch (e: Throwable) { } + private val jed = require("jed") internal val fecha = require("fecha") private val sdPatch = Snabbdom.init( arrayOf( diff --git a/src/main/kotlin/pl/treksoft/kvision/core/Widget.kt b/src/main/kotlin/pl/treksoft/kvision/core/Widget.kt index 30271cd0..41758d9b 100644 --- a/src/main/kotlin/pl/treksoft/kvision/core/Widget.kt +++ b/src/main/kotlin/pl/treksoft/kvision/core/Widget.kt @@ -32,6 +32,8 @@ import pl.treksoft.jquery.JQuery import pl.treksoft.jquery.jQuery import pl.treksoft.kvision.KVManager import pl.treksoft.kvision.dropdown.ContextMenu +import pl.treksoft.kvision.i18n.I18n +import pl.treksoft.kvision.i18n.I18n.trans import pl.treksoft.kvision.panel.Root import pl.treksoft.kvision.utils.SnOn import pl.treksoft.kvision.utils.hooks @@ -93,6 +95,8 @@ open class Widget(classes: Set = setOf()) : StyledComponent() { private var snOnCache: com.github.snabbdom.On? = null private var snHooksCache: com.github.snabbdom.Hooks? = null + private var lastLanguage: String? = null + internal fun singleRender(block: () -> T): T { getRoot()?.renderDisabled = true val t = block() @@ -120,6 +124,22 @@ open class Widget(classes: Set = setOf()) : StyledComponent() { } } + /** + * Translates given text with I18n trans function and sets lastLanguage marker. + * @param text a text marked for a dynamic translation + * @return translated text + */ + protected fun translate(text: String): String { + lastLanguage = I18n.language + return trans(text) + } + + protected fun translate(text: String?): String? { + return text?.let { + translate(it) + } + } + /** * Renders current component as a Snabbdom vnode. * @return Snabbdom vnode @@ -163,6 +183,7 @@ open class Widget(classes: Set = setOf()) : StyledComponent() { } private fun getSnAttrsInternal(): List { + if (lastLanguage != null && lastLanguage != I18n.language) snAttrsCache = null return snAttrsCache ?: { val s = getSnAttrs() snAttrsCache = s @@ -567,16 +588,17 @@ open class Widget(classes: Set = setOf()) : StyledComponent() { label: String, icon: String? = null, image: ResString? = null ): Array { + val translatedLabel = translate(label) return if (icon != null) { if (icon.startsWith("fa-")) { - arrayOf(KVManager.virtualize(""), " $label") + arrayOf(KVManager.virtualize(""), " $translatedLabel") } else { - arrayOf(KVManager.virtualize(""), " $label") + arrayOf(KVManager.virtualize(""), " $translatedLabel") } } else if (image != null) { - arrayOf(KVManager.virtualize(""), " $label") + arrayOf(KVManager.virtualize(""), " $translatedLabel") } else { - arrayOf(label) + arrayOf(translatedLabel) } } diff --git a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectInput.kt index 97f3989c..6d6f0fc5 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectInput.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectInput.kt @@ -255,7 +255,7 @@ open class SelectInput( sn.add("data-live-search" to "true") } placeholder?.let { - sn.add("title" to it) + sn.add("title" to translate(it)) } autofocus?.let { if (it) { diff --git a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOptGroup.kt b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOptGroup.kt index 3b523314..e33b3457 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOptGroup.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOptGroup.kt @@ -79,7 +79,7 @@ open class SelectOptGroup( override fun getSnAttrs(): List { val sn = super.getSnAttrs().toMutableList() - sn.add("label" to label) + sn.add("label" to translate(label)) maxOptions?.let { sn.add("data-max-options" to "" + it) } diff --git a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOption.kt b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOption.kt index 5cd23582..d1bb636e 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOption.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/select/SelectOption.kt @@ -70,7 +70,7 @@ open class SelectOption( override fun render(): VNode { return if (!divider) { - render("option", arrayOf(label ?: value)) + render("option", arrayOf(translate(label) ?: value)) } else { render("option") } @@ -83,7 +83,7 @@ open class SelectOption( sn.add("value" to it) } subtext?.let { - sn.add("data-subtext" to it) + sn.add("data-subtext" to translate(it)) } icon?.let { if (it.startsWith("fa-")) { diff --git a/src/main/kotlin/pl/treksoft/kvision/form/spinner/SpinnerInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/spinner/SpinnerInput.kt index d0bde3fe..7d3af684 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/spinner/SpinnerInput.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/spinner/SpinnerInput.kt @@ -177,7 +177,7 @@ open class SpinnerInput( sn.add("value" to it.toString()) } placeholder?.let { - sn.add("placeholder" to it) + sn.add("placeholder" to translate(it)) } name?.let { sn.add("name" to it) diff --git a/src/main/kotlin/pl/treksoft/kvision/form/text/AbstractTextInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/text/AbstractTextInput.kt index 52cc7792..e41cfb8f 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/text/AbstractTextInput.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/text/AbstractTextInput.kt @@ -99,7 +99,7 @@ abstract class AbstractTextInput( override fun getSnAttrs(): List { val sn = super.getSnAttrs().toMutableList() placeholder?.let { - sn.add("placeholder" to it) + sn.add("placeholder" to translate(it)) } name?.let { sn.add("name" to it) diff --git a/src/main/kotlin/pl/treksoft/kvision/form/text/RichTextInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/text/RichTextInput.kt index 43e522d8..88cb3b86 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/text/RichTextInput.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/text/RichTextInput.kt @@ -46,7 +46,7 @@ open class RichTextInput(value: String? = null, classes: Set = setOf()) override fun getSnAttrs(): List { val sn = super.getSnAttrs().toMutableList() placeholder?.let { - sn.add("placeholder" to it) + sn.add("placeholder" to translate(it)) } name?.let { sn.add("name" to it) diff --git a/src/main/kotlin/pl/treksoft/kvision/form/time/DateTimeInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/time/DateTimeInput.kt index 3a40d1af..6ce4d0c3 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/time/DateTimeInput.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/time/DateTimeInput.kt @@ -136,7 +136,7 @@ open class DateTimeInput( val sn = super.getSnAttrs().toMutableList() sn.add("type" to "text") placeholder?.let { - sn.add("placeholder" to it) + sn.add("placeholder" to translate(it)) } name?.let { sn.add("name" to it) diff --git a/src/main/kotlin/pl/treksoft/kvision/html/Image.kt b/src/main/kotlin/pl/treksoft/kvision/html/Image.kt index 61733fb3..4d373270 100644 --- a/src/main/kotlin/pl/treksoft/kvision/html/Image.kt +++ b/src/main/kotlin/pl/treksoft/kvision/html/Image.kt @@ -81,7 +81,7 @@ open class Image( val pr = super.getSnAttrs().toMutableList() pr.add("src" to src) alt?.let { - pr.add("alt" to it) + pr.add("alt" to translate(it)) } return pr } diff --git a/src/main/kotlin/pl/treksoft/kvision/html/List.kt b/src/main/kotlin/pl/treksoft/kvision/html/List.kt index 377b805f..5fb489da 100644 --- a/src/main/kotlin/pl/treksoft/kvision/html/List.kt +++ b/src/main/kotlin/pl/treksoft/kvision/html/List.kt @@ -116,10 +116,11 @@ open class ListTag( } private fun element(name: String, value: String, rich: Boolean): VNode { + val translatedValue = translate(value) return if (rich) { - h(name, arrayOf(KVManager.virtualize("$value"))) + h(name, arrayOf(KVManager.virtualize("$translatedValue"))) } else { - h(name, value) + h(name, translatedValue) } } diff --git a/src/main/kotlin/pl/treksoft/kvision/html/Tag.kt b/src/main/kotlin/pl/treksoft/kvision/html/Tag.kt index 75536b88..3a15a4d0 100644 --- a/src/main/kotlin/pl/treksoft/kvision/html/Tag.kt +++ b/src/main/kotlin/pl/treksoft/kvision/html/Tag.kt @@ -135,10 +135,14 @@ open class Tag( override fun render(): VNode { return if (content != null) { + val translatedContent = content?.let { translate(it) } if (rich) { - render(type.tagName, arrayOf(KVManager.virtualize("$content")) + childrenVNodes()) + render( + type.tagName, + arrayOf(KVManager.virtualize("$translatedContent")) + childrenVNodes() + ) } else { - render(type.tagName, childrenVNodes() + arrayOf(content)) + render(type.tagName, childrenVNodes() + arrayOf(translatedContent)) } } else { render(type.tagName, childrenVNodes()) diff --git a/src/main/kotlin/pl/treksoft/kvision/i18n/I18n.kt b/src/main/kotlin/pl/treksoft/kvision/i18n/I18n.kt new file mode 100644 index 00000000..6d0b0bfa --- /dev/null +++ b/src/main/kotlin/pl/treksoft/kvision/i18n/I18n.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.i18n + +import org.w3c.xhr.XMLHttpRequest +import pl.treksoft.kvision.panel.Root +import kotlin.browser.window +import kotlin.js.Promise + +external class Jed(json: dynamic) { + fun gettext(key: String): String + fun ngettext(singularKey: String, pluralKey: String, value: Int): String + fun sprintf(format: String, value: Int): String +} + +private const val I18N_SINGLE_DELIMITER = "###KvI18nS###" +private const val I18N_PLURAL_DELIMITER = "###KvI18nP###" + +/** + * A singleton object used for translations. + */ +object I18n { + + private val defaultLanguage = window.navigator.language.split("-")[0] + + /** + * Main language of the application. + */ + var language = defaultLanguage + set(value) { + field = value + Root.roots.forEach { it.reRender() } + } + + private val cache = mutableMapOf() + + /** + * I18n initialization function. + * Should be called in the main function of the application. + * @param languages a list of supported languages. + * @param initCallback a code to run after the initialization process is complete. + */ + fun init(vararg languages: String, initCallback: () -> Unit) { + val promises = languages.map { + I18n.readMessages(it) + }.toTypedArray() + Promise.all(promises).then { initCallback() } + } + + private fun readMessages(language: String): Promise { + return Promise { resolve, _ -> + val xmlHttpRequest = XMLHttpRequest() + xmlHttpRequest.overrideMimeType("application/json") + xmlHttpRequest.open("GET", "js/messages-$language.json", true) + xmlHttpRequest.onreadystatechange = { + if (xmlHttpRequest.readyState.toInt() == 4 && (xmlHttpRequest.status.toInt() == 200 || + xmlHttpRequest.status.toInt() == 0) + ) { + val json = JSON.parse(xmlHttpRequest.responseText) + val jed = Jed(json) + cache[language] = jed + resolve(jed) + } + } + xmlHttpRequest.send() + } + } + + /** + * A static translation function for a singular form. + * @param key a translation key. + * @return translated text. + */ + fun gettext(key: String): String { + return cache[language]?.gettext(key) ?: key + } + + /** + * A static translation function for a plural form. + * @param singularKey a translation key for a singular form. + * @param pluralKey a translation key for a plural form. + * @param value a count value. + * @return translated text. + */ + fun ngettext(singularKey: String, pluralKey: String, value: Int): String { + return cache[language]?.run { + sprintf(ngettext(singularKey, pluralKey, value), value) + } ?: if (value == 1) singularKey else pluralKey + } + + /** + * A dynamic translation function for a singular form. + * @param key a translation key. + * @return text marked for a dynamic translation. + */ + fun tr(key: String): String { + return I18N_SINGLE_DELIMITER + key + } + + /** + * A dynamic translation function for a plural form. + * @param singularKey a translation key for a singular form. + * @param pluralKey a translation key for a plural form. + * @param value a count value. + * @return text marked for a dynamic translation. + */ + fun ntr(singularKey: String, pluralKey: String, value: Int): String { + return I18N_PLURAL_DELIMITER + singularKey + I18N_PLURAL_DELIMITER + pluralKey + I18N_PLURAL_DELIMITER + value + } + + internal fun trans(text: String): String { + return if (text.startsWith(I18N_SINGLE_DELIMITER)) { + gettext(text.substring(I18N_SINGLE_DELIMITER.length)) + } else if (text.startsWith(I18N_PLURAL_DELIMITER)) { + val tab = text.substring(I18N_PLURAL_DELIMITER.length).split(I18N_PLURAL_DELIMITER) + if (tab.size == 3) { + ngettext(tab[0], tab[1], tab[2].toIntOrNull() ?: 1) + } else { + text + } + } else { + text + } + } + + internal fun trans(text: String?): String? { + return text?.let { + trans(it) + } + } +} diff --git a/src/main/kotlin/pl/treksoft/kvision/panel/Root.kt b/src/main/kotlin/pl/treksoft/kvision/panel/Root.kt index 667559b7..fece65e2 100644 --- a/src/main/kotlin/pl/treksoft/kvision/panel/Root.kt +++ b/src/main/kotlin/pl/treksoft/kvision/panel/Root.kt @@ -116,7 +116,7 @@ class Root(id: String, private val fixed: Boolean = false, init: (Root.() -> Uni } companion object { - private val roots: MutableList = mutableListOf() + internal val roots: MutableList = mutableListOf() internal fun getLastRoot(): Root? { return if (roots.size > 0) -- cgit