|
| 1 | +/* |
| 2 | +Copyright 2019-2026 Dmitry Barashev, BarD Software s.r.o |
| 3 | +
|
| 4 | +This file is part of GanttProject, an opensource project management tool. |
| 5 | +
|
| 6 | +GanttProject is free software: you can redistribute it and/or modify |
| 7 | +it under the terms of the GNU General Public License as published by |
| 8 | + the Free Software Foundation, either version 3 of the License, or |
| 9 | + (at your option) any later version. |
| 10 | +
|
| 11 | +GanttProject is distributed in the hope that it will be useful, |
| 12 | +but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | +GNU General Public License for more details. |
| 15 | +
|
| 16 | +You should have received a copy of the GNU General Public License |
| 17 | +along with GanttProject. If not, see <http://www.gnu.org/licenses/>. |
| 18 | +*/ |
| 19 | +package biz.ganttproject.app |
| 20 | + |
| 21 | +import biz.ganttproject.core.option.validatorI18N |
| 22 | +import javafx.beans.property.SimpleObjectProperty |
| 23 | +import javafx.beans.property.SimpleStringProperty |
| 24 | +import javafx.beans.value.ObservableValue |
| 25 | +import java.text.MessageFormat |
| 26 | +import java.util.* |
| 27 | + |
| 28 | +/** |
| 29 | + * Localized string is an observable localized string with parameters. |
| 30 | + * The typical use case is: |
| 31 | + * 1. Client code creates string from key, e.g. "hello", and passes argument "World" |
| 32 | + * 2. Internationalization code here searches for "hello" key in the localizer (it is usually a resource bundle) |
| 33 | + * and finds e.g. "Hello {0}" pattern |
| 34 | + * 3. Pattern is applied to the arguments and we get "Hello World" which becomes a new value of observable |
| 35 | + * 4. Client code then updates the arguments and passes "GanttProject". The process repeats and new observable |
| 36 | + * value "Hello GanttProject" is submitted. |
| 37 | + * |
| 38 | + * Normally instances are created with a factory in Localizer. |
| 39 | + */ |
| 40 | +class LocalizedString( |
| 41 | + private val key: String, |
| 42 | + private val i18n: Localizer, |
| 43 | + val observable: SimpleStringProperty = SimpleStringProperty(), |
| 44 | + private var args: List<Any> = emptyList()) : ObservableValue<String> by observable { |
| 45 | + init { |
| 46 | + observable.value = build() |
| 47 | + } |
| 48 | + |
| 49 | + fun clear() { |
| 50 | + observable.value = "" |
| 51 | + } |
| 52 | + |
| 53 | + fun update(vararg args: String): LocalizedString { |
| 54 | + this.args = args.toList() |
| 55 | + observable.value = build() |
| 56 | + return this |
| 57 | + } |
| 58 | + |
| 59 | + fun update(vararg args: Any): LocalizedString { |
| 60 | + this.args = args.toList() |
| 61 | + observable.value = build() |
| 62 | + return this |
| 63 | + } |
| 64 | + |
| 65 | + internal fun update() { |
| 66 | + observable.value = build() |
| 67 | + } |
| 68 | + |
| 69 | + private fun build(): String = i18n.formatText(key, *(args.toTypedArray())) |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * Creates localized observable strings, formats messages with parameters and manages current translation. |
| 74 | + */ |
| 75 | +interface Localizer { |
| 76 | + /** |
| 77 | + * Creates a new localized string from the given message key. |
| 78 | + */ |
| 79 | + fun create(key: String): LocalizedString |
| 80 | + |
| 81 | + /** |
| 82 | + * Applies pattern by the given key to the given arguments. By default, it calls formatTextOrNull |
| 83 | + * and returns key itself if the latter returns null |
| 84 | + */ |
| 85 | + fun formatText(key: String, vararg args: Any): String { |
| 86 | + return formatTextOrNull(key, *args) ?: key |
| 87 | + } |
| 88 | + |
| 89 | + /** |
| 90 | + * Searches for message by the given key and applies it to the given arguments. |
| 91 | + * Returns null if message is not found. |
| 92 | + */ |
| 93 | + fun formatTextOrNull(key: String, vararg args: Any): String? |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * This is a dummy localizer which can be used as a stub. |
| 98 | + */ |
| 99 | +object DummyLocalizer : Localizer { |
| 100 | + override fun create(key: String): LocalizedString { |
| 101 | + return LocalizedString(key, this) |
| 102 | + } |
| 103 | + |
| 104 | + override fun formatTextOrNull(key: String, vararg args: Any): String? { |
| 105 | + return null |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +var DEFAULT_TRANSLATION_LOCALIZER: Localizer = DummyLocalizer |
| 110 | + |
| 111 | +/** |
| 112 | + * This localizer allows for flexible use of shared resource bundles. |
| 113 | + * When searching for a message by the given message key, it first prepends the rootKey prefix to the |
| 114 | + * message key. If prefixed localizer is set, it is consulted first. This way we can just |
| 115 | + * use shorter message keys for a group of logically related keys (e.g. use root key "exitDialog" and |
| 116 | + * keys "title", "message", "ok" instead of "exitDialog.title", "exitDialog.message" and "exitDialog.ok"). |
| 117 | + * |
| 118 | + * If root localizer is not set or returns no message, the message is searched by prefixed key in the local |
| 119 | + * resource bundle of this localizer. In case of success it is formatted with MessageFormat, otherwise |
| 120 | + * base localizer is consulted with original message key. This way we can use a pool of common messages |
| 121 | + * which is shared between more specific localizers. E.g., for a set of dialogs where submit and cancel |
| 122 | + * buttons are usually labeled with "OK" and "Cancel", we can create a shared base localizer L0 with keys |
| 123 | + * "ok" and "cancel". For a dialog which requests user to accept some terms, we can create a localizer L1 |
| 124 | + * with root key "acceptTerms", key "acceptTerms.ok"="Accept" and L0 as a base localizer. |
| 125 | + * |
| 126 | + * When submit and cancel buttons in accept terms dialog are constructed, they will call localizer L1 |
| 127 | + * and pass "ok" and "cancel" keys. L1 will find "acceptTerms.ok" in its own bundle and will pass "cancel" |
| 128 | + * to the base localizer. |
| 129 | + * |
| 130 | + * @author dbarashev@bardsoftware.com |
| 131 | + */ |
| 132 | +open class DefaultLocalizer( |
| 133 | + private val rootKey: String = "", |
| 134 | + private val baseLocalizer: Localizer = DEFAULT_TRANSLATION_LOCALIZER, |
| 135 | + private val prefixedLocalizer: Localizer? = null, |
| 136 | + private val currentTranslation: SimpleObjectProperty<Translation?> = SimpleObjectProperty(null)) : Localizer { |
| 137 | + |
| 138 | + override fun create(key: String): LocalizedString { |
| 139 | + return LocalizedString(key, this).also { |
| 140 | + currentTranslation.addListener { _, oldValue, newValue -> |
| 141 | + FXThread.runLater { |
| 142 | + if (oldValue?.locale != newValue?.locale) { |
| 143 | + it.update() |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + override fun formatTextOrNull(key: String, vararg args: Any): String? { |
| 151 | + val prefixedKey = if (this.rootKey != "") "${this.rootKey}.$key" else key |
| 152 | + this.prefixedLocalizer?.formatTextOrNull(prefixedKey, *args)?.let { |
| 153 | + return it |
| 154 | + } |
| 155 | + return try { |
| 156 | + this.currentTranslation.value?.let { tr -> |
| 157 | + tr.mapKey(prefixedKey)?.let { value -> |
| 158 | + MessageFormat.format(value, *args) |
| 159 | + } ?: this.baseLocalizer.formatTextOrNull(key, *args) |
| 160 | + } |
| 161 | + } catch (ex: MissingResourceException) { |
| 162 | + null |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Creates a new localizer which uses this one as "prefixed" with the given prefix. |
| 168 | + */ |
| 169 | + fun createWithRootKey(rootKey: String, baseLocalizer: Localizer = DummyLocalizer): DefaultLocalizer = |
| 170 | + DefaultLocalizer(rootKey, baseLocalizer, this, this.currentTranslation) |
| 171 | +} |
| 172 | + |
| 173 | +/** |
| 174 | + * Localizer that appends a prefix to all keys and searches for the prefixed key in the `base` localizer. |
| 175 | + * Uses a `fallback` localizer if the prefixed key is not found in the base localizer. |
| 176 | + */ |
| 177 | +class PrefixedLocalizer( |
| 178 | + private val prefix: String, private val base: Localizer, private val fallback: Localizer = DummyLocalizer |
| 179 | +): Localizer { |
| 180 | + override fun create(key: String): LocalizedString = LocalizedString(key, this) |
| 181 | + |
| 182 | + override fun formatTextOrNull(key: String, vararg args: Any): String? { |
| 183 | + return this.base.formatTextOrNull("$prefix.$key", *args) ?: this.fallback.formatTextOrNull(key, *args) |
| 184 | + } |
| 185 | + |
| 186 | + override fun toString(): String { |
| 187 | + return "PrefixedLocalizer(prefix='$prefix', base=$base)" |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +class IfNullLocalizer(private val base: Localizer, private val fallback: Localizer): Localizer { |
| 192 | + override fun create(key: String): LocalizedString = LocalizedString(key, this) |
| 193 | + |
| 194 | + override fun formatTextOrNull(key: String, vararg args: Any): String? { |
| 195 | + return this.base.formatTextOrNull(key, *args) ?: this.fallback.formatTextOrNull(key, *args) |
| 196 | + } |
| 197 | +} |
| 198 | +/** |
| 199 | + * This localizer searches for key values in a map. The map values are lambdas which |
| 200 | + * allows for values calculation. |
| 201 | + */ |
| 202 | +class MappingLocalizer(val key2lambda: Map<String, ()->LocalizedString?>, val unhandledKey: (String)->LocalizedString?) : Localizer { |
| 203 | + override fun create(key: String) = LocalizedString(key, this) |
| 204 | + |
| 205 | + override fun formatTextOrNull(key: String, vararg args: Any): String? = |
| 206 | + key2lambda[key]?.invoke()?.update(*args)?.value ?: unhandledKey(key)?.update(*args)?.value |
| 207 | +} |
| 208 | + |
| 209 | +/** |
| 210 | + * Localizer which always uses the given resource bundle. |
| 211 | + */ |
| 212 | +class SingleTranslationLocalizer(val bundle: Translation) : DefaultLocalizer(currentTranslation = SimpleObjectProperty(bundle), baseLocalizer = DummyLocalizer) |
| 213 | + |
| 214 | + |
| 215 | +val ourCurrentTranslation: SimpleObjectProperty<Translation?> = SimpleObjectProperty(null) |
| 216 | +var RootLocalizer : DefaultLocalizer = DefaultLocalizer(currentTranslation = ourCurrentTranslation).also { |
| 217 | + validatorI18N = it::formatText |
| 218 | +} |
| 219 | + |
| 220 | +fun createDefaultLocalizer(fallback: Localizer): DefaultLocalizer { |
| 221 | + return DefaultLocalizer(baseLocalizer = fallback, currentTranslation = ourCurrentTranslation) |
| 222 | +} |
| 223 | + |
| 224 | +/** |
| 225 | + * Structure representing a translation with a locale and mapping function that maps translation keys to translations. |
| 226 | + */ |
| 227 | +data class Translation(val locale: Locale, val mapKey: (String) -> String?) |
0 commit comments